"""Манифест требований: читаемая проекция состояния.

Тесты закрывают приёмку историй спеки: R03.2 (детерминизм и порядок по идентификатору),
R04.1 (состав строки), R04.2 (снятое владельцем требование вместе с основанием),
R04.3 (черта и перенос строки не ломают таблицу), R05.1 (файл не протухает, включая
приёмку уровня CLI), R05.2 (ручная правка восстанавливается из состояния).
"""

from __future__ import annotations

import os
import random
import re
import subprocess
import sys
from pathlib import Path

import pytest

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

from chekan import manifest  # noqa: E402
from chekan.store import REQ_STATUSES, Run, new_state  # noqa: E402



# Контракт таблицы: ID | Статус | Из брифа | Основание | Таски.
# Держим числа здесь, чтобы смена состава колонок правилась в одном месте.
COLUMNS = 5
QUOTE = 2
BASIS = 3
TASKS = 4

@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)
    return tmp_path


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


def split_row(line: str) -> list[str]:
    """Разрезать строку таблицы так, как это делает рендерер GFM, и снять экранирование.

    Алгоритм выведен из поведения cmark-gfm (ext/table.c): разбор идёт посимвольно,
    обратный слэш забирает СЛЕДУЮЩИЙ символ в текст ячейки, любая другая черта —
    граница. Из этого следует правило чётности: `\\|` черту экранирует, а `\\\\|` уже
    нет — слэш съел слэш, и черта осталась голой.

    Написано именно проходом, а не регуляркой вида `(?<!\\)\\|`: такая регулярка —
    то же правило, что и в рендере, и тест на ней утверждал бы, что код равен сам
    себе. Ожидаемое значение должно приходить со стороны, а не считаться проверяемым
    способом. Парсер markdown из PyPI не берём: у проекта нет зависимостей, а сюита
    обязана гоняться одной командой pytest.
    """
    cells: list[str] = [""]
    i = 0
    while i < len(line):
        ch = line[i]
        if ch == "\\" and i + 1 < len(line):
            cells[-1] += line[i + 1]
            i += 2
        elif ch == "|":
            cells.append("")
            i += 1
        else:
            cells[-1] += ch
            i += 1
    return cells


def rows(text: str) -> list[list[str]]:
    """Строки таблицы с уже снятым экранированием. Первая — шапка."""
    out = []
    for line in text.splitlines():
        line = line.strip()
        if not line.startswith("|"):
            continue
        edges = split_row(line)
        assert edges[0] == "" and edges[-1] == "", f"строка не обрамлена чертами: {line!r}"
        cells = [c.strip() for c in edges[1:-1]]
        if cells and all(c and set(c) <= {"-", ":"} for c in cells):
            continue
        out.append(cells)
    return out


def row_of(text: str, rid: str) -> list[str]:
    found = [r for r in rows(text)[1:] if r[0] == rid]
    assert len(found) == 1, f"требование {rid} должно занимать ровно одну строку: {found}"
    return found[0]


def dashboard_ru() -> dict[str, str]:
    """Словарь статусов из панели — источник согласованных формулировок."""
    src = (Path(__file__).resolve().parent.parent / "assets" / "dashboard.html").read_text(
        encoding="utf-8"
    )
    block = re.search(r"REQ_RU\s*=\s*\{(.+?)\}", src, re.S).group(1)
    return dict(re.findall(r'"?([a-z-]+)"?\s*:\s*"([^"]+)"', block))


# --- детерминизм ---


def test_two_calls_give_identical_text(run: Run, repo: Path):
    run.add_requirement("R01", "делай раз", note="из брифа")
    run.add_requirement("R02", "делай два")
    assert manifest.render(run.state) == manifest.render(run.state)
    assert manifest.render(Run(repo, run.slug).state) == manifest.render(run.state)


