"""``testctl`` — the project contract surface for the warden.

None of these execute a test directly — they talk to the warden over its UDS.
``run`` is **fail-closed**: if the warden is down it refuses (nonzero), never spawns.
"""

from __future__ import annotations

import argparse
import json
import os
import signal
import sys
import tomllib
import uuid
from pathlib import Path
from typing import TYPE_CHECKING, Any

from mgf.fedres.canonical import dumpb

from . import __version__
from ._zig import ZigError, ZigUnavailable
from .catalog import (
    CollectionError,
    count_tests,
    detect_project_kind,
    write_catalog,
    write_zig_catalog,
)
from .warden import RunRequest, Warden, WardenClient, WardenDaemon, WardenUnreachable
from .warden.recorder import LiveRecorder
from .warden.runner import mtest_runner, zig_runner

if TYPE_CHECKING:
    from .warden.leases import Lease


def _project_id(root: Path) -> str:
    source = root / "fed-resources" / "source" / "project.toml"
    if source.is_file():
        try:
            data = tomllib.loads(source.read_text(encoding="utf-8"))
            return str(data.get("project", {}).get("id", root.resolve().name))
        except (tomllib.TOMLDecodeError, OSError):
            return root.resolve().name
    return root.resolve().name


def _socket(root: Path) -> Path:
    return root / "fed-resources" / "live" / "warden.sock"


def _leases_path(root: Path) -> Path:
    return root / "fed-resources" / "live" / "leases.json"


def _write_journal(path: Path, leases: list[Lease]) -> None:
    """Mirror the running leases to ``live/leases.json`` (canonical JSON) — the dead
    warden's recovery breadcrumb. Each entry carries the child ``pid`` so a recovering
    warden can kill the orphan it left behind."""
    path.parent.mkdir(parents=True, exist_ok=True)
    entries = [
        {
            "run_id": lease.run_id,
            "key": lease.key,
            "test_id": lease.test_id,
            "acquired_at": lease.acquired_at,
            "heartbeat_at": lease.heartbeat_at,
            "ttl_s": lease.ttl_s,
            "pid": lease.pid,
        }
        for lease in leases
    ]
    path.write_bytes(dumpb(entries))


def _read_journal(path: Path) -> list[dict[str, Any]]:
    """Read the on-disk lease journal; an absent/corrupt file recovers nothing."""
    if not path.is_file():
        return []
    try:
        raw = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return []
    if not isinstance(raw, list):
        return []
    entries: list[dict[str, Any]] = []
    for item in raw:
        if isinstance(item, dict):
            entries.append(item)
    return entries


def _kill_orphan(pid: object) -> None:
    """SIGTERM a dead warden's orphaned child. Guarded — already-gone (ProcessLookupError)
    or not-ours (PermissionError) both mean there is nothing left to reclaim."""
    if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
        return
    try:
        os.kill(pid, signal.SIGTERM)
    except (ProcessLookupError, PermissionError):
        return


def _recover_from_journal_file(warden: Warden, leases_path: Path) -> list[str]:
    """Crash recovery: a leftover ``leases.json`` means the previous warden died holding
    leases (the fcntl singleton proves it is gone). Audit-reclaim each lease into this
    fresh warden, SIGTERM its orphaned child, then clear the journal and serve fresh."""
    entries = _read_journal(leases_path)
    if not entries:
        return []
    reclaimed = warden.recover_from_journal(entries)  # audits lease_reclaimed + advances
    for entry in entries:
        _kill_orphan(entry.get("pid"))
    leases_path.unlink(missing_ok=True)  # cleared — the dead warden's orphans are reclaimed
    return reclaimed


def _print(obj: dict[str, Any], as_json: bool, human: str) -> None:
    print(json.dumps(obj, ensure_ascii=False) if as_json else human)


def _parse_config(pairs: list[str] | None) -> dict[str, Any]:
    config: dict[str, Any] = {}
    for pair in pairs or []:
        key, _, value = pair.partition("=")
        if value.lstrip("-").isdigit():
            config[key] = int(value)
        elif value.lower() in {"true", "false"}:
            config[key] = value.lower() == "true"
        else:
            config[key] = value
    return config


def _cmd_serve(root: Path) -> int:
    project = _project_id(root)
    recorder = LiveRecorder(root, project=project)
    leases_path = _leases_path(root)
    # The runner matches the project's kind: mtest (pytest) for a Python repo, the
    # zig adapter (`zig build <step>`) for a Zig repo. One project is one kind, so
    # the warden serves one runner; single-flight/streaming/audit are identical.
    runner = zig_runner(root) if detect_project_kind(root) == "zig" else mtest_runner(root)
    # The recorder is the warden's journal: live NDJSON per event + a run-record on
    # completion. The broker stays ignorant of fed-resources/ — it just calls back.
    # `journal` mirrors the running leases to leases.json on every lease change, the
    # breadcrumb the recover-on-startup path reads to reclaim a dead warden's orphans.
    warden = Warden(
        project,
        runner,
        on_event=recorder.on_event,
        on_complete=recorder.on_complete,
        journal=lambda leases: _write_journal(leases_path, leases),
    )
    daemon = WardenDaemon(warden, _socket(root))

    def _recover() -> None:
        # Runs AFTER the daemon takes its singleton (so the old warden is proven gone):
        # reclaim the dead warden's leases (audited), kill its orphans, clear the journal.
        _recover_from_journal_file(warden, leases_path)

    print(f"warden: serving {project} at {_socket(root)} (Ctrl-C to stop)", file=sys.stderr)
    try:
        daemon.serve_forever(on_ready=_recover)
    except KeyboardInterrupt:
        daemon.shutdown()
    return 0


