"""Tests for the lifecycle operator — evaluator, controller, and kopf handler wiring.

Tests pure policy-matching logic with mock NodeInfo objects (from Citadel's
NodeManager) rather than raw V1Node objects. Also tests that the controller
uses NodeManager.drain() and NodeManager.label_for_termination() rather than
reimplemented logic.
"""

from datetime import datetime, timezone, timedelta
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from citadel_operator.k3s.lifecycle_models import (
    ConditionMatch,
    CycleResult,
    DrainConfig,
    LifecycleConfig,
    NodeTerminationPolicy,
    PolicyMatch,
    PolicySelector,
    PolicySpec,
    PolicyStatus,
)
from citadel_operator.k3s.lifecycle_operator import (
    LifecycleController,
    LifecyclePolicyEvaluator,
)
from citadel_operator.k3s.node import DrainResult, NodeInfo


# === Helpers ===


def _make_node_info(
    name: str = "worker-1",
    age_hours: float = 10.0,
    conditions: dict | None = None,
    labels: dict | None = None,
    ready: bool = True,
    schedulable: bool = True,
) -> NodeInfo:
    """Create a NodeInfo for testing (Citadel's type, not V1Node)."""
    creation = datetime.now(timezone.utc) - timedelta(hours=age_hours)

    default_conditions = {
        "Ready": "True",
        "DiskPressure": "False",
        "MemoryPressure": "False",
        "PIDPressure": "False",
    }
    if conditions:
        default_conditions.update(conditions)

    return NodeInfo(
        name=name,
        creation_timestamp=creation,
        age_hours=round(age_hours, 2),
        ready=ready,
        schedulable=schedulable,
        conditions=default_conditions,
        labels=labels or {},
    )


def _make_age_policy(
    name: str = "max-age-48h",
    max_age_hours: float = 48,
    priority: int = 10,
    enabled: bool = True,
    dry_run: bool = False,
) -> NodeTerminationPolicy:
    return NodeTerminationPolicy(
        name=name,
        spec=PolicySpec(
            reason=f"Node exceeded {max_age_hours}h max age",
            priority=priority,
            enabled=enabled,
            dry_run=dry_run,
            selector=PolicySelector(max_age_hours=max_age_hours),
            drain=DrainConfig(timeout_seconds=300),
        ),
    )


def _make_health_policy(
    name: str = "disk-pressure",
    condition_type: str = "DiskPressure",
    condition_status: str = "True",
    priority: int = 100,
) -> NodeTerminationPolicy:
    return NodeTerminationPolicy(
        name=name,
        spec=PolicySpec(
            reason=f"{condition_type} detected",
            priority=priority,
            selector=PolicySelector(
                conditions=[ConditionMatch(type=condition_type, status=condition_status)]
            ),
            drain=DrainConfig(timeout_seconds=120),
        ),
    )


# === Evaluator Tests ===


