"""Health-check primitive — liveness + readiness, aggregator (C-4, v0.37.0).

Stability tier: **experimental** (AP-11) — added in 0.37.0.
Wave 3 federation primitive: every web consumer currently
hand-rolls ``/healthz`` and ``/readyz``; this module standardises
the **shape**, leaving the route binding to each framework
sibling (mgf-fastapi `HealthRouter` ships in 0.7+;
mgf-django `health_view` in 0.5+).

What ships here
---------------

- :class:`HealthStatus` — Literal enum
  (``"ok"`` / ``"degraded"`` / ``"unhealthy"``). JSON-stable.
- :class:`ComponentCheck` — Protocol. One check answers
  "is this dependency healthy?" — returns a ``ComponentResult``.
- :class:`ComponentResult` — frozen dataclass: status +
  message + optional latency ms.
- :class:`HealthRegistry` — aggregates many ComponentChecks
  into a single :class:`HealthReport`. Used by liveness /
  readiness routes to render the JSON response.
- :class:`LivenessCheck`, :class:`ReadinessCheck` — base classes
  that classify their check: liveness = "this process is alive",
  readiness = "this process can serve traffic." Kubernetes-shape;
  same conventions used by Infomaniak Octavia health checks
  (per the v2.7 DEPLOYMENT standard).

Why two levels (liveness vs readiness)
--------------------------------------

The orchestrator wants two different signals:

- **Liveness** (k8s `/healthz`, Octavia health-check endpoint):
  "Is this process alive? Should I restart it?" Should NOT
  fail just because a downstream is unreachable — restarting
  doesn't help.
- **Readiness** (k8s `/readyz`): "Should I send traffic here?
  Or should I route around?" SHOULD fail when downstreams are
  unreachable — routing around restores service.

Consumers register checks as one or the other (or both — a
DB check is usually both: process can't be alive without it,
and traffic can't succeed without it).

Aggregation rule
----------------

A :class:`HealthReport`'s overall status is the WORST
component status:

- Any ``unhealthy`` → ``unhealthy``.
- Else any ``degraded`` → ``degraded``.
- Else ``ok``.

Empty report = ``ok`` (the "vacuous truth" case).

Cross-references
----------------

- **AP-11** — stability tiers.
- **C-4** federation row.
- **OP-04** — the doctor / health-check pattern.
- Kubernetes pod-lifecycle docs (liveness + readiness probes).
- :mod:`mgf.fastapi` future `HealthRouter` (v0.7+).
"""

from __future__ import annotations

import logging
import time
from dataclasses import dataclass, field
from typing import (
    TYPE_CHECKING,
    Literal,
    Protocol,
    runtime_checkable,
)

if TYPE_CHECKING:
    from collections.abc import Iterable

_LOGGER = logging.getLogger(__name__)


HealthStatus = Literal["ok", "degraded", "unhealthy"]
"""Component / overall status. JSON-stable string."""

_STATUS_RANK = {"ok": 0, "degraded": 1, "unhealthy": 2}


__all__ = [
    "ComponentCheck",
    "ComponentResult",
    "HealthRegistry",
    "HealthReport",
    "HealthStatus",
    "LivenessCheck",
    "ReadinessCheck",
]


@dataclass(frozen=True)
class ComponentResult:
    """Outcome of running a single :class:`ComponentCheck`.

    Returned by :meth:`ComponentCheck.check`. The registry
    aggregates many of these into a :class:`HealthReport`.
    """

    name: str
    """Component identifier. Convention: lowercase-with-dashes
    (``db-primary``, ``redis-cache``, ``upstream-vendor-x``)."""

    status: HealthStatus
    """One of ``"ok"``, ``"degraded"``, ``"unhealthy"``."""

    message: str = ""
    """Optional one-liner explaining the status. ``"connection
    refused at 10.0.0.1:5432"``, ``"cache miss rate 30%"``,
    etc."""

    latency_ms: float | None = None
    """How long the check took, in milliseconds. ``None`` if
    not measured. Useful for ops dashboards."""


@runtime_checkable
class ComponentCheck(Protocol):
    """Health-check Protocol for a single component.

    Each implementation answers "is THIS dependency healthy?"
    The registry aggregates many to compose a /healthz response.

    Implementations decide their internals: a DB ping, a TCP
    connect, an in-process state look-up, ...

    Convention: return ``ComponentResult`` with one of three
    statuses; never raise (a raised exception is harder for
    consumers to render uniformly). If the check INTERNALLY
    fails (e.g., the DB client crashed), catch + return
    ``unhealthy`` with the exception detail in
    :attr:`ComponentResult.message`.
    """

    @property
    def name(self) -> str:
        """Component identifier."""
        ...

    def check(self) -> ComponentResult:
        """Run the check and return a result.

        Synchronous by design — async backends wrap with
        ``asyncio.to_thread`` at the route handler. Keeping
        the Protocol sync simplifies cross-framework wiring.
        """
        ...


