"""Runner adapters — how the warden actually executes a test (in a child process).

``pytest_runner`` is the spike's real runner: it subprocess-runs pytest for one
node-id in the project root and streams output as events. The production adapter
(**D-MTEST-API**) drives ``mgf-test-supervisor`` as a library in a child process
(``Supervisor(RunConfig(test_ids=[node_id], chunk_size=1)).run()`` then reads
``.gates/last-run/run.jsonl``); it's a swap-in for this function — same signature.
"""

from __future__ import annotations

import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any

from .._zig import build_step_cmd
from .outcomes import OUTCOME_TO_RESULT, UNKNOWN_RESULT

if TYPE_CHECKING:
    from .leases import RunRequest

Runner = Callable[["RunRequest", Any], str]


def pytest_runner(project_root: str | Path) -> Runner:
    root = Path(project_root)

    def run(request: RunRequest, emit: Any) -> str:
        emit({"kind": "test_start", "test_id": request.test_id})
        cmd = ["python", "-m", "pytest", request.test_id, "-q"]
        try:
            with subprocess.Popen(  # noqa: S603
                cmd,
                cwd=str(root),
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                bufsize=1,
            ) as proc:
                assert proc.stdout is not None
                for line in proc.stdout:
                    emit({"kind": "log", "stream": "stdout", "msg": line.rstrip("\n")})
                returncode = proc.wait()
            result = "pass" if returncode == 0 else "fail"
        except OSError as exc:
            emit({"kind": "error", "msg": str(exc)})
            result = "error"
        emit({"kind": "test_end", "result": result})
        return result

    return run


def zig_runner(project_root: str | Path) -> Runner:
    """Runner adapter for **Zig** projects — runs ``zig build <step>`` for the request.

    The ``test_id`` is a build-step name (e.g. ``test``) from the zig catalog; the
    zig invocation (a real ``zig`` binary or ``python -m ziglang``) is resolved at
    run time (:func:`mgf.test_web._zig.build_step_cmd`). Same ``Runner`` signature,
    streaming, and pass/fail/error mapping as :func:`pytest_runner`, so the warden
    treats it identically — single-flight, live logs, and lease audit all apply.
    """
    root = Path(project_root)

    def run(request: RunRequest, emit: Any) -> str:
        emit({"kind": "test_start", "test_id": request.test_id})
        cmd = build_step_cmd(request.test_id)
        if cmd is None:
            emit(
                {
                    "kind": "error",
                    "msg": "no Zig toolchain — install `ziglang` or put `zig` on PATH",
                }
            )
            emit({"kind": "test_end", "result": "error"})
            return "error"
        try:
            with subprocess.Popen(  # noqa: S603 — cmd from _zig.build_step_cmd + a catalog step id
                cmd,
                cwd=str(root),
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                bufsize=1,
            ) as proc:
                assert proc.stdout is not None
                for line in proc.stdout:
                    emit({"kind": "log", "stream": "stdout", "msg": line.rstrip("\n")})
                returncode = proc.wait()
            result = "pass" if returncode == 0 else "fail"
        except OSError as exc:
            emit({"kind": "error", "msg": str(exc)})
            result = "error"
        emit({"kind": "test_end", "result": result})
        return result

    return run


def mtest_runner(project_root: str | Path) -> Runner:
    """Production runner (**D-MTEST-API**) — drive ``mgf-test-supervisor`` (mtest)
    for one node-id and read the outcome back from ``.gates/last-run/run.jsonl``.

    A drop-in for :func:`pytest_runner` (same ``Runner`` signature). mtest is run
    in a *child process* (``_mtest_child``) — never the warden's own process or
    thread — because the supervisor installs process-global ``SIGINT``/``SIGTERM``
    handlers that ``sys.exit``; running it inline would let a single run's signal
    tear the broker down. The child writes the JSONL (``run()`` returns only an
    exit code, and the CLI cannot target one node-id); this adapter maps the
    record's ``outcome`` to the warden result and emits ``test_start`` / ``assert``
    / ``test_end`` (plus ``log`` lines streamed from the child's stdout).
    """
    root = Path(project_root)
    run_jsonl = root / ".gates" / "last-run" / "run.jsonl"

    def run(request: RunRequest, emit: Any) -> str:
        cmd = [
            sys.executable,
            "-m",
            "mgf.test_web.warden._mtest_child",
            str(root),
            request.test_id,
        ]
        try:
            # `with` so the child's stdout pipe is closed deterministically —
            # under `filterwarnings=error` a leaked pipe is a hard ResourceWarning.
            with subprocess.Popen(  # noqa: S603
                cmd,
                cwd=str(root),
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                bufsize=1,
            ) as proc:
                # emit the child pid on start so the warden records it on the lease —
                # its crash-recovery journal kills this orphan if the warden dies.
                emit({"kind": "test_start", "test_id": request.test_id, "pid": proc.pid})
                assert proc.stdout is not None
                for line in proc.stdout:
                    emit({"kind": "log", "stream": "stdout", "msg": line.rstrip("\n")})
                proc.wait()
        except OSError as exc:
            emit({"kind": "error", "msg": str(exc)})
            emit({"kind": "test_end", "result": "error"})
            return "error"
        result = _result_from_run(run_jsonl, request.test_id, emit)
        emit({"kind": "test_end", "result": result})
        return result

    return run


def _result_from_run(run_jsonl: Path, test_id: str, emit: Any) -> str:
    """Read mtest's ``run.jsonl``, find the record for ``test_id``, emit an
    ``assert`` event carrying its outcome, and map that outcome → warden result
    (full fidelity — see ``outcomes.OUTCOME_TO_RESULT``).

    A missing record (the run never recorded this test — crash before write,
    wrong id) is an ``error``: the warden must never silently report a green it
    cannot prove from the run log. An outcome this build has no mapping for is
    reported **loudly** as ``unknown`` (an ``error`` event names it), never
    silently as ``error`` — that is how a newer supervisor vocabulary (e.g. a
    future record type) degrades honestly without a version pin.
    """
    from mgf.test_supervisor.records import read_jsonl

    record: dict[str, Any] | None = None
    for row in read_jsonl(run_jsonl):
        # Only outcome-bearing rows are test records — a future run.jsonl may
        # interleave other record types (feature-detect by shape, not version).
        if row.get("test_id") == test_id and "outcome" in row:
            record = row  # last record wins (chunk_size=1 ⇒ exactly one expected)
    if record is None:
        emit({"kind": "error", "msg": f"no mtest record for {test_id} in {run_jsonl}"})
        return "error"
    outcome = str(record.get("outcome", ""))
    emit(
        {
            "kind": "assert",
            "test_id": test_id,
            "outcome": outcome,
            "code": record.get("code"),
            "duration_ms": record.get("duration_ms"),
            "summary": record.get("summary"),
        }
    )
    result = OUTCOME_TO_RESULT.get(outcome)
    if result is None:
        emit(
            {
                "kind": "error",
                "msg": (
                    f"unmapped mtest outcome {outcome!r} for {test_id} — reporting "
                    "'unknown' (vocabulary table: warden/outcomes.py)"
                ),
            }
        )
        return UNKNOWN_RESULT
    return result
