"""Панель прогона.

Отдаётся по http с петлевого адреса: страница читает `state.json` и `evidence.jsonl`
из каталога прогона, а `file://` этого не позволяет. Сервер записывает порт и pid,
и следующий запуск сначала убирает за предыдущим — забытый http-сервер, месяцами
раздающий бриф и манифест, это не мелочь.

Адрес постоянный: `http://localhost:8789`. Случайный порт означал, что «зайти на
панель в любой момент» невозможно — ссылка живёт ровно до следующего подъёма.
Отключается двумя рычагами, и оба отключают разное:

* `fixed_port` в `~/.config/chekan/config.json` (и `CHEKAN_FIXED_PORT=0` поверх
  файла) — снимает постоянный адрес: панель поднимется на случайном порту;
* `chekan dash --off` — гасит саму панель и запрещает подъём до `chekan dash --on`.

Занятый порт не обходится молча. Притвориться поднятой на другом порту значит
отдать владельцу ссылку, по которой он увидит чужую страницу; поэтому подъём
отказывает и называет, кто держит порт.
"""

from __future__ import annotations

import json
import os
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path

from .store import STATE_DIR, Run

PIDFILE = "serve.pid"

# Номер выбран замером, а не на глаз: 8787 держит ops_dashboard, 8788 —
# onec-build-agent, 8790 тоже занят.
FIXED_PORT = 8789
PORT_ENV = "CHEKAN_FIXED_PORT"
CONFIG_PORT = "fixed_port"
CONFIG_OFF = "dash_off"

# Значения настройки, которыми постоянный адрес выключают.
_OFF_WORDS = {"0", "", "off", "no", "false", "нет", "none"}
_ON_WORDS = {"on", "yes", "true", "да"}


class PanelOff(RuntimeError):
    """Панель выключена владельцем — подъём запрещён."""


class PortBusy(RuntimeError):
    """Постоянный порт занят: кем именно, сказано в тексте."""

# По этим строкам в командной строке процесса панель узнаёт свои серверы среди
# чужих. Второй — прежний сервер на stdlib: панели, поднятые до появления кнопки
# паузы, продолжают жить и отдавать страницу, но на команду отвечают 501. Если их
# не узнавать, они остаются висеть, а владелец жмёт кнопку в старой вкладке и
# получает ошибку. Поэтому старую строку не выбрасываем, а гасим по ней.
_MARKS = ("chekan.serve_app", "ceh.serve_app", "http.server")
_MARK = _MARKS[0]


def _is_panel(text: str) -> bool:
    return any(m in text for m in _MARKS)


def _assets() -> Path:
    return Path(__file__).resolve().parent.parent / "assets"


def _inline(html: str) -> str:
    """Вшивает иконку и кузницу: страница обязана работать одним файлом."""
    for token, name in (("__ICON96__", "kodoceh-96.png.b64"), ("__ICON32__", "kodoceh-32.png.b64")):
        f = _assets() / name
        html = html.replace(token, f.read_text(encoding="utf-8").strip() if f.is_file() else "")
    for token, name in (("__FORGE_MARKUP__", "forge.html"), ("__FORGE_CSS__", "forge.css"),
                        ("__FORGE_JS__", "forge.js")):
        f = _assets() / name
        html = html.replace(token, f.read_text(encoding="utf-8").strip() if f.is_file() else "")
    return html


def runs_root(run: Run) -> Path:
    return run.root / STATE_DIR


def build_page(run: Run) -> Path:
    """Страница прогона плюс общий список всех прогонов рядом.

    Сервер отдаёт весь `.chekan/`, а не один каталог: иначе с панели прогона
    некуда вернуться, и соседние прогоны не посмотреть.
    """
    dest = run.dir / "dashboard.html"
    dest.write_text(_inline((_assets() / "dashboard.html").read_text(encoding="utf-8")),
                    encoding="utf-8")
    build_index(runs_root(run))
    return dest


def build_index(base: Path) -> Path:
    """Список всех прогонов. Слаги вшиваются: каталог сервер не листит."""
    slugs = sorted(
        d.name for d in base.iterdir()
        if d.is_dir() and (d / "state.json").is_file()
    )
    html = _inline((_assets() / "index.html").read_text(encoding="utf-8"))
    html = html.replace("__SLUGS__", json.dumps(slugs, ensure_ascii=False))
    dest = base / "index.html"
    dest.write_text(html, encoding="utf-8")
    return dest


# --- настройки: постоянный адрес и выключатель самой панели ---


def _config() -> dict:
    """Файл настроек инструмента. Мусор в нём — как будто настройки нет."""
    from .i18n import config_path

    try:
        data = json.loads(config_path().read_text(encoding="utf-8"))
    except Exception:
        return {}
    return data if isinstance(data, dict) else {}


