"""The core supervisor: runs pytest in chunks, watches it from the
OUTSIDE, captures structured per-test outcomes + per-failure
diagnostic bundles, persists state atomically.

Why outside pytest: a pytest plugin lives inside pytest. If pytest
crashes (interpreter deadlock, segfault in a C extension, a hook
that infinite-loops), the plugin dies too. The supervisor is a
separate process that *runs* pytest as a subprocess so a pytest
crash is just one more outcome to record.

Subprocess strategy:
  * One pytest invocation per CHUNK of N tests (default 50).
    Amortises pytest startup (~200ms) without losing crash
    isolation — a chunk's crash takes down at most N tests, not
    all of them.
  * Each invocation gets `--junit-xml=<tmp>` so we parse structured
    per-test outcomes from XML, not from scrollback.
  * Wall-clock timeout per chunk = sum of per-test timeouts + 10s
    grace. On timeout: SIGTERM → 5s grace → SIGKILL; in-flight test
    marked `hung`.
  * Exit code != 0 from non-test causes (segv, abrt, OOM) →
    in-flight test marked `crashed`. Other tests in the chunk that
    haven't yet been reported in the XML get `error` (the chunk
    didn't complete). Subsequent chunks RUN — one chunk's crash
    doesn't abort the run.

State files (under `.gates/`):
  last-run/
    run.jsonl          — one TestRecord per outcome (append-only;
                         preserved + appended-to across --resume)
    summary.md         — human-readable summary written at end
    summary.json       — machine rollup: counts + per-outcome id lists
    junit.xml          — consolidated JUnit built from run.jsonl
    failures/<id>.txt  — per-failure detail bundle
    junit-<N>.xml      — raw XML per chunk (debugging aid)
  state.json           — resume checkpoint (atomic write)
  durations.json       — duration history + recency
  quarantine.json      — quarantine state
"""

from __future__ import annotations

import contextlib
import json
import os
import re
import selectors
import signal
import subprocess
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from pathlib import Path

from . import diagnostics, perf
from .durations import DurationStore
from .quarantine import QuarantineStore
from .records import (
    SECTION_HEADER_FMT,
    SECTION_ORDER,
    JsonlWriter,
    TestRecord,
    now_iso,
    read_jsonl,
    severity_for_outcome,
)
from .tiers import tier_for_markers

DEFAULT_CHUNK_SIZE = 50
DEFAULT_PER_TEST_TIMEOUT_S = 30.0
SIGTERM_GRACE_S = 5.0
# Slack added to a chunk's wall-clock budget on top of the per-test
# timeouts, and the bounded post-SIGKILL drain window (a leaked
# grandchild can hold the pipes open forever — we stop caring after
# this). Module-level so tests can shrink them.
_CHUNK_GRACE_S = 10.0
_POST_KILL_DRAIN_S = 5.0

# Hang-heuristic silence window (M2 Wave-2). A chunk missing a test
# from its JUnit XML is only classified `hung` via the INTERNAL
# pytest-timeout heuristic when, on top of rc!=0 and wall-clock >=
# 0.95x the per-test timeout, the child produced NO output in the
# final HANG_SILENCE_S seconds — a busy slow chunk keeps trickling
# pytest progress output; a hung test is silent. A death burst
# (pytest-timeout's stack dump microseconds before os._exit) inside
# the final HANG_BURST_GRACE_S does not count as activity when it
# was preceded by a >= HANG_SILENCE_S silent window.
HANG_SILENCE_S = 5.0
HANG_BURST_GRACE_S = 2.0

# The outcomes that red a run (mirrors quarantine.FAILURE_OUTCOMES;
# kept local so the supervisor module stays import-light).
_FAILURE_OUTCOMES = ("failed", "error", "hung", "crashed")

# --- Failure detail echoed to stdout (PAPER-08) --------------------
# A gate whose failures you cannot READ is a gate you cannot act on.
# In CI the `.gates/last-run/failures/*.txt` bundles are never
# uploaded, so the pipeline log has to be self-sufficient: after the
# one-line summary the supervisor echoes each failure's node-id and
# the tail of its captured detail.
#
# Bounds (a 500-failure run must not flood the log):
#   * every failing node-id is ALWAYS printed — never truncated away;
#   * the captured detail is printed for the first
#     PRINT_FAILURES_MAX_TESTS failures only, tail-truncated to
#     PRINT_FAILURES_TAIL_LINES lines each. The rest point at their
#     bundle. Worst case ≈ 20×(40+4) + N node-id lines.
PRINT_FAILURES_TAIL_LINES = 40
PRINT_FAILURES_MAX_TESTS = 20
# `auto` (default): print when stdout is NOT a TTY (piped → a CI log)
# or when CI= is set to something truthy. Interactive humans already
# have the bundles on disk one `cat` away, so auto stays quiet there.
PRINT_FAILURES_MODES = ("auto", "always", "never")
_CI_FALSEY = ("", "0", "false", "no", "off")


def _ci_env_truthy(env: dict[str, str] | None = None) -> bool:
    """Is the ubiquitous ``CI`` env var set to something truthy?

    Woodpecker / GitHub / GitLab / Drone all set ``CI=true``.
    """
    value = (env if env is not None else os.environ).get("CI")
    return value is not None and value.strip().lower() not in _CI_FALSEY


def _parse_bundle_sections(text: str) -> dict[str, str]:
    """Split a failure bundle back into ``{section: body}``.

    Only the CANONICAL headers (``records.SECTION_ORDER`` + the
    project-specific ``PERF …`` extras) start a section — a test's
    captured stdout may itself contain a ``=== something ===`` line
    and must not be mistaken for one (TS-27 bundles are grep-parsed
    by exactly this rule).
    """
    sections: dict[str, str] = {}
    current: str | None = None
    body: list[str] = []
    for line in text.splitlines():
        m = re.fullmatch(r"=== (.+) ===", line)
        name = m.group(1) if m else None
        if name is not None and (name in SECTION_ORDER or name.startswith("PERF")):
            if current is not None:
                sections[current] = "\n".join(body).strip("\n")
            current = name
            body = []
            continue
        if current is not None:
            body.append(line)
    if current is not None:
        sections[current] = "\n".join(body).strip("\n")
    return sections


@dataclass
class RunConfig:
    """All knobs for one mtest invocation. Populated by __main__
    from argparse + the project state.

    Example::

        cfg = RunConfig(repo_root=Path("."), gates_dir=Path(".gates"),
                        test_ids=test_ids, test_markers=markers, run_id=now_iso(),
                        pytest_cmd=["python", "-m", "pytest"])
    """

    repo_root: Path
    gates_dir: Path
    test_ids: list[str]
    test_markers: dict[str, tuple[str, ...]]
    run_id: str
    pytest_cmd: list[str]
    chunk_size: int = DEFAULT_CHUNK_SIZE
    fail_fast: bool = False  # `--fail-fast`: stop after first failure
    verbose: bool = False
    perf: bool = False  # `--perf`: capture per-test perf attribution (TS-28)
    # Per-test wall-clock budget the OUTER supervisor allows before it kills a
    # chunk (mirrors the project's pytest-timeout default). Overridable so a
    # caller that knows a run is intentionally slow/fast can size it (the
    # `--self-check` hang scenario uses a small value to stay cheap).
    per_test_timeout_s: float = DEFAULT_PER_TEST_TIMEOUT_S
    # Tests the selector excluded because they are quarantined (TS-25).
    # The supervisor emits one `quarantined` TestRecord (severity WARN)
    # per entry into run.jsonl so the exclusion is VISIBLE in the run's
    # structured log — a gate that silently drops flaky tests is a
    # false-green vector (M2 Wave-2 quarantine honesty).
    quarantined_ids: tuple[str, ...] = field(default_factory=tuple)
    # `--resume` (PAPER-05): preserve the interrupted run's record instead
    # of wiping it — run.jsonl is appended to, failure bundles are kept, and
    # the prior outcomes (from state.json) seed the end-of-run summary so
    # the record covers the FULL suite across the interruption, not just
    # the resumed subset. A failure recorded before the interruption still
    # reds the resumed run's exit code.
    resume: bool = False
    # Echo each failure's captured detail (node-id + traceback tail) to
    # stdout after the summary (PAPER-08). One of PRINT_FAILURES_MODES:
    #   "auto"   — print when stdout is not a TTY, or CI= is truthy;
    #   "always" — force (`--print-failures`);
    #   "never"  — suppress (`--no-print-failures`).
    print_failures: str = "auto"


