"""Line-by-line stdout/stderr streaming for child processes.

The thread-based streamer reads lines from a child's pipe + emits
them through :mod:`logging` line-by-line, one log record per child
line. The ``Redactor`` wired by :func:`mgf.common.bootstrap` (when
active) scrubs each line before it lands in the log — so a child
that prints ``DATABASE_URL=postgres://user:hunter2@host/db`` in its
startup banner does not leak the password into your log aggregator.

Each line emitted carries:

  - ``logger``  : ``mgf.process.<name>`` (the ``name=`` passed to
    :func:`mgf.common.process.run` / :class:`Service`, or
    ``argv[0]``-basename when omitted).
  - ``level``   : ``INFO`` for stdout; ``WARNING`` for stderr (so
    aggregators that filter on level surface child stderr without
    over-warning on stdout).
  - ``extra``   : ``{"child_pid": int, "stream": "stdout"|"stderr",
    "command_id": str}`` — for log-grepping by command id.
  - ``msg``     : the redacted line text (no trailing newline).

A copy of every line is also appended to a per-stream ``deque`` so
:func:`mgf.common.process.run` can return the captured output in
:class:`ProcessResult`. The deque is bounded (``maxlen=10000`` per
stream) — pathological children that emit gigabytes of stdout don't
exhaust memory; the captured tail still shows the most recent N lines.
"""

from __future__ import annotations

import contextlib
import logging
import threading
from collections import deque
from typing import IO, TYPE_CHECKING

if TYPE_CHECKING:
    import subprocess

    from mgf.common.observability.redaction import Redactor


# Per-stream capture cap. Pathological children that print 1 GB of
# stdout shouldn't exhaust memory — keep the most-recent-N lines.
# 10k lines × ~200 bytes/line ≈ 2 MiB worst case, which is acceptable.
_LINE_CAPTURE_CAP = 10_000


class _StreamCapture:
    """Reader thread that pumps a child stream into log records + a deque.

    Created twice per launched process (one for stdout, one for stderr).
    ``join()`` from the main thread waits for the child to close the
    stream; works for both completed processes and processes that
    crashed mid-run (close-on-exit semantics).
    """

    def __init__(
        self,
        *,
        stream: IO[bytes],
        kind: str,  # "stdout" | "stderr"
        logger_name: str,
        child_pid: int,
        command_id: str,
        redactor: Redactor | None,
        capture: bool,
    ) -> None:
        self._stream = stream
        self._kind = kind
        self._logger = logging.getLogger(logger_name)
        self._child_pid = child_pid
        self._command_id = command_id
        self._redactor = redactor
        self._capture = capture
        self._captured: deque[str] = deque(maxlen=_LINE_CAPTURE_CAP)
        self._thread = threading.Thread(
            target=self._pump,
            name=f"mgf-process-stream-{kind}-{child_pid}",
            daemon=True,
        )

    def start(self) -> None:
        self._thread.start()

    def join(self, timeout: float | None = None) -> None:
        self._thread.join(timeout=timeout)

    @property
    def captured(self) -> str:
        """Captured output joined as a single string. Reads after join."""
        return "\n".join(self._captured)

    @property
    def captured_tail(self) -> str:
        """Last few lines — for `ProcessNonZeroExitError.stderr_tail`."""
        # Last 10 lines is a useful diagnostic without being unwieldy.
        # ``list(deque)`` is a single bytecode in CPython — atomic
        # against the appending thread.
        tail = list(self._captured)[-10:]
        return "\n".join(tail)

    def snapshot(self) -> list[str]:
        """Atomic copy of the captured deque. Safe to call from any thread.

        The reader thread mutates ``_captured`` via ``append``; iterating
        it with ``for line in self._captured`` (or ``list.extend(deque)``,
        which uses the iterator protocol) can race and raise
        ``RuntimeError: deque mutated during iteration``. ``list(deque)``
        is implemented as a single ``LIST_APPEND`` loop in CPython that
        holds the GIL — atomic against an appending thread.
        """
        return list(self._captured)

    def _pump(self) -> None:
        """Reader loop. Drains the stream until EOF + closes.

        RA-07: ``readline()`` can raise mid-stream
        (``BrokenPipeError`` under SIGPIPE, ``OSError`` on a closed
        fd, etc.). The loop body catches every ``Exception`` so a
        transient read failure logs a WARNING naming the pid +
        stream kind and the loop exits cleanly via the ``finally``
        — the daemon streamer thread does NOT die silently with an
        uncaught exception that the parent's ``Thread.join`` would
        return-without-noticing.
        """
        level = logging.INFO if self._kind == "stdout" else logging.WARNING
        try:
            # Read line-by-line. ``stream`` is binary; decode tolerantly
            # (children print arbitrary bytes; we don't want a stray
            # 0x80 byte to terminate capture).
            try:
                for raw_line in iter(self._stream.readline, b""):
                    line = raw_line.decode("utf-8", errors="replace").rstrip(
                        "\r\n",
                    )
                    if not line:
                        continue
                    if self._redactor is not None:
                        line = self._redactor.redact_string(line)
                    if self._capture:
                        self._captured.append(line)
                    self._logger.log(
                        level,
                        line,
                        extra={
                            "child_pid": self._child_pid,
                            "stream": self._kind,
                            "command_id": self._command_id,
                        },
                    )
            except Exception as e:
                # RA-07: log + exit the loop. Suppressing here keeps
                # the streamer thread alive long enough to run the
                # ``finally`` stream.close(); the daemon doesn't die
                # with an uncaught exception that disappears into
                # the void.
                logging.getLogger(__name__).warning(
                    "streamer thread aborting on stream read failure",
                    extra={
                        "child_pid": self._child_pid,
                        "stream": self._kind,
                        "command_id": self._command_id,
                        "error_type": type(e).__name__,
                        "error_message": str(e),
                        "event": "process.streamer_read_failure",
                    },
                )
        finally:
            with contextlib.suppress(Exception):
                # ``stream.close()`` on a process whose pipes are already
                # closed by the OS can raise OSError / ValueError /
                # BrokenPipeError depending on platform + state. We don't
                # care — the stream is gone either way.
                self._stream.close()