def test_row_order_follows_id_not_insertion(run: Run):
    run.add_requirement("R03", "третье")
    run.add_requirement("R01", "первое")
    run.add_requirement("R02", "второе")
    assert [r[0] for r in rows(manifest.render(run.state))[1:]] == ["R01", "R02", "R03"]


def test_permutation_of_requirements_does_not_change_text(run: Run):
    for i in range(1, 6):
        run.add_requirement(f"R0{i}", f"цитата {i}")
    before = manifest.render(run.state)

    st = run.state
    random.Random(7).shuffle(st["requirements"])
    run.save(st)
    assert manifest.render(run.state) == before


def test_render_touches_no_files(run: Run):
    run.add_requirement("R01", "цитата")
    before = sorted(str(p) for p in run.dir.rglob("*"))
    manifest.render(run.state)
    assert sorted(str(p) for p in run.dir.rglob("*")) == before


# --- содержание ---


def test_header_names_the_run_and_slug(run: Run):
    text = manifest.render(run.state)
    assert run.state["title"] in text
    assert run.slug in text


def test_short_title_returns_one_line_name():
    assert manifest.short_title("") == "Прогон"
    assert manifest.short_title(None) == "Прогон"
    assert manifest.short_title("Бот отвечает на /start") == "Бот отвечает на /start"
    assert manifest.short_title("  Бот  \n  отвечает   ") == "Бот отвечает"


def test_long_brief_gives_one_line_heading_and_keeps_full_text(run: Run):
    """В `title` лежит короткое имя, в `brief` — весь бриф."""
    brief = (
        "Требования должны быть видны в репозитории как читаемый markdown-файл "
        "manifest.md, а не только внутри state.json.\nФайл генерируется CLI и "
        "коммитится вместе с прогоном, чтобы историю решений можно было читать "
        "и ревьюить глазами, без json-инструментов."
    )
    st = run.state
    st["title"] = "Читаемый manifest.md"
    run.save(st)
    (run.dir / "brief.md").write_text(brief, encoding="utf-8")

    text = manifest.render(run.state)
    headings = [line for line in text.splitlines() if line.startswith("# ")]
    assert len(headings) == 1
    assert headings[0] == "# Требования — Читаемый manifest.md"


def test_row_shows_id_status_quote_and_tasks(run: Run):
    run.add_requirement("R01", "бот отвечает на /start")
    run.add_task("01", "Ответ на /start", ["R01"])
    row = row_of(manifest.render(run.state), "R01")
    assert len(row) == COLUMNS
    assert row[1] == dashboard_ru()["in-task"]
    assert row[2] == "бот отвечает на /start"
    assert "01" in row[TASKS]


def test_status_wording_matches_dashboard(run: Run):
    ru = dashboard_ru()
    assert set(ru) == set(REQ_STATUSES), "панель и состояние разошлись по набору статусов"
    for i, status in enumerate(REQ_STATUSES):
        rid = f"R{i:02d}"
        run.add_requirement(rid, f"цитата {rid}", note="основание")
        run.set_requirement_status(rid, status, by_user=(status == "dropped"))

    text = manifest.render(run.state)
    for i, status in enumerate(REQ_STATUSES):
        assert ru[status] in row_of(text, f"R{i:02d}")[1]


def test_summary_counts_requirements_by_status(run: Run):
    ru = dashboard_ru()
    run.add_requirement("R01", "раз")
    run.add_requirement("R02", "два")
    run.add_requirement("R03", "три")
    run.set_requirement_status("R03", "done")

    tail = manifest.render(run.state).split("## Итог")[1]
    assert "3" in tail
    assert f"{ru['open']} 2" in tail
    assert f"{ru['done']} 1" in tail


# --- снятое владельцем ---


def test_dropped_shows_owner_mark_and_basis(run: Run):
    run.add_requirement("R01", "нужен экспорт в PDF")
    st = run.state
    next(r for r in st["requirements"] if r["id"] == "R01")["note"] = (
        "снято владельцем: печать в этом релизе не нужна"
    )
    run.save(st)
    run.set_requirement_status("R01", "dropped", by_user=True)

    text = manifest.render(run.state)
    assert row_of(text, "R01")[1] == "снято владельцем"
    assert "печать в этом релизе не нужна" in text