class LivenessCheck:
    """Mark a check as a liveness probe (orchestrator: restart on fail).

    Subclass + implement :meth:`check`. The registry uses
    ``isinstance(check, LivenessCheck)`` to route checks into
    the ``/healthz`` aggregation.
    """

    @property
    def name(self) -> str:
        raise NotImplementedError

    def check(self) -> ComponentResult:
        raise NotImplementedError


class ReadinessCheck:
    """Mark a check as a readiness probe (orchestrator: route around).

    Subclass + implement :meth:`check`. The registry uses
    ``isinstance(check, ReadinessCheck)`` to route checks into
    the ``/readyz`` aggregation.
    """

    @property
    def name(self) -> str:
        raise NotImplementedError

    def check(self) -> ComponentResult:
        raise NotImplementedError


@dataclass(frozen=True)
class HealthReport:
    """Aggregated outcome from running all registered checks.

    Returned by :meth:`HealthRegistry.evaluate_liveness` /
    :meth:`HealthRegistry.evaluate_readiness`. The route handler
    renders ``status`` into the HTTP status code (200 ok / 503
    unhealthy) and ``components`` into the JSON body.
    """

    status: HealthStatus
    """Worst-case across all components."""

    components: tuple[ComponentResult, ...] = field(default_factory=tuple)
    """Per-component results, in registration order."""

    def render_json(self) -> dict[str, object]:
        """Render the report as a JSON-friendly dict.

        Stable schema:

        .. code-block:: json

            {
              "status": "ok",
              "components": [
                {"name": "db", "status": "ok", "message": "",
                 "latency_ms": 1.2},
                ...
              ]
            }
        """
        return {
            "status": self.status,
            "components": [
                {
                    "name": c.name,
                    "status": c.status,
                    "message": c.message,
                    "latency_ms": c.latency_ms,
                }
                for c in self.components
            ],
        }


class HealthRegistry:
    """Aggregates ComponentChecks into HealthReports.

    Usage::

        registry = HealthRegistry()
        registry.add(DbReadinessCheck(engine))
        registry.add(MemoryLivenessCheck())

        # In your /readyz handler:
        report = registry.evaluate_readiness()
        return JSONResponse(
            report.render_json(),
            status_code=200 if report.status == "ok" else 503,
        )

    Checks are kept in registration order; the report renders
    them in the same order. Adding a duplicate (by ``name``)
    REPLACES the previous registration.

    The registry is thread-safe for add / evaluate (the typical
    pattern is "register at startup, evaluate per-request" —
    contention is minimal).
    """

    def __init__(self) -> None:
        self._checks: dict[str, ComponentCheck] = {}

    def add(self, check: ComponentCheck) -> None:
        """Register a check. Replaces by name."""
        self._checks[check.name] = check

    def remove(self, name: str) -> None:
        """Remove a check. No-op if not present."""
        self._checks.pop(name, None)

    def evaluate_liveness(self) -> HealthReport:
        """Run every :class:`LivenessCheck` + build the report.

        Used by ``/healthz`` route handlers. Checks that aren't
        liveness-tagged are skipped — the orchestrator should
        NOT restart on a downstream-unreachable signal.
        """
        return self._evaluate(LivenessCheck)

    def evaluate_readiness(self) -> HealthReport:
        """Run every :class:`ReadinessCheck` + build the report.

        Used by ``/readyz`` route handlers. Failing readiness
        DOES NOT cause a restart — the orchestrator routes
        around.
        """
        return self._evaluate(ReadinessCheck)

    def evaluate_all(self) -> HealthReport:
        """Run EVERY registered check regardless of liveness /
        readiness tagging. Useful for the ``/doctor`` / debug
        endpoint where operators want the full picture."""
        results = self._run(list(self._checks.values()))
        return self._build_report(results)

    def _evaluate(self, marker: type) -> HealthReport:
        relevant = [c for c in self._checks.values() if isinstance(c, marker)]
        results = self._run(relevant)
        return self._build_report(results)

    def _run(self, checks: Iterable[ComponentCheck]) -> list[ComponentResult]:
        out: list[ComponentResult] = []
        for check in checks:
            start = time.perf_counter()
            try:
                result = check.check()
            except Exception as exc:
                _LOGGER.exception(
                    "health check %r raised; reporting unhealthy",
                    check.name,
                    extra={
                        "event": "health.check_raised",
                        "component": check.name,
                    },
                )
                result = ComponentResult(
                    name=check.name,
                    status="unhealthy",
                    message=f"check raised {type(exc).__name__}: {exc}",
                )
            # Set latency if the check didn't supply one.
            if result.latency_ms is None:
                elapsed_ms = (time.perf_counter() - start) * 1000.0
                result = ComponentResult(
                    name=result.name,
                    status=result.status,
                    message=result.message,
                    latency_ms=round(elapsed_ms, 3),
                )
            out.append(result)
        return out

    @staticmethod
    def _build_report(results: list[ComponentResult]) -> HealthReport:
        if not results:
            return HealthReport(status="ok", components=())
        worst = max(_STATUS_RANK[r.status] for r in results)
        status: HealthStatus
        if worst == 2:
            status = "unhealthy"
        elif worst == 1:
            status = "degraded"
        else:
            status = "ok"
        return HealthReport(status=status, components=tuple(results))
