"""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,
            level=logging.INFO,
        )
        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,
            level=logging.INFO,
        )
        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,
            level=logging.INFO,
        )
        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"]


class TestStreamLevelOverride:
    """PAPER-164: a child whose stderr is *expected* can say so.

    The level split was hardcoded (``INFO`` for stdout, ``WARNING`` for
    stderr), and streaming happens unconditionally — so a caller probing
    a tool that writes to stderr on its success path had no way to stop
    warning about it. The only consumer-side workaround was mutating the
    level of a library-owned logger, which is racy when several children
    run concurrently.
    """

    def _drain(self, *, kind: str, level: int, logger_name: str) -> _StreamCapture:
        cap = _StreamCapture(
            stream=io.BytesIO(b"expected chatter\n"),
            kind=kind,
            logger_name=logger_name,
            child_pid=1,
            command_id="level-test",
            redactor=None,
            capture=True,
            level=level,
        )
        cap.start()
        cap.join(timeout=2.0)
        return cap

    def test_stderr_can_be_demoted_below_warning(self, caplog: pytest.LogCaptureFixture) -> None:
        with caplog.at_level(logging.DEBUG, logger="mgf.process.quiet-child"):
            self._drain(kind="stderr", level=logging.DEBUG, logger_name="mgf.process.quiet-child")
        record = next(r for r in caplog.records if r.getMessage() == "expected chatter")
        assert record.levelno == logging.DEBUG
        # The stream is still identified as stderr — only the level moved.
        assert record.stream == "stderr"

    def test_the_default_split_is_unchanged(self, caplog: pytest.LogCaptureFixture) -> None:
        """Defaults must stay INFO/WARNING — every existing consumer relies on it."""
        with caplog.at_level(logging.DEBUG, logger="mgf.process.default-child"):
            self._drain(
                kind="stderr", level=logging.WARNING, logger_name="mgf.process.default-child"
            )
            self._drain(kind="stdout", level=logging.INFO, logger_name="mgf.process.default-child")
        levels = [r.levelno for r in caplog.records if r.getMessage() == "expected chatter"]
        assert levels == [logging.WARNING, logging.INFO]

    def test_a_quiet_stream_is_still_a_captured_stream(self) -> None:
        """Demoting the level must not cost the diagnostic.

        The whole point is that an *unexpected* failure still has its
        output — so capture, and therefore ``ProcessResult.stderr`` and
        ``stderr_tail``, must be untouched by the level choice.
        """
        cap = self._drain(kind="stderr", level=logging.DEBUG, logger_name="mgf.process.captured")
        assert cap.snapshot() == ["expected chatter"]
        assert cap.captured == "expected chatter"
