"""Сервер панели.

Раньше панель раздавал `python -m http.server` — он умеет только отдавать файлы.
Кнопке паузы нужно, чтобы страница могла что-то сказать обратно, поэтому сервер
свой. Отдача файлов осталась ровно той же, добавлен приём команд: пауза прогона,
подключение бота Телеграма и нажатие кнопок команд (R13, R14). Здесь же живёт
опрос Телеграма — поэтому бот отвечает ровно столько, сколько работает панель.

Слушает только петлю и вдобавок сверяет адрес обратившегося: привязка к
127.0.0.1 защищает от подключения снаружи, а проверка адреса — от случая, когда
кто-то пробросил порт наружу и забыл.
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

LOCAL = {"127.0.0.1", "::1", "::ffff:127.0.0.1"}

# Готовые наборы аргументов для кнопок (R13.2). Команда, которой нужен аргумент,
# приходит на панель уже с ним: поля ввода у панели нет нигде (R14.1).
PRESETS: dict[str, list[list[str]]] = {
    "req": [["list"]],
    "task": [["list"]],
    "agents": [["--list"]],
    "dash": [["--no-open"]],
    "prompt": [["g2"], ["g4"]],
}

# Команды, которым нужны слова владельца. Кнопка их не унесёт, а строку ввода
# панель не заводит — поэтому они названы поимённо и с причиной, а не молча
# выброшены из списка.
NEEDS_WORDS: dict[str, str] = {
    "init": "с панели не запускается: нужно ваше название прогона",
    "ask": "с панели не запускается: нужен ваш вопрос дословно",
    "answer": "с панели не запускается: нужен ваш ответ дословно",
    "brief": "с панели не запускается: нужен номер задачи",
}

# `chekan run` — полная оболочка: любая строка, поданная ей, выполнится. Поэтому с
# панели он запускает только записанную команду проверки задачи, и строку берёт
# сервер из состояния, а не страница из запроса (R14.2).
RUN_NOTE = "с панели запускает только записанную команду проверки задачи в работе"


def _one_line(text: str, limit: int = 92) -> str:
    """Пояснение к команде — ровно одна короткая строка, как на главной."""
    line = " ".join((text or "").split())
    return line if len(line) <= limit else line[: limit - 1] + "…"


def _run_actions(base: Path) -> list[dict]:
    """Кнопки `run`: по одной на задачу в работе, аргумент — её команда проверки.

    Строка берётся из состояния на диске. Принять её из запроса значило бы
    оставить с панели путь к любой команде оболочки — ровно то, что запрещено.
    """
    from .store import Run

    root = base.resolve().parent
    slug = Run.active_slug(root)
    if not slug:
        return []
    try:
        state = Run(root, slug).state
    except Exception:  # битое состояние не должно лишать панель остальных кнопок
        return []
    actions = []
    for task in state.get("tasks", []) or []:
        verify = (task.get("verify") or "").strip()
        if task.get("status") != "in-progress" or not verify:
            continue
        actions.append({
            "id": f"run:{task['id']}",
            "label": f"chekan run · проверка задачи {task['id']}",
            "args": ["run", verify, "--task", task["id"]],
            "note": verify,
        })
    return actions


def commands_catalog(base: Path) -> list[dict]:
    """Команды для главной: имя, одна строка пояснения, готовые кнопки (R13.1).

    Истина — сам разбор аргументов CLI (`shell.commands()`). Руками
    скопированный список расходится с правдой в первую же неделю: до этой
    правки в панели не было ни `why`, ни `prompt`.
    """
    from .shell import commands

    catalog = []
    for cmd in commands():
        entry = {"name": cmd.full, "short": cmd.short, "help": _one_line(cmd.help),
                 "actions": [], "note": ""}
        if cmd.full == "run":
            entry["actions"] = _run_actions(base)
            entry["note"] = RUN_NOTE
        elif cmd.full in NEEDS_WORDS:
            entry["note"] = NEEDS_WORDS[cmd.full]
        else:
            for args in PRESETS.get(cmd.full, [[]]):
                entry["actions"].append({
                    "id": ":".join([cmd.full, *args]),
                    "label": " ".join(["chekan", cmd.full, *args]),
                    "args": [cmd.full, *args],
                    "note": "",
                })
        catalog.append(entry)
    return catalog


def find_action(base: Path, action_id: str) -> list[str] | None:
    """Аргументы кнопки по её имени. Ничего похожего нет — None, и это отказ."""
    for entry in commands_catalog(base):
        for action in entry["actions"]:
            if action["id"] == action_id:
                return list(action["args"])
    return None


def _median(values: list[int]) -> int | None:
    """Медиана как её считает страница прогона: тот же порядок и тот же выбор."""
    if not values:
        return None
    ordered = sorted(values)
    return ordered[len(ordered) // 2]


def _run_picture(root: Path, slug: str) -> dict:
    """Одна строка картины: счёт задач, гейтов, проверок и расхода по прогону."""
    from . import detach
    from .store import Run, task_work_ms

    run = Run(root, slug)
    state = run.state
    tasks = state.get("tasks", []) or []
    counts = {"total": len(tasks)}
    for status in ("done", "failed", "in-progress", "review", "repair", "pending"):
        counts[status] = sum(1 for t in tasks if t.get("status") == status)

    gates = state.get("gates", {}) or {}
    passed = sum(1 for g in gates.values() if isinstance(g, dict) and g.get("status") == "passed")

    rows = run.spend_rows()
    # Задача без названного расхода в сумму не попадает: ноль вместо «не измерен»
    # выдаёт неполную сумму за полную. Такие названы поимённо.
    tokens = sum(r["tokens"] for r in rows if isinstance(r.get("tokens"), int))
    unmeasured = [r["id"] for r in rows if not isinstance(r.get("tokens"), int)]
    duration_ms = sum(r["duration_ms"] for r in rows if isinstance(r.get("duration_ms"), int))
    check_runs = sum(r["runs"] for r in rows if isinstance(r.get("runs"), int))

    # Рычаг порчи считается отдельно от проверок: у него ненулевой код — это
    # успех, и в счёт красных проверок он попадать не должен.
    green = red = levers = 0
    for row in run.evidence():
        code = row.get("exit")
        if code is None:
            continue
        if row.get("kind") == "lever":
            levers += 1
        elif code == 0:
            green += 1
        else:
            red += 1
    # Строка `state: running` без кода возврата — это идущая проверка в отрыве,
    # а не проверка с пустым кодом. Пары «начало — исход» считает сам detach.
    running = len(detach.pending(run))

    # «Дольше обычного» меряется медианой закрытых задач этого же прогона —
    # тем же порогом, каким кольцо на странице прогона желтеет.
    median = _median([task_work_ms(state, t) for t in tasks if t.get("status") == "done"])
    slow = []
    if median:
        for t in tasks:
            if t.get("status") != "in-progress":
                continue
            work = task_work_ms(state, t)
            if work > median:
                slow.append({"id": t["id"], "title": t.get("title", ""), "work_ms": work})

    return {
        "slug": slug,
        "title": state.get("title", ""),
        "tier": state.get("tier"),
        "mode": state.get("mode"),
        "phase": state.get("phase"),
        "started_at": state.get("started_at"),
        "finished_at": state.get("finished_at"),
        "paused": bool(state.get("paused_since")),
        "live": not state.get("finished_at"),
        "tasks": counts,
        "gates_passed": passed,
        "checks": {"green": green, "red": red, "running": running, "levers": levers},
        "spend": {
            "tokens": tokens,
            "unmeasured": unmeasured,
            "duration_ms": duration_ms,
            "runs": check_runs,
        },
        "median_ms": median,
        "slow": slow,
    }


def _focus_series(root: Path, slug: str, limit: int = 300) -> dict:
    """Ход одного прогона во времени: проверки и закрытия задач по отметкам."""
    from . import detach
    from .store import Run, _ms

    run = Run(root, slug)
    state = run.state
    start = _ms(state.get("started_at"))
    checks = []
    for row in run.evidence()[-limit:]:
        at = _ms(row.get("ts"))
        code = row.get("exit")
        # Строка без кода — это отметка о НАЧАЛЕ проверки в отрыве. Рисовать её
        # как исход значит выдать идущую работу за провалившуюся; идущие
        # добавляются ниже, и только те, у которых исхода ещё нет.
        if at is None or start is None or code is None:
            continue
        checks.append({"t": at - start, "ok": code == 0,
                       "task": row.get("task"), "kind": row.get("kind")})
    for row in detach.pending(run):
        at = _ms(row.get("ts"))
        if at is not None and start is not None:
            checks.append({"t": at - start, "ok": None, "task": row.get("task"),
                           "kind": row.get("kind"), "running": True})
    checks.sort(key=lambda c: c["t"])

    closures = []
    for t in sorted(state.get("tasks", []) or [],
                    key=lambda t: t.get("closed_at") or ""):
        if t.get("status") != "done":
            continue
        at = _ms(t.get("closed_at"))
        if at is None or start is None:
            continue
        closures.append({"t": at - start, "id": t["id"]})

    end = _ms(state.get("finished_at"))
    return {
        "slug": slug,
        "title": state.get("title", ""),
        "started_at": state.get("started_at"),
        "finished_at": state.get("finished_at"),
        "tasks_total": len(state.get("tasks", []) or []),
        "span_ms": (end - start) if (end and start) else None,
        "spend": run.spend_rows(),
        "by_model": _spend_by_model(run.spend_rows()),
        "checks": checks,
        "closures": closures,
    }


def _spend_by_model(rows: list[dict]) -> list[dict]:
    """Расход, сложенный по названной модели — источник для графика на панели.

    Модель не названа — задачи не выбрасываются, а идут отдельной строкой
    (как в текстовом отчёте): иначе сумма по моделям молча меньше общей.
    """
    groups: dict[str, dict] = {}
    for r in rows:
        model = r.get("model") or r.get("agent") or "модель не названа"
        g = groups.setdefault(model, {"model": model, "tokens": 0, "duration_ms": 0,
                                       "tasks": 0, "unmeasured": 0})
        g["tasks"] += 1
        if isinstance(r.get("tokens"), int):
            g["tokens"] += r["tokens"]
        else:
            g["unmeasured"] += 1
        if isinstance(r.get("duration_ms"), int):
            g["duration_ms"] += r["duration_ms"]
    return sorted(groups.values(), key=lambda g: g["tokens"], reverse=True)


def overview(base: Path) -> dict:
    """Картина работы для главной (R10.1).

    Числа считает сервер — из состояния на диске; страница их только рисует.
    Разделение не ради архитектуры: два независимых счёта одного и того же
    расходятся молча, и главная начинает врать числом.
    """
    from .store import SPEND_MEASURED, SPEND_REPORTED, _ms

    root = base.resolve().parent
    runs, broken = [], []
    for d in sorted(p for p in base.iterdir() if p.is_dir() and (p / "state.json").is_file()):
        try:
            runs.append(_run_picture(root, d.name))
        except Exception as exc:
            broken.append({"slug": d.name, "error": f"{type(exc).__name__}: {exc}"})
    runs.sort(key=lambda r: _ms(r.get("started_at")) or 0, reverse=True)

    live = [r for r in runs if r["live"]]
    totals = {
        "runs": len(runs),
        "runs_live": len(live),
        "runs_closed": len(runs) - len(live),
        "tasks": sum(r["tasks"]["total"] for r in runs),
        "tasks_done": sum(r["tasks"]["done"] for r in runs),
        "tasks_failed": sum(r["tasks"]["failed"] for r in runs),
        "gates_passed": sum(r["gates_passed"] for r in runs),
        "tokens": sum(r["spend"]["tokens"] for r in runs),
        "unmeasured": sum(len(r["spend"]["unmeasured"]) for r in runs),
        "duration_ms": sum(r["spend"]["duration_ms"] for r in runs),
        "check_runs": sum(r["spend"]["runs"] for r in runs),
        "checks_green": sum(r["checks"]["green"] for r in runs),
        "checks_red": sum(r["checks"]["red"] for r in runs),
        "checks_running": sum(r["checks"]["running"] for r in runs),
        "levers": sum(r["checks"]["levers"] for r in runs),
        "slow": sum(len(r["slow"]) for r in runs),
    }
    focus = None
    if runs:
        focus = _focus_series(root, (live or runs)[0]["slug"])
    return {
        "runs": runs,
        "broken": broken,
        "totals": totals,
        "focus": focus,
        "sources": {"tokens": SPEND_REPORTED, "duration": SPEND_MEASURED,
                    "runs": SPEND_MEASURED},
    }


class Handler(SimpleHTTPRequestHandler):
    def log_message(self, *args) -> None:
        """Панель опрашивает состояние раз в пару секунд — журнал бесполезен."""

    def do_GET(self) -> None:  # noqa: N802 — имя задано базовым классом
        path_only = self.path.split("?")[0]
        if path_only == "/api/telegram/status":
            if self.client_address[0] not in LOCAL:
                self._json(403, {"error": "только с этого компьютера"})
                return
            from . import telegram

            self._json(200, telegram.status())
            return
        if path_only == "/api/home":
            if self.client_address[0] not in LOCAL:
                self._json(403, {"error": "только с этого компьютера"})
                return
            try:
                self._json(200, overview(Path(self.directory).resolve()))
            except Exception as exc:  # страница не должна получать трейсбек
                self._json(500, {"error": f"{type(exc).__name__}: {exc}"})
            return
        if path_only in ("/api/commands", "/api/cli/commands"):
            if self.client_address[0] not in LOCAL:
                self._json(403, {"error": "только с этого компьютера"})
                return
            try:
                self._json(200, {"commands": commands_catalog(Path(self.directory).resolve())})
            except Exception as exc:  # страница не должна получать трейсбек
                self._json(500, {"error": f"{type(exc).__name__}: {exc}"})
            return
        super().do_GET()

    def do_POST(self) -> None:  # noqa: N802 — имя задано базовым классом
        if self.client_address[0] not in LOCAL:
            self._json(403, {"error": "команды принимаются только с этого компьютера"})
            return
        parts = [p for p in self.path.split("?")[0].split("/") if p]
        if parts[:2] == ["api", "telegram"] and len(parts) == 3:
            self._telegram(parts[2])
            return
        if parts in (["api", "cli"], ["api", "command"], ["api", "cmd"]):
            self._cli_exec()
            return
        if len(parts) == 3 and parts[1] == "api" and parts[2] in ("pause", "resume"):
            slug, action = parts[0], parts[2]
            try:
                from .store import Run

                root = Path(self.directory).resolve().parent
                run = Run(root, slug)
                if not run.state_path.is_file():
                    self._json(404, {"error": f"нет прогона {slug}"})
                    return
                changed = run.pause() if action == "pause" else run.resume()
            except Exception as exc:  # страница не должна получать трейсбек
                self._json(500, {"error": f"{type(exc).__name__}: {exc}"})
                return
            if changed:
                from . import telegram

                telegram.notify(telegram.pause_text(run, action == "pause"))
            self._json(200, {"paused": run.is_paused(), "since": run.paused_since(),
                             "changed": changed})
            return
        self._json(404, {"error": "нет такой команды"})

    def _cli_exec(self) -> None:
        """Нажатие кнопки на панели (R13.2, R14.1).

        Принимается только имя заранее заданного вызова; аргументы к нему берёт
        сервер из своего списка. Произвольная строка не принимается ни под каким
        видом — веб-консоли у панели нет.
        """
        try:
            length = int(self.headers.get("Content-Length") or 0)
            body = json.loads(self.rfile.read(length).decode("utf-8")) if length else {}
        except (ValueError, OSError):
            self._json(400, {"error": "не разобрал запрос"})
            return
        if not isinstance(body, dict):
            self._json(400, {"error": "не разобрал запрос"})
            return

        for key in ("command", "cmd", "args", "argv"):
            if key in body:
                self._json(400, {"error": "панель исполняет только кнопки: "
                                          "произвольная команда не принимается"})
                return

        action_id = body.get("action")
        if not isinstance(action_id, str) or not action_id.strip():
            self._json(400, {"error": "кнопка не названа"})
            return
        action_id = action_id.strip()

        base = Path(self.directory).resolve()
        args = find_action(base, action_id)
        if args is None:
            self._json(400, {"error": f"нет такой кнопки: {action_id}"})
            return

        root = base.parent
        try:
            chekan_pkg_root = str(Path(__file__).resolve().parent.parent)
            env = dict(os.environ)
            existing_pp = env.get("PYTHONPATH", "")
            env["PYTHONPATH"] = f"{chekan_pkg_root}:{existing_pp}".rstrip(":") if existing_pp else chekan_pkg_root
            proc = subprocess.run(
                [sys.executable, "-m", "chekan", *args],
                cwd=root,
                capture_output=True,
                text=True,
                env=env,
                timeout=60,
            )
            out_str = proc.stdout or ""
            err_str = proc.stderr or ""
            combo = (out_str + ("\n" if out_str and err_str else "") + err_str).strip()
            self._json(200, {
                "ok": proc.returncode == 0,
                "code": proc.returncode,
                "stdout": out_str,
                "stderr": err_str,
                "output": combo,
                "action": action_id,
                "args": args,
            })
        except subprocess.TimeoutExpired:
            self._json(504, {"error": "превышено время ожидания (60 с)"})
        except Exception as exc:
            self._json(500, {"error": f"{type(exc).__name__}: {exc}"})

    def _telegram(self, action: str) -> None:
        """Подключение и отключение бота. Токен приходит с формы на главной."""
        from . import telegram

        if action == "disconnect":
            telegram.forget()
            self._json(200, telegram.status())
            return
        if action != "connect":
            self._json(404, {"error": "нет такой команды"})
            return
        try:
            length = int(self.headers.get("Content-Length") or 0)
            body = json.loads(self.rfile.read(length).decode("utf-8")) if length else {}
            token = (body.get("token") or "").strip()
            # Хозяин приходит тем же сохранением, что и токен: поля стоят рядом
            # на форме. Поля нет в запросе — прежнего хозяина не трогаем.
            owner = body.get("owner_id")
        except (ValueError, OSError):
            self._json(400, {"error": "не разобрал запрос"})
            return
        try:
            state = telegram.connect(token, owner)
        except telegram.TelegramError as exc:
            # Текст уже прошёл через scrub(): токена в нём нет.
            self._json(400, {"error": str(exc)})
            return
        except Exception as exc:
            self._json(500, {"error": telegram.scrub(f"{type(exc).__name__}: {exc}", token)})
            return
        self._json(200, state)

    def _json(self, code: int, payload: dict) -> None:
        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


def main(argv: list[str] | None = None) -> int:
    argv = list(sys.argv[1:] if argv is None else argv)
    port = int(argv[0])
    directory = argv[1]
    handler = partial(Handler, directory=directory)
    # Бот отвечает, пока живёт панель: отдельного демона Чекан не заводит.
    from . import telegram

    telegram.start_poller(Path(directory))
    with ThreadingHTTPServer(("127.0.0.1", port), handler) as httpd:
        httpd.serve_forever()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
