"""Tests for ``mgf.common.process._streamer`` — RA-07.

Pins the contract that the daemon streamer thread never dies
silently with an uncaught exception:

  - If ``readline()`` raises mid-stream (``BrokenPipeError`` under
    SIGPIPE, ``OSError`` on a closed fd, etc.), the loop body
    catches it, emits a WARNING with ``child_pid`` + ``stream`` +
    ``error_type`` extras, and exits the loop cleanly through the
    ``finally`` so ``stream.close()`` still runs.

Why this matters: ``Thread.join`` returns immediately when the
thread has died, so the parent (``run_command`` in ``_run.py``)
does NOT notice an uncaught exception in the streamer. Without
this guard, the child's output silently truncates and the
operator has no signal.
"""

from __future__ import annotations

import io
import logging
from typing import TYPE_CHECKING

from mgf.common.process._streamer import _StreamCapture

if TYPE_CHECKING:
    import pytest


class _RaisingStream:
    """Stub stream whose ``readline()`` raises after N successful reads.

    Used to drive ``_StreamCapture._pump`` into the RA-07 branch
    deterministically — a real subprocess pipe almost never raises
    in CI but the failure mode is real on overloaded hosts and
    certain container runtimes.
    """

    def __init__(self, lines_then_raise: list[bytes], exc: BaseException) -> None:
        self._iter = iter(lines_then_raise)
        self._exc = exc
        self.closed = False

    def readline(self) -> bytes:
        try:
            return next(self._iter)
        except StopIteration:
            raise self._exc from None

    def close(self) -> None:
        self.closed = True


class TestStreamerExceptionVisibility:
    """RA-07: readline failures log a WARNING; the thread doesn't
    die silently."""

    def test_readline_failure_logs_warning_and_exits_cleanly(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        # Stream yields one good line, then raises BrokenPipeError.
        stream = _RaisingStream(
            [b"first line\n"],
            BrokenPipeError("[Errno 32] broken pipe"),
        )
        cap = _StreamCapture(
            stream=stream,  # type: ignore[arg-type]
            kind="stdout",
            logger_name="ra07-test",
            child_pid=42,
            command_id="ra07-cmd-id",
            redactor=None,
            capture=True,
        )
        cap.start()
        with caplog.at_level(logging.WARNING, logger="mgf.common.process._streamer"):
            cap.join(timeout=2.0)
        assert not cap._thread.is_alive(), "streamer thread should exit on read failure"

        # Stream was closed via the finally block.
        assert stream.closed

        # WARNING was emitted with the RA-07 event tag.
        warnings = [
            r
            for r in caplog.records
            if r.levelno == logging.WARNING
            and getattr(r, "event", None) == "process.streamer_read_failure"
        ]
        assert len(warnings) == 1, (
            f"expected one streamer_read_failure WARNING, got: "
            f"{[(r.levelname, r.message) for r in caplog.records]}"
        )
        rec = warnings[0]
        assert rec.child_pid == 42  # type: ignore[attr-defined]
        assert rec.stream == "stdout"  # type: ignore[attr-defined]
        assert rec.command_id == "ra07-cmd-id"  # type: ignore[attr-defined]
        assert rec.error_type == "BrokenPipeError"  # type: ignore[attr-defined]
        assert "broken pipe" in rec.error_message  # type: ignore[attr-defined]

    def test_readline_failure_does_not_propagate_to_join(self) -> None:
        """An uncaught exception in a daemon thread is silently
        swallowed by the runtime — the test below would pass even
        WITHOUT the fix. The point is to PIN the contract: the
        consumer's ``join(timeout=...)`` returns normally, no
        exception bubbles up, and the captured output reflects the
        lines we DID receive before the failure."""
        stream = _RaisingStream(
            [b"keep me\n", b"and me\n"],
            OSError("closed file"),
        )
        cap = _StreamCapture(
            stream=stream,  # type: ignore[arg-type]
            kind="stderr",
            logger_name="ra07-test",
            child_pid=0,
            command_id="ra07-test",
            redactor=None,
            capture=True,
        )
        cap.start()
        # join must NOT raise; thread must terminate cleanly.
        cap.join(timeout=2.0)
        assert not cap._thread.is_alive()
        # Lines captured before the failure are preserved.
        assert cap.snapshot() == ["keep me", "and me"]


class TestStreamerHappyPath:
    """Sanity that the RA-07 try/except didn't break the normal
    EOF-driven exit path."""

    def test_eof_exits_loop_without_warning(self, caplog: pytest.LogCaptureFixture) -> None:
        # iter(stream.readline, b"") stops at b"" — natural EOF.
        # io.BytesIO returns b"" at EOF, exactly the sentinel iter() wants.
        stream = io.BytesIO(b"one\ntwo\n")
        cap = _StreamCapture(
            stream=stream,
            kind="stdout",
            logger_name="happy-test",
            child_pid=1,
            command_id="happy-test",
            redactor=None,
            capture=True,
        )
        cap.start()
        with caplog.at_level(logging.WARNING, logger="mgf.common.process._streamer"):
            cap.join(timeout=2.0)
        assert not cap._thread.is_alive()
        # No RA-07 WARNING on the normal exit path.
        events = [getattr(r, "event", None) for r in caplog.records]
        assert "process.streamer_read_failure" not in events
        # All lines captured.
        assert cap.snapshot() == ["one", "two"]
