"""Integration: multiple Services composed via :class:`ExitStack`.

The recipe documents :class:`contextlib.ExitStack` as the
canonical pattern for "start postgres, then start the app that
depends on it." Tests:

  - Two services start in declared order; both reach ready.
  - Teardown is **LIFO** (the dependent shuts down before its
    dependency) — standard ExitStack semantics.
  - A failure in the dependent's startup tears down the dependency
    cleanly via the stack — no leaked process.
"""

from __future__ import annotations

import socket
import sys
import textwrap
from contextlib import ExitStack

import pytest

from mgf.common.exceptions import ServiceNotReadyError

pytestmark = pytest.mark.integration


def _free_port() -> int:
    s = socket.socket()
    s.bind(("127.0.0.1", 0))
    port = s.getsockname()[1]
    s.close()
    return port


# Listener that reports the moment it starts and the moment it
# receives a SIGTERM (via stderr) — used to verify teardown order.
_OBSERVABLE_LISTENER = textwrap.dedent(
    """
    import socket, signal, sys
    name = NAME
    sock = socket.socket(); sock.bind(("127.0.0.1", PORT)); sock.listen(1)

    def _on_term(*_a):
        sys.stderr.write(f"TERM:{name}\\n"); sys.stderr.flush()
        sys.exit(0)

    signal.signal(signal.SIGTERM, _on_term)
    print(f"READY:{name}", flush=True)
    while True:
        try:
            conn, _ = sock.accept(); conn.close()
        except KeyboardInterrupt:
            break
    """,
).strip()


def _make_listener(port: int, name: str) -> list[str]:
    body = _OBSERVABLE_LISTENER.replace("PORT", str(port)).replace(
        "NAME",
        repr(name),
    )
    return [sys.executable, "-c", body]


def test_two_services_start_in_order_and_lifo_teardown() -> None:
    """Two services start together and both stop cleanly on context exit.

    Note: this test verifies both children receive SIGTERM and exit
    with returncode 0; it does NOT assert absolute start/stop ordering
    (that needs per-event timestamps the streamer doesn't surface).
    """
    from mgf.common.process import LogLineReady, Service

    port_a = _free_port()
    port_b = _free_port()

    with ExitStack() as stack:
        svc_a = stack.enter_context(
            Service(
                _make_listener(port_a, "A"),
                name="svc-a",
                ready=LogLineReady(r"^READY:A$", stream="stdout"),
                timeout_s=5.0,
                poll_interval_s=0.05,
                kill_grace_s=1.0,
            )
        )
        svc_b = stack.enter_context(
            Service(
                _make_listener(port_b, "B"),
                name="svc-b",
                ready=LogLineReady(r"^READY:B$", stream="stdout"),
                timeout_s=5.0,
                poll_interval_s=0.05,
                kill_grace_s=1.0,
            )
        )
        # Both alive simultaneously inside the with-block.
        assert svc_a.is_running
        assert svc_b.is_running

    # After ExitStack exit, both stopped. Order: svc_b first
    # (entered last), then svc_a. Verify via the captured stderr
    # of each — the SIGTERM handler in each child wrote
    # "TERM:<name>" to stderr, and the streamer captured it
    # before joining.
    assert not svc_a.is_running
    assert not svc_b.is_running
    # We can't observe absolute ordering without timestamps, but
    # we CAN verify both children received SIGTERM cleanly (their
    # exit codes are 0 from the signal handler's sys.exit(0)).
    assert svc_a.returncode == 0, f"svc-a unclean exit: {svc_a.returncode}"
    assert svc_b.returncode == 0, f"svc-b unclean exit: {svc_b.returncode}"


def test_dependent_failure_tears_down_dependency() -> None:
    """If the dependent's start() raises, ExitStack unwinds the
    already-started dependency — no leak."""
    from mgf.common.process import LogLineReady, PortReady, Service

    port_a = _free_port()
    unbound_port_b = _free_port()  # NOT used to bind; B will hang on it

    pids_seen: list[int] = []

    with pytest.raises(ServiceNotReadyError), ExitStack() as stack:
        svc_a = stack.enter_context(
            Service(
                _make_listener(port_a, "A"),
                name="dep-a",
                ready=LogLineReady(r"^READY:A$", stream="stdout"),
                timeout_s=5.0,
                poll_interval_s=0.05,
                kill_grace_s=1.0,
            )
        )
        assert svc_a.is_running
        pids_seen.append(svc_a.pid)

        # B never becomes ready (probe is for a port that's
        # not bound). Child stays alive but fails the probe.
        stack.enter_context(
            Service(
                [sys.executable, "-c", "import time; time.sleep(60)"],
                name="dep-b",
                ready=PortReady(
                    "127.0.0.1",
                    unbound_port_b,
                    connect_timeout_s=0.05,
                ),
                timeout_s=0.3,  # tight — fail fast
                poll_interval_s=0.05,
                kill_grace_s=0.5,
            )
        )

    # ExitStack unwound after B raised. svc-a is the dep; must
    # have been torn down.
    # Verify by re-opening the same port — if svc-a is still
    # holding it, the bind would fail.
    s = socket.socket()
    try:
        s.bind(("127.0.0.1", port_a))
    finally:
        s.close()
