"""Readiness predicates for :class:`mgf.common.process.Service`.

A readiness predicate answers a single question — "is this service
ready to receive traffic?" — by polling some observable side-effect
of its startup. The predicates compose: ``AllOf(PortReady(...),
LogLineReady(...))`` waits for both.

A predicate implements the :class:`ReadinessProbe` protocol:

  - :meth:`ReadinessProbe.check` — return True if ready right now,
    False if not yet, raise to fail-fast.
  - :meth:`ReadinessProbe.description` — short string for error
    messages and logs (``"port localhost:5432"`` etc.). Used when a
    service fails to become ready and the :class:`ServiceNotReadyError`
    error needs to name what was being waited on.

:class:`mgf.common.process.Service.start` polls its probe at
``poll_interval_s`` until the probe returns True or ``timeout_s``
elapses. The probe is consulted AFTER each poll — meaning the
service has at least one ``poll_interval_s`` worth of warm-up time
before the first check, which is usually what you want
(probing-too-early is a real failure mode for slow-startup
databases).
"""

from __future__ import annotations

import re
import socket
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from urllib.error import URLError
from urllib.request import Request, urlopen

if TYPE_CHECKING:
    from collections.abc import Callable, Sequence


@runtime_checkable
class ReadinessProbe(Protocol):
    """Protocol every readiness predicate implements."""

    def check(self) -> bool:
        """Return ``True`` when ready, ``False`` to keep polling.

        MAY raise to short-circuit the wait — a hard failure mode
        (e.g., the child crashed AND its log has the smoking gun)
        is communicated via raise, not False. The exception will
        be caught by :meth:`Service.start` and folded into the
        :class:`ServiceNotReadyError` error's ``last_error``.
        """

    @property
    def description(self) -> str:
        """Short readable name; used in error messages and logs."""


@dataclass
class PortReady:
    """Wait until ``host:port`` accepts a TCP connection.

    Useful for: Postgres, Redis, MySQL, Memcached, and any
    listening server whose readiness is "the socket is bound and
    accepting." Note: many databases bind their port BEFORE they
    finish starting up. For Postgres specifically, prefer pairing
    with :class:`LogLineReady` for ``"database system is ready to
    accept connections"`` — the port-bind happens earlier.
    """

    host: str
    port: int
    connect_timeout_s: float = 1.0

    def check(self) -> bool:
        try:
            with socket.create_connection(
                (self.host, self.port),
                timeout=self.connect_timeout_s,
            ):
                return True
        except OSError:
            return False

    @property
    def description(self) -> str:
        return f"port {self.host}:{self.port}"


@dataclass
class HttpReady:
    """Wait until an HTTP GET returns ``expected_status``.

    Stdlib-only — does not pull in ``requests`` or ``httpx``. For a
    health endpoint on the service itself (``/healthz``,
    ``/-/ready``, ``/_status``) or anything that returns a small
    response.
    """

    url: str
    expected_status: int = 200
    request_timeout_s: float = 2.0
    headers: dict[str, str] = field(default_factory=dict)

    def check(self) -> bool:
        req = Request(self.url, method="GET", headers=self.headers)  # noqa: S310 — caller-controlled URL
        try:
            with urlopen(req, timeout=self.request_timeout_s) as resp:  # noqa: S310 — caller-controlled URL
                # urllib's HTTPResponse.status is typed Any in stdlib stubs;
                # cast to int for the equality so mypy --strict's
                # warn_return_any is satisfied.
                status: int = resp.status
                return status == self.expected_status
        except URLError:
            return False
        except Exception:
            # urllib raises various unrelated exceptions for
            # transient failures (connection reset mid-handshake,
            # DNS resolution failures, etc.). All of these are
            # "not ready yet" — caller's GET hasn't connected.
            return False

    @property
    def description(self) -> str:
        return f"GET {self.url} -> {self.expected_status}"


