"""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)
    summary.md         — human-readable summary written at end
    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 os
import re
import signal
import subprocess
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path

from . import diagnostics, perf
from .durations import DurationStore
from .quarantine import QuarantineStore
from .records import JsonlWriter, TestRecord, now_iso, 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


@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


@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


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] = {}
        # 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.
        """
        # Wipe the last-run JSONL — we're starting fresh. (Resume
        # uses state.json, not run.jsonl, so it's OK to overwrite.)
        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()

        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

        any_fail = False
        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 ("failed", "error", "hung", "crashed"):
                    any_fail = True
                    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.
        elapsed = time.monotonic() - t_start
        self._write_summary(elapsed)
        self._print_terminal_summary(elapsed)

        return 1 if any_fail else 0

    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 + 10.0

        # 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,
                text=True,
                # 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 communicate() blocks.
                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,
            )

        hit_timeout = False
        try:
            stdout, stderr = proc.communicate(timeout=chunk_budget)
        except subprocess.TimeoutExpired:
            hit_timeout = True
            _signal_group(proc, signal.SIGTERM)
            try:
                stdout, stderr = proc.communicate(timeout=SIGTERM_GRACE_S)
            except subprocess.TimeoutExpired:
                _signal_group(proc, signal.SIGKILL)
                try:
                    stdout, stderr = proc.communicate(timeout=5.0)
                except subprocess.TimeoutExpired:
                    # A test-spawned child is STILL holding the pipes open
                    # after a group SIGKILL (rare). Do not let it crash the
                    # whole run — record what we have; the chunk is reported
                    # as hung (hit_timeout=True) and the run continues.
                    stdout, stderr = "", ""

        wall = time.monotonic() - t0
        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 or 0,
            signal_name=sig_name,
            wall_time_s=wall,
            stdout=stdout or "",
            stderr=stderr or "",
            hit_timeout=hit_timeout,
            pytest_pid=proc.pid,
        )

    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`.
          * Chunk wall-time exceeded a single per-test timeout AND
            exit code is non-zero with no signal → pytest-timeout
            fired internally and called os._exit(1) (skipping XML
            writing). Treat as `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.
        """
        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()
        # Heuristic for pytest-timeout-killed-pytest case: a chunk
        # with one test that took >= DEFAULT_PER_TEST_TIMEOUT_S to
        # exit. The supervisor's per-test default mirrors the
        # project's pytest-timeout default (both 30s).
        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.9
        )
        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."""
        safe_id = _safe_filename(rec.test_id)
        path = self.last_run_dir / "failures" / f"{safe_id}.txt"
        sections = [
            ("test_id", rec.test_id),
            ("outcome", rec.outcome),
            ("code", rec.code or "(none)"),
            ("summary", rec.summary or "(none)"),
            ("duration_ms", str(rec.duration_ms)),
            ("tier", str(rec.tier)),
            ("markers", ", ".join(rec.markers) or "(none)"),
            ("traceback", rec.detail or "(none)"),
            ("captured stdout (last 4000 bytes)", rec.captured_stdout or "(empty)"),
            ("captured stderr (last 4000 bytes)", rec.captured_stderr or "(empty)"),
            ("daemon state at failure", diagnostics.daemon_state()),
            ("dmesg (last 30 lines)", diagnostics.dmesg_tail(30)),
            ("environment fingerprint", diagnostics.env_fingerprint()),
            (
                "recent history of this test",
                diagnostics.recent_history(rec.test_id, self.cfg.gates_dir / "durations.json"),
            ),
            ("chunk wall-time", f"{chunk.wall_time_s:.2f}s"),
            ("chunk pytest return code", str(chunk.returncode)),
            ("chunk pytest signal", chunk.signal_name or "(none)"),
            ("perf attribution (TS-28)", perf.render_perf_section(rec.perf)),
            (
                "suggested next steps",
                "See docs/testing/DIAGNOSING_HANGS.md for failure-mode recipes.",
            ),
        ]
        try:
            lines = []
            for title, body in sections:
                lines.append(f"=== {title} ===")
                lines.append(body or "(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 ("failed", "error", "hung", "crashed")
        ]
        if fails:
            lines.append("## Failures")
            lines.append("")
            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 _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 = sum(counts.get(k, 0) for k in ("skipped", "quarantined"))
        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")
        marker = "OK" if (failed + hung + crashed) == 0 else "FAIL"
        print(f"\n[mtest] {marker}: {', '.join(bits)} ({total} total) in {elapsed_s:.2f}s")
        print(
            "[mtest] details: .gates/last-run/summary.md "
            "(+ .gates/last-run/failures/*.txt on failures)"
        )
        if self.perf_store:
            print(
                "[mtest] perf: .gates/last-run/perf-summary.md "
                "(+ per-test artifacts in .gates/last-run/perf/)"
            )


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


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 = test_id.split("::")
    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}"