def test_basis_with_pipe_does_not_break_the_file(run: Run):
    """`chekan req drop` кладёт в заметку строку с чертой — она не должна ломать разметку."""
    run.add_requirement("R01", "нужен экспорт")
    st = run.state
    next(r for r in st["requirements"] if r["id"] == "R01")["note"] = (
        "исходная заметка | снято владельцем: передумали"
    )
    run.save(st)
    run.set_requirement_status("R01", "dropped", by_user=True)

    text = manifest.render(run.state)
    assert len(row_of(text, "R01")) == COLUMNS
    assert "передумали" in text


# --- экранирование ---


def test_split_row_follows_gfm_escaping_rule():
    """Контроль самого инструмента: если сплиттер врёт, врут и все тесты на нём."""
    assert split_row("|a|b|") == ["", "a", "b", ""]
    assert split_row(r"|a\|b|") == ["", "a|b", ""]
    assert split_row(r"|a\\|b|") == ["", "a\\", "b", ""]
    assert split_row(r"|a\\\|b|") == ["", r"a\|b", ""]


@pytest.mark.parametrize(
    "quote",
    [
        "выбор: да | нет",
        # Ровно то, что владелец передал в `chekan req add`: слэш стоит перед чертой.
        r"регулярка \d+\|\d+ в конфиге",
        r"регулярка \d+|\d+ в конфиге",
        r"экранирование \\ и черта |",
        "путь C:\\",
        r"хвост \\|",
    ],
)
def test_backslash_and_pipe_survive_the_table(run: Run, quote: str):
    run.add_requirement("R01", quote)
    run.add_requirement("R02", "соседнее требование")
    text = manifest.render(run.state)

    row = row_of(text, "R01")
    assert len(row) == COLUMNS, "ячейки разъехались"
    assert row[2] == quote
    assert row_of(text, "R02")[2] == "соседнее требование"


def test_pipe_in_requirement_id_keeps_the_row_intact(run: Run):
    run.add_requirement("R|07", "цитата")
    row = row_of(manifest.render(run.state), "R|07")
    assert len(row) == COLUMNS
    assert row[2] == "цитата"


def test_newline_in_quote_collapses_to_space(run: Run):
    run.add_requirement("R01", "первая строка\nвторая строка\r\nтретья")
    row = row_of(manifest.render(run.state), "R01")
    assert len(row) == COLUMNS
    assert row[2] == "первая строка вторая строка третья"


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


def test_empty_requirements_render_without_error(run: Run):
    text = manifest.render(run.state)
    assert text.endswith("\n")
    assert rows(text) == []
    assert run.slug in text


def test_render_works_on_bare_state_without_disk():
    text = manifest.render(new_state("proba", "Пробный прогон", "normal", "T1"))
    assert "Пробный прогон" in text
    assert "proba" in text


# --- файл на диске не протухает ---


def written(run: Run) -> str:
    path = run.dir / "manifest.md"
    assert path.is_file(), "манифест должен лежать рядом с состоянием"
    return path.read_text(encoding="utf-8")


def test_file_appears_after_adding_requirement(run: Run):
    run.add_requirement("R01", "бот отвечает на /start")
    assert row_of(written(run), "R01")[2] == "бот отвечает на /start"


def test_status_change_lands_on_disk_without_extra_command(run: Run):
    run.add_requirement("R01", "нужен экспорт")
    run.set_requirement_status("R01", "done")
    assert row_of(written(run), "R01")[1] == dashboard_ru()["done"]


def test_hand_edit_is_restored_on_next_save(run: Run):
    run.add_requirement("R01", "цитата из брифа")
    from_state = written(run)
    (run.dir / "manifest.md").write_text("тут человек всё переписал\n", encoding="utf-8")
    run.save(run.state)
    assert written(run) == from_state


