"""Инварианты ядра.

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

from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

import pytest

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

from chekan import gates, proof  # noqa: E402
from chekan.secrets import scan_text  # noqa: E402
from chekan.store import Run, StateError, slugify, validate  # noqa: E402


@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


@pytest.fixture
def run(repo: Path) -> Run:
    return Run.create(repo, "Тестовый прогон", "normal", "T1")


def commit_all(repo: Path, message: str) -> None:
    subprocess.run(["git", "add", "-A"], cwd=repo, check=True)
    subprocess.run(["git", "commit", "-qm", message], cwd=repo, check=True)



def _with_task(run: Run, tid: str = "01") -> Run:
    """Доказательство теперь обязано быть привязано к существующему таску."""
    if run.requirement("R01") is None:
        run.add_requirement("R01", "цитата", note="причина")
    if run.task(tid) is None:
        run.add_task(tid, "Таск для доказательств", ["R01"])
    return run


# --- доказательства ---


def test_task_cannot_close_without_evidence(run: Run):
    run.add_requirement("R01", "делай раз")
    run.add_task("01", "Раз", ["R01"], verify="true")
    ok, messages = proof.close_task(run, "01")
    assert not ok
    assert "доказательств нет" in " ".join(messages)
    assert run.task("01")["status"] == "pending"


def test_task_cannot_close_on_red_evidence(run: Run):
    run.add_requirement("R01", "делай раз")
    run.add_task("01", "Раз", ["R01"], verify="false")
    proof.run_command(run, "exit 7", task="01")
    ok, messages = proof.close_task(run, "01")
    assert not ok
    assert "код 7" in " ".join(messages)


def test_task_closes_on_green_evidence(run: Run):
    run.add_requirement("R01", "делай раз")
    run.add_task("01", "Раз", ["R01"], verify="true")
    proof.run_command(run, "true", task="01")
    ok, _ = proof.close_task(run, "01")
    assert ok
    assert run.task("01")["status"] == "done"
    assert run.requirement("R01")["status"] == "done"


def test_evidence_from_older_commit_is_rejected(run: Run, repo: Path):
    run.add_requirement("R01", "делай раз")
    run.add_task("01", "Раз", ["R01"], verify="true")
    proof.run_command(run, "true", task="01")
    (repo / "app.py").write_text("print('изменено после проверки')\n", encoding="utf-8")
    commit_all(repo, "правка после доказательства")
    ok, messages = proof.close_task(run, "01")
    assert not ok
    assert "прогоните заново" in " ".join(messages)


def test_evidence_ledger_is_append_only(run: Run):
    _with_task(run)
    proof.run_command(run, "true", task="01")
    proof.run_command(run, "false", task="01")
    rows = run.evidence("01")
    assert len(rows) == 2
    assert [r["exit"] for r in rows] == [0, 1]


def test_evidence_never_loses_history_on_reload(run: Run, repo: Path):
    _with_task(run)
    proof.run_command(run, "true", task="01")
    again = Run(repo, run.slug)
    assert len(again.evidence("01")) == 1


# --- гейты ---


def test_g1_requires_reason_for_open(run: Run):
    run.add_requirement("R01", "цитата", note="")
    assert any("без записанной причины" in p for p in gates.g1(run))
    run.add_requirement("R02", "цитата два", note="причина есть")
    run.set_requirement_status("R01", "in-spec")
    assert not [p for p in gates.g1(run) if "R01" in p]


def test_g1_requires_verbatim_quote(run: Run):
    run.add_requirement("R01", "", note="есть причина")
    assert any("нет дословной цитаты" in p for p in gates.g1(run))


def test_g3_traceability_both_ways(run: Run):
    run.add_requirement("R01", "покрыто")
    run.add_requirement("R02", "не покрыто")
    run.add_task("01", "Раз", ["R01"])
    problems = gates.g3(run)
    assert any("R02 не покрыто" in p for p in problems)
    assert not any("R01" in p for p in problems)


def test_g3_catches_zone_collision(run: Run):
    run.add_requirement("R01", "раз")
    run.add_requirement("R02", "два")
    run.add_task("01", "Раз", ["R01"], zone="src/bot/")
    run.add_task("02", "Два", ["R02"], zone="src/bot/")
    assert any("столкнутся" in p for p in gates.g3(run))


def test_g3_allows_same_zone_when_sequenced(run: Run):
    run.add_requirement("R01", "раз")
    run.add_requirement("R02", "два")
    run.add_task("01", "Раз", ["R01"], zone="src/bot/")
    run.add_task("02", "Два", ["R02"], deps=["01"], zone="src/bot/")
    assert not any("столкнутся" in p for p in gates.g3(run))


def test_g4_rejects_done_task_without_proof(run: Run):
    run.add_requirement("R01", "раз")
    run.add_task("01", "Раз", ["R01"])
    run.set_task_status("01", "done")
    assert any("без зелёного доказательства" in p for p in gates.g4(run))


def test_g4_rejects_acceptance_that_ran_nothing(run: Run):
    st = run.state
    st["gates"] = {"G4": {"acceptance": {"profile": "generic", "cmd": None, "exit": None}}}
    run.save(st)
    assert any("ничего не прогнала" in p for p in gates.g4(run))


def test_build_invariant_finds_unproven_done(run: Run):
    run.add_requirement("R01", "раз")
    run.add_task("01", "Раз", ["R01"])
    run.set_task_status("01", "done")
    assert gates.build_invariant(run)


# --- состояние ---


def test_dropped_requires_owner(run: Run):
    run.add_requirement("R01", "цитата")
    with pytest.raises(StateError, match="только владелец"):
        run.set_requirement_status("R01", "dropped")
    run.set_requirement_status("R01", "dropped", by_user=True)
    assert run.requirement("R01")["status"] == "dropped"


def test_duplicate_requirement_rejected(run: Run):
    run.add_requirement("R01", "цитата")
    with pytest.raises(StateError, match="уже есть"):
        run.add_requirement("R01", "другая цитата")


def test_task_cannot_reference_unknown_requirement(run: Run):
    with pytest.raises(StateError, match="несуществующее требование"):
        run.add_task("01", "Раз", ["R99"])


def test_dependency_cycle_rejected(run: Run):
    run.add_requirement("R01", "раз")
    run.add_task("01", "Раз", ["R01"])
    run.add_task("02", "Два", ["R01"], deps=["01"])
    st = run.state
    next(t for t in st["tasks"] if t["id"] == "01")["deps"] = ["02"]
    with pytest.raises(StateError, match="[Цц]икл"):
        run.save(st)


def test_corrupt_state_is_caught_on_read(run: Run, repo: Path):
    run.state_path.write_text('{"version":1,"slug"', encoding="utf-8")
    with pytest.raises(StateError, match="битый JSON"):
        Run(repo, run.slug).state


def test_invalid_status_never_reaches_disk(run: Run):
    st = run.state
    st["phase"] = "выдуманная-фаза"
    with pytest.raises(StateError):
        run.save(st)
    assert json.loads(run.state_path.read_text(encoding="utf-8"))["phase"] == "preflight"


def test_ready_tasks_respect_dependencies(run: Run):
    run.add_requirement("R01", "раз")
    run.add_task("01", "Раз", ["R01"])
    run.add_task("02", "Два", ["R01"], deps=["01"])
    assert [t["id"] for t in run.ready_tasks()] == ["01"]
    proof.run_command(run, "true", task="01")
    proof.close_task(run, "01")
    assert [t["id"] for t in run.ready_tasks()] == ["02"]


def test_slugify_handles_cyrillic():
    assert slugify("Бот отвечает на /start") == "bot-otvechaet-na-start"
    assert slugify("!!!") == "run"


def test_validate_rejects_wrong_version():
    with pytest.raises(StateError, match="version"):
        validate({"version": 99})


# --- секреты ---


@pytest.mark.parametrize(
    "line,expected",
    [
        ("BOT_TOKEN=8123456789:AAHrK3mQx7ZpLv2NfWd9Ye4TgBcVhJsRkPq", True),
        ('key = "sk-proj-abcdefghijklmnopqrstuvwxyz1234567890"', True),
        ("url=postgres://admin:hunter2secret@db.local:5432/app", True),
        ("AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE", True),
        ('api_key = "${MY_KEY_FROM_ENV}"', False),
        ("TOKEN=<вставьте сюда>", False),
        ("это обычная строка без всякого секрета", False),
    ],
)
def test_secret_detection(line: str, expected: bool):
    assert bool(scan_text(line)) is expected


def test_secret_value_is_never_printed_in_full():
    secret = "sk-proj-abcdefghijklmnopqrstuvwxyz1234567890"
    findings = scan_text(f'key = "{secret}"')
    assert findings
    assert secret not in str(findings[0])


# --- синхронизация: машинно-локальные правила ---


def test_local_block_survives_sync():
    from chekan.sync import merge_local

    canon = "# Канон\n\nОбщее правило.\n"
    on_machine = (
        "# Канон\n\nСтарое общее.\n\n"
        "<!-- chekan:local -->\n## Эта машина\nПорт 8787, прокси 127.0.0.1:2081\n"
        "<!-- /chekan:local -->\n"
    )
    merged, had_local = merge_local(canon, on_machine)
    assert had_local
    assert "Общее правило." in merged
    assert "прокси 127.0.0.1:2081" in merged
    assert "Старое общее." not in merged


def test_unmarked_machine_rules_block_the_sync():
    from chekan.sync import unmarked_divergence

    canon = "# Канон\n\nОбщее правило.\n"
    on_machine = (
        "# Канон\n\nОбщее правило.\n\n"
        "НЕ подключаться по SSH к самой машине.\n"
        "Прокси: 127.0.0.1:2081\n"
        "Пути: /Users/cebers_mini/Services\n"
        "Порт дашборда 8787\n"
    )
    assert unmarked_divergence(canon, on_machine)
    assert not unmarked_divergence(canon, canon)


def test_local_block_replaces_placeholder_in_canon():
    from chekan.sync import merge_local

    canon = "# Канон\n<!-- chekan:local -->\n<!-- /chekan:local -->\nХвост.\n"
    on_machine = "<!-- chekan:local -->\nМоё\n<!-- /chekan:local -->\n"
    merged, had_local = merge_local(canon, on_machine)
    assert had_local
    assert "Моё" in merged and "Хвост." in merged


def test_evidence_keeps_summary_when_stderr_is_noisy(run: Run):
    """Многословный stderr не должен вытеснять итоговую строку прогона.

    Под псевдотерминалом потоки сливаются — ровно как их видит человек в
    терминале, — поэтому оба следа обязаны оказаться в одном хвосте.
    """
    _with_task(run)
    noisy = (
        "python3 -c \"import sys;"
        "[sys.stderr.write('предупреждение %d\\n' % i) for i in range(50)];"
        "sys.stdout.write('337 passed\\n')\""
    )
    row = proof.run_command(run, noisy, task="01")
    assert row["exit"] == 0
    assert "337 passed" in row["out_tail"]


def test_evidence_captures_tty_only_summary(run: Run):
    """Инструменты, печатающие итог только в терминал, обязаны попадать в журнал."""
    _with_task(run)
    cmd = (
        "python3 -c \"import sys; "
        "sys.stdout.write('в терминал\\n' if sys.stdout.isatty() else 'в канал\\n')\""
    )
    row = proof.run_command(run, cmd, task="01")
    assert row["exit"] == 0
    assert "в терминал" in row["out_tail"]


def test_evidence_strips_ansi_colour_codes(run: Run):
    """Под псевдотерминалом инструменты красят вывод — в журнале это мусор."""
    _with_task(run)
    cmd = "python3 -c \"print('\\033[32m337 passed\\033[0m')\""
    row = proof.run_command(run, cmd, task="01")
    assert "337 passed" in row["out_tail"]
    assert "\x1b[" not in row["out_tail"]


def test_acceptance_prefers_project_venv(tmp_path: Path):
    """Системный pytest в проекте со своим venv падает и выглядит как красная приёмка."""
    from chekan.accept import resolve_command

    (tmp_path / "venv" / "bin").mkdir(parents=True)
    (tmp_path / "venv" / "bin" / "pytest").write_text("#!/bin/sh\n")
    assert resolve_command("python3 -m pytest tests/", tmp_path) == "venv/bin/pytest tests/"
    assert resolve_command("npm run build", tmp_path) == "npm run build"


def test_g4_requires_live_scenario_when_profile_names_a_skill(run: Run):
    """Механические тесты не доказывают, что сервис отвечает пользователю.

    Гейт проверяет, что сверх механических проверок реально что-то прогонялось и
    попало в журнал. Подлинность живого сценария он подтвердить не может — этого
    не может ни один гейт; он закрывает разрыв «инструкция напечатана, но никто
    её не выполнил», а не заменяет добросовестность.
    """
    st = run.state
    st["gates"] = {"G4": {
        "acceptance": {"profile": "vakhunter", "skill": "vakhunter-e2e",
                       "cmd": "venv/bin/pytest", "exit": 0,
                       "checks": [{"cmd": "venv/bin/pytest", "exit": 0}]},
        "blind": {"done": ["работает"], "missing": []},
    }}
    run.save(st)
    proof.run_command(run, "venv/bin/pytest", kind="accept")
    assert any("живой сценарий не подтверждён" in p for p in gates.g4(run))

    proof.run_command(run, "echo прогон проверяльщика", kind="accept")
    assert not any("живой сценарий не подтверждён" in p for p in gates.g4(run))


def test_gates_of_skipped_phases_are_not_required(run: Run):
    """На T1 фазы спецификации нет — значит и её гейт к прогону неприменим."""
    from chekan import phases as ph

    assert run.state["tier"] == "T1"
    assert "G2" not in ph.applicable_gates(run)
    assert {"G1", "G3", "G4"} <= set(ph.applicable_gates(run))

    st = run.state
    st["tier"] = "T2"
    run.save(st)
    assert "G2" in ph.applicable_gates(run)


def test_auto_mode_skips_briefing_visibly(run: Run):
    """«Вопросов не задавали» — решение режима, а не забытый шаг."""
    from chekan import phases as ph

    st = run.state
    st["mode"] = "auto"
    run.save(st)
    run.add_requirement("R01", "цитата", note="причина")
    ph.advance(run)          # preflight -> manifest
    nxt = ph.advance(run)    # manifest -> (briefing пропущен) -> plan
    assert run.state["stages"]["briefing"]["status"] == "skipped"
    assert run.state["stages"]["briefing"]["reason"]
    assert nxt == "plan"


def test_never_two_active_phases(run: Run):
    """Две «идущие» фазы разом — признак того, что переход обошёл закрытие."""
    from chekan import phases as ph

    st = run.state
    st["mode"] = "auto"
    run.save(st)
    run.add_requirement("R01", "цитата", note="причина")
    for _ in range(4):
        ph.advance(run)
        active = [n for n, v in run.state["stages"].items() if v["status"] == "active"]
        assert len(active) <= 1, f"активны сразу несколько фаз: {active}"


def test_zone_conflict_respects_transitive_order(run: Run):
    """04→02→01 уже выстроены в очередь: тревога здесь была бы ложной."""
    run.add_requirement("R01", "раз")
    run.add_task("01", "Раз", ["R01"], zone="ceh/")
    run.add_task("02", "Два", ["R01"], deps=["01"], zone="ceh/")
    run.add_task("04", "Четыре", ["R01"], deps=["02"], zone="ceh/")
    assert not [p for p in gates.g3(run) if "столкнутся" in p]

    run.add_task("05", "Пять", ["R01"], zone="ceh/")
    assert [p for p in gates.g3(run) if "столкнутся" in p]


def test_g4_counts_the_users_own_acceptance_run(run: Run):
    """Профиль без своих команд не должен запирать прогон в круг из трёх команд."""
    st = run.state
    st["gates"] = {"G4": {
        "acceptance": {"profile": "generic", "skill": None, "cmd": None, "exit": None, "checks": []},
        "blind": {"done": ["работает"], "missing": []},
    }}
    run.save(st)
    assert any("ничего не прогнала" in p for p in gates.g4(run))

    proof.run_command(run, "true", kind="accept")
    assert not [p for p in gates.g4(run) if "ничего не прогнала" in p]


def test_scale_is_spoken_in_words_not_codes(run: Run):
    """«Ярус T2» не говорит владельцу ничего — масштаб называется словами."""
    from chekan import phases as ph

    assert ph.SCALE_RU["T2"] == "средняя"
    assert ph.SCALE_CODE["крупная"] == "T3"
    assert "средняя" in ph.scale_label("T2")

    st = run.state
    st["tier"] = "T2"
    run.save(st)
    rendered = ph.render(run)
    assert "Масштаб: средняя" in rendered
    assert "Ярус" not in rendered


def test_who_did_the_task_is_recorded(run: Run):
    """Отчёт о том, кто делал работу, обязан быть фактом, а не догадкой."""
    from chekan.store import harness

    run.add_requirement("R01", "цитата", note="причина")
    run.add_task("01", "Задача", ["R01"])
    run.set_task_model("01", "claude-opus-5")
    run.set_task_status("01", "in-progress")

    task = run.task("01")
    assert task["model"] == "claude-opus-5"
    assert task["agent"] == harness()  # харнесс берётся из окружения, а не выдумывается


def test_board_renders_without_ansi_when_piped(run: Run):
    """Escape-коды в канале — мусор, который потом мешает грепать журнал."""
    from chekan.board import render

    run.add_requirement("R01", "цитата", note="причина")
    run.add_task("01", "Задача", ["R01"])
    text = render(run)
    assert "\033[" not in text
    assert "ЗАДАЧИ" in text and "ФАЗЫ" in text and "ГЕЙТЫ" in text


def test_dashboard_tables_scroll_inside_themselves():
    """На телефоне таблица иначе вытаскивала вбок всю страницу."""
    page = (Path(__file__).resolve().parent.parent / "assets" / "dashboard.html").read_text(
        encoding="utf-8"
    )
    assert page.count('<div class="scroll">') == 3
    assert page.count("</table></div>") == 3
    assert "overflow-x:auto" in page


def test_dashboard_carries_a_short_how_to():
    """Главная страница обязана объяснять, как этим пользоваться."""
    page = (Path(__file__).resolve().parent.parent / "assets" / "dashboard.html").read_text(
        encoding="utf-8"
    )
    assert "Как пользоваться Чеканом" in page
    for cmd in ("chekan init", "chekan next", "chekan run", "chekan task done", "chekan handoff"):
        assert cmd in page, cmd


def test_index_lists_every_run(repo: Path):
    """С панели одного прогона должно быть куда вернуться."""
    from chekan import dash

    first = Run.create(repo, "Первый прогон", "auto", "T0")
    Run.create(repo, "Второй прогон", "auto", "T1")
    dash.build_page(first)

    index = (repo / ".chekan" / "index.html").read_text(encoding="utf-8")
    assert "pervyy-progon" in index and "vtoroy-progon" in index
    assert "__SLUGS__" not in index and "__FORGE_MARKUP__" not in index

    page = (first.dir / "dashboard.html").read_text(encoding="utf-8")
    assert '../index.html' in page, "с панели некуда вернуться к списку"


def test_forge_animation_is_inlined_and_stateful():
    """Анимация кузницы — брендовый мотив; страница обязана работать одним файлом."""
    from chekan import dash

    for name in ("dashboard.html", "index.html"):
        page = dash._inline((Path(__file__).resolve().parent.parent
                             / "assets" / name).read_text(encoding="utf-8"))
        assert "canvas.forge" in page and '<canvas class="forge"' in page, name
        assert 'dataset.forge === "forging"' in page, name
        assert 'STILL = { forging:' in page, "у остановленной анимации нет кадра"
        assert "prefers-reduced-motion" in page, "анимация обязана отключаться по просьбе системы"
        # Константы канона (CehoIsland/ForgeAnimation.swift) — не «на глаз».
        assert "CYCLE = 0.82" in page and "IX = 0.505" in page and "IY = 0.455" in page, name
        assert "1000 / 30" in page and "1000 / 12" in page, "частота кадров как в каноне"
        assert "devicePixelRatio" in page, "на ретине холст обязан быть в пикселях устройства"
        assert "__FORGE" not in page, name
        assert "http://" not in page.split("<script>")[-1], "внешних запросов быть не должно"


def test_dashboard_does_not_show_the_slug():
    """Слаг есть в адресе и ничего не объясняет — в подзаголовке ему не место."""
    page = (Path(__file__).resolve().parent.parent / "assets" / "dashboard.html").read_text(
        encoding="utf-8"
    )
    assert "st.slug" not in page
