"""Главная как картина работы (R10).

Страница числа не считает: она берёт их у сервера (`/api/home`) и рисует. Поэтому
проверка идёт с двух сторон и обе — чужим кодом:

1. Сумма и счётчики сервера пересчитываются здесь заново, прямо из `state.json` и
   `evidence.jsonl`, без единого вызова `ceh.store` и `ceh.serve_app`. Считать
   тем же кодом, что и страница, значит доказывать сам себе.
2. Разметка прогоняется настоящим движком JS: рисовалки графиков вызываются
   с ответом сервера, и в готовом SVG сверяются те же числа.

Рычаги порчи, каждый обязан краснеть: задачи без названного расхода посчитаны
нулём; число на странице разошлось с состоянием; в разметке появилась ссылка на
внешний узел.
"""

from __future__ import annotations

import json
import re
import shutil
import subprocess
import sys
import threading
from functools import partial
from http.client import HTTPConnection
from http.server import ThreadingHTTPServer
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from chekan import dash, serve_app  # noqa: E402
from chekan.store import Run, now  # noqa: E402

TOKENS_SOURCE = "со слов исполнителя"
TIME_SOURCE = "измерено"
THIN = " "  # узкий пробел между разрядами — им же разделяет страница


# --- поднятие панели -------------------------------------------------------


class Panel:
    def __init__(self, directory: Path):
        handler = partial(serve_app.Handler, directory=str(directory))
        self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
        self.port = self.httpd.server_address[1]
        threading.Thread(target=self.httpd.serve_forever, daemon=True).start()

    def close(self) -> None:
        self.httpd.shutdown()
        self.httpd.server_close()

    def get(self, path: str):
        conn = HTTPConnection("127.0.0.1", self.port, timeout=15)
        conn.request("GET", path)
        resp = conn.getresponse()
        raw = resp.read().decode("utf-8")
        conn.close()
        try:
            return resp.status, json.loads(raw)
        except ValueError:
            return resp.status, raw


# --- независимый пересчёт из файлов на диске -------------------------------


def read_runs(base: Path) -> list[tuple[dict, list[dict]]]:
    """Состояние и журнал каждого прогона — чтением файлов, без кода Чекана."""
    out = []
    for d in sorted(p for p in base.iterdir() if p.is_dir() and (p / "state.json").is_file()):
        state = json.loads((d / "state.json").read_text(encoding="utf-8"))
        rows = []
        log = d / "evidence.jsonl"
        if log.is_file():
            for line in log.read_text(encoding="utf-8").splitlines():
                if line.strip():
                    rows.append(json.loads(line))
        out.append((state, rows))
    return out


def own_count(base: Path) -> dict:
    """Те же числа, посчитанные здесь заново."""
    total = {
        "runs": 0, "runs_live": 0, "tasks": 0, "tasks_done": 0,
        "tokens": 0, "unmeasured": 0, "duration_ms": 0,
        "checks_green": 0, "checks_red": 0, "levers": 0, "checks_running": 0,
    }
    for state, rows in read_runs(base):
        total["runs"] += 1
        total["runs_live"] += 0 if state.get("finished_at") else 1
        tasks = state.get("tasks", [])
        total["tasks"] += len(tasks)
        for t in tasks:
            if t.get("status") == "done":
                total["tasks_done"] += 1
            spend = t.get("spend")
            if not isinstance(spend, dict):
                if t.get("status") == "done":
                    total["unmeasured"] += 1
                continue
            tokens = spend.get("tokens")
            if isinstance(tokens, int):
                total["tokens"] += tokens
            else:
                total["unmeasured"] += 1
            if isinstance(spend.get("duration_ms"), int):
                total["duration_ms"] += spend["duration_ms"]
        started, finished = {}, set()
        for row in rows:
            did = row.get("detach_id")
            if did:
                (finished.add(did) if row.get("state") == "done" else started.setdefault(did, row))
            code = row.get("exit")
            if code is None:
                continue
            if row.get("kind") == "lever":
                total["levers"] += 1
            elif code == 0:
                total["checks_green"] += 1
            else:
                total["checks_red"] += 1
        total["checks_running"] += len([d for d in started if d not in finished])
    return total


# --- прогон для проверки ---------------------------------------------------