@dataclass
class ChunkResult:
    """Outcome of running one chunk of tests.

    Example::

        # Returned per chunk by the supervisor; inspect:
        result.returncode, result.signal_name, result.hit_timeout
    """

    test_ids: list[str]
    junit_xml_path: Path
    returncode: int
    signal_name: str | None
    wall_time_s: float
    stdout: str
    stderr: str
    hit_timeout: bool
    pytest_pid: int | None
    # Output-activity evidence for the hang heuristic (M2 Wave-2):
    # seconds between the child's last output and its exit, and the
    # derived "silent in the final HANG_SILENCE_S window" verdict
    # (burst-tolerant — see module constants). Defaults keep older
    # call sites / fixtures constructible.
    last_output_age_s: float = 0.0
    no_recent_output: bool = False


class Supervisor:
    """The driver. One Supervisor per `mtest` invocation.

    Example::

        exit_code = Supervisor(cfg).run()
    """

    def __init__(self, cfg: RunConfig) -> None:
        self.cfg = cfg
        self.last_run_dir = cfg.gates_dir / "last-run"
        self.last_run_dir.mkdir(parents=True, exist_ok=True)
        (self.last_run_dir / "failures").mkdir(exist_ok=True)
        self.jsonl = JsonlWriter(self.last_run_dir / "run.jsonl")
        # Perf attribution (TS-28). The dir is where mgf-common's pytest
        # plugin writes per-test artifacts; we read them back out-of-process.
        self.perf_dir = self.last_run_dir / "perf"
        self.perf_store = perf.PerfStore(self.perf_dir) if cfg.perf else None
        self.durations = DurationStore(cfg.gates_dir / "durations.json")
        self.quarantine = QuarantineStore(cfg.gates_dir / "quarantine.json")
        self.state_path = cfg.gates_dir / "state.json"
        # Track completed test_ids for resume.
        self._completed: set[str] = set()
        # Track outcomes for the end-of-run summary.
        self._outcomes: dict[str, str] = {}
        # Failing records from THIS run, keyed by test_id — the source
        # for the stdout failure echo (PAPER-08). Failures inherited from
        # an interrupted run via --resume are not in here; their detail is
        # read back from the failure bundle on disk (which --resume keeps).
        self._failure_records: dict[str, TestRecord] = {}
        # Wire signal handlers so Ctrl-C flushes state.
        signal.signal(signal.SIGINT, self._on_signal)
        signal.signal(signal.SIGTERM, self._on_signal)

    def _on_signal(self, signum: int, _frame) -> None:  # type: ignore[no-untyped-def]
        """Flush state + exit cleanly. The supervisor's own
        reliability contract — never leave .gates/state.json
        half-written."""
        try:
            self._write_state()
            self.durations.save()
            self.quarantine.save()
        except Exception as e:
            print(f"mtest: signal handler flush failed: {e}", file=sys.stderr)
        sys.exit(130 if signum == signal.SIGINT else 143)

    def run(self) -> int:
        """Top-level driver. Returns process exit code:
        0 = all selected tests passed,
        1 = some test failed/errored/hung/crashed,
        2 = supervisor itself errored before completing all chunks.
        """
        resume_state = self._load_resume_state() if self.cfg.resume else None
        if resume_state is None:
            # Fresh run: wipe the last-run JSONL + failure bundles.
            run_path = self.last_run_dir / "run.jsonl"
            if run_path.exists():
                run_path.unlink()
            for f in (self.last_run_dir / "failures").iterdir():
                if f.is_file():
                    f.unlink()
            # Wipe stale perf artifacts so a prior run's tests don't pollute
            # this run's aggregate hotspot rollup.
            if self.cfg.perf:
                self.perf_dir.mkdir(parents=True, exist_ok=True)
                for f in self.perf_dir.glob("*.json"):
                    with contextlib.suppress(OSError):
                        f.unlink()
        else:
            # --resume (PAPER-05): the interrupted run's run.jsonl + failure
            # bundles stay in place (this run APPENDS), and its outcomes seed
            # the summary/rollup so they describe the whole suite. Records
            # for tests completed before the interruption are not lost.
            completed, prior_outcomes = resume_state
            self._completed.update(completed)
            self._outcomes.update(prior_outcomes)
            if self.cfg.perf:
                self.perf_dir.mkdir(parents=True, exist_ok=True)

        # Quarantine honesty (M2 Wave-2): every test the selector
        # excluded by quarantine gets a real `quarantined` record
        # (severity WARN) in run.jsonl — the exclusion is part of the
        # run's truth, not a silent subtraction from it.
        self._emit_quarantined_records()

        t_start = time.monotonic()
        chunks = [
            self.cfg.test_ids[i : i + self.cfg.chunk_size]
            for i in range(0, len(self.cfg.test_ids), self.cfg.chunk_size)
        ]
        if not chunks:
            print("mtest: no tests selected to run.")
            return 0

        # A failure recorded before an interruption (seeded above on
        # --resume) still reds the resumed run — resuming must not
        # launder a red partial run into a green completion.
        any_fail = any(o in _FAILURE_OUTCOMES for o in self._outcomes.values())
        for chunk_idx, chunk in enumerate(chunks):
            if self.cfg.verbose:
                print(f"mtest: chunk {chunk_idx + 1}/{len(chunks)} ({len(chunk)} tests)")
            result = self._run_chunk(chunk_idx, chunk)
            records = self._records_for_chunk(chunk, result)
            for rec in records:
                self.jsonl.append(rec)
                self._outcomes[rec.test_id] = rec.outcome
                self._completed.add(rec.test_id)
                # Update duration history — but NOT under --perf: cProfile
                # inflates each test's wall-time, so recording it would
                # corrupt the baseline the budget selector + auto-tier rely
                # on. A perf run is diagnostic, not a timing sample.
                if not self.cfg.perf:
                    self.durations.record(
                        rec.test_id,
                        rec.outcome,
                        rec.duration_ms,
                        self.cfg.run_id,
                    )
                # Quarantine tracks OUTCOMES (pass/fail), which perf doesn't
                # affect — so keep updating it regardless.
                summary_line = rec.summary or rec.code or ""
                self.quarantine.update_outcome(
                    rec.test_id,
                    rec.outcome,
                    summary_line,
                    now_iso(),
                )
                if rec.outcome in _FAILURE_OUTCOMES:
                    any_fail = True
                    self._failure_records[rec.test_id] = rec
                    self._write_failure_bundle(rec, result)
            self._write_state()
            if any_fail and self.cfg.fail_fast:
                break

        # Persist durations + quarantine post-run.
        self.durations.save()
        self.quarantine.save()

        # Summary report (human) + machine rollup (PAPER-05).
        elapsed = time.monotonic() - t_start
        self._write_summary(elapsed)
        self._write_machine_summary(elapsed)
        self._write_merged_junit(elapsed)
        self._print_terminal_summary(elapsed)
        self._print_failure_details()

        return 1 if any_fail else 0

    def _load_resume_state(self) -> tuple[set[str], dict[str, str]] | None:
        """The interrupted run's ``(completed, outcomes)`` from
        state.json, for ``--resume`` seeding (PAPER-05). None when
        there is nothing usable to resume from — the run proceeds
        as a fresh one."""
        try:
            payload = json.loads(self.state_path.read_text(encoding="utf-8"))
        except (OSError, ValueError):
            return None
        completed = payload.get("completed")
        outcomes = payload.get("outcomes")
        if not isinstance(completed, list) or not isinstance(outcomes, dict):
            return None
        return (
            {str(t) for t in completed},
            {str(k): str(v) for k, v in outcomes.items()},
        )

    def _emit_quarantined_records(self) -> None:
        """One `quarantined` TestRecord per selector-excluded test.

        The dead ``Outcome.QUARANTINED`` lives (Court decision, PLAN
        §1.4): consumers reading run.jsonl see WHICH tests the gate
        did not run and why, with severity WARN — attention-worthy,
        not a pass, not a silent hole.
        """
        entries = self.quarantine.all()
        for tid in self.cfg.quarantined_ids:
            if tid in self._outcomes:
                # --resume: the interrupted run already recorded this
                # exclusion in run.jsonl — don't duplicate it.
                continue
            entry = entries.get(tid)
            markers = self.cfg.test_markers.get(tid, ())
            since = entry.quarantined_at if entry and entry.quarantined_at else ""
            rec = TestRecord(
                test_id=tid,
                outcome="quarantined",
                severity=severity_for_outcome("quarantined"),
                duration_ms=0,
                tier=tier_for_markers(markers),
                markers=markers,
                code="QUARANTINED",
                summary=(
                    f"skipped by quarantine (quarantined at {since})"
                    if since
                    else "skipped by quarantine"
                ),
                detail=(entry.last_failure_summary if entry else None),
                why=(
                    "excluded from the gate by flake state (TS-25, 3+ failures "
                    "in the last 5 runs); `mtest --quarantine` shows the entry"
                ),
                remediation=(
                    "fix the flake, then run `mtest --quarantine-attempt` — "
                    "3 consecutive passes evict the test from quarantine"
                ),
                run_id=self.cfg.run_id,
                ts_iso=now_iso(),
            )
            self.jsonl.append(rec)
            self._outcomes[tid] = "quarantined"

    def _run_chunk(self, chunk_idx: int, test_ids: list[str]) -> ChunkResult:
        """Spawn pytest for one chunk; return raw outcome data
        (XML path, stdout/stderr, returncode, signal, wall_time)."""
        junit_path = self.last_run_dir / f"junit-{chunk_idx:04d}.xml"
        argv = [
            *self.cfg.pytest_cmd,
            "-p",
            "no:pytest_cov",
            "-p",
            "no:cacheprovider",
            "--junit-xml",
            str(junit_path),
            "-o",
            "addopts=",  # blank the project default `-m '...'` exclusion
            *test_ids,
        ]
        # Per-chunk wall clock = sum(per-test default timeout) + grace.
        # Per-test pytest-timeout still fires INSIDE pytest at the
        # individual-test level; this is the OUTER safety net.
        chunk_budget = len(test_ids) * self.cfg.per_test_timeout_s + _CHUNK_GRACE_S

        # Always mark the child so tests can detect they're running UNDER the
        # supervisor — lets a self-referential meta-test (one that itself
        # invokes mtest, e.g. a `--self-check` smoke test) skip rather than
        # nest mtest-in-mtest (which over-runs the per-test wall-clock budget
        # and gets reported as a false `hung`). Plain `pytest` runs never set
        # this, so the meta-test still runs there.
        #
        # When perf capture is on, also tell mgf-common's pytest plugin (in the
        # subprocess) to write artifacts into our run dir. Harmless if the
        # plugin isn't installed — the env vars are simply unread.
        child_env = {**os.environ, "MGF_TEST_SUPERVISOR_ACTIVE": "1"}
        if self.cfg.perf:
            child_env["MGF_PERF"] = "1"
            child_env["MGF_PERF_DIR"] = str(self.perf_dir)

        t0 = time.monotonic()
        try:
            proc = subprocess.Popen(
                argv,
                cwd=str(self.cfg.repo_root),
                env=child_env,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                # Binary pipes: the drain loop below reads with os.read
                # (non-blocking via selectors) and decodes once at the
                # end — text-mode buffered readers don't mix with
                # select-style readiness.
                # Put pytest + any subprocess a test itself spawns into a
                # NEW session/group so the timeout path can signal the
                # whole group (os.killpg), not just the pytest parent.
                # Without this, a test that spawns a child and hangs leaves
                # an orphan holding the stdout/stderr pipes open, and the
                # post-SIGKILL drain would block.
                start_new_session=True,
            )
        except OSError as e:
            return ChunkResult(
                test_ids=test_ids,
                junit_xml_path=junit_path,
                returncode=-1,
                signal_name="SPAWN_FAILED",
                wall_time_s=0.0,
                stdout="",
                stderr=f"pytest spawn failed: {e}",
                hit_timeout=False,
                pytest_pid=None,
            )

        drain = _OutputDrain(proc)
        hit_timeout = not drain.drain_until(t0 + chunk_budget)
        if hit_timeout:
            _signal_group(proc, signal.SIGTERM)
            if not drain.drain_until(time.monotonic() + SIGTERM_GRACE_S):
                _signal_group(proc, signal.SIGKILL)
                # A test-spawned child can STILL hold the pipes open after
                # a group SIGKILL (rare). The bounded drain means it cannot
                # wedge the whole run — we record what we have; the chunk
                # is reported as hung (hit_timeout=True) and the run
                # continues.
                drain.drain_until(time.monotonic() + _POST_KILL_DRAIN_S)

        exit_t = time.monotonic()
        wall = exit_t - t0
        stdout, stderr = drain.decoded()
        rc = proc.returncode
        sig_name = None
        if rc is not None and rc < 0:
            sig_name = _signal_name(-rc)

        return ChunkResult(
            test_ids=test_ids,
            junit_xml_path=junit_path,
            returncode=rc if rc is not None else 0,
            signal_name=sig_name,
            wall_time_s=wall,
            stdout=stdout,
            stderr=stderr,
            hit_timeout=hit_timeout,
            pytest_pid=proc.pid,
            last_output_age_s=drain.last_output_age(exit_t),
            no_recent_output=drain.silent_before(exit_t),
        )

    def _records_for_chunk(
        self,
        chunk: list[str],
        result: ChunkResult,
    ) -> list[TestRecord]:
        """Build TestRecord per test in the chunk from JUnit XML
        + outcome inference for tests that didn't get an XML entry
        (hang/crash mid-chunk).

        Classification when a test is missing from the XML:
          * Chunk hit our outer wall-clock timeout → `hung`.
          * The INTERNAL pytest-timeout heuristic (all three legs
            required, M2 Wave-2 — see below) → `hung`.
          * Chunk exited via a fatal signal (segfault, abort) → `crashed`.
          * Anything else (clean exit but no XML for some test) →
            `error` with the chunk metadata; the test never
            ran in pytest at all.

        The internal-timeout heuristic detects pytest-timeout firing
        inside the chunk (its thread method dumps stacks then calls
        os._exit(1), skipping XML writing). It requires ALL of:

          1. wall-clock >= 0.95 x the per-test timeout (was 0.9 —
             a multi-test chunk legitimately busy for ~27s of a 30s
             budget could trip it),
          2. exit code != 0 with no fatal signal,
          3. NO output in the final HANG_SILENCE_S seconds
             (burst-tolerant — pytest-timeout's stack dump right
             before death doesn't count as activity; see
             ChunkResult.no_recent_output). A busy slow chunk keeps
             trickling pytest progress output and therefore is NOT
             classified hung — it falls through to `crashed` /
             `error` with the real exit metadata instead of a
             false-red hang verdict.
        """
        seen_in_xml = self._parse_junit(result.junit_xml_path)
        # Pull in any perf artifacts the chunk's subprocess just wrote.
        if self.perf_store is not None:
            self.perf_store.refresh()
        internal_timeout_fired = (
            not result.hit_timeout
            and result.signal_name is None
            and result.returncode != 0
            and result.wall_time_s >= self.cfg.per_test_timeout_s * 0.95
            and result.no_recent_output
        )
        out: list[TestRecord] = []
        for test_id in chunk:
            xml_data = seen_in_xml.get(_node_id_to_classkey(test_id))
            if xml_data is not None:
                out.append(self._record_from_xml(test_id, xml_data, result))
            else:
                # Not in XML: chunk crashed/hung before pytest got to
                # serialise this test. Mark accordingly.
                if result.hit_timeout:
                    outcome = "hung"
                    code = "WALL_CLOCK_HUNG"
                elif internal_timeout_fired:
                    outcome = "hung"
                    code = "PYTEST_TIMEOUT_FIRED"
                elif result.signal_name in ("SIGSEGV", "SIGABRT", "SIGBUS", "SIGILL", "SIGFPE"):
                    outcome = "crashed"
                    code = f"CHUNK_{result.signal_name}"
                elif result.returncode != 0:
                    outcome = "crashed"
                    code = (
                        f"CHUNK_{result.signal_name}"
                        if result.signal_name
                        else f"CHUNK_EXIT_{result.returncode}"
                    )
                else:
                    outcome = "error"
                    code = "MISSING_FROM_XML"
                out.append(
                    TestRecord(
                        test_id=test_id,
                        outcome=outcome,
                        severity="FAIL",
                        duration_ms=0,
                        tier=tier_for_markers(self.cfg.test_markers.get(test_id, ())),
                        markers=self.cfg.test_markers.get(test_id, ()),
                        code=code,
                        summary=(
                            f"chunk wall-clock exceeded ({result.wall_time_s:.1f}s)"
                            if result.hit_timeout
                            else f"chunk exited with {result.signal_name or result.returncode}"
                        ),
                        detail=(
                            result.stdout[-2000:] + "\n--- stderr ---\n" + result.stderr[-2000:]
                        ),
                        captured_stdout=result.stdout[-4000:],
                        captured_stderr=result.stderr[-4000:],
                        perf=self._perf_for(test_id),
                        run_id=self.cfg.run_id,
                        ts_iso=now_iso(),
                    )
                )
        return out

    def _perf_for(self, test_id: str) -> dict[str, object] | None:
        """The perf artifact for a test, or None when capture is off."""
        return self.perf_store.get(test_id) if self.perf_store is not None else None

    def _parse_junit(self, path: Path) -> dict[tuple[str, str], _JunitTestcase]:
        """Parse pytest's JUnit XML, keyed by the canonical
        ``(classname, name)`` pair pytest emits per ``<testcase>``.

        Reconciliation used to reconstruct a pytest node-id string from
        the dotted ``classname`` + a ``file`` attribute. But pytest's
        JUnit output omits ``file`` by default, so the fallback turned
        ``tests.test_x.TestY`` into ``tests/test_x/TestY.py`` — correct
        for module-level tests by accident, but WRONG for class-based
        tests, which then never matched their collected node-id and were
        flagged ``MISSING_FROM_XML`` → silently quarantined. Keying both
        sides by ``(classname, name)`` (see :func:`_node_id_to_classkey`)
        removes the fragile path reconstruction entirely.
        """
        if not path.exists():
            return {}
        try:
            tree = ET.parse(str(path))  # noqa: S314 — pytest-controlled file
        except (ET.ParseError, OSError) as e:
            print(f"mtest: failed to parse {path}: {e}", file=sys.stderr)
            return {}
        out: dict[tuple[str, str], _JunitTestcase] = {}
        for tc in tree.getroot().iter("testcase"):
            classname = tc.attrib.get("classname", "")
            name = tc.attrib.get("name", "")
            time_s = float(tc.attrib.get("time", "0") or "0")

            failure = tc.find("failure")
            error = tc.find("error")
            skipped = tc.find("skipped")
            outcome = "passed"
            message = None
            longrepr = None
            if failure is not None:
                outcome = "failed"
                message = failure.attrib.get("message", "")
                longrepr = (failure.text or "").strip()
            elif error is not None:
                outcome = "error"
                message = error.attrib.get("message", "")
                longrepr = (error.text or "").strip()
            elif skipped is not None:
                outcome = "skipped"
                message = skipped.attrib.get("message", "")
            sys_out = tc.find("system-out")
            sys_err = tc.find("system-err")
            out[(classname, name)] = _JunitTestcase(
                outcome=outcome,
                duration_s=time_s,
                message=message,
                longrepr=longrepr,
                stdout=(sys_out.text or "") if sys_out is not None else "",
                stderr=(sys_err.text or "") if sys_err is not None else "",
            )
        return out

    def _record_from_xml(
        self,
        test_id: str,
        xml: _JunitTestcase,
        chunk: ChunkResult,
    ) -> TestRecord:
        markers = self.cfg.test_markers.get(test_id, ())
        code = None
        if xml.outcome in ("failed", "error") and xml.longrepr:
            code = _failure_code(xml.message or "", xml.longrepr)
        return TestRecord(
            test_id=test_id,
            outcome=xml.outcome,
            severity=severity_for_outcome(xml.outcome),
            duration_ms=int(xml.duration_s * 1000),
            tier=tier_for_markers(markers),
            markers=markers,
            code=code,
            summary=xml.message[:200] if xml.message else None,
            detail=xml.longrepr,
            captured_stdout=xml.stdout[-4000:] if xml.stdout else "",
            captured_stderr=xml.stderr[-4000:] if xml.stderr else "",
            perf=self._perf_for(test_id),
            run_id=self.cfg.run_id,
            ts_iso=now_iso(),
        )

    def _write_failure_bundle(self, rec: TestRecord, chunk: ChunkResult) -> None:
        """One file per failure with all the diagnostic context.

        Emitted THROUGH the vendored ``records.SECTION_ORDER`` /
        ``SECTION_HEADER_FMT`` (byte-pinned against
        ``mgf.common.testing`` by the alignment tests): every
        canonical section appears, in order, with the exact
        ``=== <section> ===`` header — empty sections still get
        their header (body ``(empty)``) so downstream grep-parsers
        can rely on the structure (TS-27). The writer previously
        emitted 19 ad-hoc lowercase sections that matched zero
        headers of the schema it claimed to implement (M2 Wave-2
        schema-truth fix). Project-specific extras (perf
        attribution) go AFTER the canonical set, per
        ``FailureBundle.sections`` semantics.
        """
        safe_id = _safe_filename(rec.test_id)
        path = self.last_run_dir / "failures" / f"{safe_id}.txt"
        outcome_lines = [
            rec.outcome + (f" ({rec.code})" if rec.code else ""),
            f"summary: {rec.summary or '(none)'}",
            f"duration_ms: {rec.duration_ms}",
            f"tier: {rec.tier}",
            f"markers: {', '.join(rec.markers) or '(none)'}",
            f"chunk wall-time: {chunk.wall_time_s:.2f}s",
            f"chunk exit code: {chunk.returncode}",
            f"chunk signal: {chunk.signal_name or '-'}",
        ]
        section_bodies: dict[str, str] = {
            "TEST": rec.test_id,
            "OUTCOME": "\n".join(outcome_lines),
            "TRACEBACK": rec.detail or "",
            # Last-4KB tails by convention (the record fields are
            # already tail-truncated); headers stay EXACTLY the
            # SECTION_ORDER names — never annotated.
            "STDOUT": rec.captured_stdout or "",
            "STDERR": rec.captured_stderr or "",
            "PROCESS STATE": diagnostics.daemon_state(),
            "DMESG TAIL": diagnostics.dmesg_tail(30),
            "ENV": diagnostics.env_fingerprint(),
            "HISTORY": diagnostics.recent_history(
                rec.test_id, self.cfg.gates_dir / "durations.json"
            ),
            "NEXT STEPS": (
                "See docs/testing/DIAGNOSING_HANGS.md (mgf-common) for "
                "failure-mode recipes, and docs/testing/SUPERVISOR_DESIGN.md "
                "(mgf-test-supervisor) for how this outcome was classified."
            ),
        }
        extra_sections: list[tuple[str, str]] = []
        if rec.perf is not None:
            extra_sections.append(("PERF ATTRIBUTION (TS-28)", perf.render_perf_section(rec.perf)))
        try:
            lines = []
            for section in SECTION_ORDER:
                lines.append(SECTION_HEADER_FMT.format(name=section))
                body = section_bodies[section]
                lines.append(body if body else "(empty)")
                lines.append("")
            for name, body in extra_sections:
                lines.append(SECTION_HEADER_FMT.format(name=name))
                lines.append(body if body else "(empty)")
                lines.append("")
            path.write_text("\n".join(lines), encoding="utf-8")
        except OSError as e:
            print(f"mtest: failed to write failure bundle for {rec.test_id}: {e}", file=sys.stderr)

    def _write_state(self) -> None:
        """Atomic checkpoint for --resume. Written after every chunk."""
        import json

        payload = {
            "run_id": self.cfg.run_id,
            "completed": sorted(self._completed),
            "outcomes": dict(self._outcomes),
        }
        try:
            fd, tmp = tempfile.mkstemp(
                prefix=".state-",
                suffix=".json.tmp",
                dir=str(self.cfg.gates_dir),
            )
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                json.dump(payload, f, indent=2)
            Path(tmp).replace(self.state_path)
        except OSError as e:
            print(f"mtest: failed to write state: {e}", file=sys.stderr)

    def _write_summary(self, elapsed_s: float) -> None:
        counts: dict[str, int] = {}
        for o in self._outcomes.values():
            counts[o] = counts.get(o, 0) + 1
        lines = [
            "# mtest run summary",
            "",
            f"- run_id: `{self.cfg.run_id}`",
            f"- duration: {elapsed_s:.2f}s",
            f"- tests run: {len(self._outcomes)}",
            "- outcomes:",
        ]
        for outcome in sorted(counts):
            lines.append(f"  - {outcome}: {counts[outcome]}")
        lines.append("")
        # Failure list
        fails = [(tid, o) for tid, o in self._outcomes.items() if o in _FAILURE_OUTCOMES]
        if fails:
            lines.append("## Failures")
            lines.append("")
            gates = self._gates_display()
            for tid, o in sorted(fails):
                safe = _safe_filename(tid)
                lines.append(f"- `{tid}` — {o} — see `{gates}/last-run/failures/{safe}.txt`")
        try:
            (self.last_run_dir / "summary.md").write_text(
                "\n".join(lines),
                encoding="utf-8",
            )
        except OSError as e:
            print(f"mtest: failed to write summary: {e}", file=sys.stderr)

        # Run-level perf rollup (TS-28), when capture was on + produced data.
        if self.perf_store:
            perf_md = perf.render_run_summary(self.perf_store)
            if perf_md:
                try:
                    (self.last_run_dir / "perf-summary.md").write_text(
                        perf_md,
                        encoding="utf-8",
                    )
                except OSError as e:
                    print(f"mtest: failed to write perf summary: {e}", file=sys.stderr)

    def _write_machine_summary(self, elapsed_s: float) -> None:
        """CI-consumable rollup — ``summary.json`` (PAPER-05).

        Counts + per-outcome test-id lists, so a CI system or dashboard
        can ingest the run's verdict without re-parsing the human
        ``summary.md`` prose. Covers the FULL suite across ``--resume``
        (outcomes are seeded from the interrupted run's checkpoint).
        """
        by_outcome: dict[str, list[str]] = {}
        for tid in sorted(self._outcomes):
            by_outcome.setdefault(self._outcomes[tid], []).append(tid)
        payload: dict[str, object] = {
            "schema_version": 1,
            "run_id": self.cfg.run_id,
            "duration_s": round(elapsed_s, 3),
            "total": len(self._outcomes),
            "counts": {o: len(ids) for o, ids in by_outcome.items()},
            "outcomes": by_outcome,
            "ok": not any(o in _FAILURE_OUTCOMES for o in self._outcomes.values()),
        }
        try:
            (self.last_run_dir / "summary.json").write_text(
                json.dumps(payload, indent=2, sort_keys=True) + "\n",
                encoding="utf-8",
            )
        except OSError as e:
            print(f"mtest: failed to write summary.json: {e}", file=sys.stderr)

    def _write_merged_junit(self, elapsed_s: float) -> None:
        """One consolidated ``junit.xml`` for the whole run (PAPER-05).

        Built from run.jsonl RECORDS, not by concatenating the per-chunk
        ``junit-NNNN.xml`` debugging aids: the records carry the
        supervisor's inferred outcomes (``hung`` / ``crashed`` /
        ``quarantined``) that never made it into any chunk's XML, so this
        file is the honest machine mirror of the run — and because
        run.jsonl is preserved across ``--resume``, it covers the full
        suite after an interruption too.

        Outcome mapping: passed → plain testcase; failed → ``<failure>``;
        error/hung/crashed → ``<error>``; skipped/quarantined →
        ``<skipped>`` (the quarantine exclusion keeps its message so it
        can't be mistaken for an ordinary skip).
        """
        suite = ET.Element("testsuite", {"name": "mtest"})
        tests = failures = errors = skipped = 0
        for raw in read_jsonl(self.last_run_dir / "run.jsonl"):
            test_id = str(raw.get("test_id", ""))
            outcome = str(raw.get("outcome", ""))
            classname, name = _node_id_to_classkey(test_id)
            duration_ms = raw.get("duration_ms", 0)
            time_s = (int(duration_ms) if isinstance(duration_ms, int) else 0) / 1000.0
            tc = ET.SubElement(
                suite,
                "testcase",
                {"classname": classname, "name": name, "time": f"{time_s:.3f}"},
            )
            tests += 1
            message = str(raw.get("summary") or raw.get("code") or outcome)
            detail = str(raw.get("detail") or "")
            if outcome == "failed":
                failures += 1
                el = ET.SubElement(tc, "failure", {"message": message})
                el.text = detail
            elif outcome in ("error", "hung", "crashed"):
                errors += 1
                el = ET.SubElement(tc, "error", {"message": f"{outcome}: {message}"})
                el.text = detail
            elif outcome in ("skipped", "quarantined"):
                skipped += 1
                ET.SubElement(tc, "skipped", {"message": f"{outcome}: {message}"})
        suite.set("tests", str(tests))
        suite.set("failures", str(failures))
        suite.set("errors", str(errors))
        suite.set("skipped", str(skipped))
        suite.set("time", f"{elapsed_s:.3f}")
        root = ET.Element("testsuites")
        root.append(suite)
        try:
            ET.ElementTree(root).write(
                str(self.last_run_dir / "junit.xml"),
                encoding="utf-8",
                xml_declaration=True,
            )
        except OSError as e:
            print(f"mtest: failed to write junit.xml: {e}", file=sys.stderr)

    def _print_terminal_summary(self, elapsed_s: float) -> None:
        counts: dict[str, int] = {}
        for o in self._outcomes.values():
            counts[o] = counts.get(o, 0) + 1
        passed = counts.get("passed", 0)
        failed = sum(counts.get(k, 0) for k in ("failed", "error"))
        hung = counts.get("hung", 0)
        crashed = counts.get("crashed", 0)
        skipped = counts.get("skipped", 0)
        quarantined = counts.get("quarantined", 0)
        total = len(self._outcomes)
        bits = [f"{passed} passed"]
        if failed:
            bits.append(f"{failed} failed")
        if hung:
            bits.append(f"{hung} HUNG")
        if crashed:
            bits.append(f"{crashed} CRASHED")
        if skipped:
            bits.append(f"{skipped} skipped")
        if quarantined:
            # Quarantined is WARN, not a pass — say it separately so the
            # one-line summary can't launder a flake-hole as "skipped".
            bits.append(f"{quarantined} quarantined (WARN)")
        marker = "OK" if (failed + hung + crashed) == 0 else "FAIL"
        gates = self._gates_display()
        print(f"\n[mtest] {marker}: {', '.join(bits)} ({total} total) in {elapsed_s:.2f}s")
        print(
            f"[mtest] details: {gates}/last-run/summary.md "
            f"(+ {gates}/last-run/failures/*.txt on failures)"
        )
        print(f"[mtest] machine: {gates}/last-run/summary.json + junit.xml")
        if self.perf_store:
            print(
                f"[mtest] perf: {gates}/last-run/perf-summary.md "
                f"(+ per-test artifacts in {gates}/last-run/perf/)"
            )

    # -- failure detail on stdout (PAPER-08) ---------------------------

    def _gates_display(self) -> str:
        """The gates dir as the reader should type it.

        The pointer lines used to hardcode ``.gates/`` — a lie under
        ``MGF_GATES_DIR=/somewhere/else``, which is exactly the CI
        configuration whose log the pointer exists to serve. Print the
        path relative to the CWD when it is under it, else absolute.
        """
        try:
            return str(self.cfg.gates_dir.resolve().relative_to(Path.cwd().resolve()))
        except (ValueError, OSError):
            return str(self.cfg.gates_dir)

    def _should_print_failures(self) -> bool:
        mode = self.cfg.print_failures
        if mode == "never":
            return False
        if mode == "always":
            return True
        # "auto": a CI log (stdout piped / CI= set) needs the detail
        # inline — the bundles under .gates/ are never uploaded.
        try:
            is_tty = sys.stdout.isatty()
        except (AttributeError, ValueError):  # pragma: no cover - detached stdout
            is_tty = False
        return _ci_env_truthy() or not is_tty

    def _failure_detail_for(self, test_id: str) -> tuple[str, str]:
        """``(summary_line, detail)`` for a failing test.

        Preferred source is this run's in-memory ``TestRecord``. A
        failure inherited from an interrupted run (``--resume``) has no
        record here — read its bundle back off disk instead, which also
        proves the bundle pointer we print is real.
        """
        rec = self._failure_records.get(test_id)
        if rec is not None:
            return (rec.summary or "", rec.detail or "")
        path = self.last_run_dir / "failures" / f"{_safe_filename(test_id)}.txt"
        try:
            sections = _parse_bundle_sections(path.read_text(encoding="utf-8"))
        except OSError:
            return ("", "")
        summary = ""
        for line in sections.get("OUTCOME", "").splitlines():
            if line.startswith("summary: "):
                summary = line[len("summary: ") :]
                break
        detail = sections.get("TRACEBACK", "")
        return (summary, "" if detail == "(empty)" else detail)

    def _print_failure_details(self) -> None:
        """Echo every failure's node-id (+ detail tail) to stdout.

        The one-line ``[mtest] FAIL: …`` summary tells you THAT the gate
        is red; a CI log that never sees ``.gates/`` cannot tell you
        WHICH test or WHY. This closes that (PAPER-08): node-ids always,
        detail tails bounded (see PRINT_FAILURES_* above).
        """
        fails = sorted(tid for tid, o in self._outcomes.items() if o in _FAILURE_OUTCOMES)
        if not fails or not self._should_print_failures():
            return
        gates = self._gates_display()
        print(f"\n[mtest] ===== failure detail — {len(fails)} failing test(s) =====")
        for i, tid in enumerate(fails, start=1):
            outcome = self._outcomes[tid]
            summary, detail = self._failure_detail_for(tid)
            bundle = f"{gates}/last-run/failures/{_safe_filename(tid)}.txt"
            print(f"\n[mtest] --- {i}/{len(fails)} {outcome.upper()}: {tid}")
            if summary:
                print(f"[mtest]     {summary.splitlines()[0][:200]}")
            print(f"[mtest]     bundle: {bundle}")
            if i > PRINT_FAILURES_MAX_TESTS:
                print(
                    f"[mtest]     (detail withheld — only the first "
                    f"{PRINT_FAILURES_MAX_TESTS} failures are expanded; "
                    f"read the bundle above)"
                )
                continue
            lines = detail.splitlines()
            if not lines:
                print("[mtest]     (no captured detail — see the bundle for process/env context)")
                continue
            if len(lines) > PRINT_FAILURES_TAIL_LINES:
                print(
                    f"[mtest]     … {len(lines) - PRINT_FAILURES_TAIL_LINES} earlier "
                    f"line(s) elided — last {PRINT_FAILURES_TAIL_LINES} follow:"
                )
                lines = lines[-PRINT_FAILURES_TAIL_LINES:]
            for line in lines:
                print(f"    {line}")
        print(f"\n[mtest] ===== end failure detail ({len(fails)} failing) =====")


