"""Учёт расхода по задачам (R01).

Числа токенов инструменту взять неоткуда: их называет исполнитель, и сверить его
не с чем. Поэтому здесь проверяется не столько арифметика, сколько граница между
измеренным и сообщённым — и то, что сумма не выдаётся за полную.

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

from __future__ import annotations

import json
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 proof, report  # noqa: E402
from chekan.store import (  # noqa: E402
    SPEND_MEASURED,
    SPEND_REPORTED,
    Run,
    StateError,
    validate,
)


@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 close(run: Run, tid: str, tokens: int | None = None) -> dict:
    """Закрыть задачу так, как это делает `chekan task done`."""
    ok, messages = proof.close_task(run, tid)
    assert ok, messages
    return run.record_spend(tid, tokens=tokens)


def spend_text(run: Run) -> str:
    """Раздел «Расход» из отчёта. Соседние разделы нумеруют задачи так же."""
    text = report.build(run)
    assert "## Расход" in text, text
    return text.split("## Расход", 1)[1].split("\n## ", 1)[0]


def spend_line(run: Run, tid: str) -> str:
    return next(ln for ln in spend_text(run).splitlines() if ln.startswith(f"- {tid} "))


def task(run: Run, tid: str, title: str, tokens: int | None = None, model: str = "") -> None:
    if run.requirement("R01") is None:
        run.add_requirement("R01", "учитывать расход")
    run.add_task(tid, title, ["R01"], verify="true")
    if model:
        run.set_task_model(tid, model)
    run.set_task_status(tid, "in-progress")
    proof.run_command(run, "true", task=tid)
    close(run, tid, tokens=tokens)


# --- R01.1: что записывается при закрытии ---


def test_close_records_model_time_runs_and_tokens(run: Run):
    task(run, "01", "Раз", tokens=41000, model="claude-opus-5")
    spend = run.task("01")["spend"]
    assert spend["model"] == "claude-opus-5"
    assert spend["tokens"] == 41000
    assert spend["runs"] == 1
    assert isinstance(spend["duration_ms"], int) and spend["duration_ms"] >= 0


def test_runs_counted_by_the_tool_itself(run: Run):
    run.add_requirement("R01", "учитывать расход")
    run.add_task("01", "Раз", ["R01"], verify="true")
    run.set_task_status(run.task("01")["id"], "in-progress")
    proof.run_command(run, "false", task="01")
    proof.run_command(run, "false", task="01")
    proof.run_command(run, "true", task="01")
    close(run, "01")
    # Три прогона, два из них красные: считаются все, а не только победный.
    assert run.task("01")["spend"]["runs"] == 3


def test_spend_survives_restart(run: Run):
    task(run, "01", "Раз", tokens=1234, model="claude-opus-5")
    fresh = Run(run.root, run.slug)
    assert fresh.task("01")["spend"]["tokens"] == 1234
    on_disk = json.loads((run.dir / "state.json").read_text(encoding="utf-8"))
    assert on_disk["tasks"][0]["spend"]["model"] == "claude-opus-5"


def test_tokens_must_be_a_whole_number(run: Run):
    task(run, "01", "Раз")
    with pytest.raises(StateError):
        run.record_spend("01", tokens=-5)
    with pytest.raises(StateError):
        run.record_spend("01", tokens="много")  # type: ignore[arg-type]


# --- R01.2: сообщённое помечено как сообщённое ---


def test_sources_are_separated_in_state(run: Run):
    task(run, "01", "Раз", tokens=100, model="claude-opus-5")
    spend = run.task("01")["spend"]
    assert spend["tokens_source"] == SPEND_REPORTED
    assert spend["duration_source"] == SPEND_MEASURED
    assert spend["runs_source"] == SPEND_MEASURED


def test_sources_are_separated_in_report(run: Run):
    task(run, "01", "Раз", tokens=100, model="claude-opus-5")
    line = spend_line(run, "01")
    assert f"токены 100 ({SPEND_REPORTED})" in line
    assert f"время" in line and f"({SPEND_MEASURED})" in line
    assert f"прогонов 1 ({SPEND_MEASURED})" in line


def test_task_without_tokens_says_not_measured(run: Run):
    task(run, "01", "Раз")
    spend = run.task("01")["spend"]
    assert spend["tokens"] is None and spend["tokens_source"] is None
    line = spend_line(run, "01")
    assert "не измерен" in line
    # Ни нуля, ни пропуска: измеренное у такой задачи на месте, сообщённого нет.
    assert "токены 0" not in line
    assert f"прогонов 1 ({SPEND_MEASURED})" in line


# --- R01.3: суммы в отчёте ---


def report_numbers(text: str) -> dict:
    tokens = re.search(r"Сумма токенов: (\d+) — .*?по (\d+) задач(?:е|ам) из (\d+)\.", text)
    assert tokens, "в отчёте нет суммы токенов"
    return {
        "tokens": int(tokens.group(1)),
        "counted": int(tokens.group(2)),
        "total": int(tokens.group(3)),
    }


def test_report_sums_match_independently_counted(run: Run):
    task(run, "01", "Раз", tokens=1000, model="claude-opus-5")
    task(run, "02", "Два", tokens=2500, model="claude-opus-5")
    task(run, "03", "Три")
    text = report.build(run)

    # Считаем независимо — прямо из состояния на диске, чужим кодом.
    state = json.loads((run.dir / "state.json").read_text(encoding="utf-8"))
    named = [t for t in state["tasks"] if (t.get("spend") or {}).get("tokens") is not None]
    expected = sum(t["spend"]["tokens"] for t in named)

    got = report_numbers(text)
    assert got["tokens"] == expected == 3500
    assert got["counted"] == len(named) == 2
    assert got["total"] == len(state["tasks"]) == 3


def test_report_names_tasks_without_spend(run: Run):
    task(run, "01", "Раз", tokens=1000)
    task(run, "02", "Два")
    text = report.build(run)
    silent = next(ln for ln in text.splitlines() if ln.startswith("Расход не измерен"))
    assert "02 Два" in silent
    assert "01 Раз" not in silent


def test_report_sums_measured_time_separately(run: Run):
    task(run, "01", "Раз", tokens=1000)
    task(run, "02", "Два")
    text = report.build(run)
    line = next(ln for ln in text.splitlines() if ln.startswith("Сумма измеренного времени"))
    # Время меряется по всем закрытым задачам, включая ту, у которой токенов нет.
    assert "по 2 задачам" in line


# --- рычаги порчи ---


def test_lever_tokens_passed_off_as_measured(run: Run):
    """Рычаг порчи: подписать токены как измеренные — состояние не проходит проверку."""
    task(run, "01", "Раз", tokens=1000)
    state = run.state
    state["tasks"][0]["spend"]["tokens_source"] = SPEND_MEASURED
    with pytest.raises(StateError) as exc:
        validate(state)
    assert SPEND_REPORTED in str(exc.value)


def test_lever_measured_fields_passed_off_as_reported(run: Run):
    """Рычаг порчи: обратная подмена — время «со слов» — тоже красное."""
    task(run, "01", "Раз", tokens=1000)
    state = run.state
    state["tasks"][0]["spend"]["duration_source"] = SPEND_REPORTED
    with pytest.raises(StateError):
        validate(state)


def test_lever_missing_spend_counted_as_zero(run: Run):
    """Рычаг порчи: считать задачу без расхода нулём.

    Ноль тихо увеличивает знаменатель и делает сумму «полной». Отчёт обязан
    показывать такую задачу словом и держать её вне суммы.
    """
    task(run, "01", "Раз", tokens=1000)
    task(run, "02", "Два")
    text = spend_text(run)
    got = report_numbers(text)
    assert got["counted"] == 1, "задача без расхода попала в число посчитанных"
    assert got["counted"] < got["total"], "сумма выдана за полную"
    line = spend_line(run, "02")
    assert "токены 0" not in line and "не измерен" in line


def test_lever_sum_disagrees_with_its_parts(run: Run):
    """Рычаг порчи: сумма разошлась со слагаемыми — сверка обязана краснеть."""
    task(run, "01", "Раз", tokens=1000)
    task(run, "02", "Два", tokens=2000)
    text = report.build(run)
    parts = [int(m) for m in re.findall(r"· токены (\d+) \(", text)]
    assert parts == [1000, 2000]
    assert report_numbers(text)["tokens"] == sum(parts)
    # Подмена одного слагаемого разводит сумму со строками — и это видно.
    broken = text.replace("токены 2000 (", "токены 9000 (", 1)
    broken_parts = [int(m) for m in re.findall(r"· токены (\d+) \(", broken)]
    assert report_numbers(broken)["tokens"] != sum(broken_parts)
