"""``mgf.common.process.run`` — one-shot subprocess wrapper with structure.

The structured-result, redaction-aware, OTel-instrumented replacement
for raw ``subprocess.run`` calls in tools, scripts, and tests.
"""

from __future__ import annotations

import contextlib
import logging
import os
import secrets
import signal
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING

from mgf.common.exceptions import (
    ProcessNonZeroExitError,
    ProcessSignaledError,
    ProcessTimeoutError,
)
from mgf.common.process._streamer import make_streamers, resolve_redactor

if TYPE_CHECKING:
    from collections.abc import Mapping

_LOG = logging.getLogger(__name__)

# RA-09: one-shot guard for the Windows process-group warning.
# Emitting on every ``run_command(... process_group=True)`` call
# would spam consumers that legitimately ignore the limitation;
# emitting once on the first hit gives the operator a single
# clear signal that grandchildren won't be cascade-killed.
_WINDOWS_PGROUP_WARNED: bool = False


@dataclass(frozen=True)
class ProcessResult:
    """Structured result of a one-shot :func:`run` call.

    All fields are populated regardless of return code or success
    state. ``returncode == 0`` is the only "success" signal —
    callers that want to assert success either pass ``check=True``
    (default; non-zero raises :class:`ProcessNonZeroExitError`) or check
    ``returncode`` themselves.
    """

    command: list[str]
    """The argv as launched. Never the shell-string form — :func:`run`
    is list-only by construction (eliminates command injection)."""

    returncode: int
    """The child's exit code. Negative values indicate signal
    termination on POSIX (``-N`` = killed by signal N); when
    ``check=True`` those raise :class:`ProcessSignaledError` instead."""

    stdout: str
    """Captured stdout (joined lines, no trailing newline). Empty
    string when ``capture_output=False`` was passed."""

    stderr: str
    """Captured stderr (joined lines, no trailing newline). Empty
    string when ``capture_output=False`` was passed."""

    duration_s: float
    """Wall-clock seconds from launch to exit."""

    command_id: str
    """Short opaque id stamped onto every log record emitted while
    this process ran. Use to grep ``logs/<app>.log`` for the captured
    output (``command_id=<id>`` is in every line's ``extra``)."""

    pid: int = field(default=-1)
    """The child's PID. ``-1`` if the launch failed before fork (rare —
    e.g., bad executable path; raises :class:`FileNotFoundError`
    before this dataclass is constructed)."""


