"""Контрольные точки и долгие проверки в отрыве (R06, R07).

Два убытка, из которых выросла эта работа. Первый: у владельца выключился ноутбук
посреди работы исполнителя — полтора часа пропали целиком, коммита не было. Второй:
пятнадцатиминутная проверка держит оркестратора занятым и умирает вместе с ним.

Рычаги порчи на эту работу — каждый обязан краснеть здесь:
1. Не закреплять точку ссылкой (`update-ref` в `checkpoint.capture`): снимок есть,
   но его съедает `git gc` — точка протухает молча.
2. Не писать запись о начале отрыва (`append_evidence` в `detach.start`): «идёт»
   становится неотличимо от «не запускали».
3. Снять замок журнала (`_evidence_lock` в `store.append_evidence`): параллельные
   писатели рвут строки.
4. Пропускать незавершённый отрыв в `chekan task done`: задача закрывается по
   доказательству, которого ещё нет.
"""

from __future__ import annotations

import json
import os
import signal
import subprocess
import sys
import time
from pathlib import Path

import pytest

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

from chekan import board, checkpoint, detach  # noqa: E402
from chekan.__main__ import main  # noqa: E402
from chekan.handoff import build as handoff_text  # noqa: E402
from chekan.store import Run  # 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:
    r = Run.create(repo, "Тестовый прогон", "normal", "T1")
    r.add_requirement("R07", "долгие проверки в отрыве", note="причина")
    r.add_task("01", "Задача с долгой проверкой", ["R07"], zone="app.py", verify="true")
    return r


def cli(repo: Path, *args: str) -> int:
    cwd = Path.cwd()
    try:
        os.chdir(repo)
        return main(list(args))
    finally:
        os.chdir(cwd)


def journal(run: Run) -> list[dict]:
    return [
        json.loads(line)
        for line in run.evidence_path.read_text(encoding="utf-8").splitlines()
        if line.strip()
    ]


def git(repo: Path, *args: str) -> str:
    done = subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True)
    return done.stdout.strip()


def child_env() -> dict:
    env = dict(os.environ)
    env["PYTHONPATH"] = os.pathsep.join(
        [str(ROOT), *([env["PYTHONPATH"]] if env.get("PYTHONPATH") else [])]
    )
    return env


def wait_for(check, limit: float = 30.0):
    """Отрыв асинхронен: ждём событие, а не угадываем задержку."""
    deadline = time.monotonic() + limit
    while time.monotonic() < deadline:
        value = check()
        if value:
            return value
        time.sleep(0.1)
    return None


# --- R06: контрольные точки ---


def test_checkpoint_is_pinned_by_ref(run: Run, repo: Path):
    """Снимок без ссылки съедает `git gc` — точка обязана быть закреплена."""
    (repo / "app.py").write_text("print('правка на середине')\n", encoding="utf-8")

    point = checkpoint.capture(repo, run.slug)

    assert point["status"] == "saved", point
    assert point["ref"].startswith(f"refs/chekan/checkpoints/{run.slug}/"), point
    refs = git(repo, "for-each-ref", "--format=%(refname) %(objectname)", "refs/chekan")
    assert f"{point['ref']} {point['sha']}" in refs, refs
    # Ссылка настоящая, а не запись в чужом журнале: точка переживает сборку мусора.
    subprocess.run(["git", "gc", "--prune=now", "-q"], cwd=repo, check=True)
    assert git(repo, "cat-file", "-t", point["sha"]) == "commit"
    # Рабочая копия не тронута: прогон продолжается с тем же деревом.
    assert (repo / "app.py").read_text(encoding="utf-8") == "print('правка на середине')\n"


def test_checkpoint_on_clean_tree_says_so(run: Run, repo: Path):
    point = checkpoint.capture(repo, run.slug)
    assert point["status"] == "clean"
    assert "снимать нечего" in point["text"]
    assert git(repo, "for-each-ref", "refs/chekan") == ""


def test_checkpoint_outside_git_does_not_break_run(tmp_path: Path, capsys):
    """Вне репозитория точек нет — но прогон это не роняет и не врёт."""
    bare = tmp_path / "без-гита"
    bare.mkdir()
    r = Run.create(bare, "Прогон без гита", "normal", "T1")
    r.add_requirement("R06", "контрольные точки", note="причина")
    r.add_task("01", "Задача", ["R06"], verify="true")

    assert checkpoint.capture(bare, r.slug)["status"] == "nogit"
    assert cli(bare, "run", "true", "--task", "01") == 0
    text = capsys.readouterr().out
    assert "репозитория нет, точек не будет" in text, text


def test_run_takes_checkpoint(run: Run, repo: Path, capsys):
    (repo / "app.py").write_text("print('незакоммиченное')\n", encoding="utf-8")
    assert cli(repo, "run", "true", "--task", "01") == 0
    text = capsys.readouterr().out
    assert "контрольная точка" in text, text
    assert git(repo, "for-each-ref", "refs/chekan") != ""


def test_handoff_shows_restore_command_that_works(run: Run, repo: Path):
    """Команда из вывода восстанавливает дерево — её копируют, а не пересказывают."""
    (repo / "app.py").write_text("print('час работы')\n", encoding="utf-8")
    point = checkpoint.capture(repo, run.slug)

    text = handoff_text(run)
    assert "не сохранено:" in text, text
    assert "app.py" in text, text
    command = checkpoint.restore_command(point["ref"])
    assert command in text, text

    subprocess.run(["git", "checkout", "--", "app.py"], cwd=repo, check=True)
    assert (repo / "app.py").read_text(encoding="utf-8") == "print('ok')\n"
    done = subprocess.run(command, shell=True, cwd=repo, capture_output=True, text=True)
    assert done.returncode == 0, done.stderr
    assert (repo / "app.py").read_text(encoding="utf-8") == "print('час работы')\n"


