"""Долгие проверки в отрыве (R07).

Пятнадцатиминутный прогон держит оркестратора занятым и умирает вместе с ним:
закрыли терминал — проверка потеряна, а исполнитель об этом не узнает. `chekan run
--detach` отдаёт управление сразу, а проверку доигрывает отдельный процесс в своей
сессии: родителя можно убить, исход всё равно допишется в журнал.

Раз исход приходит потом, в журнале появляется запись о НАЧАЛЕ: иначе «идёт»
взяться неоткуда — между стартом и концом прогон выглядел бы как несуществующий.
Пара записей связана полем `detach_id`, а не соседством строк: писателей теперь
несколько, и строки перемешиваются.

Этот же модуль — точка входа дочернего процесса: `python -m ceh.detach <файл>`.
"""

from __future__ import annotations

import json
import os
import secrets
import subprocess
import sys
from pathlib import Path

from .store import Run, harness, is_pid_alive, now

RUNNING = "running"
DONE = "done"


def _dir(run: Run) -> Path:
    d = run.dir / "detach"
    d.mkdir(parents=True, exist_ok=True)
    return d


def start(
    run: Run,
    cmd: str,
    task: str | None = None,
    kind: str = "check",
    model: str | None = None,
    timeout: int = 1800,
    reuse: bool = True,
) -> dict:
    """Запустить проверку в отрыве и вернуться немедленно."""
    did = secrets.token_hex(5)
    d = _dir(run)
    payload = {
        "id": did,
        "root": str(run.root),
        "slug": run.slug,
        "cmd": cmd,
        "task": task,
        "kind": kind,
        "model": model,
        "timeout": timeout,
        "reuse": reuse,
    }
    payload_path = d / f"{did}.json"
    payload_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")

    row = {
        "ts": now(),
        "task": task,
        "kind": kind,
        "cmd": cmd,
        "detached": True,
        "detach_id": did,
        "state": RUNNING,
        "cwd": str(run.root),
        "agent": harness(),
        "model": model,
    }
    run.append_evidence(row)

    env = dict(os.environ)
    package_home = str(Path(__file__).resolve().parent.parent)
    env["PYTHONPATH"] = os.pathsep.join(
        [package_home, *([env["PYTHONPATH"]] if env.get("PYTHONPATH") else [])]
    )
    log_path = d / f"{did}.log"
    with log_path.open("ab") as log:
        proc = subprocess.Popen(
            [sys.executable, "-m", "chekan.detach", str(payload_path)],
            cwd=str(run.root),
            stdin=subprocess.DEVNULL,
            stdout=log,
            stderr=subprocess.STDOUT,
            # Своя сессия — родителя можно убивать вместе с его группой процессов.
            start_new_session=True,
            close_fds=True,
            env=env,
        )
    # Отдельным файлом, а не в payload: дочерний процесс читает payload прямо
    # сейчас, и дозапись в тот же файл дала бы ему обрывок.
    (d / f"{did}.pid").write_text(str(proc.pid), encoding="utf-8")
    return {"id": did, "pid": proc.pid, "log": str(log_path), "row": row}


def pending(run: Run, task: str | None = None) -> list[dict]:
    """Отрывы с записью о начале и без записи об исходе."""
    started: dict[str, dict] = {}
    finished: set[str] = set()
    for row in run.evidence():
        did = row.get("detach_id")
        if not did:
            continue
        if row.get("state") == DONE:
            finished.add(did)
        elif row.get("state") == RUNNING:
            started[did] = row
    rows = [row for did, row in started.items() if did not in finished]
    if task is not None:
        rows = [row for row in rows if row.get("task") == task]
    return rows


def pid_of(run: Run, did: str) -> int | None:
    try:
        return int((run.dir / "detach" / f"{did}.pid").read_text(encoding="utf-8").strip())
    except (OSError, ValueError):
        return None


def refusal(run: Run, rows: list[dict]) -> list[str]:
    """Отказ закрыть задачу, пока проверка ещё идёт. Говорим, чего именно ждём."""
    lines = ["задачу нельзя закрыть: проверка ещё идёт в отрыве."]
    lost = False
    for row in rows:
        pid = pid_of(run, row.get("detach_id", ""))
        alive = pid is not None and is_pid_alive(pid)
        lost = lost or not alive
        lines.append(
            f"  ждём `{row.get('cmd')}` (отрыв {row.get('detach_id')}"
            + (f", pid {pid}" if pid else "")
            + f", начата {(row.get('ts') or '')[11:19]}) — "
            + ("идёт" if alive else "процесс не жив, а исход не записан")
        )
    lines.append("Ход проверки: `chekan board`. Исход допишется сам — дождитесь его.")
    if lost:
        lines.append(
            "Процесс убит вместе с машиной — прогоните проверку заново: "
            "`chekan run \"<команда>\" --task <NN>`."
        )
    return lines


def _child(payload_path: str) -> int:
    """Дочерний процесс: гоняет команду и дописывает исход в журнал."""
    payload = json.loads(Path(payload_path).read_text(encoding="utf-8"))
    run = Run(Path(payload["root"]), payload["slug"])
    mark = {"detach_id": payload["id"], "state": DONE, "detached": True}
    from . import proof

    try:
        row = proof.run_command(
            run,
            payload["cmd"],
            task=payload.get("task"),
            kind=payload.get("kind") or "check",
            model=payload.get("model"),
            timeout=int(payload.get("timeout") or 1800),
            reuse=bool(payload.get("reuse", True)),
            extra=mark,
        )
        return 0 if row.get("exit") == 0 else 1
    except Exception as exc:  # исход обязан появиться даже так — иначе задача висит вечно
        run.append_evidence({
            "ts": now(),
            "task": payload.get("task"),
            "kind": payload.get("kind") or "check",
            "cmd": payload["cmd"],
            "exit": 127,
            "duration_ms": 0,
            "cwd": payload["root"],
            "agent": harness(),
            "out_tail": "",
            "err_tail": f"отрыв не смог прогнать команду: {type(exc).__name__}: {exc}",
            **mark,
        })
        return 127


if __name__ == "__main__":
    sys.exit(_child(sys.argv[1]) if len(sys.argv) > 1 else 2)