def run(
    command: list[str],
    *,
    cwd: str | os.PathLike[str] | None = None,
    env_extra: Mapping[str, str] | None = None,
    timeout: float | None = None,
    check: bool = True,
    capture_output: bool = True,
    name: str | None = None,
    redact: bool | None = None,
    process_group: bool = True,
    kill_grace_s: float = 5.0,
) -> ProcessResult:
    """Launch ``command``, wait for completion, return :class:`ProcessResult`.

    Parameters
    ----------
    command
        The argv list. Must be a list of strings — :mod:`mgf.common.process`
        does not accept shell strings. Eliminates command-injection at
        the API surface (a string with spaces won't be re-parsed).
    cwd
        Working directory for the child. Default: inherit caller's.
    env_extra
        Mapping of additional / overriding environment variables.
        Merged on top of ``os.environ`` for the child's environment.
        Pass an explicit ``{}`` to inherit unchanged. To run with a
        cleaned environment, build the dict at the call site
        (``run([...], env_extra={"PATH": "/usr/bin", ...})``) — this
        wrapper does not provide a "clean env" toggle because it's
        easy to footgun.
    timeout
        Wall-clock seconds before the child is terminated. ``None``
        (default) means wait forever — appropriate for CLI use, NOT
        for test code. Test suites SHOULD pass an explicit timeout
        so a hung child fails the test instead of hanging it.
        On expiry: SIGTERM is sent; ``kill_grace_s`` later, SIGKILL
        if still running. Raises :class:`ProcessTimeoutError`.
    check
        Default ``True``. When the child exits non-zero, raises
        :class:`ProcessNonZeroExitError` (or :class:`ProcessSignaledError` if
        the exit was a signal kill). Pass ``check=False`` to inspect
        ``returncode`` on the returned :class:`ProcessResult`
        without exception.
    capture_output
        Default ``True``. Both stdout AND stderr are captured AND
        streamed live to ``logging.getLogger("mgf.process.<name>")``.
        Pass ``False`` for processes whose output is intentionally
        large or already routed elsewhere (e.g., a child that
        already logs to the same aggregator). Note that streaming
        via the logger ALWAYS happens; ``capture_output=False`` only
        means the captured strings on the result are empty.
    name
        Logger name suffix and the readable handle in logs/spans.
        Default: basename of ``command[0]`` (e.g., ``alembic``).
    redact
        Override redaction. ``None`` (default) means "use the
        :class:`Redactor` wired by :func:`mgf.common.bootstrap` if
        active; otherwise no redaction." Pass ``True`` to require
        redaction (raises if no Redactor is available); ``False`` to
        explicitly skip redaction even when bootstrap is wired (for
        debugging, never in production).
    process_group
        Default ``True`` on POSIX. The child is launched with its
        own process group via ``start_new_session=True``; on
        timeout / kill, SIGTERM goes to the whole group via
        ``os.killpg``. Saves callers from "I started docker-compose,
        my test failed, now I have orphaned containers." On Windows,
        ``os.killpg`` is unavailable so the kill path falls back to
        per-PID ``proc.terminate()``; grandchildren are NOT
        cascade-killed. The first call with ``process_group=True``
        on Windows emits a one-shot
        :class:`mgf.common._warnings.MgfPlatformLimitation` warning
        (RA-09); pass ``process_group=False`` to silence.
    kill_grace_s
        Default 5.0 seconds. When a timeout fires (or :class:`Service`
        is shutting down), the child is sent SIGTERM and given this
        many seconds to clean up before SIGKILL. Tune up for
        services that need to flush state on shutdown (databases),
        tune down for tests that want fast failure.

    Returns
    -------
    :class:`ProcessResult`
        Always populated; ``returncode`` is the contract for success/
        failure in the ``check=False`` shape.

    Raises
    ------
    :class:`ProcessTimeoutError`
        ``timeout`` elapsed; the child was terminated.
    :class:`ProcessNonZeroExitError`
        ``check=True`` and the child exited with non-zero ``returncode``.
        Carries ``stderr_tail`` (last 10 lines of stderr) for
        diagnostics.
    :class:`ProcessSignaledError`
        ``check=True`` and the child was killed by a signal.
    :class:`FileNotFoundError`
        ``command[0]`` is not on ``$PATH`` (or the absolute path
        doesn't exist). Stdlib raise — no wrapping; the standard
        Python idiom is correct here.
    """
    command = _validate_command(command)

    command_id = _generate_command_id()
    label = name or Path(command[0]).name

    redactor = resolve_redactor(redact)

    # Resolve env: caller's environ + env_extra overrides.
    env: dict[str, str] | None
    if env_extra is None:
        env = None  # inherit
    else:
        env = {**os.environ, **dict(env_extra)}

    # POSIX-only: process-group via start_new_session. Windows
    # silently no-ops (CREATE_NEW_PROCESS_GROUP exists but its
    # signal-cascade semantics are different; the right Windows
    # answer is taskkill /T which our kill path doesn't model).
    start_new_session = process_group and sys.platform != "win32"

    # RA-09: warn ONCE per process when a Windows consumer asks
    # for process_group=True. The kill path silently falls back to
    # per-PID proc.terminate() — grandchildren leak. Operators
    # running docker-compose / similar tooling under run_command
    # need to know.
    if process_group and sys.platform == "win32":
        global _WINDOWS_PGROUP_WARNED
        if not _WINDOWS_PGROUP_WARNED:
            _WINDOWS_PGROUP_WARNED = True
            import warnings as _warnings

            from mgf.common._warnings import MgfPlatformLimitation

            _warnings.warn(
                "run_command(process_group=True) is a best-effort no-op "
                "on Windows: there is no os.killpg, so the kill path "
                "falls back to per-PID proc.terminate(). Grandchildren "
                "(e.g., docker-compose children) are NOT cascade-killed. "
                "Pass process_group=False to silence this warning, or "
                "switch to a process-supervisor library if cascade-kill "
                "is required on Windows.",
                MgfPlatformLimitation,
                stacklevel=2,
            )

    # Best-effort OTel span. Gated on bootstrap state: when
    # mgf.common.bootstrap() is not active, the call below would
    # trigger UnconfiguredWarning (because operation_span reads
    # app_name() to name the tracer); use a nullcontext instead.
    span_cm = _maybe_operation_span(
        f"process.run.{label}",
        command_id=command_id,
        command_argv0=command[0],
    )

    started_at = time.monotonic()
    with span_cm:
        proc = subprocess.Popen(
            command,
            cwd=cwd,
            env=env,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            start_new_session=start_new_session,
        )

        stdout_capture, stderr_capture = make_streamers(
            proc,
            name=label,
            command_id=command_id,
            redactor=redactor,
            capture=capture_output,
        )

        timed_out = False
        try:
            proc.wait(timeout=timeout)
        except subprocess.TimeoutExpired:
            timed_out = True
            _terminate(
                proc,
                grace_s=kill_grace_s,
                process_group=start_new_session,
                command_id=command_id,
            )
        finally:
            # Drain reader threads. Using a short timeout — the OS
            # closes pipes when the child exits, so the readers complete
            # almost immediately; the timeout catches edge cases where
            # the kill signal failed (rare, but don't hang the caller).
            if stdout_capture is not None:
                stdout_capture.join(timeout=2.0)
            if stderr_capture is not None:
                stderr_capture.join(timeout=2.0)

        duration_s = time.monotonic() - started_at
        stdout_text = stdout_capture.captured if stdout_capture else ""
        stderr_text = stderr_capture.captured if stderr_capture else ""

        if timed_out:
            raise ProcessTimeoutError(
                command=command,
                command_id=command_id,
                timeout=timeout if timeout is not None else 0.0,
            )

        result = ProcessResult(
            command=command,
            returncode=proc.returncode,
            stdout=stdout_text,
            stderr=stderr_text,
            duration_s=duration_s,
            command_id=command_id,
            pid=proc.pid,
        )

        if check:
            if proc.returncode is None:
                # Defensive — wait() returned without setting returncode.
                # Treat as a process-state error rather than a clean exit.
                raise ProcessNonZeroExitError(
                    command=command,
                    command_id=command_id,
                    returncode=-1,
                    stderr_tail=(stderr_capture.captured_tail if stderr_capture else ""),
                )
            if proc.returncode < 0:
                raise ProcessSignaledError(
                    command=command,
                    command_id=command_id,
                    signal=-proc.returncode,
                )
            if proc.returncode != 0:
                raise ProcessNonZeroExitError(
                    command=command,
                    command_id=command_id,
                    returncode=proc.returncode,
                    stderr_tail=(stderr_capture.captured_tail if stderr_capture else ""),
                )

    return result