def test_state_is_saved_even_if_manifest_write_fails(run: Run, repo: Path, monkeypatch):
    def boom(state: dict) -> str:
        raise OSError("на диске нет места")

    monkeypatch.setattr(manifest, "render", boom)
    run.add_requirement("R01", "цитата")
    assert [r["id"] for r in Run(repo, run.slug).state["requirements"]] == ["R01"]


# --- приёмка уровня CLI ---


def ceh(repo: Path, *args: str) -> None:
    """CLI так же, как его зовёт шим установщика: путь к пакету в sys.path."""
    package_root = str(Path(__file__).resolve().parent.parent)
    subprocess.run(
        [sys.executable, "-m", "chekan", *args],
        cwd=repo,
        env={**os.environ, "PYTHONPATH": package_root},
        capture_output=True,
        text=True,
        check=True,
    )


def test_cli_req_commands_update_the_file_on_disk(repo: Path):
    ceh(repo, "init", "Прогон из CLI", "--no-dash")
    slug = (repo / ".chekan" / "active").read_text(encoding="utf-8").strip()
    path = repo / ".chekan" / slug / "manifest.md"

    ceh(repo, "req", "add", "R01", "--quote", "нужен экспорт в PDF")
    assert row_of(path.read_text(encoding="utf-8"), "R01")[2] == "нужен экспорт в PDF"

    ceh(repo, "req", "status", "R01", "--value", "in-spec")
    assert row_of(path.read_text(encoding="utf-8"), "R01")[1] == dashboard_ru()["in-spec"]

    ceh(repo, "req", "drop", "R01", "--quote", "печать в этом релизе не нужна")
    text = path.read_text(encoding="utf-8")
    assert row_of(text, "R01")[1] == "снято владельцем"
    assert "печать в этом релизе не нужна" in text


# --- сбой записи слышен ---


def _break_render(monkeypatch) -> None:
    def boom(state: dict) -> str:
        raise OSError("на диске нет места")

    monkeypatch.setattr(manifest, "render", boom)


def test_failed_write_warns_to_stderr_with_path_and_reason(run: Run, monkeypatch, capsys):
    _break_render(monkeypatch)
    run.add_requirement("R01", "цитата")

    err = capsys.readouterr().err
    assert str(run.dir / "manifest.md") in err, "по предупреждению должно быть видно, какой файл отстал"
    assert "на диске нет места" in err
    assert len(err.strip().splitlines()) == 1, f"одна строка без трассировки, а не {err!r}"


def test_state_is_saved_while_the_warning_is_printed(run: Run, repo: Path, monkeypatch, capsys):
    _break_render(monkeypatch)
    run.add_requirement("R01", "цитата")

    assert capsys.readouterr().err.strip(), "сбой проекции обязан быть слышен"
    assert [r["id"] for r in Run(repo, run.slug).state["requirements"]] == ["R01"]


def test_repeated_failures_do_not_repeat_the_warning(run: Run, monkeypatch, capsys):
    _break_render(monkeypatch)
    run.add_requirement("R01", "раз")
    assert capsys.readouterr().err.strip(), "первый сбой обязан быть слышен"

    run.add_requirement("R02", "два")
    run.set_requirement_status("R02", "in-spec")
    run.save(run.state)
    assert capsys.readouterr().err == "", "одна и та же поломка не должна печататься на каждое сохранение"


def test_warning_comes_back_after_a_successful_write(run: Run, monkeypatch, capsys):
    _break_render(monkeypatch)
    run.add_requirement("R01", "раз")
    capsys.readouterr()

    monkeypatch.undo()
    run.add_requirement("R02", "два")
    assert capsys.readouterr().err == "", "успешная запись молчит"

    _break_render(monkeypatch)
    run.add_requirement("R03", "три")
    assert str(run.dir / "manifest.md") in capsys.readouterr().err, "новый сбой — новое предупреждение"