def _cmd_catalog(root: Path, as_json: bool) -> int:
    project = _project_id(root)
    kind = detect_project_kind(root)
    if kind == "unknown":
        # No recognized test project — nothing to enumerate. Skip cleanly instead
        # of forcing pytest on a repo that never had Python tests.
        _print(
            {"ok": True, "skipped": "unknown", "project": project},
            as_json,
            f"SKIP  {project}: no recognized test project (no pyproject.toml / setup.py / "
            "pytest.ini, and no build.zig) — no catalog written.",
        )
        return 0
    try:
        if kind == "zig":
            path, catalog = write_zig_catalog(root, project)
        else:
            path, catalog = write_catalog(root, project)
    except (OSError, CollectionError, ZigUnavailable, ZigError) as exc:
        _print({"ok": False, "error": str(exc)}, as_json, f"ERROR  {exc}")
        return 1
    tests = count_tests(catalog)
    cats = len(catalog["categories"])
    _print(
        {"ok": True, "path": str(path), "tests": tests, "categories": cats},
        as_json,
        f"wrote {tests} tests in {cats} categories -> {path}",
    )
    return 0


def _cmd_run(
    root: Path, test_id: str, config: dict[str, Any], origin: str, independent: bool, as_json: bool
) -> int:
    client = WardenClient(_socket(root))
    request = RunRequest(
        run_id="run_" + uuid.uuid4().hex[:12],
        test_id=test_id,
        config=config,
        origin=origin,
        independent=independent,
    )
    try:
        response = client.run(request)
    except WardenUnreachable as exc:
        _print({"ok": False, "error": str(exc)}, as_json, f"REFUSED  {exc}")
        return 1
    human = f"{response.get('status')}  {test_id} -> {response.get('run_id')}"
    _print(response, as_json, human)
    return 0


def _cmd_status(root: Path, as_json: bool) -> int:
    client = WardenClient(_socket(root))
    try:
        status = client.status()
    except WardenUnreachable:
        status = {
            "schema_version": "0.1",
            "kind": "test_status",
            "project": _project_id(root),
            "broker": "down",
        }
    human = (
        f"broker={status.get('broker')}  running={len(status.get('running', []))}  "
        f"queued={len(status.get('queued', []))}"
    )
    _print(status, as_json, human)
    return 0


def _cmd_cancel(root: Path, run_id: str, as_json: bool) -> int:
    client = WardenClient(_socket(root))
    try:
        response = client.cancel(run_id)
    except WardenUnreachable as exc:
        _print({"ok": False, "error": str(exc)}, as_json, f"REFUSED  {exc}")
        return 1
    found = bool(response.get("found"))
    human = (
        f"cancelled  {run_id} ({response.get('state')}) — releases when the child exits"
        if found
        else f"not found  {run_id} — already finished? (nothing to cancel)"
    )
    _print(response, as_json, human)
    return 0 if found else 1


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="testctl", description="the warden contract surface")
    parser.add_argument("--version", action="version", version=f"testctl {__version__}")
    parser.add_argument("-C", "--root", default=".", help="project root (default: cwd)")
    parser.add_argument("--format", choices=["text", "json"], default="text")
    sub = parser.add_subparsers(dest="command", required=True)

    sub.add_parser("serve", help="run the warden (the resident authority)")
    p_catalog = sub.add_parser("catalog", help="generate the gated test_catalog.json")
    p_catalog.add_argument(
        "--refresh",
        action="store_true",
        help="(accepted for contract parity — catalog always regenerates deterministically)",
    )
    p_run = sub.add_parser("run", help="submit a run request to the warden (fail-closed)")
    p_run.add_argument("test_id")
    p_run.add_argument("--config", action="append", metavar="K=V", help="per-run config overlay")
    p_run.add_argument("--origin", default="cli", choices=["web", "cli", "agent", "ci"])
    p_run.add_argument("--independent", action="store_true")
    sub.add_parser("status", help="read the lease registry (honest broker:down if unreachable)")
    p_cancel = sub.add_parser("cancel", help="cancel a run by id (running lease or queued)")
    p_cancel.add_argument("run_id")

    args = parser.parse_args(argv)
    root = Path(args.root)
    as_json = args.format == "json"

    if args.command == "serve":
        return _cmd_serve(root)
    if args.command == "catalog":
        return _cmd_catalog(root, as_json)
    if args.command == "run":
        return _cmd_run(
            root, args.test_id, _parse_config(args.config), args.origin, args.independent, as_json
        )
    if args.command == "status":
        return _cmd_status(root, as_json)
    if args.command == "cancel":
        return _cmd_cancel(root, args.run_id, as_json)
    parser.error(f"unknown command {args.command!r}")
    return 2


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(main())