class TestLifecyclePolicyEvaluator:
    """Tests for the evaluator using Citadel's NodeInfo (not V1Node)."""

    def setup_method(self):
        self.evaluator = LifecyclePolicyEvaluator()

    def test_no_policies_no_matches(self):
        nodes = [_make_node_info()]
        matches = self.evaluator.evaluate([], nodes)
        assert len(matches) == 0

    def test_no_nodes_no_matches(self):
        policies = [_make_age_policy()]
        matches = self.evaluator.evaluate(policies, [])
        assert len(matches) == 0

    def test_age_policy_matches_old_node(self):
        policies = [_make_age_policy(max_age_hours=48)]
        nodes = [_make_node_info(name="old-node", age_hours=50)]
        matches = self.evaluator.evaluate(policies, nodes)

        assert len(matches) == 1
        assert matches[0].node_name == "old-node"
        assert matches[0].policy_name == "max-age-48h"

    def test_age_policy_skips_young_node(self):
        policies = [_make_age_policy(max_age_hours=48)]
        nodes = [_make_node_info(name="young-node", age_hours=10)]
        matches = self.evaluator.evaluate(policies, nodes)

        assert len(matches) == 0

    def test_health_policy_matches_sick_node(self):
        policies = [_make_health_policy(condition_type="DiskPressure")]
        nodes = [_make_node_info(conditions={"DiskPressure": "True"})]
        matches = self.evaluator.evaluate(policies, nodes)

        assert len(matches) == 1
        assert matches[0].policy_name == "disk-pressure"

    def test_health_policy_skips_healthy_node(self):
        policies = [_make_health_policy(condition_type="DiskPressure")]
        nodes = [_make_node_info()]
        matches = self.evaluator.evaluate(policies, nodes)

        assert len(matches) == 0

    def test_disabled_policy_skipped(self):
        policies = [_make_age_policy(enabled=False)]
        nodes = [_make_node_info(age_hours=100)]
        matches = self.evaluator.evaluate(policies, nodes)

        assert len(matches) == 0

    def test_higher_priority_wins(self):
        age_policy = _make_age_policy(name="age", max_age_hours=48, priority=10)
        health_policy = _make_health_policy(name="health", priority=100)

        nodes = [_make_node_info(age_hours=50, conditions={"DiskPressure": "True"})]
        matches = self.evaluator.evaluate([age_policy, health_policy], nodes)

        assert len(matches) == 1
        assert matches[0].policy_name == "health"

    def test_already_labeled_node_skipped(self):
        policies = [_make_age_policy(max_age_hours=48)]
        nodes = [_make_node_info(
            age_hours=50,
            labels={"citadel.dev/terminate-reason": "already-marked"},
        )]
        matches = self.evaluator.evaluate(policies, nodes)

        assert len(matches) == 0

    def test_multiple_nodes_evaluated(self):
        policies = [_make_age_policy(max_age_hours=48)]
        nodes = [
            _make_node_info(name="young", age_hours=10),
            _make_node_info(name="old-1", age_hours=50),
            _make_node_info(name="old-2", age_hours=72),
        ]
        matches = self.evaluator.evaluate(policies, nodes)

        assert len(matches) == 2
        matched_names = {m.node_name for m in matches}
        assert matched_names == {"old-1", "old-2"}

    def test_dry_run_propagated(self):
        policies = [_make_age_policy(dry_run=True)]
        nodes = [_make_node_info(age_hours=50)]
        matches = self.evaluator.evaluate(policies, nodes)

        assert len(matches) == 1
        assert matches[0].dry_run is True

    def test_not_ready_condition_match(self):
        policy = _make_health_policy(
            name="not-ready",
            condition_type="Ready",
            condition_status="False",
            priority=200,
        )
        nodes = [_make_node_info(conditions={"Ready": "False"})]
        matches = self.evaluator.evaluate([policy], nodes)

        assert len(matches) == 1
        assert matches[0].priority == 200

    def test_node_selector_label_match(self):
        policy = NodeTerminationPolicy(
            name="gpu-recycle",
            spec=PolicySpec(
                reason="GPU node recycle",
                priority=50,
                selector=PolicySelector(
                    max_age_hours=24,
                    node_selector={"node-type": "gpu"},
                ),
                drain=DrainConfig(),
            ),
        )
        nodes = [
            _make_node_info(name="gpu-node", age_hours=30, labels={"node-type": "gpu"}),
            _make_node_info(name="cpu-node", age_hours=30, labels={"node-type": "cpu"}),
        ]
        matches = self.evaluator.evaluate([policy], nodes)

        assert len(matches) == 1
        assert matches[0].node_name == "gpu-node"

    def test_drain_config_propagated(self):
        """Drain config fields are carried through to PolicyMatch."""
        policy = NodeTerminationPolicy(
            name="force-drain",
            spec=PolicySpec(
                reason="Force drain test",
                selector=PolicySelector(max_age_hours=24),
                drain=DrainConfig(
                    timeout_seconds=120,
                    delete_emptydir_data=False,
                    force_after_timeout=60,
                ),
            ),
        )
        nodes = [_make_node_info(age_hours=30)]
        matches = self.evaluator.evaluate([policy], nodes)

        assert len(matches) == 1
        assert matches[0].drain_timeout == 120
        assert matches[0].delete_emptydir_data is False
        assert matches[0].force_after_timeout == 60


