"""Tests for the forced drain after timeout behaviour.

Verifies that NodeManager.drain() can force-delete pods that are
blocked by PodDisruptionBudgets when ``force_after_timeout`` is set.
All tests mock the Kubernetes API — no cluster required.
"""

from __future__ import annotations

import time
from unittest.mock import MagicMock, patch

import pytest
from kubernetes.client.exceptions import ApiException

from citadel_operator.k3s.node import NodeManager


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

def _make_pod(
    name: str,
    namespace: str = "default",
    *,
    daemonset: bool = False,
    mirror: bool = False,
) -> MagicMock:
    """Build a minimal mock V1Pod."""
    pod = MagicMock()
    pod.metadata.name = name
    pod.metadata.namespace = namespace
    pod.metadata.deletion_timestamp = None

    if mirror:
        pod.metadata.annotations = {"kubernetes.io/config.mirror": "true"}
    else:
        pod.metadata.annotations = {}

    if daemonset:
        ref = MagicMock()
        ref.kind = "DaemonSet"
        pod.metadata.owner_references = [ref]
    else:
        pod.metadata.owner_references = []

    pod.spec.volumes = []
    pod.spec.node_name = "worker-1"
    return pod


def _api_exception(status: int, reason: str = "") -> ApiException:
    """Build an ApiException with the given status code."""
    exc = ApiException(status=status, reason=reason)
    exc.status = status
    return exc


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture()
def node_manager():
    """Create a NodeManager with mocked Kubernetes clients."""
    with patch("citadel_operator.k3s.node.config"):
        mgr = NodeManager()

    mgr._core = MagicMock()
    mgr._policy = MagicMock()
    return mgr


# ---------------------------------------------------------------------------
# Tests: drain without force
# ---------------------------------------------------------------------------

class TestDrainWithoutForce:
    """Baseline: drain respects PDBs and records failures when no force."""

    def test_eviction_success(self, node_manager):
        pods = MagicMock()
        pods.items = [_make_pod("app-1"), _make_pod("app-2")]
        node_manager._core.list_pod_for_all_namespaces.return_value = pods
        # read_namespaced_pod raises 404 → pod is gone
        node_manager._core.read_namespaced_pod.side_effect = _api_exception(404)

        result = node_manager.drain("worker-1", timeout_seconds=10)

        assert result.success
        assert result.pods_evicted == 2
        assert result.pods_failed == []
        assert result.pods_force_deleted == []

    def test_pdb_blocks_eviction_fails(self, node_manager):
        """When PDB blocks for entire timeout, drain fails."""
        pods = MagicMock()
        pods.items = [_make_pod("stuck-pod")]
        node_manager._core.list_pod_for_all_namespaces.return_value = pods

        # Eviction always returns 429 (PDB blocking)
        node_manager._core.create_namespaced_pod_eviction.side_effect = (
            _api_exception(429, "Too Many Requests")
        )

        # Use a tiny timeout so the test runs fast
        result = node_manager.drain(
            "worker-1", timeout_seconds=0, force_after_timeout=None
        )

        assert not result.success
        assert "default/stuck-pod" in result.pods_failed
        assert result.pods_force_deleted == []


# ---------------------------------------------------------------------------
# Tests: drain with force_after_timeout
# ---------------------------------------------------------------------------

