"""Tests for Apollo health probe discovery from annotated pods.

Tests annotation parsing, probe discovery via kubectl, HTTP health
checks, entity health aggregation, and integration with the
HealthChecker. All external I/O is mocked -- no cluster or network
calls required.
"""

from __future__ import annotations

import json
import subprocess
import urllib.error
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch

import pytest

from citadel_operator.k3s.probe_discovery import (
    ANNO_ENTITY_NAME,
    ANNO_HEALTH_PATH,
    ANNO_HEALTH_PORT,
    ANNO_HEALTH_SCHEME,
    DiscoveredProbe,
    EntityHealth,
    ProbeAnnotation,
    ProbeDiscoverer,
    ProbeResult,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_pod_dict(
    name: str = "my-pod",
    namespace: str = "default",
    pod_ip: str = "10.42.0.5",
    annotations: dict[str, str] | None = None,
) -> dict:
    """Build a minimal pod dict as returned by ``kubectl get pods -o json``."""
    return {
        "metadata": {
            "name": name,
            "namespace": namespace,
            "annotations": annotations,
        },
        "status": {
            "podIP": pod_ip,
        },
    }


def _make_annotated_pod(
    name: str = "svc-abc123",
    namespace: str = "default",
    pod_ip: str = "10.42.0.5",
    port: str = "8081",
    path: str = "/ready",
    scheme: str = "http",
    entity_name: str = "my-service",
) -> dict:
    """Build a pod dict with full Apollo health annotations."""
    return _make_pod_dict(
        name=name,
        namespace=namespace,
        pod_ip=pod_ip,
        annotations={
            ANNO_HEALTH_PORT: port,
            ANNO_HEALTH_PATH: path,
            ANNO_HEALTH_SCHEME: scheme,
            ANNO_ENTITY_NAME: entity_name,
        },
    )


def _kubectl_output(pods: list[dict]) -> str:
    """Wrap pod dicts into kubectl JSON output format."""
    return json.dumps({"items": pods})


def _make_probe(
    pod_name: str = "svc-abc",
    pod_ip: str = "10.42.0.5",
    namespace: str = "default",
    entity_name: str = "my-service",
    port: int = 8081,
    path: str = "/ready",
    scheme: str = "http",
) -> DiscoveredProbe:
    """Build a DiscoveredProbe for testing."""
    return DiscoveredProbe(
        pod_name=pod_name,
        pod_ip=pod_ip,
        namespace=namespace,
        entity_name=entity_name,
        port=port,
        path=path,
        scheme=scheme,
    )


# ---------------------------------------------------------------------------
# Tests: ProbeAnnotation parsing
# ---------------------------------------------------------------------------

class TestProbeAnnotation:
    """Validate annotation parsing from pod metadata."""

    def test_parse_full_annotations(self):
        annotations = {
            ANNO_HEALTH_PORT: "8081",
            ANNO_HEALTH_PATH: "/ready",
            ANNO_HEALTH_SCHEME: "https",
            ANNO_ENTITY_NAME: "my-service",
        }
        parsed = ProbeAnnotation.from_annotations(annotations)

        assert parsed is not None
        assert parsed.port == 8081
        assert parsed.path == "/ready"
        assert parsed.scheme == "https"
        assert parsed.entity_name == "my-service"

    def test_parse_minimal_annotations(self):
        """Only port and entity-name are required; path and scheme use defaults."""
        annotations = {
            ANNO_HEALTH_PORT: "9090",
            ANNO_ENTITY_NAME: "backend",
        }
        parsed = ProbeAnnotation.from_annotations(annotations)

        assert parsed is not None
        assert parsed.port == 9090
        assert parsed.path == "/health"
        assert parsed.scheme == "http"
        assert parsed.entity_name == "backend"

    def test_parse_missing_port(self):
        annotations = {ANNO_ENTITY_NAME: "my-service"}
        parsed = ProbeAnnotation.from_annotations(annotations)
        assert parsed is None

    def test_parse_missing_entity_name(self):
        annotations = {ANNO_HEALTH_PORT: "8081"}
        parsed = ProbeAnnotation.from_annotations(annotations)
        assert parsed is None

    def test_parse_empty_annotations(self):
        parsed = ProbeAnnotation.from_annotations({})
        assert parsed is None

    def test_parse_none_annotations(self):
        parsed = ProbeAnnotation.from_annotations(None)
        assert parsed is None

    def test_parse_invalid_port(self):
        annotations = {
            ANNO_HEALTH_PORT: "not-a-number",
            ANNO_ENTITY_NAME: "my-service",
        }
        parsed = ProbeAnnotation.from_annotations(annotations)
        assert parsed is None

    def test_frozen(self):
        annotations = {
            ANNO_HEALTH_PORT: "8081",
            ANNO_ENTITY_NAME: "svc",
        }
        parsed = ProbeAnnotation.from_annotations(annotations)
        with pytest.raises(AttributeError):
            parsed.port = 9999


# ---------------------------------------------------------------------------
# Tests: DiscoveredProbe
# ---------------------------------------------------------------------------

class TestDiscoveredProbe:
    """Validate DiscoveredProbe construction and URL building."""

    def test_url_construction(self):
        probe = _make_probe(
            pod_ip="10.42.0.5",
            port=8081,
            path="/ready",
            scheme="http",
        )
        assert probe.url == "http://10.42.0.5:8081/ready"

    def test_url_https(self):
        probe = _make_probe(scheme="https", port=443, path="/healthz")
        assert probe.url == "https://10.42.0.5:443/healthz"

    def test_frozen(self):
        probe = _make_probe()
        with pytest.raises(AttributeError):
            probe.pod_name = "different"


# ---------------------------------------------------------------------------
# Tests: ProbeDiscoverer.discover_probes
# ---------------------------------------------------------------------------

class TestDiscoverProbes:
    """Test pod discovery from kubectl output."""

    @patch("citadel_operator.k3s.probe_discovery.subprocess.run")
    def test_discover_annotated_pods(self, mock_run):
        pods = [_make_annotated_pod(name="svc-1"), _make_annotated_pod(name="svc-2")]
        mock_run.return_value = MagicMock(
            returncode=0, stdout=_kubectl_output(pods), stderr=""
        )

        discoverer = ProbeDiscoverer(namespace="prod")
        probes = discoverer.discover_probes()

        assert len(probes) == 2
        assert probes[0].pod_name == "svc-1"
        assert probes[1].pod_name == "svc-2"

    @patch("citadel_operator.k3s.probe_discovery.subprocess.run")
    def test_skip_pods_without_annotations(self, mock_run):
        pods = [
            _make_annotated_pod(name="annotated"),
            _make_pod_dict(name="plain", annotations=None),
            _make_pod_dict(name="partial", annotations={ANNO_HEALTH_PORT: "8080"}),
        ]
        mock_run.return_value = MagicMock(
            returncode=0, stdout=_kubectl_output(pods), stderr=""
        )

        discoverer = ProbeDiscoverer()
        probes = discoverer.discover_probes()

        assert len(probes) == 1
        assert probes[0].pod_name == "annotated"

    @patch("citadel_operator.k3s.probe_discovery.subprocess.run")
    def test_skip_pods_without_ip(self, mock_run):
        pod = _make_annotated_pod(name="no-ip", pod_ip="")
        mock_run.return_value = MagicMock(
            returncode=0, stdout=_kubectl_output([pod]), stderr=""
        )

        discoverer = ProbeDiscoverer()
        probes = discoverer.discover_probes()

        assert len(probes) == 0

    @patch("citadel_operator.k3s.probe_discovery.subprocess.run")
    def test_empty_namespace(self, mock_run):
        mock_run.return_value = MagicMock(
            returncode=0, stdout=_kubectl_output([]), stderr=""
        )

        discoverer = ProbeDiscoverer(namespace="empty-ns")
        probes = discoverer.discover_probes()

        assert probes == []

    @patch("citadel_operator.k3s.probe_discovery.subprocess.run")
    def test_kubectl_failure(self, mock_run):
        mock_run.return_value = MagicMock(
            returncode=1, stdout="", stderr="connection refused"
        )

        discoverer = ProbeDiscoverer()
        probes = discoverer.discover_probes()

        assert probes == []

    @patch("citadel_operator.k3s.probe_discovery.subprocess.run")
    def test_kubectl_not_found(self, mock_run):
        mock_run.side_effect = FileNotFoundError("kubectl not found")

        discoverer = ProbeDiscoverer()
        probes = discoverer.discover_probes()

        assert probes == []

    @patch("citadel_operator.k3s.probe_discovery.subprocess.run")
    def test_kubectl_timeout(self, mock_run):
        mock_run.side_effect = subprocess.TimeoutExpired(cmd="kubectl", timeout=30)

        discoverer = ProbeDiscoverer()
        probes = discoverer.discover_probes()

        assert probes == []

    @patch("citadel_operator.k3s.probe_discovery.subprocess.run")
    def test_kubectl_invalid_json(self, mock_run):
        mock_run.return_value = MagicMock(
            returncode=0, stdout="not json", stderr=""
        )

        discoverer = ProbeDiscoverer()
        probes = discoverer.discover_probes()

        assert probes == []

    @patch("citadel_operator.k3s.probe_discovery.subprocess.run")
    def test_namespace_passed_to_kubectl(self, mock_run):
        mock_run.return_value = MagicMock(
            returncode=0, stdout=_kubectl_output([]), stderr=""
        )

        discoverer = ProbeDiscoverer(namespace="kube-system")
        discoverer.discover_probes()

        call_args = mock_run.call_args[0][0]
        assert "--namespace" in call_args
        ns_idx = call_args.index("--namespace")
        assert call_args[ns_idx + 1] == "kube-system"


# ---------------------------------------------------------------------------
# Tests: ProbeDiscoverer.check_probe
# ---------------------------------------------------------------------------

class TestCheckProbe:
    """Test HTTP health check execution."""

    @patch("citadel_operator.k3s.probe_discovery.urllib.request.urlopen")
    def test_healthy_probe(self, mock_urlopen):
        mock_response = MagicMock()
        mock_response.status = 200
        mock_response.__enter__ = MagicMock(return_value=mock_response)
        mock_response.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_response

        discoverer = ProbeDiscoverer()
        probe = _make_probe()
        result = discoverer.check_probe(probe)

        assert result.healthy
        assert result.status_code == 200
        assert result.error is None
        assert result.response_time_ms >= 0
        assert result.checked_at is not None

    @patch("citadel_operator.k3s.probe_discovery.urllib.request.urlopen")
    def test_unhealthy_http_error(self, mock_urlopen):
        mock_urlopen.side_effect = urllib.error.HTTPError(
            url="http://10.42.0.5:8081/ready",
            code=503,
            msg="Service Unavailable",
            hdrs=None,
            fp=None,
        )

        discoverer = ProbeDiscoverer()
        result = discoverer.check_probe(_make_probe())

        assert not result.healthy
        assert result.status_code == 503
        assert "HTTP 503" in result.error

    @patch("citadel_operator.k3s.probe_discovery.urllib.request.urlopen")
    def test_connection_refused(self, mock_urlopen):
        mock_urlopen.side_effect = urllib.error.URLError(
            reason="Connection refused"
        )

        discoverer = ProbeDiscoverer()
        result = discoverer.check_probe(_make_probe())

        assert not result.healthy
        assert result.status_code is None
        assert "Connection failed" in result.error

    @patch("citadel_operator.k3s.probe_discovery.urllib.request.urlopen")
    def test_timeout(self, mock_urlopen):
        mock_urlopen.side_effect = TimeoutError()

        discoverer = ProbeDiscoverer(health_check_timeout=3)
        result = discoverer.check_probe(_make_probe())

        assert not result.healthy
        assert result.status_code is None
        assert "Timeout" in result.error
        assert "3s" in result.error

    @patch("citadel_operator.k3s.probe_discovery.urllib.request.urlopen")
    def test_os_error(self, mock_urlopen):
        mock_urlopen.side_effect = OSError("Network unreachable")

        discoverer = ProbeDiscoverer()
        result = discoverer.check_probe(_make_probe())

        assert not result.healthy
        assert result.status_code is None
        assert "Network unreachable" in result.error

    @patch("citadel_operator.k3s.probe_discovery.urllib.request.urlopen")
    def test_non_2xx_success(self, mock_urlopen):
        """A 3xx or other non-2xx successful response is treated as unhealthy."""
        mock_response = MagicMock()
        mock_response.status = 301
        mock_response.__enter__ = MagicMock(return_value=mock_response)
        mock_response.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_response

        discoverer = ProbeDiscoverer()
        result = discoverer.check_probe(_make_probe())

        assert not result.healthy
        assert result.status_code == 301
        assert "HTTP 301" in result.error

    @patch("citadel_operator.k3s.probe_discovery.urllib.request.urlopen")
    def test_probe_result_has_correct_probe_reference(self, mock_urlopen):
        mock_response = MagicMock()
        mock_response.status = 200
        mock_response.__enter__ = MagicMock(return_value=mock_response)
        mock_response.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_response

        probe = _make_probe(pod_name="specific-pod", entity_name="specific-entity")
        discoverer = ProbeDiscoverer()
        result = discoverer.check_probe(probe)

        assert result.probe is probe
        assert result.probe.pod_name == "specific-pod"
        assert result.probe.entity_name == "specific-entity"


# ---------------------------------------------------------------------------
# Tests: ProbeDiscoverer.check_all
# ---------------------------------------------------------------------------

class TestCheckAll:
    """Test the combined discover + check workflow."""

    @patch("citadel_operator.k3s.probe_discovery.urllib.request.urlopen")
    @patch("citadel_operator.k3s.probe_discovery.subprocess.run")
    def test_check_all_discovers_and_checks(self, mock_run, mock_urlopen):
        pods = [
            _make_annotated_pod(name="svc-1", pod_ip="10.42.0.1"),
            _make_annotated_pod(name="svc-2", pod_ip="10.42.0.2"),
        ]
        mock_run.return_value = MagicMock(
            returncode=0, stdout=_kubectl_output(pods), stderr=""
        )

        mock_response = MagicMock()
        mock_response.status = 200
        mock_response.__enter__ = MagicMock(return_value=mock_response)
        mock_response.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_response

        discoverer = ProbeDiscoverer()
        results = discoverer.check_all()

        assert len(results) == 2
        assert all(r.healthy for r in results)

    @patch("citadel_operator.k3s.probe_discovery.subprocess.run")
    def test_check_all_empty_namespace(self, mock_run):
        mock_run.return_value = MagicMock(
            returncode=0, stdout=_kubectl_output([]), stderr=""
        )

        discoverer = ProbeDiscoverer()
        results = discoverer.check_all()

        assert results == []


# ---------------------------------------------------------------------------
# Tests: EntityHealth aggregation
# ---------------------------------------------------------------------------

class TestAggregateHealth:
    """Test health aggregation by entity."""

    def test_single_entity_all_healthy(self):
        probe_a = _make_probe(pod_name="svc-1", entity_name="my-svc")
        probe_b = _make_probe(pod_name="svc-2", entity_name="my-svc")
        results = [
            ProbeResult(
                probe=probe_a, healthy=True, status_code=200,
                response_time_ms=5.0, error=None,
                checked_at="2026-02-28T00:00:00+00:00",
            ),
            ProbeResult(
                probe=probe_b, healthy=True, status_code=200,
                response_time_ms=3.0, error=None,
                checked_at="2026-02-28T00:00:00+00:00",
            ),
        ]

        entities = ProbeDiscoverer._aggregate_results(results)

        assert len(entities) == 1
        entity = entities[0]
        assert entity.entity_name == "my-svc"
        assert entity.total_pods == 2
        assert entity.healthy_pods == 2
        assert entity.unhealthy_pods == 0
        assert entity.health_ratio == 1.0
        assert entity.fully_healthy
        assert not entity.fully_unhealthy

    def test_single_entity_mixed_health(self):
        probe_ok = _make_probe(pod_name="svc-1", entity_name="my-svc")
        probe_bad = _make_probe(pod_name="svc-2", entity_name="my-svc")
        results = [
            ProbeResult(
                probe=probe_ok, healthy=True, status_code=200,
                response_time_ms=5.0, error=None,
                checked_at="2026-02-28T00:00:00+00:00",
            ),
            ProbeResult(
                probe=probe_bad, healthy=False, status_code=503,
                response_time_ms=10.0, error="HTTP 503",
                checked_at="2026-02-28T00:00:00+00:00",
            ),
        ]

        entities = ProbeDiscoverer._aggregate_results(results)

        assert len(entities) == 1
        entity = entities[0]
        assert entity.total_pods == 2
        assert entity.healthy_pods == 1
        assert entity.unhealthy_pods == 1
        assert entity.health_ratio == 0.5
        assert not entity.fully_healthy
        assert not entity.fully_unhealthy

    def test_single_entity_all_unhealthy(self):
        probe = _make_probe(pod_name="svc-1", entity_name="dead-svc")
        results = [
            ProbeResult(
                probe=probe, healthy=False, status_code=500,
                response_time_ms=2.0, error="HTTP 500",
                checked_at="2026-02-28T00:00:00+00:00",
            ),
        ]

        entities = ProbeDiscoverer._aggregate_results(results)

        assert len(entities) == 1
        assert entities[0].health_ratio == 0.0
        assert entities[0].fully_unhealthy
        assert not entities[0].fully_healthy

    def test_multiple_entities(self):
        probe_a = _make_probe(pod_name="svc-a-1", entity_name="service-a")
        probe_b = _make_probe(pod_name="svc-b-1", entity_name="service-b")
        results = [
            ProbeResult(
                probe=probe_a, healthy=True, status_code=200,
                response_time_ms=5.0, error=None,
                checked_at="2026-02-28T00:00:00+00:00",
            ),
            ProbeResult(
                probe=probe_b, healthy=False, status_code=503,
                response_time_ms=10.0, error="HTTP 503",
                checked_at="2026-02-28T00:00:00+00:00",
            ),
        ]

        entities = ProbeDiscoverer._aggregate_results(results)

        assert len(entities) == 2
        # Sorted by name
        assert entities[0].entity_name == "service-a"
        assert entities[0].healthy_pods == 1
        assert entities[1].entity_name == "service-b"
        assert entities[1].unhealthy_pods == 1

    def test_multiple_pods_per_entity(self):
        probes = [
            _make_probe(pod_name=f"svc-{i}", entity_name="big-service")
            for i in range(5)
        ]
        # First 3 healthy, last 2 unhealthy
        results = [
            ProbeResult(
                probe=probes[i],
                healthy=i < 3,
                status_code=200 if i < 3 else 500,
                response_time_ms=float(i),
                error=None if i < 3 else "HTTP 500",
                checked_at="2026-02-28T00:00:00+00:00",
            )
            for i in range(5)
        ]

        entities = ProbeDiscoverer._aggregate_results(results)

        assert len(entities) == 1
        entity = entities[0]
        assert entity.total_pods == 5
        assert entity.healthy_pods == 3
        assert entity.unhealthy_pods == 2
        assert entity.health_ratio == 0.6
        assert len(entity.probe_results) == 5

    def test_aggregate_empty_results(self):
        entities = ProbeDiscoverer._aggregate_results([])
        assert entities == []

    def test_entities_sorted_by_name(self):
        probes = [
            _make_probe(pod_name="z-pod", entity_name="z-service"),
            _make_probe(pod_name="a-pod", entity_name="a-service"),
            _make_probe(pod_name="m-pod", entity_name="m-service"),
        ]
        results = [
            ProbeResult(
                probe=p, healthy=True, status_code=200,
                response_time_ms=1.0, error=None,
                checked_at="2026-02-28T00:00:00+00:00",
            )
            for p in probes
        ]

        entities = ProbeDiscoverer._aggregate_results(results)

        names = [e.entity_name for e in entities]
        assert names == ["a-service", "m-service", "z-service"]

    def test_health_ratio_rounding(self):
        """health_ratio should be rounded to 4 decimal places."""
        probes = [
            _make_probe(pod_name=f"svc-{i}", entity_name="ratio-test")
            for i in range(3)
        ]
        results = [
            ProbeResult(
                probe=probes[0], healthy=True, status_code=200,
                response_time_ms=1.0, error=None,
                checked_at="2026-02-28T00:00:00+00:00",
            ),
            ProbeResult(
                probe=probes[1], healthy=False, status_code=500,
                response_time_ms=1.0, error="err",
                checked_at="2026-02-28T00:00:00+00:00",
            ),
            ProbeResult(
                probe=probes[2], healthy=False, status_code=500,
                response_time_ms=1.0, error="err",
                checked_at="2026-02-28T00:00:00+00:00",
            ),
        ]

        entities = ProbeDiscoverer._aggregate_results(results)

        # 1/3 = 0.3333...
        assert entities[0].health_ratio == 0.3333


# ---------------------------------------------------------------------------
# Tests: EntityHealth properties
# ---------------------------------------------------------------------------

class TestEntityHealthProperties:
    """Test EntityHealth dataclass properties."""

    def test_fully_healthy(self):
        entity = EntityHealth(
            entity_name="svc",
            total_pods=3,
            healthy_pods=3,
            unhealthy_pods=0,
            health_ratio=1.0,
            probe_results=(),
        )
        assert entity.fully_healthy
        assert not entity.fully_unhealthy

    def test_fully_unhealthy(self):
        entity = EntityHealth(
            entity_name="svc",
            total_pods=2,
            healthy_pods=0,
            unhealthy_pods=2,
            health_ratio=0.0,
            probe_results=(),
        )
        assert entity.fully_unhealthy
        assert not entity.fully_healthy

    def test_partially_healthy(self):
        entity = EntityHealth(
            entity_name="svc",
            total_pods=4,
            healthy_pods=2,
            unhealthy_pods=2,
            health_ratio=0.5,
            probe_results=(),
        )
        assert not entity.fully_healthy
        assert not entity.fully_unhealthy

    def test_empty_entity_not_unhealthy(self):
        """An entity with zero pods is neither fully healthy nor fully unhealthy."""
        entity = EntityHealth(
            entity_name="ghost",
            total_pods=0,
            healthy_pods=0,
            unhealthy_pods=0,
            health_ratio=0.0,
            probe_results=(),
        )
        assert not entity.fully_healthy
        assert not entity.fully_unhealthy

    def test_frozen(self):
        entity = EntityHealth(
            entity_name="svc",
            total_pods=1,
            healthy_pods=1,
            unhealthy_pods=0,
            health_ratio=1.0,
            probe_results=(),
        )
        with pytest.raises(AttributeError):
            entity.entity_name = "changed"


# ---------------------------------------------------------------------------
# Tests: ProbeResult
# ---------------------------------------------------------------------------

class TestProbeResult:
    """Test ProbeResult dataclass."""

    def test_frozen(self):
        result = ProbeResult(
            probe=_make_probe(),
            healthy=True,
            status_code=200,
            response_time_ms=5.0,
            error=None,
            checked_at="2026-02-28T00:00:00+00:00",
        )
        with pytest.raises(AttributeError):
            result.healthy = False

    def test_iso_timestamp_format(self):
        result = ProbeResult(
            probe=_make_probe(),
            healthy=True,
            status_code=200,
            response_time_ms=1.0,
            error=None,
            checked_at="2026-02-28T12:30:00+00:00",
        )
        # Should be parseable as ISO 8601
        parsed = datetime.fromisoformat(result.checked_at)
        assert parsed.tzinfo is not None


# ---------------------------------------------------------------------------
# Tests: Integration with HealthChecker
# ---------------------------------------------------------------------------

class TestHealthCheckerIntegration:
    """Verify entity health feeds into node health reports."""

    def test_entity_health_attached_to_report(self):
        from datetime import timedelta

        from citadel_operator.k3s.health import HealthChecker, HealthState
        from citadel_operator.k3s.node import NodeInfo

        node = NodeInfo(
            name="worker-1",
            creation_timestamp=datetime.now(timezone.utc) - timedelta(hours=10),
            age_hours=10.0,
            ready=True,
            schedulable=True,
            conditions={
                "Ready": "True",
                "DiskPressure": "False",
                "MemoryPressure": "False",
                "PIDPressure": "False",
            },
        )

        entity = EntityHealth(
            entity_name="my-service",
            total_pods=2,
            healthy_pods=1,
            unhealthy_pods=1,
            health_ratio=0.5,
            probe_results=(),
        )

        hc = HealthChecker(max_age_hours=48)
        report = hc.check(node, entity_health=[entity])

        # Entity health is informational, does not affect computed state
        assert report.state == HealthState.HEALTHY
        assert report.entity_health is not None
        assert len(report.entity_health) == 1
        assert report.entity_health[0].entity_name == "my-service"
        assert len(report.unhealthy_entities) == 1

    def test_no_entity_health_backwards_compatible(self):
        from datetime import timedelta

        from citadel_operator.k3s.health import HealthChecker, HealthState
        from citadel_operator.k3s.node import NodeInfo

        node = NodeInfo(
            name="worker-1",
            creation_timestamp=datetime.now(timezone.utc) - timedelta(hours=10),
            age_hours=10.0,
            ready=True,
            schedulable=True,
            conditions={
                "Ready": "True",
                "DiskPressure": "False",
                "MemoryPressure": "False",
                "PIDPressure": "False",
            },
        )

        hc = HealthChecker(max_age_hours=48)
        report = hc.check(node)

        assert report.entity_health is None
        assert report.unhealthy_entities == []

    def test_all_entities_healthy_returns_empty_unhealthy(self):
        from datetime import timedelta

        from citadel_operator.k3s.health import HealthChecker
        from citadel_operator.k3s.node import NodeInfo

        node = NodeInfo(
            name="worker-1",
            creation_timestamp=datetime.now(timezone.utc) - timedelta(hours=10),
            age_hours=10.0,
            ready=True,
            schedulable=True,
            conditions={
                "Ready": "True",
                "DiskPressure": "False",
                "MemoryPressure": "False",
                "PIDPressure": "False",
            },
        )

        entity = EntityHealth(
            entity_name="good-service",
            total_pods=3,
            healthy_pods=3,
            unhealthy_pods=0,
            health_ratio=1.0,
            probe_results=(),
        )

        hc = HealthChecker(max_age_hours=48)
        report = hc.check(node, entity_health=[entity])

        assert report.unhealthy_entities == []


# ---------------------------------------------------------------------------
# Tests: ProbeDiscoverer.aggregate_health (end-to-end with mocked I/O)
# ---------------------------------------------------------------------------

class TestAggregateHealthEndToEnd:
    """Test the full discover -> check -> aggregate pipeline."""

    @patch("citadel_operator.k3s.probe_discovery.urllib.request.urlopen")
    @patch("citadel_operator.k3s.probe_discovery.subprocess.run")
    def test_full_pipeline(self, mock_run, mock_urlopen):
        pods = [
            _make_annotated_pod(
                name="svc-a-1", pod_ip="10.42.0.1", entity_name="svc-a"
            ),
            _make_annotated_pod(
                name="svc-a-2", pod_ip="10.42.0.2", entity_name="svc-a"
            ),
            _make_annotated_pod(
                name="svc-b-1", pod_ip="10.42.0.3", entity_name="svc-b"
            ),
        ]
        mock_run.return_value = MagicMock(
            returncode=0, stdout=_kubectl_output(pods), stderr=""
        )

        mock_response = MagicMock()
        mock_response.status = 200
        mock_response.__enter__ = MagicMock(return_value=mock_response)
        mock_response.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_response

        discoverer = ProbeDiscoverer(namespace="prod")
        entities = discoverer.aggregate_health()

        assert len(entities) == 2
        svc_a = next(e for e in entities if e.entity_name == "svc-a")
        svc_b = next(e for e in entities if e.entity_name == "svc-b")
        assert svc_a.total_pods == 2
        assert svc_a.health_ratio == 1.0
        assert svc_b.total_pods == 1
        assert svc_b.health_ratio == 1.0