def _signal_group(proc: subprocess.Popen[str], 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*$")


def collect_tests(
    pytest_cmd: list[str],
    repo_root: Path,
) -> list[tuple[str, tuple[str, ...]]]:
    """Run `pytest --collect-only --quiet -p no:cacheprovider`,
    parse node-ids. Markers are looked up via a separate
    `--collect-only -q --co --markers`-ish step; for the supervisor
    today we accept markers may be empty for the budget knapsack
    (tier defaults to DEFAULT_TIER). A future pass adds proper
    marker collection via `-co --markers` or a custom conftest hook.


    Example::

        collect_tests(["python", "-m", "pytest"], Path("."))
        # -> [(test_id, (marker, ...)), ...]
    """
    argv = [
        *list(pytest_cmd),
        "--collect-only",
        "-q",
        "-p",
        "no:pytest_cov",
        "-p",
        "no:cacheprovider",
        "-o",
        "addopts=",
    ]
    try:
        result = subprocess.run(
            argv,
            cwd=str(repo_root),
            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: list[tuple[str, tuple[str, ...]]] = []
    for line in result.stdout.splitlines():
        match = _COLLECT_LINE_RE.match(line.strip())
        if match:
            out.append((match.group(1), ()))
    # 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 collect_test_markers(
    pytest_cmd: list[str],
    repo_root: Path,
) -> dict[str, tuple[str, ...]]:
    """Best-effort marker extraction.

    Uses `pytest --collect-only --co --quiet -m '<marker>'` per
    known marker, intersecting the result with `not <marker>` to
    discover which tests carry which markers. Cheap for the
    handful of markers we have (10).


    Example::

        collect_test_markers(["python", "-m", "pytest"], Path("."))
    """
    from .tiers import MARKER_TIER

    markers: dict[str, set[str]] = {}
    for marker in MARKER_TIER:
        argv = [
            *list(pytest_cmd),
            "--collect-only",
            "-q",
            "-p",
            "no:pytest_cov",
            "-p",
            "no:cacheprovider",
            "-m",
            marker,
            "-o",
            "addopts=",
        ]
        try:
            result = subprocess.run(
                argv,
                cwd=str(repo_root),
                capture_output=True,
                text=True,
                timeout=30,
                check=False,
            )
        except (OSError, subprocess.TimeoutExpired):
            continue
        matched_any = False
        for line in result.stdout.splitlines():
            m = _COLLECT_LINE_RE.match(line.strip())
            if m:
                markers.setdefault(m.group(1), set()).add(marker)
                matched_any = True
        # L6 lesson (see collect_tests): surface failures that
        # don't look like "marker selected nothing." When pytest
        # legitimately matches no tests for a marker, returncode is
        # still 0 (or 5 — "no tests collected"). We only flag
        # returncodes that indicate the invocation itself broke
        # (unknown-flag = 4, internal error = 3).
        if not matched_any and result.returncode in (3, 4):
            print(
                f"mtest: marker-collection for {marker!r} failed "
                f"(exit {result.returncode}). pytest stderr:",
                file=sys.stderr,
            )
            for line in (result.stderr or "").splitlines():
                print(f"  {line}", file=sys.stderr)
    return {tid: tuple(sorted(s)) for tid, s in markers.items()}


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