"""Subprocess wrapper used by every module that shells out.

All subprocess invocations in VManager core code go through
:func:`run_command`. It enforces the rules documented in
``docs/ARCHITECTURE.md``:

* ``shell=False`` always. Callers pass argv as a ``list[str]`` — there is
  no string interpolation, so quoting and escaping are not concerns.
* Non-zero exits raise :class:`CommandError` (a :class:`VManagerError`
  subclass) so callers can match on a typed exception rather than parse
  return codes.
* ``stdout`` can be captured all-at-once or streamed line-by-line via a
  :class:`ProgressReporter`, depending on the use case (image downloads
  and ``virt-install`` need streaming; ``virsh version`` does not).
* The call respects a :class:`CancellationToken` — when cancelled, the
  child process is terminated and a ``CommandError`` with ``returncode
  = -15`` (SIGTERM) is raised so callers see a distinct error.
* **Every call carries a finite timeout** (rule **SC-07**). The default
  is :data:`DEFAULT_TIMEOUT_SECONDS` (300 s — long enough for
  ``apt update`` / ``qemu-img convert`` on a slow disk; short enough to
  fail-fast on a hung child). Callers that need longer pass an explicit
  value; callers that need NO timeout pass ``timeout=None`` (now an
  explicit override, not the implicit default).

No core module imports :mod:`subprocess` directly. Any future code that
needs a subprocess uses :func:`run_command` or gains a typed wrapper here.
"""

from __future__ import annotations

import logging
import signal
import subprocess
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING

from mgf.common.observability import operation_span

from mgf.vm.core.exceptions import CommandError

_LOG = logging.getLogger("mgf.vm.common.shell")

if TYPE_CHECKING:
    import os

    from mgf.vm.core.common.progress import ProgressReporter

# Return code we synthesise when a child process is killed by our own
# cancellation logic, matching what subprocess reports for SIGTERM
# (``-signal.SIGTERM``).
CANCELLED_RETURNCODE = -signal.SIGTERM

# Default wall-clock cap for every :func:`run_command` invocation
# (rule **SC-07**). 300 s covers the heaviest VManager-internal calls
# (``apt update`` + first-pkg install on a freshly-booted guest;
# ``qemu-img convert`` of a 20 GiB cloud image on a spinning disk)
# without leaving the "blocks forever" path open. Callers that genuinely
# need longer pass an explicit ``timeout=`` value. Callers that need NO
# timeout pass ``timeout=None`` — now an explicit override, not the
# implicit default.
DEFAULT_TIMEOUT_SECONDS: float = 300.0


@dataclass
class CommandResult:
    """Outcome of a successful :func:`run_command` invocation.

    On failure, :func:`run_command` raises :class:`CommandError` instead
    of returning this type, so every ``CommandResult`` you hold is by
    definition a success.
    """

    argv: list[str]
    returncode: int
    stdout: str
    stderr: str


def run_command(
    argv: list[str],
    *,
    cwd: str | os.PathLike[str] | None = None,
    env: dict[str, str] | None = None,
    timeout: float | None = DEFAULT_TIMEOUT_SECONDS,
    input_text: str | None = None,
    reporter: ProgressReporter | None = None,
    check: bool = True,
) -> CommandResult:
    """Invoke ``argv`` as a subprocess, never via a shell.

    Parameters:
        argv: the command and its arguments as a list. ``argv[0]`` must
            be a program name or absolute path; no shell expansion is
            performed.
        cwd: working directory for the child process.
        env: environment for the child process. If ``None``, the parent
            environment is inherited.
        timeout: overall wall-clock limit in seconds. Exceeding it kills
            the child and raises ``CommandError`` with a message ending
            in ``"timed out"``.
        input_text: optional stdin text piped to the child at startup.
        reporter: when provided, stdout is streamed line-by-line to
            :meth:`ProgressReporter.report`. stderr is still captured for
            the resulting ``CommandResult`` / ``CommandError``. The
            reporter's :meth:`is_cancelled` is polled between lines so
            the child dies promptly when the user cancels.
        check: when ``True`` (default), a non-zero exit raises
            :class:`CommandError`; when ``False``, the failing result is
            returned so the caller can inspect it.

    Returns:
        :class:`CommandResult` with stdout, stderr, and the exit code.

    Raises:
        :class:`CommandError`: on non-zero exit (when ``check`` is
            ``True``), on a timeout, or on cancellation.
    """
    if not argv:
        raise ValueError("argv must contain at least the program name")

    # Open a span around every subprocess invocation. When the OTel
    # SDK is not initialised (the common case in dev / CI) this is a
    # no-op — the ``with`` block runs normally with zero overhead.
    # When OTel is on, traces show the program name + duration +
    # exit code + (on failure) the typed CommandError, which makes
    # diagnosing flaky shell-out paths much easier.
    with operation_span("subprocess.run", argv0=argv[0]):
        # Streaming mode: we read stdout line-by-line and forward to
        # the reporter until EOF or cancellation. Everything else goes
        # through a plain subprocess.run for simplicity and accurate
        # timeout handling.
        if reporter is not None:
            return _run_streaming(
                argv,
                cwd=cwd,
                env=env,
                timeout=timeout,
                input_text=input_text,
                reporter=reporter,
                check=check,
            )

        start = time.perf_counter()
        try:
            completed = subprocess.run(
                argv,
                cwd=cwd,
                env=env,
                input=input_text,
                capture_output=True,
                text=True,
                timeout=timeout,
                check=False,
                shell=False,
            )
        except subprocess.TimeoutExpired as e:
            # Action hint per rule EH-09: tell the user the two
            # recovery paths. The "timed out after" prefix is
            # preserved so callers / tests that match on it keep
            # working.
            raise CommandError(
                argv=argv,
                returncode=-1,
                stderr=(
                    f"timed out after {timeout} s: {e}; raise the per-call "
                    f"timeout or reduce the workload"
                ),
            ) from e

        result = CommandResult(
            argv=list(argv),
            returncode=completed.returncode,
            stdout=completed.stdout or "",
            stderr=completed.stderr or "",
        )
        # Attach outcome attributes to the active span — visible in
        # the trace viewer when OTel is active. ``operation_span``
        # uses the currently-active span so this works without a
        # reference to it. No-op when OTel isn't installed.
        _annotate_span(
            returncode=result.returncode, duration_ms=(time.perf_counter() - start) * 1000
        )
        if check and result.returncode != 0:
            raise CommandError(
                argv=result.argv,
                returncode=result.returncode,
                stderr=result.stderr,
            )
        return result


