"""Tests for readiness predicates (mgf.common.process._readiness)."""

from __future__ import annotations

import socket
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer

import pytest

from mgf.common.process._readiness import (
    AllOf,
    AnyOf,
    CallableReady,
    HttpReady,
    LogLineReady,
    PortReady,
    wait_for_ready,
)


class TestPortReady:
    def test_unbound_port_returns_false(self) -> None:
        # Port 1 is reserved (tcpmux) — almost never bound on dev.
        probe = PortReady("127.0.0.1", 1, connect_timeout_s=0.1)
        assert probe.check() is False

    def test_bound_port_returns_true(self) -> None:
        # Bind a temporary listener.
        sock = socket.socket()
        sock.bind(("127.0.0.1", 0))
        sock.listen(1)
        port = sock.getsockname()[1]
        try:
            probe = PortReady("127.0.0.1", port, connect_timeout_s=0.5)
            assert probe.check() is True
        finally:
            sock.close()

    def test_description_includes_host_and_port(self) -> None:
        probe = PortReady("localhost", 5432)
        assert "localhost" in probe.description
        assert "5432" in probe.description


class _OkHandler(BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"ok")

    def log_message(self, format: str, *args: object) -> None:  # noqa: A002
        pass  # silence stdlib's per-request stderr line


class TestHttpReady:
    @pytest.fixture
    def http_server(self):
        server = HTTPServer(("127.0.0.1", 0), _OkHandler)
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        yield server
        server.shutdown()
        thread.join(timeout=2.0)

    def test_200_returns_true(self, http_server: HTTPServer) -> None:
        port = http_server.server_address[1]
        probe = HttpReady(f"http://127.0.0.1:{port}/", expected_status=200)
        assert probe.check() is True

    @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
    def test_unreachable_returns_false(self) -> None:
        # Port 1 unreachable. urllib's connection-refused path GC's
        # the connect-side socket from a finalizer — pytest's
        # unraisable-hook may surface it; we ignore that here, the
        # behaviour we're pinning is "probe.check() returns False
        # without raising."
        import gc

        probe = HttpReady("http://127.0.0.1:1/", request_timeout_s=0.1)
        assert probe.check() is False
        gc.collect()

    def test_description_includes_url(self) -> None:
        probe = HttpReady("http://example.com/health")
        assert "http://example.com/health" in probe.description


class TestLogLineReady:
    def test_unbound_capture_returns_false(self) -> None:
        # Pre-bind state: capture_provider is None until Service
        # binds it. Probe MUST return False (Service isn't started
        # yet) rather than raising.
        probe = LogLineReady(r"ready")
        assert probe.check() is False

    def test_matching_line_returns_true(self) -> None:
        probe = LogLineReady(r"database system is ready")
        probe._capture_provider = lambda: [
            "starting up",
            "loading config",
            "database system is ready to accept connections",
        ]
        assert probe.check() is True

    def test_no_match_returns_false(self) -> None:
        probe = LogLineReady(r"NEVER")
        probe._capture_provider = lambda: ["line1", "line2"]
        assert probe.check() is False

    def test_invalid_stream_raises(self) -> None:
        with pytest.raises(ValueError, match="stdout"):
            LogLineReady(r"x", stream="banana")


class TestCallableReady:
    def test_predicate_invoked(self) -> None:
        calls = []

        def _pred() -> bool:
            calls.append(1)
            return len(calls) >= 3

        probe = CallableReady(_pred, description_str="3rd call")
        assert probe.check() is False
        assert probe.check() is False
        assert probe.check() is True
        assert probe.description == "3rd call"


class TestComposites:
    def test_allof_short_circuits_on_first_false(self) -> None:
        calls = {"a": 0, "b": 0}

        class _Counter:
            def __init__(self, name: str, ready: bool) -> None:
                self.name = name
                self.ready = ready

            def check(self) -> bool:
                calls[self.name] += 1
                return self.ready

            @property
            def description(self) -> str:
                return self.name

        a = _Counter("a", ready=False)
        b = _Counter("b", ready=True)
        composite = AllOf([a, b])
        assert composite.check() is False
        assert calls["a"] == 1
        # Short-circuit: b shouldn't have been called.
        assert calls["b"] == 0

    def test_allof_true_only_when_all_true(self) -> None:
        a = CallableReady(lambda: True, "a")
        b = CallableReady(lambda: True, "b")
        assert AllOf([a, b]).check() is True

    def test_anyof_true_when_any_true(self) -> None:
        a = CallableReady(lambda: False, "a")
        b = CallableReady(lambda: True, "b")
        assert AnyOf([a, b]).check() is True

    def test_anyof_false_only_when_all_false(self) -> None:
        a = CallableReady(lambda: False, "a")
        b = CallableReady(lambda: False, "b")
        assert AnyOf([a, b]).check() is False

    def test_descriptions_compose(self) -> None:
        a = CallableReady(lambda: True, "alpha")
        b = CallableReady(lambda: True, "beta")
        assert "alpha" in AllOf([a, b]).description
        assert "AND" in AllOf([a, b]).description
        assert "OR" in AnyOf([a, b]).description


class TestWaitForReady:
    def test_returns_immediately_when_ready(self) -> None:
        probe = CallableReady(lambda: True)
        ready, last_error = wait_for_ready(
            probe,
            timeout_s=1.0,
            poll_interval_s=0.01,
        )
        assert ready is True
        assert last_error == ""

    def test_returns_false_on_timeout(self) -> None:
        probe = CallableReady(lambda: False)
        ready, _ = wait_for_ready(
            probe,
            timeout_s=0.1,
            poll_interval_s=0.02,
        )
        assert ready is False

    def test_records_last_error_on_raise(self) -> None:
        def _raises() -> bool:
            raise RuntimeError("upstream broke")

        probe = CallableReady(_raises)
        ready, last_error = wait_for_ready(
            probe,
            timeout_s=0.05,
            poll_interval_s=0.01,
        )
        assert ready is False
        assert "RuntimeError" in last_error
        assert "upstream broke" in last_error