@dataclass
class _JunitTestcase:
    outcome: str
    duration_s: float
    message: str | None
    longrepr: str | None
    stdout: str
    stderr: str


def _split_nodeid(test_id: str) -> list[str]:
    """Split a pytest node-id on its structural ``::`` separators only.

    ``::`` also appears *inside* a parametrise suffix when the parameter
    value is IPv6-ish — ``test_conn[::1]``,
    ``test_v6[2001:db8::1]`` — and a naive ``str.split("::")`` shreds the
    id there, corrupting the ``(classname, name)`` key and false-reding the
    coverage gate. The ``[param]`` suffix only ever rides the final path
    component, so split on ``::`` at bracket-depth 0 and leave anything
    inside ``[...]`` intact.
    """
    parts: list[str] = []
    buf: list[str] = []
    depth = 0
    i = 0
    n = len(test_id)
    while i < n:
        ch = test_id[i]
        if ch == "[":
            depth += 1
            buf.append(ch)
        elif ch == "]":
            depth = max(0, depth - 1)
            buf.append(ch)
        elif depth == 0 and ch == ":" and i + 1 < n and test_id[i + 1] == ":":
            parts.append("".join(buf))
            buf = []
            i += 2
            continue
        else:
            buf.append(ch)
        i += 1
    parts.append("".join(buf))
    return parts


