"""The timeout/kill path must not crash the run when a hung test holds
the pipes open after SIGKILL (F-TS-001).

Regression: previously the final ``communicate(timeout=5.0)`` after
``proc.kill()`` was unwrapped — a test-spawned child surviving the kill
raised an unhandled TimeoutExpired that crashed the whole run, losing
the chunk's outcomes. The M2 Wave-2 streaming drain keeps the same
contract: every wait is bounded (chunk budget → SIGTERM grace →
post-KILL drain) and none of them can raise out of ``_run_chunk``.

The fake models the worst case: real pipe fds that NEVER produce data
and never reach EOF, from a "process" that never exits — the drain hits
every deadline and the escalation ladder still runs to completion.
"""

from __future__ import annotations

import contextlib
import os
import signal
from typing import TYPE_CHECKING

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

if TYPE_CHECKING:
    from pathlib import Path

    import pytest


class _PipeHolderProc:
    """A Popen stand-in whose stdout/stderr pipes never close and whose
    process never exits — models a test-spawned child that survives the
    group SIGKILL holding the pipes open."""

    def __init__(self) -> None:
        self.pid = 2**31 - 1  # not a live pid → killpg falls back to send_signal
        self.returncode: int | None = None
        self.signals: list[int] = []
        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")

    def poll(self) -> int | None:
        return None  # never exits

    def send_signal(self, sig: int) -> None:
        self.signals.append(int(sig))

    def kill(self) -> None:
        self.signals.append(int(signal.SIGKILL))

    def close(self) -> None:
        for fd in (self._w_out, self._w_err):
            with contextlib.suppress(OSError):
                os.close(fd)
        for f in (self.stdout, self.stderr):
            with contextlib.suppress(OSError):
                f.close()


def test_run_chunk_survives_unkillable_pipe_holder(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Shrink every bounded wait so the full escalation ladder runs in
    # well under a second (the ladder itself, not the timings, is the
    # contract under test).
    monkeypatch.setattr(sup_mod, "_CHUNK_GRACE_S", 0.05)
    monkeypatch.setattr(sup_mod, "_POST_KILL_DRAIN_S", 0.05)
    monkeypatch.setattr(sup_mod, "SIGTERM_GRACE_S", 0.05)
    # Shrink the silence window with the rest of the clock so the
    # silence verdict is observable at test speed.
    monkeypatch.setattr(sup_mod, "HANG_SILENCE_S", 0.02)

    cfg = RunConfig(
        repo_root=tmp_path,
        gates_dir=tmp_path / ".gates",
        test_ids=["t1"],
        test_markers={},
        run_id="killtest",
        pytest_cmd=["python", "-m", "pytest"],
        per_test_timeout_s=0.05,
    )
    sup = Supervisor(cfg)
    sup.last_run_dir.mkdir(parents=True, exist_ok=True)

    fake = _PipeHolderProc()
    monkeypatch.setattr(sup_mod.subprocess, "Popen", lambda *a, **k: fake)

    try:
        # Must NOT raise, even though nothing ever exits or EOFs.
        result = sup._run_chunk(0, ["t1"])
    finally:
        fake.close()

    assert result.hit_timeout is True
    # Escalation reached SIGTERM then SIGKILL (via the send_signal fallback,
    # since the fake pid isn't a real process group).
    assert int(signal.SIGTERM) in fake.signals
    assert int(signal.SIGKILL) in fake.signals
    # A never-outputting chunk is silent by definition — the evidence
    # fields the hang heuristic reads must say so.
    assert result.no_recent_output is True