def _run_streaming(
    argv: list[str],
    *,
    cwd: str | os.PathLike[str] | None,
    env: dict[str, str] | None,
    timeout: float | None,
    input_text: str | None,
    reporter: ProgressReporter,
    check: bool,
) -> CommandResult:
    """Stream stdout line-by-line to the reporter.

    Implementation detail of :func:`run_command`. stderr is buffered
    in full; stdout is forwarded a line at a time. Cancellation is
    checked between lines; when cancelled, the child is sent SIGTERM
    and a ``CommandError`` with ``returncode = CANCELLED_RETURNCODE``
    is raised.
    """
    # We need the child process's stdout as a pipe so we can read it
    # incrementally. ``text=True`` gives us str instead of bytes.
    proc = subprocess.Popen(
        argv,
        cwd=cwd,
        env=env,
        stdin=subprocess.PIPE if input_text is not None else None,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        bufsize=1,  # line-buffered
        shell=False,
    )

    # Send stdin up front (not streamed — if you have a long stdin,
    # write a dedicated helper instead).
    if input_text is not None and proc.stdin is not None:
        try:
            proc.stdin.write(input_text)
        finally:
            proc.stdin.close()

    collected_stdout: list[str] = []
    assert proc.stdout is not None  # for mypy — stdout=PIPE guarantees this

    try:
        for line in proc.stdout:
            collected_stdout.append(line)
            reporter.report(line.rstrip("\n"))
            if reporter.is_cancelled():
                _terminate(proc)
                raise CommandError(
                    argv=argv,
                    returncode=CANCELLED_RETURNCODE,
                    stderr="cancelled by user",
                )
    finally:
        try:
            _, stderr_tail = proc.communicate(timeout=timeout)
        except subprocess.TimeoutExpired as e:
            _terminate(proc)
            # Action hint per rule EH-09 — same shape as the
            # non-streaming path above.
            raise CommandError(
                argv=argv,
                returncode=-1,
                stderr=(
                    f"timed out after {timeout} s; raise the per-call "
                    f"timeout or reduce the workload"
                ),
            ) from e

    result = CommandResult(
        argv=list(argv),
        returncode=proc.returncode,
        stdout="".join(collected_stdout),
        stderr=stderr_tail or "",
    )
    # Annotate the parent ``subprocess.run`` span (opened by
    # ``run_command``) with the streaming-path outcome.
    _annotate_span(returncode=result.returncode, streamed=True)
    if check and result.returncode != 0:
        raise CommandError(
            argv=result.argv,
            returncode=result.returncode,
            stderr=result.stderr,
        )
    return result


def _annotate_span(**attributes: object) -> None:
    """Attach attributes to the currently-active OTel span, if any.

    No-op when the OTel SDK is not installed OR no span is active.
    Lets the shell wrapper publish ``returncode`` / ``duration_ms``
    to the trace viewer without holding an explicit span reference.
    """
    try:
        from opentelemetry import trace
    except ImportError:
        return
    # ``getattr`` rather than direct attribute access — keeps the
    # mypy story clean across SDK versions (matches the same trick
    # in ``observability/json_formatter.py::_add_otel_context``).
    get_current_span = getattr(trace, "get_current_span", None)
    if get_current_span is None:
        return
    span = get_current_span()
    ctx = span.get_span_context() if span is not None else None
    if ctx is None or not ctx.is_valid:
        return
    for k, v in attributes.items():
        if v is None:
            continue
        span.set_attribute(k, v)


def _terminate(proc: subprocess.Popen[str]) -> None:
    """Best-effort kill of a child process.

    Sends SIGTERM, waits 2 s, escalates to SIGKILL, waits 2 s more. If
    the child still has not exited after SIGKILL — a rare but real
    case (D-state, an overloaded host, certain container runtimes) —
    we log a WARNING naming the pid and return. We do **not** raise
    out of this cleanup function: a leaked zombie is preferable to an
    untyped ``subprocess.TimeoutExpired`` escaping into the caller's
    cancellation/timeout path, where it would defeat
    :func:`run_command`'s typed-``CommandError`` contract (rule
    RA-02). The operator finds the survivor via the WARNING + pid.
    """
    if proc.poll() is not None:
        return
    try:
        proc.terminate()
        try:
            proc.wait(timeout=2.0)
        except subprocess.TimeoutExpired:
            proc.kill()
            try:
                proc.wait(timeout=2.0)
            except subprocess.TimeoutExpired:
                # Survived SIGKILL. Best-effort log + return — never
                # raise from a cleanup function (see RA-02).
                _LOG.warning(
                    "child %d survived SIGKILL; possible zombie",
                    proc.pid,
                    extra={"child_pid": proc.pid, "event": "shell.reap_timeout"},
                )
    except ProcessLookupError:
        # Race: child exited between our poll and terminate.
        pass


__all__ = [
    "CANCELLED_RETURNCODE",
    "CommandResult",
    "run_command",
]