def _node_id_to_classkey(test_id: str) -> tuple[str, str]:
    """Map a pytest node-id to the ``(classname, name)`` key JUnit uses.

    Node-id ``tests/test_x.py::TestY::test_z`` → ``("tests.test_x.TestY",
    "test_z")``; module-level ``tests/test_x.py::test_z`` →
    ``("tests.test_x", "test_z")``; nested classes join with ``.``.
    Parametrised ids keep the ``[param]`` suffix in ``name`` (it lives in
    JUnit's ``name`` attribute, not ``classname``). This is the inverse of
    how pytest builds ``classname``/``name``, so it matches exactly without
    relying on the (often-absent) JUnit ``file`` attribute.
    """
    parts = _split_nodeid(test_id)
    file_part = parts[0]
    module_dotted = (
        file_part[:-3].replace("/", ".")
        if file_part.endswith(".py")
        else file_part.replace("/", ".")
    )
    name = parts[-1]
    classes = parts[1:-1]
    classname = module_dotted + ("." + ".".join(classes) if classes else "")
    return (classname, name)


def _failure_code(message: str, longrepr: str) -> str:
    """Extract a short, stable error tag from pytest's failure text.

    Patterns we look for, in order:
      * The first `E   ExceptionType:` line in the longrepr.
      * The exception name at the start of the message.
      * Fallback: "GENERIC_FAILURE".
    """
    if longrepr:
        match = re.search(r"^E\s+([A-Za-z_][A-Za-z0-9_.]*Error)\b", longrepr, re.MULTILINE)
        if match:
            return match.group(1)
        match = re.search(r"^E\s+([A-Za-z_][A-Za-z0-9_.]*Exception)\b", longrepr, re.MULTILINE)
        if match:
            return match.group(1)
    if message:
        first_word = message.split(":", 1)[0].split()[-1] if message else ""
        if first_word and re.match(r"^[A-Za-z_][A-Za-z0-9_.]*$", first_word):
            return first_word
    return "GENERIC_FAILURE"