# === Controller Tests ===


class TestLifecycleController:
    """Tests that the controller uses NodeManager and HealthChecker correctly."""

    @patch("citadel_operator.k3s.lifecycle_operator.client")
    @patch("citadel_operator.k3s.lifecycle_operator.k8s_config")
    @patch("citadel_operator.k3s.lifecycle_operator.NodeManager")
    def test_drain_uses_node_manager(self, MockNodeManager, mock_config, mock_client):
        """Controller should use NodeManager.drain(), not reimplemented drain."""
        mock_nm = MockNodeManager.return_value
        mock_nm.list_nodes.return_value = [
            _make_node_info(name="old-node", age_hours=50)
        ]
        mock_nm.drain.return_value = DrainResult(
            node_name="old-node", success=True, pods_evicted=3
        )

        mock_custom = MagicMock()
        mock_client.CustomObjectsApi.return_value = mock_custom
        mock_custom.list_cluster_custom_object.return_value = {
            "items": [
                {
                    "metadata": {"name": "max-age-48h", "labels": {}},
                    "spec": {
                        "reason": "Old node",
                        "selector": {"maxAgeHours": 48},
                        "drain": {"timeoutSeconds": 300},
                    },
                    "status": {},
                }
            ]
        }
        mock_custom.get_cluster_custom_object.return_value = {"status": {}}

        controller = LifecycleController()

        import asyncio
        result = asyncio.get_event_loop().run_until_complete(
            controller.run_evaluation_cycle()
        )

        # Verify NodeManager.drain was called (not reimplemented drain)
        mock_nm.drain.assert_called_once()
        call_kwargs = mock_nm.drain.call_args
        assert call_kwargs[1]["name"] == "old-node"

    @patch("citadel_operator.k3s.lifecycle_operator.client")
    @patch("citadel_operator.k3s.lifecycle_operator.k8s_config")
    @patch("citadel_operator.k3s.lifecycle_operator.NodeManager")
    def test_label_uses_node_manager(self, MockNodeManager, mock_config, mock_client):
        """Controller should use NodeManager.label_for_termination()."""
        mock_nm = MockNodeManager.return_value
        mock_nm.list_nodes.return_value = [
            _make_node_info(name="old-node", age_hours=50)
        ]
        mock_nm.drain.return_value = DrainResult(
            node_name="old-node", success=True, pods_evicted=0
        )

        mock_custom = MagicMock()
        mock_client.CustomObjectsApi.return_value = mock_custom
        mock_custom.list_cluster_custom_object.return_value = {
            "items": [
                {
                    "metadata": {"name": "test-policy", "labels": {}},
                    "spec": {
                        "reason": "Test",
                        "selector": {"maxAgeHours": 48},
                        "drain": {"timeoutSeconds": 300},
                    },
                    "status": {},
                }
            ]
        }
        mock_custom.get_cluster_custom_object.return_value = {"status": {}}

        controller = LifecycleController()

        import asyncio
        asyncio.get_event_loop().run_until_complete(
            controller.run_evaluation_cycle()
        )

        mock_nm.label_for_termination.assert_called_once()

    @patch("citadel_operator.k3s.lifecycle_operator.client")
    @patch("citadel_operator.k3s.lifecycle_operator.k8s_config")
    @patch("citadel_operator.k3s.lifecycle_operator.NodeManager")
    def test_dry_run_skips_drain(self, MockNodeManager, mock_config, mock_client):
        """Dry-run mode should label but not drain."""
        mock_nm = MockNodeManager.return_value
        mock_nm.list_nodes.return_value = [
            _make_node_info(name="old-node", age_hours=50)
        ]

        mock_custom = MagicMock()
        mock_client.CustomObjectsApi.return_value = mock_custom
        mock_custom.list_cluster_custom_object.return_value = {
            "items": [
                {
                    "metadata": {"name": "dry-policy", "labels": {}},
                    "spec": {
                        "reason": "Dry run",
                        "dryRun": True,
                        "selector": {"maxAgeHours": 48},
                        "drain": {"timeoutSeconds": 300},
                    },
                    "status": {},
                }
            ]
        }
        mock_custom.get_cluster_custom_object.return_value = {"status": {}}

        controller = LifecycleController()

        import asyncio
        result = asyncio.get_event_loop().run_until_complete(
            controller.run_evaluation_cycle()
        )

        mock_nm.drain.assert_not_called()
        assert result.nodes_matched == 1
        assert result.dry_run_only == ["old-node"]

    @patch("citadel_operator.k3s.lifecycle_operator.client")
    @patch("citadel_operator.k3s.lifecycle_operator.k8s_config")
    @patch("citadel_operator.k3s.lifecycle_operator.NodeManager")
    def test_empty_policies_returns_zero_cycle(self, MockNodeManager, mock_config, mock_client):
        """No policies should result in a zero-count cycle."""
        mock_custom = MagicMock()
        mock_client.CustomObjectsApi.return_value = mock_custom
        mock_custom.list_cluster_custom_object.return_value = {"items": []}

        controller = LifecycleController()

        import asyncio
        result = asyncio.get_event_loop().run_until_complete(
            controller.run_evaluation_cycle()
        )

        assert result.policies_evaluated == 0
        assert result.nodes_checked == 0


