"""The internal-timeout hang heuristic (M2 Wave-2).

A test missing from the chunk's JUnit XML is classified `hung` via the
INTERNAL pytest-timeout heuristic only when ALL THREE legs hold:
wall >= 0.95 x per-test timeout, rc != 0 (no fatal signal), and NO
output in the final HANG_SILENCE_S window (burst-tolerant). A busy
slow chunk near the timeout with rc != 0 — the pre-Wave-2 false-red —
now falls through to `crashed` with the real exit metadata.
"""

from __future__ import annotations

import contextlib
import os
import time
from typing import TYPE_CHECKING

from mgf.test_supervisor import supervisor as sup_mod
from mgf.test_supervisor.supervisor import ChunkResult, RunConfig, Supervisor, _OutputDrain

if TYPE_CHECKING:
    from pathlib import Path

    import pytest


def _mk_supervisor(tmp_path: Path) -> Supervisor:
    cfg = RunConfig(
        repo_root=tmp_path,
        gates_dir=tmp_path / ".gates",
        test_ids=["t::a"],
        test_markers={},
        run_id="heuristic",
        pytest_cmd=["true"],
        per_test_timeout_s=30.0,
    )
    return Supervisor(cfg)


def _chunk(tmp_path: Path, *, wall: float, rc: int, silent: bool) -> ChunkResult:
    return ChunkResult(
        test_ids=["t::a"],
        junit_xml_path=tmp_path / "junit-none.xml",  # absent → test missing from XML
        returncode=rc,
        signal_name=None,
        wall_time_s=wall,
        stdout="",
        stderr="",
        hit_timeout=False,
        pytest_pid=123,
        last_output_age_s=10.0 if silent else 0.1,
        no_recent_output=silent,
    )


class TestInternalTimeoutHeuristic:
    def test_silent_long_nonzero_chunk_is_hung(self, tmp_path: Path) -> None:
        sup = _mk_supervisor(tmp_path)
        [rec] = sup._records_for_chunk(["t::a"], _chunk(tmp_path, wall=29.0, rc=1, silent=True))
        assert rec.outcome == "hung"
        assert rec.code == "PYTEST_TIMEOUT_FIRED"

    def test_busy_slow_chunk_is_not_hung(self, tmp_path: Path) -> None:
        # THE false-red case: a multi-test chunk legitimately busy for
        # ~29s of its 30s budget, one test missing from XML, rc=1 —
        # but output kept flowing. Not a hang.
        sup = _mk_supervisor(tmp_path)
        [rec] = sup._records_for_chunk(["t::a"], _chunk(tmp_path, wall=29.0, rc=1, silent=False))
        assert rec.outcome == "crashed"
        assert rec.code == "CHUNK_EXIT_1"

    def test_below_095_wall_is_not_hung_even_if_silent(self, tmp_path: Path) -> None:
        # 0.9x used to be enough (28s of a 30s budget tripped it);
        # 0.95x is the Wave-2 floor.
        sup = _mk_supervisor(tmp_path)
        [rec] = sup._records_for_chunk(["t::a"], _chunk(tmp_path, wall=28.0, rc=1, silent=True))
        assert rec.outcome == "crashed"

    def test_outer_wall_clock_timeout_still_wins(self, tmp_path: Path) -> None:
        sup = _mk_supervisor(tmp_path)
        chunk = _chunk(tmp_path, wall=40.0, rc=-15, silent=False)
        chunk.hit_timeout = True
        [rec] = sup._records_for_chunk(["t::a"], chunk)
        assert rec.outcome == "hung"
        assert rec.code == "WALL_CLOCK_HUNG"


class _FakeProc:
    """Bare proc shim for driving _OutputDrain against real pipes."""

    def __init__(self) -> None:
        r_out, self.w_out = os.pipe()
        r_err, self.w_err = os.pipe()
        self.stdout = os.fdopen(r_out, "rb")
        self.stderr = os.fdopen(r_err, "rb")
        self.returncode: int | None = None

    def poll(self) -> int | None:
        return self.returncode

    def close_write_ends(self) -> None:
        for fd in (self.w_out, self.w_err):
            with contextlib.suppress(OSError):
                os.close(fd)


class TestOutputDrainSilence:
    def test_silent_tail_detected(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setattr(sup_mod, "HANG_SILENCE_S", 0.15)
        proc = _FakeProc()
        drain = _OutputDrain(proc)  # type: ignore[arg-type]
        os.write(proc.w_out, b"early output\n")
        drain.drain_until(time.monotonic() + 0.05)  # pick up the early write
        time.sleep(0.2)  # silence
        proc.close_write_ends()
        proc.returncode = 1
        assert drain.drain_until(time.monotonic() + 2.0) is True
        exit_t = time.monotonic()
        assert drain.silent_before(exit_t) is True
        assert drain.last_output_age(exit_t) >= 0.15
        out, err = drain.decoded()
        assert out == "early output\n"
        assert err == ""

    def test_death_burst_after_silence_still_counts_as_silent(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        # pytest-timeout's shape: silence, then a stack-dump burst
        # microseconds before os._exit. The burst must not launder
        # the silence.
        monkeypatch.setattr(sup_mod, "HANG_SILENCE_S", 0.15)
        proc = _FakeProc()
        drain = _OutputDrain(proc)  # type: ignore[arg-type]
        os.write(proc.w_out, b".")
        drain.drain_until(time.monotonic() + 0.05)
        time.sleep(0.2)  # the hung window
        os.write(proc.w_err, b"Timeout >30.0s\nStack of MainThread...\n")  # the dump
        proc.close_write_ends()
        proc.returncode = 1
        assert drain.drain_until(time.monotonic() + 2.0) is True
        exit_t = time.monotonic()
        assert drain.silent_before(exit_t) is True

    def test_steady_output_is_not_silent(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setattr(sup_mod, "HANG_SILENCE_S", 0.5)
        proc = _FakeProc()
        drain = _OutputDrain(proc)  # type: ignore[arg-type]
        deadline = time.monotonic() + 2.0
        for _ in range(4):
            os.write(proc.w_out, b".")
            drain.drain_until(time.monotonic() + 0.05)
        proc.close_write_ends()
        proc.returncode = 1
        assert drain.drain_until(deadline) is True
        assert drain.silent_before(time.monotonic()) is False