def _safe_filename(test_id: str) -> str:
    """Make a test_id safe to use as a file basename."""
    return re.sub(r"[^A-Za-z0-9._-]", "_", test_id)


def _signal_name(num: int) -> str:
    """Map a signal number to its name (SIGSEGV, SIGABRT, etc)."""
    try:
        return signal.Signals(num).name
    except ValueError:
        return f"SIG_{num}"


class _OutputDrain:
    """Incrementally drain a child's stdout/stderr, timestamping output
    activity, so the hang heuristic can tell "busy but slow" (steady
    pytest progress output) from "silent, then dead" (a hung test).

    POSIX: a ``selectors`` pump over the two pipes (binary reads,
    decoded once at the end). Non-POSIX platforms fall back to
    ``communicate`` — full output capture, but no activity timeline
    (``silent_before`` then reports False and the internal-timeout
    heuristic simply never fires there; the outer wall-clock hang
    detection is unaffected).
    """

    def __init__(self, proc: subprocess.Popen[bytes]) -> None:
        self._proc = proc
        self._chunks: dict[str, list[bytes]] = {"out": [], "err": []}
        self._start = time.monotonic()
        self._last_output = self._start
        self._big_gap_end: float | None = None
        self._streaming = os.name == "posix"
        self._open = 0
        if self._streaming:
            self._sel = selectors.DefaultSelector()
            for name, stream in (("out", proc.stdout), ("err", proc.stderr)):
                if stream is not None:
                    self._sel.register(stream, selectors.EVENT_READ, name)
                    self._open += 1

    def drain_until(self, deadline: float) -> bool:
        """Pump output until the child exits AND both pipes reach EOF,
        or ``deadline`` (monotonic) passes. True = fully reaped."""
        if not self._streaming:
            return self._drain_fallback(deadline)
        while True:
            now = time.monotonic()
            if now >= deadline:
                return False
            if self._open:
                events = self._sel.select(timeout=min(deadline - now, 0.25))
                got = time.monotonic()
                for key, _ in events:
                    self._read_one(key, got)
            elif self._proc.poll() is not None:
                return True
            else:
                # Pipes closed, child not yet reaped — poll cheaply.
                time.sleep(0.02)
            if not self._open and self._proc.poll() is not None:
                return True

    def _drain_fallback(self, deadline: float) -> bool:
        try:
            out, err = self._proc.communicate(timeout=max(deadline - time.monotonic(), 0.001))
        except subprocess.TimeoutExpired:
            return False
        self._chunks["out"] = [out or b""]
        self._chunks["err"] = [err or b""]
        return True

    def _read_one(self, key: selectors.SelectorKey, now: float) -> None:
        stream = key.fileobj
        try:
            data = os.read(stream.fileno(), 65536)  # type: ignore[union-attr]
        except OSError:
            data = b""
        if not data:
            self._sel.unregister(stream)
            with contextlib.suppress(OSError):
                stream.close()  # type: ignore[union-attr]
            self._open -= 1
            return
        gap = now - self._last_output
        if gap >= HANG_SILENCE_S:
            self._big_gap_end = now
        self._last_output = now
        self._chunks[str(key.data)].append(data)

    def decoded(self) -> tuple[str, str]:
        return (
            b"".join(self._chunks["out"]).decode("utf-8", errors="replace"),
            b"".join(self._chunks["err"]).decode("utf-8", errors="replace"),
        )

    def last_output_age(self, exit_t: float) -> float:
        """Seconds between the child's last output and ``exit_t``."""
        if not self._streaming:
            return 0.0
        return max(0.0, exit_t - self._last_output)

    def silent_before(self, exit_t: float) -> bool:
        """True when the child produced no output in the final
        :data:`HANG_SILENCE_S` seconds before ``exit_t``.

        Burst-tolerant: pytest-timeout's thread dump lands
        microseconds before its ``os._exit(1)``, so a hung chunk's
        LAST output is right at death. A burst that ended a
        >= HANG_SILENCE_S silent window and was followed by exit
        within :data:`HANG_BURST_GRACE_S` still counts as silence.
        """
        if not self._streaming:
            return False
        if exit_t - self._last_output >= HANG_SILENCE_S:
            return True
        return self._big_gap_end is not None and (exit_t - self._big_gap_end) <= HANG_BURST_GRACE_S