# --- R07: отрыв ---


def test_detach_returns_at_once(run: Run, repo: Path, capsys):
    started = time.monotonic()
    assert cli(repo, "run", "sh -c 'sleep 5; printf x >> hits.txt'", "--task", "01", "--detach") == 0
    elapsed = time.monotonic() - started
    assert elapsed < 3, f"управление вернулось только через {elapsed:.1f} с"
    assert "ушло в отрыв" in capsys.readouterr().out

    finished = wait_for(lambda: [r for r in journal(run) if r.get("state") == "done"], 40)
    assert finished, "исход отрыва так и не записан"
    assert finished[0]["exit"] == 0
    assert (repo / "hits.txt").read_text(encoding="utf-8") == "x"


def test_detach_survives_parent_death(run: Run, repo: Path):
    """Родителя убивают на середине — исход всё равно дописывается."""
    parent = subprocess.Popen(
        [sys.executable, "-m", "chekan", "run",
         "sh -c 'sleep 3; printf x >> hits.txt'", "--task", "01", "--detach"],
        cwd=repo, env=child_env(),
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
    )
    assert wait_for(lambda: [r for r in journal(run) if r.get("state") == "running"], 30), \
        "записи о начале нет — «идёт» узнать неоткуда"
    try:
        os.kill(parent.pid, signal.SIGKILL)
    except ProcessLookupError:
        pass
    parent.wait(timeout=30)

    finished = wait_for(lambda: [r for r in journal(run) if r.get("state") == "done"], 60)
    assert finished, "родитель умер и унёс проверку с собой"
    assert finished[0]["exit"] == 0
    assert (repo / "hits.txt").is_file()


def test_detach_writes_start_record_and_board_shows_it(run: Run, repo: Path):
    info = detach.start(run, "sh -c 'sleep 4; true'", task="01")

    started = [r for r in journal(run) if r.get("detach_id") == info["id"]]
    assert started and started[0]["state"] == "running", journal(run)
    assert started[0]["cmd"] == "sh -c 'sleep 4; true'"
    assert detach.pending(run, "01"), "идущий отрыв не виден"
    assert "идёт в отрыве" in board.render(run)

    assert wait_for(lambda: not detach.pending(run), 40), "исход не записан"
    picture = board.render(run)
    assert "идёт в отрыве" not in picture
    assert "sh -c 'sleep 4; true'" in picture


def test_task_done_refuses_while_detach_runs(run: Run, repo: Path, capsys):
    run.set_task_status("01", "in-progress")
    detach.start(run, "sh -c 'sleep 4; true'", task="01")
    capsys.readouterr()

    assert cli(repo, "task", "done", "01") == 1
    text = capsys.readouterr().err
    assert "идёт в отрыве" in text, text
    assert "sh -c 'sleep 4; true'" in text, text
    assert run.task("01")["status"] == "in-progress"

    assert wait_for(lambda: not detach.pending(run), 40)


APPENDERS, ROWS_EACH = 10, 12
WRITER = """
import json, sys
from pathlib import Path
sys.path.insert(0, sys.argv[1])
from chekan.store import Run
run = Run(Path(sys.argv[2]), sys.argv[3])
who, rows = sys.argv[4], int(sys.argv[5])
for i in range(rows):
    run.append_evidence({
        "ts": "2026-08-20T00:00:00+03:00", "kind": "check", "cmd": "конкурент",
        "exit": 0, "writer": who, "n": i,
        # Строка заведомо длиннее буфера ввода-вывода: короткую система может
        # дописать одним куском и без замка, и порча осталась бы незамеченной.
        "out_tail": "щ" * 20000,
    })
"""


def writer(run: Run, repo: Path, who: str, rows: int) -> subprocess.Popen:
    return subprocess.Popen(
        [sys.executable, "-c", WRITER, str(ROOT), str(repo), run.slug, who, str(rows)],
        cwd=repo,
    )


def test_append_waits_for_the_lock(run: Run, repo: Path):
    """Замок настоящий: пока он занят, второй писатель ждёт, а не лезет в файл.

    Проверять только целостность строк мало — файловая система на этой машине
    успевает дописать короткую строку одним куском и без замка, и снятый замок
    остался бы незамеченным до первого длинного вывода.
    """
    import fcntl

    guard = open(run.dir / ".evidence.lock", "a+")
    fcntl.flock(guard, fcntl.LOCK_EX)
    other = writer(run, repo, "ждущий", 1)
    try:
        time.sleep(3)
        assert other.poll() is None, "дозапись прошла мимо замка: журнал открыт двум сразу"
    finally:
        fcntl.flock(guard, fcntl.LOCK_UN)
        guard.close()

    assert other.wait(timeout=30) == 0
    assert any(r.get("writer") == "ждущий" for r in journal(run))


def test_parallel_appends_lose_nothing(run: Run, repo: Path):
    """Десять одновременных писателей: ни одной потерянной и ни одной битой строки."""
    before = len(journal(run))
    procs = [writer(run, repo, f"w{i}", ROWS_EACH) for i in range(APPENDERS)]
    for p in procs:
        assert p.wait(timeout=120) == 0

    lines = [
        ln for ln in run.evidence_path.read_text(encoding="utf-8").splitlines() if ln.strip()
    ]
    rows = []
    for line in lines:
        rows.append(json.loads(line))  # битая строка — сразу красное
    written = [r for r in rows if r.get("cmd") == "конкурент"]
    assert len(rows) == before + APPENDERS * ROWS_EACH
    assert {(r["writer"], r["n"]) for r in written} == {
        (f"w{i}", n) for i in range(APPENDERS) for n in range(ROWS_EACH)
    }