@pytest.fixture
def repo(tmp_path: Path) -> Path:
    subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
    subprocess.run(["git", "config", "user.email", "t@t"], cwd=tmp_path, check=True)
    subprocess.run(["git", "config", "user.name", "t"], cwd=tmp_path, check=True)
    (tmp_path / "app.py").write_text("print('ok')\n", encoding="utf-8")
    subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True)
    subprocess.run(["git", "commit", "-qm", "init"], cwd=tmp_path, check=True)
    return tmp_path


def stamp(hours_ago: float) -> str:
    from datetime import datetime, timedelta, timezone

    return (datetime.now(timezone.utc) - timedelta(hours=hours_ago)).isoformat()


@pytest.fixture
def run(repo: Path) -> Run:
    """Прогон с расходом у одной задачи, без расхода у другой и с идущей третьей."""
    r = Run.create(repo, "Проверочный прогон панели", "normal", "T1")
    r.add_requirement("R01", "видеть картину работы на главной")
    for tid, title in (("01", "Первая"), ("02", "Вторая"), ("03", "Третья"), ("04", "Четвёртая")):
        r.add_task(tid, title, ["R01"], verify="true")
    r.set_task_model("01", "claude-opus-5")

    # Журнал: зелёная проверка, красная, рычаг порчи и проверка в отрыве.
    r.append_evidence({"ts": stamp(2.0), "task": "01", "kind": "check", "cmd": "pytest", "exit": 0})
    r.append_evidence({"ts": stamp(1.9), "task": "01", "kind": "check", "cmd": "pytest", "exit": 1})
    r.append_evidence({"ts": stamp(1.8), "task": "01", "kind": "lever", "cmd": "порча", "exit": 3})
    r.append_evidence({"ts": stamp(1.7), "task": "02", "kind": "check", "cmd": "pytest",
                       "detached": True, "detach_id": "aaa", "state": "running"})
    r.append_evidence({"ts": stamp(1.6), "task": "02", "kind": "check", "cmd": "pytest",
                       "detached": True, "detach_id": "aaa", "state": "done", "exit": 0})
    r.append_evidence({"ts": stamp(0.5), "task": "03", "kind": "check", "cmd": "pytest -k long",
                       "detached": True, "detach_id": "bbb", "state": "running"})

    with r.mutate() as st:
        by_id = {t["id"]: t for t in st["tasks"]}
        by_id["01"].update(status="done", started_at=stamp(2.5), closed_at=stamp(2.0))
        by_id["02"].update(status="done", started_at=stamp(1.9), closed_at=stamp(1.6))
        by_id["03"].update(status="in-progress", started_at=stamp(4.0))
    r.record_spend("01", tokens=121254)
    r.record_spend("02")  # расход не назван — «не измерен», а не ноль
    return r


@pytest.fixture
def panel(run: Run):
    base = run.root / ".chekan"
    dash.build_index(base)
    p = Panel(base)
    yield p
    p.close()


# --- 1. числа сервера против независимого счёта -----------------------------


def test_totals_match_state_on_disk(panel, run):
    code, data = panel.get("/api/home")
    assert code == 200, data
    mine = own_count(run.root / ".chekan")
    for key, value in mine.items():
        assert data["totals"][key] == value, f"{key}: панель {data['totals'][key]}, счёт {value}"
    assert data["totals"]["runs_closed"] == mine["runs"] - mine["runs_live"]


def test_unmeasured_task_is_named_not_zero(panel, run):
    _, data = panel.get("/api/home")
    picture = data["runs"][0]
    assert picture["spend"]["unmeasured"] == ["02"]
    assert picture["spend"]["tokens"] == 121254, "задача без расхода не должна прибавлять ноль"
    rows = {r["id"]: r for r in data["focus"]["spend"]}
    assert rows["02"]["tokens"] is None and rows["02"]["tokens_source"] is None
    assert rows["01"]["tokens_source"] == TOKENS_SOURCE


def test_sources_kept_apart(panel):
    _, data = panel.get("/api/home")
    assert data["sources"]["tokens"] == TOKENS_SOURCE
    assert data["sources"]["duration"] == TIME_SOURCE and data["sources"]["runs"] == TIME_SOURCE