def _signal_group(proc: subprocess.Popen[bytes], sig: int) -> None:
    """Send ``sig`` to the whole process group of ``proc``.

    pytest is launched with ``start_new_session=True``, so it leads its
    own group; signalling the GROUP reaps any subprocess a test itself
    spawned (which a bare ``proc.send_signal`` would orphan). Falls back
    to signalling the single process where ``os.killpg``/``getpgid`` are
    unavailable (Windows) or the group is already gone. Never raises —
    the kill path must not become a new failure source.
    """
    killpg = getattr(os, "killpg", None)
    getpgid = getattr(os, "getpgid", None)
    if killpg is not None and getpgid is not None:
        try:
            killpg(getpgid(proc.pid), sig)
            return
        except (ProcessLookupError, PermissionError, OSError):
            pass
    with contextlib.suppress(ProcessLookupError, OSError):
        proc.send_signal(sig)


# --- Test-set collection -----------------------------------------------------


_COLLECT_LINE_RE = re.compile(r"^(.+?\.py::[A-Za-z_][\w\[\]:.\-]*)\s*$")

# A tiny pytest plugin, written to a temp dir and loaded via
# `-p _mgf_ts_collect_plugin` (temp dir prepended to the child's
# PYTHONPATH), that dumps `[[nodeid, [marker, ...]], ...]` JSON at
# the end of collection. ONE pytest spawn thus yields node-ids AND
# real markers — pre-Wave-2 the supervisor spawned pytest once per
# MARKER_TIER entry (13 extra startups per run; PLAN §2.3). The
# plugin is stdlib-only and runs inside the CONSUMER's pytest; it
# defines a hook function without importing pytest at all.
_MARKER_DUMP_PLUGIN = '''\
"""Marker-dump collection plugin, injected by mgf-test-supervisor.

Writes [[nodeid, [marker, ...]], ...] JSON to $MGF_TS_COLLECT_DUMP
after collection. Structural marks (parametrize / usefixtures /
filterwarnings) are omitted — they say how a test is built, not
what it is; taxonomy + tier selection only need the semantic marks.
"""
import json
import os

_STRUCTURAL = {"parametrize", "usefixtures", "filterwarnings"}


def pytest_collection_finish(session):
    path = os.environ.get("MGF_TS_COLLECT_DUMP")
    if not path:
        return
    items = []
    for item in session.items:
        try:
            names = sorted({m.name for m in item.iter_markers()} - _STRUCTURAL)
        except Exception:  # noqa: BLE001 - never break the consumer's collection
            names = []
        items.append([item.nodeid, names])
    try:
        with open(path, "w", encoding="utf-8") as f:
            json.dump(items, f)
    except OSError:
        pass
'''


