"""Короткое имя прогона отдельно от брифа (R20).

Заказ владельца дословно: «Заголовки должны быть краткие и по делу и так чтобы
было видно полностью их названия». До этой правки заголовком становился весь
бриф — абзац на шесть строк: панель резала его многоточием, а строкой ниже тот же
текст стоял целиком. Один текст на экране дважды, и оба раза неудобно.

Проверка идёт с трёх сторон, и ни одна не верит другой на слово:

1. Диск. Имя читается из `state.json` как его записал CLI, бриф — из `brief.md`.
   Сравнение идёт с исходным текстом заказа, а не с тем, что вернул сам инструмент.
2. Панель. `/api/home` обязан отдать то же имя знак в знак, а разметка — нарисовать
   его настоящим движком JS без единого многоточия, с брифом отдельной строкой.
3. Настоящие прогоны этого репозитория, в том числе заведённые ДО правки: имя у
   них сворачивается по тому же правилу, а полный текст остаётся в брифе.

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

from __future__ import annotations

import json
import os
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 STATE_DIR, TITLE_LIMIT, Run, new_state, run_title  # noqa: E402

REPO = Path(__file__).resolve().parent.parent
INDEX = REPO / "assets" / "index.html"

# Заказ владельца одним куском — ровно такой бриф и ломал заголовок.
BRIEF = (
    "Чекан удобнее, экономичнее и отказоустойчивее: учёт расхода токенов по "
    "задачам, замок на зону, рычаг порчи как часть инструмента, повторное "
    "использование доказательства по отпечатку зоны, сборка брифа задачи, "
    "контрольные точки перед прогоном, долгие проверки в отрыве. Плюс лицо "
    "продукта: главная страница панели информативнее, с графиками."
)
NAME = "Чекан удобнее, экономичнее и отказоустойчивее"


def ceh(repo: Path, *args: str) -> str:
    package_root = str(REPO)
    done = subprocess.run(
        [sys.executable, "-m", "chekan", *args],
        cwd=repo,
        env={**os.environ, "PYTHONPATH": package_root},
        capture_output=True,
        text=True,
        check=True,
    )
    return done.stdout


@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 on_disk(repo: Path) -> tuple[dict, str, Path]:
    """Состояние и бриф прямо с диска — без единого вызова `ceh.store`."""
    slug = (repo / STATE_DIR / "active").read_text(encoding="utf-8").strip()
    run_dir = repo / STATE_DIR / slug
    state = json.loads((run_dir / "state.json").read_text(encoding="utf-8"))
    brief = (run_dir / "brief.md").read_text(encoding="utf-8").strip()
    return state, brief, run_dir


# --- 1. имя и бриф — разные поля -------------------------------------------


def test_name_is_the_first_link_not_the_whole_brief(repo: Path):
    """R20.1: имя выводится из брифа, бриф сохраняется целиком."""
    ceh(repo, "init", BRIEF, "--no-dash")
    state, brief, _ = on_disk(repo)

    assert state["title"] == NAME, "имя прогона — не первое смысловое звено брифа"
    assert len(state["title"]) <= TITLE_LIMIT, f"имя длиннее {TITLE_LIMIT} знаков: {state['title']!r}"
    assert brief == BRIEF, "бриф на диске разошёлся с тем, что назвал владелец"
    assert state["title"] != brief, "имя и бриф снова оказались одной строкой"


def test_owner_can_name_the_run(repo: Path):
    """R20.1: назвали `--title` — имя ровно такое, бриф всё равно целый."""
    ceh(repo, "init", BRIEF, "--title", "Короткие заголовки", "--no-dash")
    state, brief, _ = on_disk(repo)

    assert state["title"] == "Короткие заголовки"
    assert brief == BRIEF


def test_brief_is_never_trimmed(repo: Path):
    """Бриф длиннее любого имени и остаётся дословным — вплоть до переносов строк."""
    text = "Первая строка заказа: чего хотим.\n\nВторая строка: чего не хотим.\n"
    ceh(repo, "init", text, "--no-dash")
    _, brief, _ = on_disk(repo)
    assert brief == text.strip(), "бриф пришёл на диск не дословно"


@pytest.mark.parametrize(
    "brief, expected",
    [
        ("Заголовки короткие: и по делу, и видно целиком", "Заголовки короткие"),
        ("Заголовки короткие. Видно целиком.", "Заголовки короткие"),
        ("Заголовки короткие; видно целиком", "Заголовки короткие"),
        ("Одно короткое имя без знаков препинания", "Одно короткое имя без знаков препинания"),
        # Двоеточие сразу после первого слова — не граница: имя вышло бы обрубком.
        ("Чекан: панель, бот и отчёт", "Чекан: панель, бот и отчёт"),
        ("", "Прогон"),
    ],
)
def test_name_rule(brief: str, expected: str):
    assert run_title(brief) == expected


def test_long_link_folds_by_words_without_dots():
    """Звено длиннее шестидесяти знаков сворачивается по слову и без многоточия."""
    long = "Панель показывает заголовок целиком и не режет его многоточием ни на главной ни в карточке"
    name = run_title(long)
    assert len(name) <= TITLE_LIMIT, name
    assert "…" not in name and not name.endswith("..."), f"в имени осталось многоточие: {name!r}"
    assert long.startswith(name), "имя перестало быть началом брифа"
    assert name.split()[-1] in long.split(), "имя обрезано посреди слова"


# --- 2. старые прогоны ------------------------------------------------------


def old_run(repo: Path, slug: str, title: str, brief: str | None) -> Run:
    """Прогон, заведённый ДО R20: в `title` лежит весь бриф."""
    run_dir = repo / STATE_DIR / slug
    (run_dir / "tasks").mkdir(parents=True)
    (run_dir / "evidence.jsonl").touch()
    state = new_state(slug, title, "normal", "T1")
    (run_dir / "state.json").write_text(
        json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
    )
    if brief is not None:
        (run_dir / "brief.md").write_text(brief + "\n", encoding="utf-8")
    (repo / STATE_DIR / "active").write_text(slug + "\n", encoding="utf-8")
    return Run(repo, slug)


def test_old_run_folds_the_name_and_keeps_the_brief(repo: Path):
    """R20.4: старый прогон открывается, имя короткое, бриф целый."""
    run = old_run(repo, "staryy", BRIEF, BRIEF)
    assert run.state["title"] == NAME
    assert len(run.state["title"]) <= TITLE_LIMIT
    assert run.brief() == BRIEF, "полный текст пропал из брифа"

    # Первая же правка состояния кладёт короткое имя на диск, а бриф не трогает.
    with run.mutate() as st:
        st["phase"] = "manifest"
    state, brief, _ = on_disk(repo)
    assert state["title"] == NAME
    assert brief == BRIEF


def test_old_run_without_brief_file_does_not_lose_the_text(repo: Path):
    """Имя не обрезается молча: без brief.md полный текст сначала уходит в бриф."""
    run = old_run(repo, "bez-brifa", BRIEF, None)
    assert run.state["title"] == NAME
    assert (run.dir / "brief.md").read_text(encoding="utf-8").strip() == BRIEF
    assert run.brief() == BRIEF


@pytest.mark.skipif(not (REPO / STATE_DIR).is_dir(), reason="в этом репозитории прогонов нет")
def test_real_runs_of_this_repository():
    """Настоящие прогоны: и старые, и текущий, у которого имя длиной в абзац."""
    base = REPO / STATE_DIR
    slugs = sorted(d.name for d in base.iterdir() if (d / "state.json").is_file())
    assert slugs, "в каталоге состояния нет ни одного прогона — проверять нечего"
    for slug in slugs:
        run = Run(REPO, slug)
        title = run.state["title"]
        assert len(title) <= TITLE_LIMIT, f"{slug}: заголовок длиной {len(title)} знаков"
        assert "…" not in title, f"{slug}: заголовок с многоточием — {title!r}"
        assert run.brief(), f"{slug}: бриф пуст"
        assert len(run.brief()) >= len(title), f"{slug}: бриф короче заголовка"


# --- 3. панель --------------------------------------------------------------


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


@pytest.fixture
def panel(repo: Path):
    ceh(repo, "init", BRIEF, "--no-dash")
    run = Run.load(repo)
    dash.build_index(dash.runs_root(run))
    p = Panel(repo / STATE_DIR)
    try:
        yield p, run
    finally:
        p.close()


def test_panel_gives_the_title_letter_for_letter(panel):
    """R20.2: заголовок на панели совпадает с состоянием знак в знак."""
    p, run = panel
    code, data = p.get("/api/home")
    assert code == 200, data
    card = next(r for r in data["runs"] if r["slug"] == run.slug)
    assert card["title"] == run.state["title"] == NAME

    code, brief = p.get(f"/{run.slug}/brief.md")
    assert code == 200 and brief.strip() == BRIEF, "бриф с панели не отдаётся целиком"


def test_markup_has_no_truncation_of_the_run_title():
    """R20.2 по разметке: заголовок карточки рисуется как есть, без обрезки."""
    html = INDEX.read_text(encoding="utf-8")
    card = re.search(r"function runCard\(r\)\{(.*?)\n\}", html, re.S)
    assert card, "не нашёл рисовалку карточки прогона"
    body = card.group(1)
    assert "<h2>${esc(r.title)}</h2>" in body, "заголовок карточки идёт не из состояния как есть"
    for line in [ln for ln in html.splitlines() if "r.title" in ln or "s.title" in ln]:
        assert "…" not in line and ".slice(" not in line and ".substr" not in line, \
            f"заголовок на главной обрезают: {line.strip()!r}"
    assert "shortTitle" not in html, "на главной осталась рубилка заголовков"


def draw(html: str, payload: dict, briefs: dict) -> str:
    """Карточки прогонов — настоящим движком JS, с брифами как их отдаёт панель."""
    block = re.search(r"<script>\s*(const SLUGS.*?)</script>", html, re.S)
    assert block, "не нашёл главный скрипт страницы"
    pure = block.group(1)
    program = pure[: pure.index("async function load(")] + """