# === Model Tests ===


class TestLifecycleModels:
    """Tests for CRD model parsing."""

    def test_from_dict_age_policy(self):
        data = {
            "metadata": {
                "name": "max-age-48h",
                "labels": {"citadel.dev/policy-type": "age"},
            },
            "spec": {
                "reason": "Node exceeded 48-hour maximum age",
                "priority": 10,
                "enabled": True,
                "selector": {"maxAgeHours": 48},
                "drain": {"timeoutSeconds": 300},
            },
            "status": {"nodesMatched": 5, "nodesTerminated": 2},
        }
        policy = NodeTerminationPolicy.from_dict(data)

        assert policy.name == "max-age-48h"
        assert policy.spec.reason == "Node exceeded 48-hour maximum age"
        assert policy.spec.priority == 10
        assert policy.spec.selector.max_age_hours == 48
        assert policy.spec.drain.timeout_seconds == 300
        assert policy.status.nodes_matched == 5
        assert policy.status.nodes_terminated == 2
        assert policy.policy_type == "age"

    def test_from_dict_health_policy(self):
        data = {
            "metadata": {"name": "disk-pressure", "labels": {}},
            "spec": {
                "reason": "Node under disk pressure",
                "priority": 100,
                "selector": {
                    "conditions": [
                        {"type": "DiskPressure", "status": "True"},
                    ],
                },
            },
            "status": {},
        }
        policy = NodeTerminationPolicy.from_dict(data)

        assert policy.name == "disk-pressure"
        assert len(policy.spec.selector.conditions) == 1
        assert policy.spec.selector.conditions[0].type == "DiskPressure"
        assert policy.policy_type == "health"

    def test_from_dict_defaults(self):
        data = {
            "metadata": {"name": "minimal"},
            "spec": {
                "reason": "test",
                "selector": {},
            },
            "status": {},
        }
        policy = NodeTerminationPolicy.from_dict(data)

        assert policy.spec.priority == 10
        assert policy.spec.enabled is True
        assert policy.spec.dry_run is False
        assert policy.spec.drain.timeout_seconds == 300

    def test_drain_config_force_after_timeout(self):
        data = {
            "metadata": {"name": "force"},
            "spec": {
                "reason": "test",
                "selector": {"maxAgeHours": 24},
                "drain": {
                    "timeoutSeconds": 120,
                    "forceAfterTimeoutSeconds": 60,
                },
            },
            "status": {},
        }
        policy = NodeTerminationPolicy.from_dict(data)
        assert policy.spec.drain.force_after_timeout == 60
        assert policy.spec.drain.timeout_seconds == 120

    def test_status_to_dict(self):
        status = PolicyStatus(
            nodes_matched=3,
            nodes_terminated=1,
        )
        d = status.to_dict()
        assert d["nodesMatched"] == 3
        assert d["nodesTerminated"] == 1