def collect_tests(
    pytest_cmd: list[str],
    repo_root: Path,
) -> list[tuple[str, tuple[str, ...]]]:
    """Collect node-ids AND their markers in ONE pytest spawn.

    Runs ``pytest --collect-only -q --no-header`` with a temp-dir
    marker-dump plugin injected (see ``_MARKER_DUMP_PLUGIN``). The
    dump file is the primary source (real markers per test, pytest's
    own collection order). If the dump is missing — the plugin
    failed to load, e.g. an exotic conftest that clears PYTHONPATH —
    collection falls back to parsing the ``-q`` stdout listing with
    empty marker tuples, which is exactly the pre-Wave-2 degradation
    (tier falls back to DEFAULT_TIER); it never blocks the run.


    Example::

        collect_tests(["python", "-m", "pytest"], Path("."))
        # -> [(test_id, (marker, ...)), ...]
    """
    with tempfile.TemporaryDirectory(prefix="mtest-collect-") as td:
        plugin_dir = Path(td)
        (plugin_dir / "_mgf_ts_collect_plugin.py").write_text(_MARKER_DUMP_PLUGIN, encoding="utf-8")
        dump_path = plugin_dir / "collect-dump.json"
        child_env = {**os.environ, "MGF_TS_COLLECT_DUMP": str(dump_path)}
        existing_pp = child_env.get("PYTHONPATH")
        child_env["PYTHONPATH"] = (
            str(plugin_dir) + os.pathsep + existing_pp if existing_pp else str(plugin_dir)
        )
        argv = [
            *list(pytest_cmd),
            "--collect-only",
            "-q",
            "--no-header",
            "-p",
            "_mgf_ts_collect_plugin",
            "-p",
            "no:pytest_cov",
            "-p",
            "no:cacheprovider",
            "-o",
            "addopts=",
        ]
        try:
            result = subprocess.run(
                argv,
                cwd=str(repo_root),
                env=child_env,
                capture_output=True,
                text=True,
                timeout=60,
                check=False,
            )
        except (OSError, subprocess.TimeoutExpired) as e:
            print(f"mtest: collection failed: {e}", file=sys.stderr)
            return []

        out = _parse_collect_dump(dump_path)
        if out is None:
            # Fallback: parse the -q stdout listing (no markers).
            out = []
            for line in result.stdout.splitlines():
                match = _COLLECT_LINE_RE.match(line.strip())
                if match:
                    out.append((match.group(1), ()))
            if out:
                print(
                    "mtest: marker-dump plugin produced no data; collected "
                    f"{len(out)} tests from stdout with markers unknown "
                    "(tier falls back to the unmarked default).",
                    file=sys.stderr,
                )

    # Lesson L6 (mgf-apiprobe sibling-#7 adoption): collection silently
    # returned [] when pytest exited non-zero on an unrecognised flag
    # (`--no-cov` against a project without pytest-cov). The supervisor
    # then said "no tests" with no actionable diagnostic. NEVER suppress
    # collection-failure stderr — if collection yielded nothing AND the
    # subprocess exited non-zero, surface what pytest told us. The cost
    # is a few extra lines when the run is genuinely empty; the value
    # is that the next plugin-shape adopter sees the real error first.
    if not out and result.returncode != 0:
        print(
            f"mtest: collection returned no tests and pytest exited "
            f"{result.returncode}. pytest stderr follows:",
            file=sys.stderr,
        )
        for line in (result.stderr or "").splitlines():
            print(f"  {line}", file=sys.stderr)
        for line in (result.stdout or "").splitlines()[-20:]:
            print(f"  [stdout] {line}", file=sys.stderr)
    return out