class TestDrainWithForce:
    """Force drain: pods blocked by PDB are deleted directly after timeout."""

    def test_force_deletes_pdb_blocked_pods(self, node_manager):
        """Pods that fail eviction are force-deleted when force is enabled."""
        pods = MagicMock()
        pods.items = [_make_pod("stuck-pod")]
        node_manager._core.list_pod_for_all_namespaces.return_value = pods

        # Eviction blocked by PDB
        node_manager._core.create_namespaced_pod_eviction.side_effect = (
            _api_exception(429, "Too Many Requests")
        )
        # Force delete succeeds; pod disappears on read
        node_manager._core.delete_namespaced_pod.return_value = None
        node_manager._core.read_namespaced_pod.side_effect = _api_exception(404)

        result = node_manager.drain(
            "worker-1",
            timeout_seconds=0,
            force_after_timeout=10,
        )

        assert result.success
        assert result.pods_failed == []
        assert "default/stuck-pod" in result.pods_force_deleted

        # Verify the force delete was called with grace_period_seconds=0
        call_args = node_manager._core.delete_namespaced_pod.call_args
        assert call_args.kwargs["name"] == "stuck-pod"
        assert call_args.kwargs["namespace"] == "default"
        assert call_args.kwargs["body"].grace_period_seconds == 0

    def test_force_delete_already_gone(self, node_manager):
        """If the pod vanishes before force-delete, treat it as success."""
        pods = MagicMock()
        pods.items = [_make_pod("vanished")]
        node_manager._core.list_pod_for_all_namespaces.return_value = pods

        # Eviction blocked
        node_manager._core.create_namespaced_pod_eviction.side_effect = (
            _api_exception(429)
        )
        # Force delete → 404 (already gone)
        node_manager._core.delete_namespaced_pod.side_effect = _api_exception(404)

        result = node_manager.drain(
            "worker-1",
            timeout_seconds=0,
            force_after_timeout=10,
        )

        assert result.success
        assert "default/vanished" in result.pods_force_deleted
        assert result.pods_failed == []

    def test_force_delete_fails(self, node_manager):
        """If force-delete itself fails, the pod is still reported as failed."""
        pods = MagicMock()
        pods.items = [_make_pod("really-stuck")]
        node_manager._core.list_pod_for_all_namespaces.return_value = pods

        # Eviction blocked
        node_manager._core.create_namespaced_pod_eviction.side_effect = (
            _api_exception(429)
        )
        # Force delete also fails (e.g. RBAC or API server error)
        node_manager._core.delete_namespaced_pod.side_effect = (
            _api_exception(403, "Forbidden")
        )

        result = node_manager.drain(
            "worker-1",
            timeout_seconds=0,
            force_after_timeout=10,
        )

        assert not result.success
        assert "default/really-stuck" in result.pods_failed
        assert result.pods_force_deleted == []

    @patch("citadel_operator.k3s.node.time")
    def test_mixed_eviction_and_force(self, mock_time, node_manager):
        """Some pods evict cleanly, others need force-delete."""
        pods = MagicMock()
        pods.items = [_make_pod("clean"), _make_pod("stuck")]
        node_manager._core.list_pod_for_all_namespaces.return_value = pods

        # Control time: deadline is at 30, first eviction happens at t=0..1,
        # second eviction gets a 429 and by next check we're past deadline.
        clock = iter([
            0,    # drain: deadline = 0 + 30 = 30
            1,    # _evict_pod_with_retry("clean"): while 1 < 30 → enter loop
            2,    # _wait_pod_deleted("clean"): while 2 < 30 → check
            3,    # _evict_pod_with_retry("stuck"): while 3 < 30 → enter loop
            # 429 → sleep(5), retry check:
            31,   # while 31 < 30 → exit loop, eviction timed out
            # Force-delete phase:
            40,   # force deadline = 40 + 10 = 50
            41,   # _wait_pod_deleted("stuck"): while 41 < 50 → check
        ])
        mock_time.monotonic.side_effect = lambda: next(clock)
        mock_time.sleep = MagicMock()  # no-op sleep

        # First eviction succeeds, second is PDB-blocked
        eviction_calls = [0]

        def eviction_side_effect(**kwargs):
            eviction_calls[0] += 1
            if eviction_calls[0] == 1:
                return None  # clean eviction
            raise _api_exception(429)

        node_manager._core.create_namespaced_pod_eviction.side_effect = (
            eviction_side_effect
        )
        # read_namespaced_pod → 404 for both (pod gone after eviction/force)
        node_manager._core.read_namespaced_pod.side_effect = _api_exception(404)
        # Force delete of the stuck pod succeeds
        node_manager._core.delete_namespaced_pod.return_value = None

        result = node_manager.drain(
            "worker-1",
            timeout_seconds=30,
            force_after_timeout=10,
        )

        assert result.success
        assert result.pods_evicted == 1
        assert "default/stuck" in result.pods_force_deleted
        assert result.pods_failed == []

    def test_no_force_when_eviction_succeeds(self, node_manager):
        """When all pods evict cleanly, force-delete is never invoked."""
        pods = MagicMock()
        pods.items = [_make_pod("app-1")]
        node_manager._core.list_pod_for_all_namespaces.return_value = pods
        node_manager._core.read_namespaced_pod.side_effect = _api_exception(404)

        result = node_manager.drain(
            "worker-1",
            timeout_seconds=10,
            force_after_timeout=30,
        )

        assert result.success
        assert result.pods_evicted == 1
        assert result.pods_force_deleted == []
        # delete_namespaced_pod should never have been called
        node_manager._core.delete_namespaced_pod.assert_not_called()

    def test_daemonset_pods_still_skipped_with_force(self, node_manager):
        """DaemonSet pods are skipped even when force drain is enabled."""
        pods = MagicMock()
        pods.items = [
            _make_pod("ds-pod", daemonset=True),
            _make_pod("regular-pod"),
        ]
        node_manager._core.list_pod_for_all_namespaces.return_value = pods
        node_manager._core.read_namespaced_pod.side_effect = _api_exception(404)

        result = node_manager.drain(
            "worker-1",
            timeout_seconds=10,
            force_after_timeout=30,
        )

        assert result.success
        assert result.pods_evicted == 1  # only the regular pod
