"""v0.22.0 polish — reliability + simplicity tests.

Adds coverage for the four reliability fixes + the one new feature
(`Service.wait()`). Runs alongside the existing v0.21.0 process
tests, which still pass unchanged after the refactor.

Reliability:
  - early-exit detection: child crashing during readiness wait
    surfaces ServiceNotReadyError FAST (with returncode in last_error)
    instead of burning the timeout.
  - thread-safe LineSnapshot: high-volume stdout doesn't race the
    LogLineReady probe.
  - setup-failure rollback: the no-leak guarantee covers any
    failure path between Popen and ready-resolved.

Feature:
  - Service.wait(): blocks until natural exit (or timeout).
"""

from __future__ import annotations

import sys
import textwrap
import time

import pytest

from mgf.common.exceptions import ServiceNotReadyError
from mgf.common.process import (
    LogLineReady,
    PortReady,
    Service,
)

# ---------------------------------------------------------------------------
# Reliability — early-exit detection
# ---------------------------------------------------------------------------


class TestEarlyExitDetection:
    def test_child_exits_before_ready_raises_quickly(self) -> None:
        # Child that exits with returncode 7 immediately. The
        # readiness probe (PortReady on a port that will never bind)
        # would otherwise burn the full timeout.
        start = time.monotonic()
        with pytest.raises(ServiceNotReadyError) as ei:
            Service(
                [sys.executable, "-c", "import sys; sys.exit(7)"],
                ready=PortReady("127.0.0.1", 1, connect_timeout_s=0.05),
                timeout_s=10.0,  # generous — should NOT use it
                poll_interval_s=0.05,
                kill_grace_s=0.5,
            ).start()
        elapsed = time.monotonic() - start

        # Relative bound (not an absolute budget — those flake on a loaded
        # host): early-exit detection must short-circuit the timeout, so
        # elapsed is a small fraction of timeout_s (10.0). If it were broken
        # it would burn the full ~10 s.
        assert elapsed < 10.0 / 2, (
            f"early-exit detection too slow: {elapsed:.2f}s (should be far below the 10.0s timeout)"
        )
        # The ServiceNotReadyError should mention the returncode.
        assert "returncode 7" in ei.value.last_error
        assert "before becoming ready" in ei.value.last_error

    def test_child_exits_with_stderr_includes_tail(self) -> None:
        # Child writes to stderr then exits non-zero. The tail should
        # be in last_error to help diagnosis.
        with pytest.raises(ServiceNotReadyError) as ei:
            Service(
                [
                    sys.executable,
                    "-c",
                    "import sys; sys.stderr.write('CONFIG_ERROR: missing key\\n'); sys.exit(1)",
                ],
                ready=PortReady("127.0.0.1", 1, connect_timeout_s=0.05),
                timeout_s=5.0,
                poll_interval_s=0.05,
                kill_grace_s=0.5,
            ).start()

        assert "CONFIG_ERROR" in ei.value.last_error
        assert "stderr tail" in ei.value.last_error

    def test_long_running_child_still_uses_timeout(self) -> None:
        # Normal failure mode: child stays alive but never ready.
        # Must use the configured timeout — early-exit detection
        # only fires when the child actually exits.
        start = time.monotonic()
        with pytest.raises(ServiceNotReadyError) as ei:
            Service(
                [sys.executable, "-c", "import time; time.sleep(30)"],
                ready=PortReady("127.0.0.1", 1, connect_timeout_s=0.05),
                timeout_s=0.3,
                poll_interval_s=0.05,
                kill_grace_s=0.5,
            ).start()
        elapsed = time.monotonic() - start

        # Relative bounds keyed off the operation's own parameters
        # (timeout_s=0.3, kill_grace_s=0.5), not absolute budgets: it MUST
        # wait ~the timeout (proving it did not early-exit) and then kill
        # promptly — far below the child's 30 s sleep, with generous fixed
        # slack for thread-join / process-kill on a loaded host.
        assert elapsed >= 0.3 * 0.8, f"did not wait the timeout: {elapsed:.2f}s"
        assert elapsed < 0.3 + 0.5 + 2.0, f"kill not prompt: {elapsed:.2f}s"
        assert ei.value.timeout == 0.3
        # last_error is empty (no probe raise; no early exit).
        assert "returncode" not in ei.value.last_error


# ---------------------------------------------------------------------------
# Reliability — thread-safe LineSnapshot
# ---------------------------------------------------------------------------


# A child that prints a marker line continuously to stress the
# concurrent capture-vs-snapshot path. The LogLineReady probe scans
# the snapshot on every poll-tick; without atomic snapshotting,
# extending into a deque mid-append would race.
_NOISY_LISTENER = textwrap.dedent(
    """
    import socket, sys, time
    sock = socket.socket(); sock.bind(("127.0.0.1", PORT)); sock.listen(1)
    # Spam stdout BEFORE printing the ready signal so the deque is
    # being heavily mutated when LogLineReady starts polling.
    for i in range(2000):
        print(f"noise line {i}", flush=True)
    print("READY-NOW", flush=True)
    while True:
        try:
            conn, _ = sock.accept(); conn.close()
        except KeyboardInterrupt:
            break
    """,
).strip()


