"""``mgf.common.process.Service`` — long-lived process with readiness wait + clean shutdown.

The orchestration primitive for "launch this dev server / database /
worker / docker-compose stack, wait until it's ready to accept
traffic, run my scope, tear it down." Used as a context manager
(recommended; teardown on exit guaranteed) or via explicit
``start() / stop()`` for finer control.

Out of scope (deliberately):

  - **Restart-on-crash supervision.** That's systemd / supervisord
    / Procfile territory. :class:`Service` is for "launch + wait
    + monitor while running + tear down" — the demo / test /
    script orchestration shape, not the production daemon shape.
  - **Restart policies + backoff.** Same scope reasoning.
  - **Multi-service dependency ordering.** A consumer who needs
    "start postgres, wait, then start the app" composes two
    :class:`Service`s with two ``with``-blocks (or a stack of
    ``ExitStack.enter_context`` calls). The library does not
    ship a docker-compose-style declarative orchestrator.
"""

from __future__ import annotations

import logging
import os
import secrets
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import TYPE_CHECKING

from mgf.common.exceptions import ServiceNotReadyError
from mgf.common.process._readiness import LogLineReady, ReadinessProbe
from mgf.common.process._run import _terminate, _validate_command
from mgf.common.process._streamer import make_streamers, resolve_redactor

if TYPE_CHECKING:
    from collections.abc import Callable, Mapping
    from types import TracebackType

    from mgf.common.process._streamer import _StreamCapture