def _write_config(key: str, value) -> Path:
    """Дописать один ключ, сохранив соседние: рядом живёт выбор языка."""
    from .i18n import config_path

    path = config_path()
    data = _config()
    data[key] = value
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    return path


def _as_port(value) -> int | None:
    """Значение настройки → номер порта или None, если постоянный адрес выключен."""
    if value is None:
        return FIXED_PORT  # ключа нет — постоянный адрес по умолчанию включён
    if isinstance(value, bool):
        return FIXED_PORT if value else None
    text = str(value).strip().lower()
    if text in _OFF_WORDS:
        return None
    if text in _ON_WORDS:
        return FIXED_PORT
    try:
        number = int(text)
    except ValueError:
        return FIXED_PORT  # непонятное значение не должно молча менять порт
    return number if 0 < number < 65536 else None


def fixed_port() -> int | None:
    """Порт постоянного адреса или None, если владелец его отключил.

    Переменная окружения бьёт файл: разовый запуск на случайном порту не должен
    требовать правки настроек.
    """
    env = os.environ.get(PORT_ENV)
    if env is not None and env.strip() != "":
        return _as_port(env)
    return _as_port(_config().get(CONFIG_PORT))


def enabled() -> bool:
    return not bool(_config().get(CONFIG_OFF))


def disable() -> Path:
    return _write_config(CONFIG_OFF, True)


def enable() -> Path:
    return _write_config(CONFIG_OFF, False)


def _pidfile(run: Run) -> Path:
    return run.dir / PIDFILE


def _read_pid(run: Run) -> tuple[int, int] | None:
    f = _pidfile(run)
    if not f.is_file():
        return None
    try:
        port, pid = f.read_text(encoding="utf-8").split()
        return int(port), int(pid)
    except (ValueError, OSError):
        return None


def _alive(pid: int) -> bool:
    try:
        os.kill(pid, 0)
    except (OSError, ProcessLookupError):
        return False
    return True


def _is_ours(pid: int, run: Run) -> bool:
    """Наш ли это процесс.

    Pid переиспользуются системой: устаревший serve.pid без этой проверки даёт
    отстрел случайного чужого процесса. Сверяем командную строку с каталогом прогона.
    """
    if not _alive(pid):
        return False
    try:
        out = subprocess.run(
            ["ps", "-o", "command=", "-p", str(pid)],
            capture_output=True, text=True, timeout=10,
        ).stdout
    except (OSError, subprocess.SubprocessError):
        return False
    return _is_panel(out) and str(runs_root(run)) in out


def _processes() -> list[tuple[int, str]]:
    try:
        out = subprocess.run(["ps", "-axo", "pid=,command="], capture_output=True,
                             text=True, timeout=15).stdout
    except (OSError, subprocess.SubprocessError):
        return []
    rows = []
    for line in out.splitlines():
        head, _, rest = line.strip().partition(" ")
        if head.isdigit():
            rows.append((int(head), rest.strip()))
    return rows


def _all_ours(run: Run) -> list[int]:
    """Все наши серверы на этот прогон, а не только записанный в serve.pid.

    Гонка при параллельных вызовах оставляла лишние процессы, раздающие бриф и
    спеку, — а гасился ровно один, записанный последним.
    """
    return [pid for pid, cmd in _processes()
            if _is_panel(cmd) and str(runs_root(run)) in cmd]


def stop(run: Run, quiet: bool = False) -> bool:
    found = _read_pid(run)
    _pidfile(run).unlink(missing_ok=True)
    targets = set(_all_ours(run))
    if found and _is_ours(found[1], run):
        targets.add(found[1])
    if not targets:
        return False
    for pid in sorted(targets):
        try:
            os.kill(pid, signal.SIGTERM)
        except OSError:
            pass
    if not quiet:
        port = found[0] if found else "?"
        extra = f" (+{len(targets) - 1} осиротевших)" if len(targets) > 1 else ""
        print(f"Панель остановлена (порт {port}){extra}.")
    return True


def _free_port() -> int:
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


def _listening(port: int) -> bool:
    with socket.socket() as s:
        s.settimeout(0.3)
        return s.connect_ex(("127.0.0.1", port)) == 0


def _wait_free(port: int, seconds: float = 2.0) -> bool:
    """Дать только что убитым своим серверам отпустить порт."""
    deadline = time.monotonic() + seconds
    while _listening(port):
        if time.monotonic() > deadline:
            return False
        time.sleep(0.05)
    return True