def _generate_command_id() -> str:
    """8-hex-char opaque id for log correlation. Non-secret; not for auth."""
    return secrets.token_hex(4)


def _maybe_operation_span(
    span_name: str,
    **attributes: object,
) -> contextlib.AbstractContextManager[None]:
    """Return :func:`mgf.common.observability.operation_span` when bootstrap is active; else nullcontext.

    The bare ``operation_span`` is documented as no-op when OTel
    isn't installed, but it ALWAYS calls :func:`app_name` to name
    its tracer — and ``app_name()`` emits ``UnconfiguredWarning``
    when no bootstrap is active. Test suites that elevate
    ``UnconfiguredWarning`` to error fail spuriously.

    This wrapper short-circuits to ``contextlib.nullcontext`` when
    no global :class:`AppContext` is registered, avoiding the
    warning path entirely. When bootstrap IS active, it delegates
    to the real span (which itself decides whether OTel is wired
    enough to emit anything).
    """
    from mgf.common._lifecycle import _GLOBAL_CONTEXT

    if _GLOBAL_CONTEXT is None:
        return contextlib.nullcontext()
    from mgf.common.observability import operation_span

    return operation_span(span_name, **attributes)


def _validate_command(command: object) -> list[str]:
    """Coerce + validate a command argument.

    Accepts ``list[str]`` or ``tuple[str, ...]`` (v0.23.0); rejects
    bare strings (shell-string opt-out is the design's invariant —
    pass ``["/bin/sh", "-c", "..."]`` if you want a shell). Returns
    a fresh ``list[str]`` so the caller's input cannot be mutated
    by the launched process and the downstream ``Popen`` always sees
    a list.
    """
    if isinstance(command, str):
        raise TypeError(
            "command must be a list or tuple of strings, not a str. "
            "Shell strings are rejected by design — pass "
            "['/bin/sh', '-c', 'foo | bar'] explicitly if you need "
            "a shell, accepting the command-injection surface."
        )
    if not isinstance(command, (list, tuple)):
        raise TypeError(f"command must be a list or tuple of strings; got {type(command).__name__}")
    items = list(command)
    if not items:
        raise ValueError("command must not be empty")
    if not all(isinstance(a, str) for a in items):
        raise TypeError("every element of command must be a string; got " + repr(items)[:80])
    return items