class TestThreadSafeSnapshot:
    def test_logline_probe_on_noisy_stdout_no_race(self) -> None:
        # Bind a free port; we don't actually need PortReady here, but
        # the listener wants to bind (it's the realistic shape).
        import socket as _socket

        s = _socket.socket()
        s.bind(("127.0.0.1", 0))
        port = s.getsockname()[1]
        s.close()

        # Tiny poll interval + LogLineReady on a marker that follows
        # 2000 noise lines stresses the snapshot path.
        with Service(
            [sys.executable, "-c", _NOISY_LISTENER.replace("PORT", str(port))],
            ready=LogLineReady(r"^READY-NOW$", stream="stdout"),
            timeout_s=10.0,
            poll_interval_s=0.001,  # aggressive — many snapshot ticks
            kill_grace_s=1.0,
        ) as svc:
            # If we got here without RuntimeError("deque mutated
            # during iteration"), the snapshot path is race-safe.
            assert svc.is_running


# ---------------------------------------------------------------------------
# Reliability — setup-failure rollback (no leak)
# ---------------------------------------------------------------------------


class TestNoLeakOnFailure:
    def test_failed_start_leaves_no_running_process(self) -> None:
        # Already covered by v0.21 tests, but re-verify against the
        # refactored try/except path. A child that runs forever +
        # readiness probe that fails fast.
        svc = Service(
            [sys.executable, "-c", "import time; time.sleep(60)"],
            ready=PortReady("127.0.0.1", 1, connect_timeout_s=0.05),
            timeout_s=0.2,
            poll_interval_s=0.05,
            kill_grace_s=0.5,
        )
        with pytest.raises(ServiceNotReadyError):
            svc.start()
        # Process is dead — verify via _proc.poll() (None means alive).
        assert svc._proc is not None
        assert svc._proc.poll() is not None  # exited
        assert not svc.is_running


# ---------------------------------------------------------------------------
# Feature — Service.wait()
# ---------------------------------------------------------------------------


_QUICK_LISTENER_THAT_EXITS = textwrap.dedent(
    """
    import socket, sys, time
    sock = socket.socket(); sock.bind(("127.0.0.1", PORT)); sock.listen(1)
    print("READY", flush=True)
    # Process exits naturally after a short delay — this models a
    # service that runs to completion (e.g., a one-shot task).
    time.sleep(0.3)
    sys.exit(0)
    """,
).strip()


class TestServiceWait:
    @pytest.fixture
    def free_port(self) -> int:
        import socket

        s = socket.socket()
        s.bind(("127.0.0.1", 0))
        port = s.getsockname()[1]
        s.close()
        return port

    def test_wait_returns_returncode_on_natural_exit(
        self,
        free_port: int,
    ) -> None:
        with Service(
            [
                sys.executable,
                "-c",
                _QUICK_LISTENER_THAT_EXITS.replace("PORT", str(free_port)),
            ],
            ready=PortReady("127.0.0.1", free_port, connect_timeout_s=0.5),
            timeout_s=5.0,
            poll_interval_s=0.05,
            kill_grace_s=1.0,
        ) as svc:
            rc = svc.wait(timeout=2.0)
            # Child exits via sys.exit(0) → returncode 0.
            assert rc == 0
            assert not svc.is_running

    def test_wait_returns_none_on_timeout(self) -> None:
        # A child that runs forever: wait(timeout=…) should return
        # None and leave the child running.
        port = self._free_port()
        long_listener = textwrap.dedent(f"""
            import socket
            sock = socket.socket(); sock.bind(('127.0.0.1', {port})); sock.listen(1)
            print('READY', flush=True)
            while True:
                conn, _ = sock.accept(); conn.close()
        """).strip()

        with Service(
            [sys.executable, "-c", long_listener],
            ready=PortReady("127.0.0.1", port, connect_timeout_s=0.5),
            timeout_s=5.0,
            poll_interval_s=0.05,
            kill_grace_s=1.0,
        ) as svc:
            rc = svc.wait(timeout=0.2)
            assert rc is None
            assert svc.is_running  # still alive

    def test_wait_before_start_returns_none(self) -> None:
        svc = Service(
            [sys.executable, "-c", "pass"],
            ready=PortReady("127.0.0.1", 1, connect_timeout_s=0.05),
            timeout_s=1.0,
        )
        # Never started — wait() returns None immediately.
        assert svc.wait(timeout=0.1) is None

    @staticmethod
    def _free_port() -> int:
        import socket

        s = socket.socket()
        s.bind(("127.0.0.1", 0))
        port = s.getsockname()[1]
        s.close()
        return port


# ---------------------------------------------------------------------------
# Simplicity — verify shared helpers still work
# ---------------------------------------------------------------------------


class TestSharedHelpers:
    def test_resolve_redactor_none_returns_none_when_no_bootstrap(self) -> None:
        from mgf.common.process._streamer import resolve_redactor

        # No bootstrap active in the test environment by default.
        # resolve_redactor(None) should return None silently; not raise.
        assert resolve_redactor(None) is None

    def test_resolve_redactor_false_returns_none(self) -> None:
        from mgf.common.process._streamer import resolve_redactor

        assert resolve_redactor(False) is None

    def test_resolve_redactor_true_raises_when_no_bootstrap(self) -> None:
        from mgf.common.process._streamer import resolve_redactor

        # No bootstrap active in this test → True must raise.
        with pytest.raises(RuntimeError, match="bootstrap"):
            resolve_redactor(True)
