#!/usr/bin/python3
# Экран установки bj при первой загрузке сервера (bj-firstboot) — в том же оформлении, что экран установки
# системы (bj-install-progress): рамка Bridge&Join, шаги установщика с отметками, полоса хода. Запускает
# установщик, весь его вывод пишет в журнал (--log) и по этому выводу рисует экран; код возврата — как у
# установщика. Последний кадр остаётся на экране (сверху) — под ним bj-firstboot выводит учётные записи.
#
#   bj-firstboot-screen --log /var/log/bj/firstboot-install.log -- bj-installer install --bundle …
#
# Установщик с BJ_SCREEN=1 печатает в начале «@@plan шаг|шаг|…», дальше — «[i/n] Шаг готово|ошибка»,
# строки-подробности с отступом, в конце «bj установлен» с адресом и кодом или «Установка не завершена.».
# Знаки ✓ ▶ █ ░ ═ — только со шрифтом консоли установщика (BJ_SCREEN_FONT=1), иначе простые символы.
# Проверка без установки: bj-firstboot-screen --demo <файл вывода установщика> [--html кадр.html]

import os
import re
import select
import shutil
import subprocess
import sys
import time

FANCY = os.environ.get("BJ_SCREEN_FONT") == "1"
G = dict(ok="✓", run="▶", wait="·", err="✗", full="█", empty="░", h="═", v="║", tl="╔", tr="╗", bl="╚", br="╝") if FANCY else \
    dict(ok="+", run=">", wait=".", err="x", full="#", empty="-", h="=", v="|", tl="+", tr="+", bl="+", br="+")
RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
GREEN, CYAN, RED, WHITE, GREY, YELLOW = "\033[32m", "\033[36m", "\033[31m", "\033[97m", "\033[90m", "\033[33m"
STEP_RE = re.compile(r"\[(\d+)/(\d+)\]\s+(.*?)\s*(готово|ошибка)?\s*$")


class State:
    def __init__(self):
        self.plan, self.status, self.cur = [], {}, 0  # status: номер шага → ok|err
        self.cur_started = time.time()
        self.started = time.time()
        self.caption = ""
        self.done = False
        self.error = ""
        self.urls, self.code = [], ""
        self.buf = ""

    def feed(self, text):
        self.buf += text
        while "\n" in self.buf:
            line, self.buf = self.buf.split("\n", 1)
            self.line(line)
        m = STEP_RE.search(self.buf)  # строка шага без конца — шаг идёт
        if m:
            self.start_step(int(m.group(1)), int(m.group(2)), m.group(3))

    def start_step(self, i, n, title):
        if not self.plan:
            self.plan = [""] * n
        if i <= len(self.plan) and not self.plan[i - 1]:
            self.plan[i - 1] = title
        if i != self.cur:
            self.cur, self.cur_started, self.caption = i, time.time(), ""

    def line(self, line):
        s = line.strip()
        if s.startswith("@@plan "):
            self.plan = s[7:].split("|")
            return
        m = STEP_RE.search(line)
        if m:
            i = int(m.group(1))
            self.start_step(i, int(m.group(2)), m.group(3))
            if m.group(4) == "готово":
                self.status[i] = "ok"
            elif m.group(4) == "ошибка":
                self.status[i] = "err"
            return
        if s in ("готово", "ошибка") and self.cur:  # отметка шага пришла отдельной строкой
            self.status[self.cur] = "ok" if s == "готово" else "err"
            return
        if s.startswith("Установка не завершена."):
            self.error = s[len("Установка не завершена."):].strip()
        elif s == "bj установлен":
            self.done = True
        elif "/first-run" in s and s.startswith("http"):
            self.urls.append(s)
        elif s.startswith("Одноразовый код первичной настройки:"):
            self.code = s.split(":", 1)[1].strip()
        elif line.startswith("        ") and s and self.cur and not self.done:
            self.caption = s  # подробность последнего шага

    def percent(self):
        n = max(1, len(self.plan))
        k = sum(1 for v in self.status.values() if v == "ok")
        if self.cur and self.cur not in self.status:  # текущий шаг — доля по времени, не больше 90 %
            k += min(0.9, (time.time() - self.cur_started) / 60.0)
        return min(100, int(100 * k / n))