def _terminate(
    proc: subprocess.Popen[bytes],
    *,
    grace_s: float,
    process_group: bool,
    command_id: str | None = None,
) -> None:
    """SIGTERM → wait grace_s → SIGKILL. POSIX-only process-group cascade.

    Best-effort — the child may already be dead, the process group
    may have changed (rare), the platform may not support
    ``killpg``. Each step is independently guarded so a failure at
    one level still tries the next.

    RA-06: if SIGKILL fails to reap the child within 2 s (the
    final ``proc.wait`` window), emit a WARNING naming the pid +
    command_id so an operator can correlate. Without this log
    the function returned silently and unreaped children
    accumulated as zombies — a slow leak under sustained timeout
    pressure (services that repeatedly time-out child processes).
    """
    try:
        if process_group and hasattr(os, "killpg"):
            os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
        else:
            proc.terminate()
    except (ProcessLookupError, PermissionError):
        # ProcessLookupError: child already exited.
        # PermissionError: PID reused by another user (very rare).
        return

    try:
        proc.wait(timeout=grace_s)
        return
    except subprocess.TimeoutExpired:
        pass

    try:
        if process_group and hasattr(os, "killpg"):
            os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
        else:
            proc.kill()
    except (ProcessLookupError, PermissionError):
        return

    # Give the kernel a moment to reap. Don't wait forever — at
    # this point we've sent SIGKILL; if it didn't take, something
    # is fundamentally broken and the caller deserves a log line.
    try:
        proc.wait(timeout=2.0)
    except subprocess.TimeoutExpired:
        # RA-06: the child resisted SIGKILL + a 2-second reap
        # window. The kernel may still reap it later, but as far
        # as we can tell now it is in trouble. Operators see this
        # in the log alongside the elevated process count from
        # `ps`.
        _LOG.warning(
            "child process did not reap within 2s of SIGKILL; "
            "may linger as a zombie or hung process",
            extra={
                "child_pid": proc.pid,
                "command_id": command_id,
                "event": "process.reap_timeout",
            },
        )