def get_active_redactor() -> Redactor | None:
    """Return the Redactor wired by :func:`mgf.common.bootstrap`, if any.

    Walks ``logging.root.handlers`` once looking for a formatter
    exposing a :class:`Redactor` via the public ``redactor`` property
    (v0.23.0+). Older formatters that only expose ``_redactor``
    privately are also supported as a fallback. Returns ``None`` when
    no bootstrap context is active or when the consumer's framework
    owns logging (BOOTSTRAP-01 ``setup_logging=False`` shape — no mgf
    handlers on root). In that case, callers fall back to "no
    redaction," matching the "default-on but only when bootstrap
    installed handlers" ergonomic.
    """
    for handler in logging.root.handlers:
        formatter = getattr(handler, "formatter", None)
        if formatter is None:
            continue
        # Prefer the public property (v0.23.0+); fall back to the
        # private attribute for older formatters.
        redactor: Redactor | None = getattr(formatter, "redactor", None)
        if redactor is None:
            redactor = getattr(formatter, "_redactor", None)
        if redactor is not None:
            return redactor
    return None


def resolve_redactor(redact: bool | None) -> Redactor | None:
    """Resolve the ``redact=`` kwarg to a concrete :class:`Redactor` or None.

    - ``None`` (default for :func:`run` / :class:`Service`) — use the
      bootstrap-wired Redactor when active; ``None`` otherwise.
    - ``False`` — explicitly skip redaction even when bootstrap is wired.
      For debugging; never in production.
    - ``True`` — require redaction. Raises :class:`RuntimeError` if no
      Redactor is available (bootstrap not active OR framework owns
      logging via ``setup_logging=False``).
    """
    if redact is False:
        return None
    redactor = get_active_redactor()
    if redact is True and redactor is None:
        raise RuntimeError(
            "redact=True requires mgf.common.bootstrap() to be active "
            "with setup_logging=True (the default). Pass redact=False "
            "or redact=None, or call bootstrap() before launching."
        )
    return redactor


def make_streamers(
    proc: subprocess.Popen[bytes],
    *,
    name: str,
    command_id: str,
    redactor: Redactor | None,
    capture: bool,
) -> tuple[_StreamCapture | None, _StreamCapture | None]:
    """Create + start the stdout/stderr streamers for ``proc``.

    Returns a ``(stdout, stderr)`` pair. Either may be ``None`` if the
    Popen object had no corresponding pipe (e.g., redirected to
    ``DEVNULL``) — the streamer is just skipped and captured output
    is empty.
    """
    logger_name = f"mgf.process.{name}"
    stdout_capture: _StreamCapture | None = None
    stderr_capture: _StreamCapture | None = None
    if proc.stdout is not None:
        stdout_capture = _StreamCapture(
            stream=proc.stdout,
            kind="stdout",
            logger_name=logger_name,
            child_pid=proc.pid,
            command_id=command_id,
            redactor=redactor,
            capture=capture,
        )
        stdout_capture.start()
    if proc.stderr is not None:
        stderr_capture = _StreamCapture(
            stream=proc.stderr,
            kind="stderr",
            logger_name=logger_name,
            child_pid=proc.pid,
            command_id=command_id,
            redactor=redactor,
            capture=capture,
        )
        stderr_capture.start()
    return stdout_capture, stderr_capture