def test_running_check_is_not_a_null_code(panel):
    """Строка `state: running` без кода — идущая проверка, а не красная."""
    _, data = panel.get("/api/home")
    checks = data["runs"][0]["checks"]
    assert checks["running"] == 1, "отрыв без исхода должен считаться идущим"
    assert checks["red"] == 1 and checks["green"] == 2
    assert checks["levers"] == 1, "ненулевой код рычага порчи — не красная проверка"
    live = [c for c in data["focus"]["checks"] if c.get("running")]
    assert len(live) == 1 and live[0]["ok"] is None


def test_slow_task_is_measured_by_median(panel):
    _, data = panel.get("/api/home")
    slow = data["runs"][0]["slow"]
    assert [s["id"] for s in slow] == ["03"], slow
    assert slow[0]["work_ms"] > data["runs"][0]["median_ms"] > 0


def test_only_from_this_computer(monkeypatch, run: Run):
    """Картина — тоже состояние прогона: с чужого адреса её не отдают."""
    handler = object.__new__(serve_app.Handler)
    handler.client_address = ("192.168.1.100", 12345)
    handler.directory = str(run.root / ".chekan")
    written = []
    monkeypatch.setattr(handler, "_json", lambda code, payload: written.append((code, payload)))
    handler.path = "/api/home"
    handler.do_GET()
    assert written and written[0][0] == 403


# --- 2. разметка: без сети и без второго счёта -------------------------------


def built_index(run: Run) -> str:
    return (run.root / ".chekan" / "index.html").read_text(encoding="utf-8")


def external_subresources(html: str) -> list[str]:
    """Всё, что страница потянет из сети при открытии.

    Ссылка в подвале — не подгрузка: по ней жмут руками, и без сети страница
    открывается всё равно. Ищутся именно загружаемые узлы.
    """
    found = []
    for m in re.finditer(r"<(script|img|link|iframe|source|video|audio|object|embed)\b[^>]*>",
                         html, re.I):
        for attr in re.finditer(r'(?:src|href)\s*=\s*"([^"]*)"', m.group(0), re.I):
            if attr.group(1).strip().lower().startswith(("http://", "https://", "//")):
                found.append(m.group(0)[:80])
    found += [m.group(0)[:80] for m in re.finditer(r"url\(\s*['\"]?(?:https?:)?//", html, re.I)]
    found += [m.group(0)[:80] for m in re.finditer(r"@import", html, re.I)]
    found += [m.group(0)[:80] for m in re.finditer(r"fetch\(\s*[\"'`](?:https?:)?//", html, re.I)]
    return found


def test_page_opens_without_network(panel, run):
    html = built_index(run)
    assert not external_subresources(html), external_subresources(html)
    assert "<svg" in html.replace("`<svg", "<svg"), "графики рисуются встроенным SVG"


def test_page_takes_numbers_from_the_server(panel, run):
    """Второго счёта на странице нет: она читает `/api/home`, а не state.json."""
    html = built_index(run)
    assert 'fetch("/api/home' in html
    assert "state.json" not in html


# --- 3. разметка графиков: тем же движком JS --------------------------------


def draw(html: str, payload: dict) -> dict:
    """Вызвать рисовалки страницы настоящим движком JS и вернуть их вывод."""
    block = re.search(r"<script>\s*(const SLUGS.*?)</script>", html, re.S)
    assert block, "не нашёл главный скрипт страницы"
    pure = block.group(1)
    cut = pure.index("async function load(")
    program = pure[:cut] + """
const data = JSON.parse(require("fs").readFileSync(0, "utf8"));
process.stdout.write(JSON.stringify({
  figs: figures(data.totals),
  spend: spendChart(data.focus),
  model: modelChart(data.focus),
  time: timeChart(data.focus),
  slow: slowLines(data.runs),
  cards: data.runs.map(runCard).join("")
}));
"""
    proc = subprocess.run(["node", "-e", program], input=json.dumps(payload),
                          capture_output=True, text=True, timeout=60)
    assert proc.returncode == 0, proc.stderr
    return json.loads(proc.stdout)