def _parse_collect_dump(dump_path: Path) -> list[tuple[str, tuple[str, ...]]] | None:
    """Parse the marker-dump JSON. None = dump unusable (fall back)."""
    if not dump_path.exists():
        return None
    try:
        raw = json.loads(dump_path.read_text(encoding="utf-8"))
    except (OSError, ValueError) as e:
        print(f"mtest: collect dump unreadable ({e}); falling back", file=sys.stderr)
        return None
    if not isinstance(raw, list):
        return None
    out: list[tuple[str, tuple[str, ...]]] = []
    for row in raw:
        try:
            nodeid, names = row
            out.append((str(nodeid), tuple(str(n) for n in names)))
        except (TypeError, ValueError):
            continue
    return out


def collect_test_markers(
    pytest_cmd: list[str],
    repo_root: Path,
) -> dict[str, tuple[str, ...]]:
    """Marker map for all collected tests — ONE pytest spawn.

    Thin derivation over :func:`collect_tests` (which now returns
    real markers from the injected dump plugin). Pre-Wave-2 this
    spawned pytest once per ``MARKER_TIER`` entry — 13 collect-only
    startups per run (PLAN §2.3); the per-marker probing is gone.
    Tests with no (semantic) markers are omitted, matching the old
    contract — callers use ``.get(test_id, ())``.


    Example::

        collect_test_markers(["python", "-m", "pytest"], Path("."))
    """
    return {tid: markers for tid, markers in collect_tests(pytest_cmd, repo_root) if markers}


__all__ = [
    "DEFAULT_CHUNK_SIZE",
    "DEFAULT_PER_TEST_TIMEOUT_S",
    "SIGTERM_GRACE_S",
    "ChunkResult",
    "RunConfig",
    "Supervisor",
    "collect_test_markers",
    "collect_tests",
]