def _panel_on(port: int) -> tuple[int, str] | None:
    """Наша же панель на этом порту: (pid, каталог прогонов, который она раздаёт)."""
    for pid, cmd in _processes():
        if not _is_panel(cmd):
            continue
        parts = cmd.split()
        for i, token in enumerate(parts):
            if not any(m in token for m in _MARKS):
                continue
            rest = parts[i + 1:]
            if not rest or rest[0] != str(port):
                continue
            rest = rest[1:]
            if rest and rest[0] == "--directory":  # прежний сервер на stdlib
                rest = rest[1:]
            return pid, (rest[0] if rest else "")
    return None


def _foreign_on(port: int) -> str:
    """Чужой держатель порта словами: имя команды и pid, если удалось узнать."""
    try:
        out = subprocess.run(["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN"],
                             capture_output=True, text=True, timeout=10).stdout
    except (OSError, subprocess.SubprocessError):
        return ""
    for line in out.splitlines()[1:]:
        parts = line.split()
        if len(parts) >= 2 and parts[1].isdigit():
            return f"{parts[0]} (pid {parts[1]})"
    return ""


def _busy(port: int, run: Run) -> PortBusy:
    """Кто держит порт. Два случая разные, и путать их нельзя.

    Своя панель другого проекта — поправимая мелочь: сказано, какой каталог она
    раздаёт, и предложен случайный порт. Чужой процесс — чужая ответственность.
    """
    panel = _panel_on(port)
    if panel:
        pid, served = panel
        if served and Path(served) == runs_root(run):
            return PortBusy(
                f"Порт {port} держит панель этого же прогона (pid {pid}) и не отпускает. "
                f"Погасите её: `chekan dash --stop`."
            )
        where = served or "неизвестный каталог"
        return PortBusy(
            f"Порт {port} занят панелью Чекана другого проекта (pid {pid}): "
            f"она раздаёт {where}. Панель не поднята.\n"
            f"Поднять эту на случайном порту: `{PORT_ENV}=0 chekan dash`."
        )
    who = _foreign_on(port) or "чужой процесс"
    return PortBusy(
        f"Порт {port} занят: {who} — это не панель Чекана. Панель не поднята.\n"
        f"Освободите порт или снимите постоянный адрес: `{PORT_ENV}=0 chekan dash`."
    )


def serve(run: Run) -> tuple[str, bool]:
    """Возвращает (url, поднят_ли_новый_сервер). Одна панель на прогон."""
    if not enabled():
        raise PanelOff(
            "Панель выключена (`chekan dash --off`) — подъём запрещён. "
            "Включить обратно: `chekan dash --on`."
        )
    build_page(run)

    with run._lock():  # иначе параллельные вызовы поднимают по серверу каждый
        return _serve_locked(run)


def _url(port: int, run: Run) -> str:
    # localhost, не 127.0.0.1: встроенные панели просмотра принимают первое и
    # отклоняют второе, а страница одна и та же.
    return f"http://localhost:{port}/{run.slug}/dashboard.html"


def _serve_locked(run: Run) -> tuple[str, bool]:
    found = _read_pid(run)
    if found and _is_ours(found[1], run):
        return _url(found[0], run), False

    strays = _all_ours(run)
    for stray in strays:
        try:
            os.kill(stray, signal.SIGTERM)
        except OSError:
            pass
    _pidfile(run).unlink(missing_ok=True)

    want = fixed_port()
    if want is None:
        port = _free_port()
    else:
        if strays:
            _wait_free(want)  # свои же серверы отпускают порт не мгновенно
        if _listening(want):
            raise _busy(want, run)
        port = want
    # Чекан ставится шимом, который сам кладёт корень в sys.path, — отдельно
    # запущенный python про пакет `chekan` не знает. Раньше сервером был stdlib-модуль,
    # и вопрос не стоял; свой модуль без этого просто не стартует.
    env = dict(os.environ)
    root = str(Path(__file__).resolve().parent.parent)
    env["PYTHONPATH"] = root + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
    proc = subprocess.Popen(
        [sys.executable, "-m", _MARK, str(port), str(runs_root(run))],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        start_new_session=True, env=env,
    )
    _pidfile(run).write_text(f"{port} {proc.pid}\n", encoding="utf-8")

    for _ in range(40):
        with socket.socket() as s:
            s.settimeout(0.2)
            if s.connect_ex(("127.0.0.1", port)) == 0:
                break
        time.sleep(0.05)
    return _url(port, run), True


def open_in_browser(url: str) -> bool:
    if os.environ.get("SSH_CONNECTION") or os.environ.get("CI"):
        return False
    opener = "open" if sys.platform == "darwin" else "xdg-open"
    try:
        subprocess.run([opener, url], check=False, timeout=10,
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        return True
    except (OSError, subprocess.SubprocessError):
        return False