class Service:
    """A long-lived process with a readiness predicate + clean shutdown.

    Construct, then either:

    1. Use as a context manager (recommended)::

           with Service(["postgres", "-D", db_dir], ready=PortReady("localhost", 5432)) as svc:
               run_my_smoke_test()
           # Service is stopped on with-exit, even on test failure.

    2. Or call :meth:`start` / :meth:`stop` explicitly when the
       lifecycle doesn't fit a single scope::

           svc = Service([...], ready=...)
           svc.start()
           try:
               run_my_thing()
           finally:
               svc.stop()

    :meth:`start` blocks until the readiness predicate resolves
    (default 30s timeout) or raises :class:`ServiceNotReadyError`. The
    service is fully torn down before that exception is raised —
    no leaked process; the failure mode is "tried, didn't work,
    cleaned up."

    **Early-exit detection (v0.22.0).** If the child process exits
    BEFORE the readiness probe resolves (typo in command, missing
    dependency, segfault on startup), :meth:`start` raises
    :class:`ServiceNotReadyError` immediately with the returncode +
    stderr tail in ``last_error`` — does not wait for the readiness
    timeout to elapse.

    Parameters
    ----------
    command
        argv list. List-only, no shell strings (same constraint as
        :func:`run`).
    ready
        :class:`ReadinessProbe` to poll. Use one of
        :class:`PortReady` / :class:`HttpReady` / :class:`LogLineReady`
        / :class:`CallableReady`, or :class:`AllOf` / :class:`AnyOf`
        for composition.
    timeout_s
        Default 30.0. How long :meth:`start` waits for the readiness
        probe to resolve before raising :class:`ServiceNotReadyError`.
    poll_interval_s
        Default 0.2. How often the readiness probe is polled.
    name
        Default: basename of ``command[0]``. Used in log records
        and error messages.
    cwd / env_extra
        Same semantics as :func:`run`.
    process_group / kill_grace_s
        Same semantics as :func:`run`. Process-group cascade is the
        default for the same reason — services with child processes
        (uvicorn workers, celery prefork pools, docker-compose
        sub-services) need group-kill on shutdown to not leak.
    redact
        Same semantics as :func:`run`. ``None`` means
        "redact-via-bootstrap-Redactor when active."
    """

    def __init__(
        self,
        command: list[str],
        *,
        ready: ReadinessProbe,
        timeout_s: float = 30.0,
        poll_interval_s: float = 0.2,
        name: str | None = None,
        cwd: str | os.PathLike[str] | None = None,
        env_extra: Mapping[str, str] | None = None,
        process_group: bool = True,
        kill_grace_s: float = 5.0,
        redact: bool | None = None,
    ) -> None:
        self.command: list[str] = _validate_command(command)
        self.ready: ReadinessProbe = ready
        self.timeout_s: float = timeout_s
        self.poll_interval_s: float = poll_interval_s
        self.name: str = name or Path(command[0]).name
        self.cwd = cwd
        self.env_extra = env_extra
        self.process_group: bool = process_group
        self.kill_grace_s: float = kill_grace_s
        self.redact = redact

        self.command_id: str = secrets.token_hex(4)
        self._proc: subprocess.Popen[bytes] | None = None
        self._stdout_capture: _StreamCapture | None = None
        self._stderr_capture: _StreamCapture | None = None
        self._started_at: float | None = None
        self._lock = threading.Lock()

    def __repr__(self) -> str:
        """Concise debug representation.

        Shape: ``Service(name=<name>, command=[...], status=<state>, pid=<pid|->)``.
        ``status`` is ``not-started`` | ``running`` | ``exited(<rc>)``.
        """
        if self._proc is None:
            status = "not-started"
            pid_str = "-"
        elif self._proc.poll() is None:
            status = "running"
            pid_str = str(self._proc.pid)
        else:
            status = f"exited({self._proc.returncode})"
            pid_str = str(self._proc.pid)
        return (
            f"Service(name={self.name!r}, command={self.command!r}, status={status}, pid={pid_str})"
        )

    # ------------------------------------------------------------------
    # Public lifecycle
    # ------------------------------------------------------------------

    def __enter__(self) -> Service:
        self.start()
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        self.stop()

    @property
    def is_running(self) -> bool:
        """``True`` iff the child has been started AND has not exited."""
        if self._proc is None:
            return False
        return self._proc.poll() is None

    @property
    def pid(self) -> int | None:
        """The child's PID, or ``None`` if not started."""
        if self._proc is None:
            return None
        return self._proc.pid

    @property
    def returncode(self) -> int | None:
        """The child's exit code, or ``None`` if still running."""
        if self._proc is None:
            return None
        return self._proc.returncode

    def start(self) -> None:
        """Launch + wait until ready. Raises :class:`ServiceNotReadyError` on timeout.

        Idempotent: calling :meth:`start` on an already-started
        service returns immediately. The "already started" check is
        ``is_running`` — so a service that has crashed will be
        re-launched on a second :meth:`start` call (which is usually
        the right behaviour for test fixtures).

        On any failure between the ``Popen`` call and successful
        readiness resolution, the launched process is terminated +
        streamers drained before :class:`ServiceNotReadyError` is raised.
        No leaked process is the contract.
        """
        # Best-effort OTel span (v0.23.0). Gated on bootstrap state
        # via :func:`mgf.common.process._run._maybe_operation_span` —
        # nullcontext when no AppContext is active so the OTel path
        # doesn't trigger UnconfiguredWarning. When bootstrap IS
        # wired, the span records the readiness wait + any
        # ServiceNotReadyError exception via __cause__.
        from mgf.common.process._run import _maybe_operation_span

        span_cm = _maybe_operation_span(
            f"service.start.{self.name}",
            command_id=self.command_id,
            service_name=self.name,
        )
        with self._lock, span_cm:
            if self.is_running:
                return

            logger = logging.getLogger(f"mgf.service.{self.name}")
            logger.info(
                "service starting",
                extra={
                    "command_id": self.command_id,
                    "command": self.command,
                    "service_name": self.name,
                },
            )

            redactor = resolve_redactor(self.redact)
            env = None if self.env_extra is None else {**os.environ, **dict(self.env_extra)}
            start_new_session = self.process_group and sys.platform != "win32"

            self._started_at = time.monotonic()
            self._proc = subprocess.Popen(
                self.command,
                cwd=self.cwd,
                env=env,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                start_new_session=start_new_session,
            )

            # Setup-failure rollback: any exception between Popen and
            # the ready-resolved log line below tears the child down
            # before the exception escapes. Streamers always start
            # successfully (they spawn daemon threads); the failure
            # surface is the readiness wait raising.
            try:
                self._stdout_capture, self._stderr_capture = make_streamers(
                    self._proc,
                    name=self.name,
                    command_id=self.command_id,
                    redactor=redactor,
                    capture=True,
                )

                # Bind LogLineReady probes to our capture so they can
                # scan recent lines without coupling to streamer
                # internals.
                _bind_log_probes(
                    self.ready,
                    stdout_snap=self._stdout_capture.snapshot
                    if self._stdout_capture is not None
                    else _empty_snapshot,
                    stderr_snap=self._stderr_capture.snapshot
                    if self._stderr_capture is not None
                    else _empty_snapshot,
                )

                # Readiness wait with concurrent child-exit detection.
                ready_resolved, last_error = self._wait_for_ready_or_exit()
                if not ready_resolved:
                    raise ServiceNotReadyError(
                        command=self.command,
                        command_id=self.command_id,
                        probe_description=self.ready.description,
                        timeout=self.timeout_s,
                        last_error=last_error,
                    )
            except BaseException:
                # Tear down before re-raising. Any failure here
                # (ServiceNotReadyError, KeyboardInterrupt, anything else)
                # gets the no-leak guarantee.
                self._stop_locked()
                raise

            logger.info(
                "service ready",
                extra={
                    "command_id": self.command_id,
                    "service_name": self.name,
                    "elapsed_s": time.monotonic() - self._started_at,
                },
            )

    def stop(self) -> None:
        """Send SIGTERM → grace → SIGKILL. Idempotent.

        Safe to call multiple times; safe to call when the service
        has already exited (the exit-code is preserved on
        ``returncode``).
        """
        with self._lock:
            self._stop_locked()

    def wait(self, timeout: float | None = None) -> int | None:
        """Block until the child exits naturally OR ``timeout`` elapses.

        Returns the child's ``returncode`` on natural exit, or
        ``None`` if ``timeout`` elapsed and the process is still
        running (in which case the caller can :meth:`stop` to
        terminate it).

        Use case: "start the service, let it run until it crashes
        or until I'm ready to tear it down explicitly." Without
        :meth:`wait`, the only escape from a started :class:`Service`
        is to :meth:`stop`, which kills the child even if it was
        about to exit cleanly on its own.

        Does NOT block indefinitely with ``timeout=None`` if the
        service was never started — returns ``None`` immediately.

        Parameters
        ----------
        timeout
            Seconds to wait. ``None`` (default) means wait forever
            (the child's natural lifetime); pass an explicit timeout
            in tests so a hung child fails the test instead of
            hanging it.
        """
        if self._proc is None:
            return None
        try:
            return self._proc.wait(timeout=timeout)
        except subprocess.TimeoutExpired:
            return None

    # ------------------------------------------------------------------
    # Internals
    # ------------------------------------------------------------------

    def _wait_for_ready_or_exit(self) -> tuple[bool, str]:
        """Poll readiness probe + child liveness in lockstep.

        Returns ``(ready, last_error)``. Three resolution paths:

        1. ``probe.check()`` returns True → ``(True, "")``.
        2. ``proc.poll()`` is not None (child exited before ready)
           → ``(False, "child exited with returncode N; stderr tail: ...")``.
        3. ``timeout_s`` elapsed → ``(False, last_error_from_probe_raise)``.

        The early-exit detection (path 2) is a v0.22.0 reliability
        improvement — without it, a typo in ``command[0]`` made the
        readiness wait burn the full timeout before surfacing the
        useless "probe X did not resolve" error.
        """
        assert self._proc is not None
        deadline = time.monotonic() + self.timeout_s
        last_error = ""
        while time.monotonic() < deadline:
            try:
                if self.ready.check():
                    return True, last_error
            except Exception as exc:
                last_error = f"{type(exc).__name__}: {exc}"
            # Did the child crash during startup?
            if self._proc.poll() is not None:
                tail = (
                    self._stderr_capture.captured_tail if self._stderr_capture is not None else ""
                )
                exit_msg = (
                    f"child exited with returncode {self._proc.returncode} before becoming ready"
                )
                if tail:
                    exit_msg += f"; stderr tail: {tail}"
                return False, exit_msg
            time.sleep(self.poll_interval_s)
        return False, last_error

    def _stop_locked(self) -> None:
        """Stop logic with the lock already held."""
        if self._proc is None:
            return
        if self._proc.poll() is None:
            _terminate(
                self._proc,
                grace_s=self.kill_grace_s,
                process_group=self.process_group and sys.platform != "win32",
            )
        # Drain streamer threads.
        if self._stdout_capture is not None:
            self._stdout_capture.join(timeout=2.0)
        if self._stderr_capture is not None:
            self._stderr_capture.join(timeout=2.0)
        # NOTE: we DON'T null out self._proc — caller may want to
        # inspect ``returncode`` after stop. ``is_running`` still
        # returns False because poll() returned a value.

        logger = logging.getLogger(f"mgf.service.{self.name}")
        elapsed = time.monotonic() - self._started_at if self._started_at is not None else 0.0
        logger.info(
            "service stopped",
            extra={
                "command_id": self.command_id,
                "service_name": self.name,
                "returncode": self._proc.returncode,
                "elapsed_s": elapsed,
            },
        )


