"""Health probe discovery from annotated Kubernetes pods.

Watches pods with Apollo health probe annotations, queries their health
endpoints, and aggregates health per logical entity. This bridges the gap
between pod-level liveness and entity-level health as understood by Apollo's
service model.

Annotations follow the ``apollo.citadel.io/*`` namespace:

    apollo.citadel.io/health-port: "8081"
    apollo.citadel.io/health-path: "/ready"
    apollo.citadel.io/health-scheme: "http"
    apollo.citadel.io/entity-name: "my-service"

Pods missing the required ``health-port`` and ``entity-name`` annotations
are silently skipped -- they are not Apollo-managed workloads.
"""

from __future__ import annotations

import json
import logging
import subprocess
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone

logger = logging.getLogger(__name__)

# Annotation keys
_ANNO_PREFIX = "apollo.citadel.io"
ANNO_HEALTH_PORT = f"{_ANNO_PREFIX}/health-port"
ANNO_HEALTH_PATH = f"{_ANNO_PREFIX}/health-path"
ANNO_HEALTH_SCHEME = f"{_ANNO_PREFIX}/health-scheme"
ANNO_ENTITY_NAME = f"{_ANNO_PREFIX}/entity-name"

# Required annotations -- pods without both of these are skipped
_REQUIRED_ANNOTATIONS = {ANNO_HEALTH_PORT, ANNO_ENTITY_NAME}

# Defaults for optional annotations
_DEFAULT_PATH = "/health"
_DEFAULT_SCHEME = "http"


@dataclass(frozen=True)
class ProbeAnnotation:
    """Parsed Apollo health probe annotations from a pod.

    Only ``port`` and ``entity_name`` are required. ``path`` defaults
    to ``/health`` and ``scheme`` defaults to ``http``.
    """

    port: int
    path: str
    scheme: str
    entity_name: str

    @classmethod
    def from_annotations(cls, annotations: dict[str, str]) -> ProbeAnnotation | None:
        """Parse from a pod's annotation dict.

        Returns None when required annotations are missing, allowing
        the caller to silently skip non-Apollo pods.
        """
        if not annotations:
            return None

        if not _REQUIRED_ANNOTATIONS.issubset(annotations):
            return None

        raw_port = annotations[ANNO_HEALTH_PORT]
        try:
            port = int(raw_port)
        except (ValueError, TypeError):
            logger.warning(
                "Invalid health-port annotation value %r, skipping pod",
                raw_port,
            )
            return None

        return cls(
            port=port,
            path=annotations.get(ANNO_HEALTH_PATH, _DEFAULT_PATH),
            scheme=annotations.get(ANNO_HEALTH_SCHEME, _DEFAULT_SCHEME),
            entity_name=annotations[ANNO_ENTITY_NAME],
        )


@dataclass(frozen=True)
class DiscoveredProbe:
    """A health probe endpoint discovered from an annotated pod."""

    pod_name: str
    pod_ip: str
    namespace: str
    entity_name: str
    port: int
    path: str
    scheme: str = "http"

    @property
    def url(self) -> str:
        """Build the full health check URL."""
        return f"{self.scheme}://{self.pod_ip}:{self.port}{self.path}"


@dataclass(frozen=True)
class ProbeResult:
    """Result of a single health probe check."""

    probe: DiscoveredProbe
    healthy: bool
    status_code: int | None
    response_time_ms: float
    error: str | None
    checked_at: str  # ISO 8601 timestamp


@dataclass(frozen=True)
class EntityHealth:
    """Aggregated health for a logical entity across all its pods."""

    entity_name: str
    total_pods: int
    healthy_pods: int
    unhealthy_pods: int
    health_ratio: float
    probe_results: tuple[ProbeResult, ...]

    @property
    def fully_healthy(self) -> bool:
        """True when every pod is healthy and there is at least one pod."""
        return self.total_pods > 0 and self.healthy_pods == self.total_pods

    @property
    def fully_unhealthy(self) -> bool:
        """True when no pod is healthy."""
        return self.healthy_pods == 0 and self.total_pods > 0