def mmss(sec):
    sec = int(max(0, sec))
    return "%d:%02d" % (sec // 60, sec % 60)


def frame(st, cols, rows, final=False):
    W = 70
    inner = W - 2
    lines = []

    def row(parts=()):
        parts, text = list(parts), ""
        for i, (t, c) in enumerate(parts):
            if len(text) + len(t) > inner:
                parts = parts[:i] + [(t[: inner - len(text)], c)]
                text += parts[-1][0]
                break
            text += t
        lines.append([(G["v"], GREY)] + parts + [(" " * (inner - len(text)), ""), (G["v"], GREY)])

    lines.append([(G["tl"] + G["h"] * inner + G["tr"], GREY)])
    row()
    row([("   Bridge&Join", BOLD + WHITE)])
    row([("   шлюз, объединяющий системы финансовой организации", DIM)])
    row()
    row([("   Установка bj на сервер", BOLD)])
    row()
    for i, title in enumerate(st.plan, 1):
        title = title or "…"
        stv = st.status.get(i)
        if stv == "ok":
            row([("   " + G["ok"] + "  ", GREEN), (title, "")])
        elif stv == "err":
            row([("   " + G["err"] + "  ", RED), (title, BOLD + RED)])
        elif i == st.cur and not final:
            t = mmss(time.time() - st.cur_started)
            row([("   " + G["run"] + "  ", CYAN), (title, BOLD + CYAN), (" " * max(1, inner - 9 - len(title) - len(t)), ""), (t, DIM)])
        else:
            row([("   " + G["wait"] + "  ", GREY), (title, GREY)])
    row()
    if st.error or (final and not st.done):
        row([("   " + G["err"] + " Установка не завершена.", BOLD + RED)])
        for chunk in wrap(st.error or "подробности — в журнале установки", inner - 6):
            row([("     " + chunk, "")])
    elif st.done:
        row([("   " + G["ok"] + " bj установлен", BOLD + GREEN)])
        row()
        row([("   Откройте в браузере на своём компьютере:", "")])
        for u in st.urls[:3]:
            row([("      " + u, BOLD + WHITE)])
        if st.code:
            row([("   Одноразовый код первичной настройки:  ", ""), (st.code, BOLD + YELLOW)])
        row()
        row([("   Браузер предупредит о сертификате — это сертификат самого", DIM)])
        row([("   сервера bj: подтвердите исключение.", DIM)])
    else:
        pct = st.percent()
        bar_w = 48
        full = int(bar_w * pct / 100)
        row([("   ", ""), (G["full"] * full, CYAN), (G["empty"] * (bar_w - full), GREY), ("  %3d %%" % pct, BOLD)])
        row([("   " + st.caption, DIM)] if st.caption else [])
        row()
        row([("   Не выключайте сервер: обычно это занимает несколько минут.", "")])
    row()
    lines.append([(G["bl"] + G["h"] * inner + G["br"], GREY)])
    foot = "прошло " + mmss(time.time() - st.started)
    lines.append([(" " * max(0, (W - len(foot)) // 2) + foot, GREY)])
    left = max(0, (cols - W) // 2)
    top = 1 if final else max(0, (rows - len(lines)) // 2)  # итог — сверху: ниже bj-firstboot выведет учётные записи
    return [[("", "")]] * top + [[(" " * left, "")] + ln for ln in lines]


def wrap(text, width):
    out, cur = [], ""
    for w in text.split():
        if cur and len(cur) + 1 + len(w) > width:
            out.append(cur)
            cur = w
        else:
            cur = (cur + " " + w).strip()
    if cur:
        out.append(cur)
    return out[:4] or [""]


def to_ansi(fr):
    return "\033[H\033[2J" + "".join("".join((c + t + RESET) if c else t for t, c in ln) + "\n" for ln in fr)


def to_html(fr):
    css = {GREEN: "#4e9a06", CYAN: "#06989a", RED: "#cc0000", GREY: "#888", DIM: "#9a9a9a", WHITE: "#fff", YELLOW: "#c4a000"}
    rows = []
    for ln in fr:
        h = ""
        for t, c in ln:
            t = t.replace("&", "&amp;").replace("<", "&lt;")
            if not c:
                h += t
                continue
            color = next((css[k] for k in (GREEN, CYAN, RED, GREY, WHITE, YELLOW, DIM) if k in c), "#ddd")
            h += '<span style="color:%s;font-weight:%s">%s</span>' % (color, "700" if BOLD in c else "400", t)
        rows.append(h)
    return ('<!doctype html><meta charset="utf-8"><body style="margin:0;background:#000"><pre style="margin:0;padding:8px;'
            'font:16px/1 \'DejaVu Sans Mono\',monospace;color:#ddd;background:#000">' + "\n".join(rows) + "</pre>")


def main():
    a = sys.argv[1:]
    if len(a) >= 2 and a[0] == "--demo":
        st = State()
        st.feed(open(a[1], encoding="utf-8").read())
        st.started -= 95
        st.cur_started -= 24
        fr = frame(st, 100, 40, final=st.done or bool(st.error))
        if len(a) >= 4 and a[2] == "--html":
            open(a[3], "w", encoding="utf-8").write(to_html(fr))
        else:
            sys.stdout.write(to_ansi(fr))
        return 0
    if len(a) < 4 or a[0] != "--log" or a[2] != "--":
        sys.stderr.write("bj-firstboot-screen --log ФАЙЛ -- команда …\n")
        return 2
    log_path, cmd = a[1], a[3:]
    os.makedirs(os.path.dirname(log_path), exist_ok=True)
    log = open(log_path, "ab", buffering=0)
    log.write(("\n==== %s: %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), " ".join(cmd))).encode())
    env = dict(os.environ, BJ_SCREEN="1", NO_COLOR="1")
    p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, env=env, bufsize=0)
    st = State()
    sys.stdout.write("\033[?25l")
    try:
        while True:
            r, _, _ = select.select([p.stdout], [], [], 1.0)
            if r:
                chunk = os.read(p.stdout.fileno(), 65536)
                if not chunk:
                    break
                log.write(chunk)
                st.feed(chunk.decode("utf-8", "replace"))
            cols, rows = shutil.get_terminal_size((100, 40))
            sys.stdout.write(to_ansi(frame(st, cols, rows)))
            sys.stdout.flush()
        code = p.wait()
        if code != 0 and not st.error:
            st.error = "установщик завершился с кодом %d" % code
        cols, rows = shutil.get_terminal_size((100, 40))
        sys.stdout.write(to_ansi(frame(st, cols, rows, final=True)))
        return code
    finally:
        sys.stdout.write("\033[?25h")
        sys.stdout.flush()


if __name__ == "__main__":
    sys.exit(main())