# ---------------------------------------------------------------------------
# LogLineReady binding helpers (module-level — no closure capture state)
# ---------------------------------------------------------------------------


def _empty_snapshot() -> list[str]:
    """Snapshot fallback for streams that weren't created (rare)."""
    return []


def _bind_log_probes(
    probe: ReadinessProbe,
    *,
    stdout_snap: Callable[[], list[str]],
    stderr_snap: Callable[[], list[str]],
) -> None:
    """Walk the probe tree and bind any :class:`LogLineReady` to a snapshot.

    :class:`LogLineReady` needs to peek at the captured-line history to
    do its regex match — but the history lives on the streamer, which
    the probe doesn't know about. This binder hands each
    :class:`LogLineReady` a thunk that returns the merged-recent-lines
    view at call time. The thunk is just :meth:`_StreamCapture.snapshot`
    pre-bound for the right stream.
    """
    # Local import avoids a circular: AllOf / AnyOf live in
    # _readiness; importing them at module level is fine but the
    # isinstance checks belong here.
    from mgf.common.process._readiness import AllOf, AnyOf

    if isinstance(probe, LogLineReady):
        if probe.stream == "stdout":
            probe._capture_provider = stdout_snap
        elif probe.stream == "stderr":
            probe._capture_provider = stderr_snap
        else:  # "both"
            probe._capture_provider = lambda: stdout_snap() + stderr_snap()
    elif isinstance(probe, (AllOf, AnyOf)):
        for sub in probe.probes:
            _bind_log_probes(
                sub,
                stdout_snap=stdout_snap,
                stderr_snap=stderr_snap,
            )