class ProbeDiscoverer:
    """Discovers and checks Apollo health probes from annotated pods.

    Uses ``kubectl`` for pod discovery and ``urllib.request`` for health
    checks. This keeps the dependency footprint minimal -- no kubernetes
    client library required for the read path.

    Usage::

        discoverer = ProbeDiscoverer(namespace="production", poll_interval=30)
        probes = discoverer.discover_probes()
        results = discoverer.check_all()
        for entity in discoverer.aggregate_health():
            print(f"{entity.entity_name}: {entity.health_ratio:.0%} healthy")
    """

    def __init__(
        self,
        namespace: str = "default",
        poll_interval: int = 30,
        health_check_timeout: int = 5,
    ) -> None:
        self.namespace = namespace
        self.poll_interval = poll_interval
        self.health_check_timeout = health_check_timeout

    def discover_probes(self) -> list[DiscoveredProbe]:
        """Query the Kubernetes API for pods with Apollo health annotations.

        Returns a list of DiscoveredProbe for every pod that has the
        required annotations (``health-port`` and ``entity-name``).
        Pods without these annotations are silently skipped.
        """
        pods = self._get_annotated_pods()
        probes: list[DiscoveredProbe] = []

        for pod in pods:
            metadata = pod.get("metadata", {})
            status = pod.get("status", {})
            annotations = metadata.get("annotations") or {}

            parsed = ProbeAnnotation.from_annotations(annotations)
            if parsed is None:
                continue

            pod_ip = status.get("podIP", "")
            if not pod_ip:
                logger.debug(
                    "Pod %s has no IP yet, skipping",
                    metadata.get("name", "<unknown>"),
                )
                continue

            probes.append(
                DiscoveredProbe(
                    pod_name=metadata.get("name", ""),
                    pod_ip=pod_ip,
                    namespace=metadata.get("namespace", self.namespace),
                    entity_name=parsed.entity_name,
                    port=parsed.port,
                    path=parsed.path,
                    scheme=parsed.scheme,
                )
            )

        return probes

    def check_probe(self, probe: DiscoveredProbe) -> ProbeResult:
        """Execute an HTTP GET against a single probe endpoint.

        Returns a ProbeResult regardless of outcome -- network errors
        and non-2xx responses are captured, not raised.
        """
        url = probe.url
        start = time.monotonic()
        checked_at = datetime.now(timezone.utc).isoformat()

        try:
            request = urllib.request.Request(url, method="GET")
            with urllib.request.urlopen(
                request, timeout=self.health_check_timeout
            ) as response:
                elapsed_ms = (time.monotonic() - start) * 1000
                status_code = response.status
                healthy = 200 <= status_code < 300
                return ProbeResult(
                    probe=probe,
                    healthy=healthy,
                    status_code=status_code,
                    response_time_ms=round(elapsed_ms, 2),
                    error=None if healthy else f"HTTP {status_code}",
                    checked_at=checked_at,
                )
        except urllib.error.HTTPError as e:
            elapsed_ms = (time.monotonic() - start) * 1000
            return ProbeResult(
                probe=probe,
                healthy=False,
                status_code=e.code,
                response_time_ms=round(elapsed_ms, 2),
                error=f"HTTP {e.code}",
                checked_at=checked_at,
            )
        except urllib.error.URLError as e:
            elapsed_ms = (time.monotonic() - start) * 1000
            return ProbeResult(
                probe=probe,
                healthy=False,
                status_code=None,
                response_time_ms=round(elapsed_ms, 2),
                error=f"Connection failed: {e.reason}",
                checked_at=checked_at,
            )
        except TimeoutError:
            elapsed_ms = (time.monotonic() - start) * 1000
            return ProbeResult(
                probe=probe,
                healthy=False,
                status_code=None,
                response_time_ms=round(elapsed_ms, 2),
                error=f"Timeout after {self.health_check_timeout}s",
                checked_at=checked_at,
            )
        except OSError as e:
            elapsed_ms = (time.monotonic() - start) * 1000
            return ProbeResult(
                probe=probe,
                healthy=False,
                status_code=None,
                response_time_ms=round(elapsed_ms, 2),
                error=str(e),
                checked_at=checked_at,
            )

    def check_all(self) -> list[ProbeResult]:
        """Discover all probes and check them.

        Convenience method that combines discover_probes() and
        check_probe() into a single call.
        """
        probes = self.discover_probes()
        return [self.check_probe(p) for p in probes]

    def aggregate_health(self) -> list[EntityHealth]:
        """Discover, check, and aggregate health per entity.

        Groups probe results by entity_name and computes health ratios.
        Returns one EntityHealth per distinct entity, sorted by name.
        """
        results = self.check_all()
        return self._aggregate_results(results)

    @staticmethod
    def _aggregate_results(results: list[ProbeResult]) -> list[EntityHealth]:
        """Group probe results by entity and compute health ratios.

        Exposed as a static method so callers can aggregate externally
        obtained results without re-running discovery.
        """
        by_entity: dict[str, list[ProbeResult]] = {}
        for r in results:
            by_entity.setdefault(r.probe.entity_name, []).append(r)

        entities: list[EntityHealth] = []
        for entity_name in sorted(by_entity):
            entity_results = by_entity[entity_name]
            total = len(entity_results)
            healthy = sum(1 for r in entity_results if r.healthy)
            unhealthy = total - healthy
            ratio = healthy / total if total > 0 else 0.0

            entities.append(
                EntityHealth(
                    entity_name=entity_name,
                    total_pods=total,
                    healthy_pods=healthy,
                    unhealthy_pods=unhealthy,
                    health_ratio=round(ratio, 4),
                    probe_results=tuple(entity_results),
                )
            )

        return entities

    def _get_annotated_pods(self) -> list[dict]:
        """Fetch pods from the Kubernetes API via kubectl.

        Uses ``kubectl get pods`` with JSON output. Only pods in the
        configured namespace are returned.
        """
        cmd = [
            "kubectl",
            "get",
            "pods",
            "--namespace",
            self.namespace,
            "--output",
            "json",
        ]

        try:
            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=30,
                check=False,
            )
        except FileNotFoundError:
            logger.error("kubectl not found in PATH")
            return []
        except subprocess.TimeoutExpired:
            logger.error("kubectl timed out listing pods in namespace %s", self.namespace)
            return []

        if result.returncode != 0:
            logger.error(
                "kubectl failed (exit %d): %s",
                result.returncode,
                result.stderr.strip()[:200],
            )
            return []

        try:
            data = json.loads(result.stdout)
        except json.JSONDecodeError:
            logger.error("Failed to parse kubectl JSON output")
            return []

        return data.get("items", [])