const data = JSON.parse(require("fs").readFileSync(0, "utf8"));
Object.assign(BRIEFS, data.briefs);
process.stdout.write(data.runs.map(runCard).join(""));
"""
    proc = subprocess.run(
        ["node", "-e", program],
        input=json.dumps({**payload, "briefs": briefs}),
        capture_output=True, text=True, timeout=60,
    )
    assert proc.returncode == 0, proc.stderr
    return proc.stdout


@pytest.mark.skipif(shutil.which("node") is None, reason="нет node — движок JS не поднять")
def test_card_shows_the_whole_title_and_the_brief_under_it(panel):
    """R20.2 и R20.3: заголовок целиком, бриф — отдельной строкой под ним."""
    p, run = panel
    _, data = p.get("/api/home")
    index = (run.root / STATE_DIR / "index.html").read_text(encoding="utf-8")
    cards = draw(index, data, {run.slug: BRIEF})

    assert f"<h2>{NAME}</h2>" in cards, f"заголовка целиком в карточке нет: {cards!r}"
    assert "…" not in cards, "панель всё-таки режет заголовок многоточием"
    brief_line = re.search(r'<p class="brief">(.*?)</p>', cards, re.S)
    assert brief_line, "бриф из карточки пропал — осталось одно короткое имя"
    assert brief_line.group(1).strip() == BRIEF, "бриф в карточке урезан"
    assert cards.index(f"<h2>{NAME}</h2>") < brief_line.start(), "бриф стоит выше заголовка"
    assert cards.count(BRIEF) == 1, "тот же текст в карточке дважды — ровно то, что убирали"