@pytest.mark.skipif(shutil.which("node") is None, reason="нет node — движок JS не поднять")
def test_svg_numbers_match_state(panel, run):
    _, data = panel.get("/api/home")
    drawn = draw(built_index(run), data)
    mine = own_count(run.root / ".chekan")

    money = f"121{THIN}254"
    assert money in drawn["spend"], "расход задачи 01 нарисован не тем числом"
    assert drawn["spend"].count(">не измерен</text>") == 1, "у задачи без расхода должно стоять слово"
    assert money in drawn["figs"] and TOKENS_SOURCE in drawn["figs"]
    assert TIME_SOURCE in drawn["figs"]
    assert f"{mine['tasks_done']}" in drawn["figs"] and f"из {mine['tasks']}" in drawn["figs"]

    ticks = re.findall(r'class="tick (\w+)"', drawn["time"])
    assert len(ticks) == len(data["focus"]["checks"])
    assert ticks.count("live") == mine["checks_running"] == 1
    assert ticks.count("lever") == mine["levers"] == 1
    assert ticks.count("bad") == mine["checks_red"] == 1
    assert f"задач доказано {mine['tasks_done']} из" in drawn["time"]
    assert "дольше медианы" in drawn["slow"]
    assert drawn["cards"].count('class="panel"') == mine["runs"]


@pytest.mark.skipif(shutil.which("node") is None, reason="нет node — движок JS не поднять")
def test_page_never_draws_zero_for_unmeasured(panel, run):
    """Рычаг: подмени «не измерен» нулём — полос станет больше, чем названных чисел."""
    _, data = panel.get("/api/home")
    drawn = draw(built_index(run), data)
    bars = len(re.findall(r'class="bar"', drawn["spend"]))
    named = len([r for r in data["focus"]["spend"] if isinstance(r["tokens"], int)])
    assert bars == named == 1, "полоса рисуется только у задачи с названным расходом"
    assert len(re.findall(r'class="gap"', drawn["spend"])) == len(data["runs"][0]["spend"]["unmeasured"])


# --- 4. расход по моделям ---------------------------------------------------


def test_by_model_groups_spend(panel):
    """Токены и время сложены по названной модели, а не потеряны в общей сумме."""
    _, data = panel.get("/api/home")
    by_model = data["focus"]["by_model"]
    assert sum(m["tokens"] for m in by_model) == data["totals"]["tokens"] == 121254

    opus = next(m for m in by_model if m["model"] == "claude-opus-5")
    assert opus["tokens"] == 121254 and opus["tasks"] == 1 and opus["unmeasured"] == 0

    rest = [m for m in by_model if m["model"] != "claude-opus-5"]
    assert len(rest) == 1, "у задачи 02 нет модели — она должна лечь отдельной строкой"
    assert rest[0]["tokens"] == 0 and rest[0]["unmeasured"] == 1


@pytest.mark.skipif(shutil.which("node") is None, reason="нет node — движок JS не поднять")
def test_model_chart_shows_tokens_and_names_unmeasured(panel, run):
    """Рычаг: подмени «не измерен» нулём в группе без токенов — число расходится."""
    _, data = panel.get("/api/home")
    drawn = draw(built_index(run), data)
    money = f"121{THIN}254"
    assert "claude-opus-5" in drawn["model"] and money in drawn["model"]
    by_model = data["focus"]["by_model"]
    zero_token_groups = len([m for m in by_model if not m["tokens"]])
    assert drawn["model"].count(">не измерен</text>") == zero_token_groups == 1


def test_no_runs_no_lie(tmp_path: Path, repo: Path):
    """Пустой каталог прогонов — картины нет, а не нули во всех числах."""
    base = repo / ".chekan"
    base.mkdir(exist_ok=True)
    panel = Panel(base)
    try:
        code, data = panel.get("/api/home")
        assert code == 200
        assert data["totals"]["runs"] == 0 and data["focus"] is None and data["runs"] == []
    finally:
        panel.close()


def test_broken_run_named_not_hidden(panel, run):
    """Битое состояние соседнего прогона не роняет главную и не молчит о себе."""
    bad = run.root / ".chekan" / "slomannyy"
    bad.mkdir()
    (bad / "state.json").write_text("{ не json", encoding="utf-8")
    code, data = panel.get("/api/home")
    assert code == 200
    assert [b["slug"] for b in data["broken"]] == ["slomannyy"]
    assert len(data["runs"]) == 1
    assert now  # импорт держится: время в фикстурах пишется тем же форматом