@dataclass
class LogLineReady:
    """Wait until a captured log line matches ``pattern``.

    The :class:`Service` exposes a thread-safe view of recent
    captured lines (last 1000 lines per stream); this probe scans
    them on each poll. Pass ``stream="both"`` to scan stdout +
    stderr; ``"stdout"`` or ``"stderr"`` for one stream only.

    Common use: pair with :class:`PortReady` for Postgres —
    ``"database system is ready to accept connections"`` is
    Postgres' authoritative ready signal; the port-bind happens
    several seconds earlier.
    """

    pattern: str
    stream: str = "both"  # "stdout" | "stderr" | "both"

    # Populated by Service.start() before polling begins; lets the
    # probe peek into the live capture without coupling to its
    # internal type.
    _capture_provider: Callable[[], list[str]] | None = field(
        default=None,
        repr=False,
        compare=False,
    )

    def __post_init__(self) -> None:
        if self.stream not in ("stdout", "stderr", "both"):
            raise ValueError(
                f"LogLineReady.stream must be one of "
                f"'stdout' | 'stderr' | 'both'; got {self.stream!r}"
            )
        self._regex = re.compile(self.pattern)

    def check(self) -> bool:
        if self._capture_provider is None:
            # Service.start hasn't bound us yet — return False so
            # the wait loop continues; binding happens before the
            # first poll-tick by Service.
            return False
        return any(self._regex.search(line) for line in self._capture_provider())

    @property
    def description(self) -> str:
        return f"log line matching {self.pattern!r} on {self.stream}"


@dataclass
class CallableReady:
    """Escape hatch — arbitrary user-supplied predicate.

    The callable takes no arguments and returns ``bool``. Use
    when the readiness signal is something the built-in probes
    don't cover: a file appearing on disk, a Unix socket
    accepting, a custom RPC. Keep the implementation cheap — it
    runs every ``poll_interval_s``.
    """

    predicate: Callable[[], bool]
    description_str: str = "custom callable"

    def check(self) -> bool:
        return bool(self.predicate())

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


@dataclass
class AllOf:
    """Composite — ready when every sub-probe is ready.

    Short-circuits: as soon as any sub-probe returns False, the
    composite returns False. So an expensive probe (HTTP) ordered
    after a cheap one (port) only fires once the cheap one passes.
    """

    probes: Sequence[ReadinessProbe]

    def check(self) -> bool:
        return all(p.check() for p in self.probes)

    @property
    def description(self) -> str:
        return "all of: " + " AND ".join(p.description for p in self.probes)


@dataclass
class AnyOf:
    """Composite — ready when at least one sub-probe is ready.

    Useful for "service is ready when EITHER the HTTP healthcheck
    passes OR the log line appears" — covers services where the
    canonical signal is platform-dependent or inconsistent.
    """

    probes: Sequence[ReadinessProbe]

    def check(self) -> bool:
        return any(p.check() for p in self.probes)

    @property
    def description(self) -> str:
        return "any of: " + " OR ".join(p.description for p in self.probes)


def wait_for_ready(
    probe: ReadinessProbe,
    *,
    timeout_s: float,
    poll_interval_s: float = 0.2,
) -> tuple[bool, str]:
    """Block until ``probe`` returns True or ``timeout_s`` elapses.

    Returns ``(ready, last_error)``. ``last_error`` is empty when
    the probe simply kept returning False; populated with the
    string form of any exception raised by ``probe.check()`` (the
    probe protocol allows raising for hard-failure modes). The
    caller (:meth:`Service.start`) folds ``last_error`` into the
    :class:`ServiceNotReadyError` exception when the wait fails.
    """
    deadline = time.monotonic() + timeout_s
    last_error = ""
    while time.monotonic() < deadline:
        try:
            if probe.check():
                return True, last_error
        except Exception as exc:
            last_error = f"{type(exc).__name__}: {exc}"
        time.sleep(poll_interval_s)
    return False, last_error
