"""Tests for ``mgf.common.process.Service``.

Tests use lightweight Python subprocesses as the "service under
supervision" — eliminates external dependencies (no postgres, no
docker) and gives deterministic readiness signals.
"""

from __future__ import annotations

import socket
import sys
import textwrap

import pytest

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

# A tiny TCP listener written as a Python one-liner. Binds to the
# given port AFTER printing "STARTING" to stderr + waiting for the
# socket to be ready, then prints "READY" to stdout. Used as a
# realistic stand-in for a service whose readiness signal is "port
# is bound."
_TINY_LISTENER = textwrap.dedent(
    """
    import socket, sys, time
    sys.stderr.write("STARTING\\n"); sys.stderr.flush()
    sock = socket.socket()
    sock.bind(("127.0.0.1", PORT))
    sock.listen(1)
    print("READY", flush=True)
    while True:
        try:
            conn, _ = sock.accept()
            conn.close()
        except KeyboardInterrupt:
            break
    """,
).strip()


def _free_port() -> int:
    """Return an OS-assigned free TCP port."""
    sock = socket.socket()
    sock.bind(("127.0.0.1", 0))
    port = sock.getsockname()[1]
    sock.close()
    return port


class TestBasicLifecycle:
    def test_starts_and_stops_via_context_manager(self) -> None:
        port = _free_port()
        with Service(
            [sys.executable, "-c", _TINY_LISTENER.replace("PORT", str(port))],
            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:
            assert svc.is_running
            assert svc.pid is not None
            # Confirm the port is actually accepting now.
            with socket.create_connection(("127.0.0.1", port), timeout=1.0):
                pass
        # After exit, the service is stopped.
        assert not svc.is_running

    def test_explicit_start_and_stop(self) -> None:
        port = _free_port()
        svc = Service(
            [sys.executable, "-c", _TINY_LISTENER.replace("PORT", str(port))],
            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,
        )
        try:
            svc.start()
            assert svc.is_running
        finally:
            svc.stop()
        assert not svc.is_running

    def test_double_start_is_idempotent(self) -> None:
        port = _free_port()
        svc = Service(
            [sys.executable, "-c", _TINY_LISTENER.replace("PORT", str(port))],
            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,
        )
        try:
            svc.start()
            first_pid = svc.pid
            svc.start()  # idempotent — same process
            assert svc.pid == first_pid
        finally:
            svc.stop()

    def test_double_stop_is_safe(self) -> None:
        port = _free_port()
        svc = Service(
            [sys.executable, "-c", _TINY_LISTENER.replace("PORT", str(port))],
            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,
        )
        svc.start()
        svc.stop()
        svc.stop()  # second call must not raise


class TestReadinessFailure:
    def test_service_not_ready_when_probe_never_resolves(self) -> None:
        # Use a port that will never bind: launch a service that
        # never listens, but probe a different port.
        unbound_port = _free_port()
        with pytest.raises(ServiceNotReadyError) as ei:
            Service(
                [sys.executable, "-c", "import time; time.sleep(30)"],
                ready=PortReady("127.0.0.1", unbound_port, connect_timeout_s=0.05),
                timeout_s=0.3,
                poll_interval_s=0.05,
                kill_grace_s=0.5,
            ).start()
        # Service should be torn down before the exception is raised.
        assert ei.value.timeout == 0.3
        assert "127.0.0.1" in ei.value.probe_description
        assert str(unbound_port) in ei.value.probe_description

    def test_failed_start_does_not_leak_process(self) -> None:
        unbound_port = _free_port()
        svc = Service(
            [sys.executable, "-c", "import time; time.sleep(30)"],
            ready=PortReady("127.0.0.1", unbound_port, connect_timeout_s=0.05),
            timeout_s=0.3,
            poll_interval_s=0.05,
            kill_grace_s=0.5,
        )
        with pytest.raises(ServiceNotReadyError):
            svc.start()
        # The process MUST have been stopped by the failed start.
        assert not svc.is_running


class TestLogLineReady:
    def test_log_line_probe_resolves(self) -> None:
        port = _free_port()
        # Service emits "READY" on stdout; LogLineReady alone (no
        # PortReady) is sufficient.
        with Service(
            [sys.executable, "-c", _TINY_LISTENER.replace("PORT", str(port))],
            ready=LogLineReady(r"^READY$", stream="stdout"),
            timeout_s=5.0,
            poll_interval_s=0.05,
            kill_grace_s=1.0,
        ) as svc:
            assert svc.is_running

    def test_log_line_can_match_stderr(self) -> None:
        port = _free_port()
        with Service(
            [sys.executable, "-c", _TINY_LISTENER.replace("PORT", str(port))],
            ready=LogLineReady(r"STARTING", stream="stderr"),
            timeout_s=5.0,
            poll_interval_s=0.05,
            kill_grace_s=1.0,
        ) as svc:
            assert svc.is_running

    def test_composite_log_plus_port(self) -> None:
        port = _free_port()
        # Realistic: postgres-style. Wait for both the port AND
        # the log line.
        with Service(
            [sys.executable, "-c", _TINY_LISTENER.replace("PORT", str(port))],
            ready=AllOf(
                [
                    PortReady("127.0.0.1", port, connect_timeout_s=0.5),
                    LogLineReady(r"^READY$", stream="stdout"),
                ]
            ),
            timeout_s=5.0,
            poll_interval_s=0.05,
            kill_grace_s=1.0,
        ) as svc:
            assert svc.is_running


class TestCallableReady:
    def test_callable_predicate_resolves(self) -> None:
        port = _free_port()
        flag = {"count": 0}

        def _always_after_3() -> bool:
            flag["count"] += 1
            return flag["count"] >= 3

        with Service(
            [sys.executable, "-c", _TINY_LISTENER.replace("PORT", str(port))],
            ready=CallableReady(_always_after_3, "count >= 3"),
            timeout_s=2.0,
            poll_interval_s=0.05,
            kill_grace_s=1.0,
        ) as svc:
            assert svc.is_running


class TestRejectsBadInput:
    def test_command_must_be_list(self) -> None:
        with pytest.raises(TypeError):
            Service(
                "echo hello",  # type: ignore[arg-type]
                ready=PortReady("127.0.0.1", 1),
            )

    def test_empty_command_rejected(self) -> None:
        with pytest.raises(ValueError, match="empty"):
            Service([], ready=PortReady("127.0.0.1", 1))


class TestProperties:
    def test_returncode_set_after_stop(self) -> None:
        port = _free_port()
        svc = Service(
            [sys.executable, "-c", _TINY_LISTENER.replace("PORT", str(port))],
            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,
        )
        svc.start()
        svc.stop()
        # SIGTERM kill yields negative returncode on POSIX.
        assert svc.returncode is not None
