"""Tests for k8s/client.py — real K8s client with mocked kubernetes library.

Tests the boundary layer: retry logic, dict normalization, label selector
building, and correct API wiring. Does NOT test against a real cluster —
that's what contract tests (Phase 13) are for.
"""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock, patch

import pytest
from kubernetes.client.exceptions import ApiException  # type: ignore[import-untyped]
from kubernetes.config.config_exception import ConfigException  # type: ignore[import-untyped]

from kuberoku.exceptions import NoKubeConfigError
from kuberoku.k8s.client import K8sClient, _label_selector_str, _to_dict

# ── Pure function tests ──────────────────────────────────────────


class TestToDict:
    def test_dict_passthrough(self) -> None:
        d = {"kind": "ConfigMap", "metadata": {"name": "test"}}
        assert _to_dict(d) is d

    def test_object_with_to_dict(self) -> None:
        obj = MagicMock()
        obj.to_dict.return_value = {"kind": "Pod"}
        assert _to_dict(obj) == {"kind": "Pod"}

    def test_invalid_type_raises(self) -> None:
        with pytest.raises(TypeError, match="Cannot convert"):
            _to_dict(42)


class TestLabelSelectorStr:
    def test_single_label(self) -> None:
        assert _label_selector_str({"app": "myapi"}) == "app=myapi"

    def test_multiple_labels(self) -> None:
        result = _label_selector_str({"app": "myapi", "env": "prod"})
        assert "app=myapi" in result
        assert "env=prod" in result
        assert "," in result

    def test_empty_labels(self) -> None:
        assert _label_selector_str({}) == ""


# ── K8sClient construction ──────────────────────────────────────


class TestK8sClientInit:
    @patch("kuberoku.k8s.client.kubernetes")
    def test_creates_api_clients(self, mock_k8s: MagicMock) -> None:
        api_client = MagicMock()
        K8sClient(api_client)
        mock_k8s.client.CoreV1Api.assert_called_once_with(api_client)
        mock_k8s.client.AppsV1Api.assert_called_once_with(api_client)
        mock_k8s.client.NetworkingV1Api.assert_called_once_with(api_client)
        mock_k8s.client.BatchV1Api.assert_called_once_with(api_client)
        mock_k8s.client.AutoscalingV2Api.assert_called_once_with(api_client)
        mock_k8s.client.AuthorizationV1Api.assert_called_once_with(api_client)

    @patch("kuberoku.k8s.client.kubernetes")
    def test_from_context_default(self, mock_k8s: MagicMock) -> None:
        client = K8sClient.from_context()
        mock_k8s.config.load_kube_config.assert_called_once()
        assert isinstance(client, K8sClient)

    @patch("kuberoku.k8s.client.kubernetes")
    def test_from_context_named(self, mock_k8s: MagicMock) -> None:
        K8sClient.from_context(context="production")
        call_kwargs = mock_k8s.config.load_kube_config.call_args
        assert call_kwargs[1]["context"] == "production"

    @patch("kuberoku.k8s.client.kubernetes")
    def test_from_context_no_kubeconfig_gives_friendly_error(self, mock_k8s: MagicMock) -> None:
        mock_k8s.config.load_kube_config.side_effect = ConfigException(
            "Invalid kube-config file. No configuration found."
        )
        with pytest.raises(NoKubeConfigError, match="No Kubernetes cluster connected"):
            K8sClient.from_context()


# ── Retry logic ──────────────────────────────────────────────────


class TestRetry:
    """Verify exponential backoff on 429/503, immediate raise on other errors."""

    def _make_client(self) -> K8sClient:
        with patch("kuberoku.k8s.client.kubernetes"):
            return K8sClient(MagicMock())

    def test_success_no_retry(self) -> None:
        client = self._make_client()
        fn = MagicMock(return_value="ok")
        assert client._retry(fn) == "ok"
        assert fn.call_count == 1

    @patch("kuberoku.k8s.client.time.sleep")
    def test_retries_on_429(self, mock_sleep: MagicMock) -> None:
        client = self._make_client()
        exc = ApiException(status=429, reason="Too Many Requests")
        fn = MagicMock(side_effect=[exc, exc, "ok"])
        assert client._retry(fn) == "ok"
        assert fn.call_count == 3
        assert mock_sleep.call_count == 2

    @patch("kuberoku.k8s.client.time.sleep")
    def test_retries_on_503(self, mock_sleep: MagicMock) -> None:
        client = self._make_client()
        exc = ApiException(status=503, reason="Service Unavailable")
        fn = MagicMock(side_effect=[exc, "ok"])
        assert client._retry(fn) == "ok"
        assert fn.call_count == 2

    def test_no_retry_on_404(self) -> None:
        client = self._make_client()
        exc = ApiException(status=404, reason="Not Found")
        fn = MagicMock(side_effect=exc)
        with pytest.raises(ApiException):
            client._retry(fn)
        assert fn.call_count == 1

    def test_no_retry_on_400(self) -> None:
        client = self._make_client()
        exc = ApiException(status=400, reason="Bad Request")
        fn = MagicMock(side_effect=exc)
        with pytest.raises(ApiException):
            client._retry(fn)
        assert fn.call_count == 1

    @patch("kuberoku.k8s.client.time.sleep")
    def test_exponential_backoff(self, mock_sleep: MagicMock) -> None:
        client = self._make_client()
        exc = ApiException(status=429, reason="Too Many Requests")
        fn = MagicMock(side_effect=[exc, exc, exc, "ok"])
        client._retry(fn)
        sleeps = [call.args[0] for call in mock_sleep.call_args_list]
        assert sleeps[0] == 0.5
        assert sleeps[1] == 1.0
        assert sleeps[2] == 2.0

    @patch("kuberoku.k8s.client.time.sleep")
    def test_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
        client = self._make_client()
        exc = ApiException(status=429, reason="Too Many Requests")
        fn = MagicMock(side_effect=exc)
        with pytest.raises(ApiException):
            client._retry(fn)
        assert fn.call_count == 5  # _MAX_RETRIES


# ── Namespace methods ────────────────────────────────────────────


class TestNamespaceMethods:
    def _make_client(self) -> K8sClient:
        with patch("kuberoku.k8s.client.kubernetes"):
            client = K8sClient(MagicMock())
        return client

    @patch("kuberoku.k8s.client.kubernetes")
    def test_namespace_exists_true(self, mock_k8s: MagicMock) -> None:
        client = K8sClient(MagicMock())
        client._core.read_namespace.return_value = MagicMock()
        assert client.namespace_exists("default") is True

    @patch("kuberoku.k8s.client.kubernetes")
    def test_namespace_exists_false_on_404(self, mock_k8s: MagicMock) -> None:
        client = K8sClient(MagicMock())
        client._core.read_namespace.side_effect = ApiException(status=404)
        assert client.namespace_exists("missing") is False

    @patch("kuberoku.k8s.client.kubernetes")
    def test_namespace_exists_raises_on_other_error(self, mock_k8s: MagicMock) -> None:
        client = K8sClient(MagicMock())
        client._core.read_namespace.side_effect = ApiException(status=403)
        with pytest.raises(ApiException):
            client.namespace_exists("forbidden")

    @patch("kuberoku.k8s.client.kubernetes")
    def test_create_namespace(self, mock_k8s: MagicMock) -> None:
        client = K8sClient(MagicMock())
        client._core.create_namespace.return_value = {"metadata": {"name": "test"}}
        result = client.create_namespace("test")
        assert result["metadata"]["name"] == "test"

    @patch("kuberoku.k8s.client.kubernetes")
    def test_get_namespace(self, mock_k8s: MagicMock) -> None:
        client = K8sClient(MagicMock())
        client._core.read_namespace.return_value = {"metadata": {"name": "test"}}
        result = client.get_namespace("test")
        assert result["metadata"]["name"] == "test"


# ── CRUD methods wire to correct APIs ────────────────────────────


class TestCrudWiring:
    """Each CRUD method calls the correct kubernetes API — not a redundancy test,
    this verifies that arguments are passed in the right order (kubernetes API
    takes (name, namespace) not (namespace, name) for reads)."""

    @patch("kuberoku.k8s.client.kubernetes")
    def _client(self, mock_k8s: MagicMock) -> K8sClient:
        return K8sClient(MagicMock())

    def _make_client(self) -> K8sClient:
        return self._client()

    def test_create_configmap(self) -> None:
        client = self._make_client()
        body = {"metadata": {"name": "test"}}
        client._core.create_namespaced_config_map.return_value = body
        client.create_configmap("ns", body)
        client._core.create_namespaced_config_map.assert_called()

    def test_get_configmap(self) -> None:
        client = self._make_client()
        client._core.read_namespaced_config_map.return_value = {"data": {}}
        client.get_configmap("ns", "name")
        client._core.read_namespaced_config_map.assert_called_with("name", "ns")

    def test_update_configmap(self) -> None:
        client = self._make_client()
        body = {"data": {"key": "val"}}
        client._core.replace_namespaced_config_map.return_value = body
        client.update_configmap("ns", "name", body)
        client._core.replace_namespaced_config_map.assert_called_with("name", "ns", body)

    def test_delete_configmap(self) -> None:
        client = self._make_client()
        client.delete_configmap("ns", "name")
        client._core.delete_namespaced_config_map.assert_called_with("name", "ns")

    def test_list_configmaps_no_labels(self) -> None:
        client = self._make_client()
        mock_result = MagicMock()
        mock_result.items = []
        client._core.list_namespaced_config_map.return_value = mock_result
        client.list_configmaps("ns")
        client._core.list_namespaced_config_map.assert_called_with("ns")

    def test_list_configmaps_with_labels(self) -> None:
        client = self._make_client()
        mock_result = MagicMock()
        mock_result.items = []
        client._core.list_namespaced_config_map.return_value = mock_result
        client.list_configmaps("ns", labels={"app": "myapi"})
        call_kwargs = client._core.list_namespaced_config_map.call_args[1]
        assert "label_selector" in call_kwargs

    def test_create_deployment(self) -> None:
        client = self._make_client()
        body: dict[str, Any] = {"kind": "Deployment"}
        client._apps.create_namespaced_deployment.return_value = body
        client.create_deployment("ns", body)
        client._apps.create_namespaced_deployment.assert_called()

    def test_delete_deployment(self) -> None:
        client = self._make_client()
        client.delete_deployment("ns", "deploy-name")
        client._apps.delete_namespaced_deployment.assert_called_with("deploy-name", "ns")

    def test_create_service(self) -> None:
        client = self._make_client()
        body: dict[str, Any] = {"kind": "Service"}
        client._core.create_namespaced_service.return_value = body
        client.create_service("ns", body)
        client._core.create_namespaced_service.assert_called()

    def test_create_secret(self) -> None:
        client = self._make_client()
        body: dict[str, Any] = {"kind": "Secret"}
        client._core.create_namespaced_secret.return_value = body
        client.create_secret("ns", body)
        client._core.create_namespaced_secret.assert_called()

    def test_create_ingress(self) -> None:
        client = self._make_client()
        body: dict[str, Any] = {"kind": "Ingress"}
        client._networking.create_namespaced_ingress.return_value = body
        client.create_ingress("ns", body)
        client._networking.create_namespaced_ingress.assert_called()

    def test_create_statefulset(self) -> None:
        client = self._make_client()
        body: dict[str, Any] = {"kind": "StatefulSet"}
        client._apps.create_namespaced_stateful_set.return_value = body
        client.create_statefulset("ns", body)
        client._apps.create_namespaced_stateful_set.assert_called()

    def test_create_job(self) -> None:
        client = self._make_client()
        body: dict[str, Any] = {"kind": "Job"}
        client._batch.create_namespaced_job.return_value = body
        client.create_job("ns", body)
        client._batch.create_namespaced_job.assert_called()

    def test_create_hpa(self) -> None:
        client = self._make_client()
        body: dict[str, Any] = {"kind": "HPA"}
        client._autoscaling.create_namespaced_horizontal_pod_autoscaler.return_value = body
        client.create_hpa("ns", body)
        client._autoscaling.create_namespaced_horizontal_pod_autoscaler.assert_called()

    def test_delete_pod(self) -> None:
        client = self._make_client()
        client.delete_pod("ns", "pod-name")
        client._core.delete_namespaced_pod.assert_called_with("pod-name", "ns")

    def test_list_pods_with_labels(self) -> None:
        client = self._make_client()
        mock_result = MagicMock()
        mock_result.items = []
        client._core.list_namespaced_pod.return_value = mock_result
        client.list_pods("ns", labels={"app": "myapi"})
        call_kwargs = client._core.list_namespaced_pod.call_args[1]
        assert "label_selector" in call_kwargs

    def test_get_deployment(self) -> None:
        client = self._make_client()
        client._apps.read_namespaced_deployment.return_value = {"kind": "Deployment"}
        client.get_deployment("ns", "name")
        client._apps.read_namespaced_deployment.assert_called_with("name", "ns")

    def test_update_deployment(self) -> None:
        client = self._make_client()
        body: dict[str, Any] = {"kind": "Deployment"}
        client._apps.replace_namespaced_deployment.return_value = body
        client.update_deployment("ns", "name", body)
        client._apps.replace_namespaced_deployment.assert_called_with("name", "ns", body)

    def test_list_deployments_with_labels(self) -> None:
        client = self._make_client()
        mock_result = MagicMock()
        mock_result.items = []
        client._apps.list_namespaced_deployment.return_value = mock_result
        client.list_deployments("ns", labels={"app": "x"})
        call_kwargs = client._apps.list_namespaced_deployment.call_args[1]
        assert "label_selector" in call_kwargs

    def test_get_secret(self) -> None:
        client = self._make_client()
        client._core.read_namespaced_secret.return_value = {"data": {}}
        client.get_secret("ns", "name")
        client._core.read_namespaced_secret.assert_called_with("name", "ns")

    def test_update_secret(self) -> None:
        client = self._make_client()
        body: dict[str, Any] = {"data": {}}
        client._core.replace_namespaced_secret.return_value = body
        client.update_secret("ns", "name", body)
        client._core.replace_namespaced_secret.assert_called_with("name", "ns", body)

    def test_delete_secret(self) -> None:
        client = self._make_client()
        client.delete_secret("ns", "name")
        client._core.delete_namespaced_secret.assert_called_with("name", "ns")

    def test_get_service(self) -> None:
        client = self._make_client()
        client._core.read_namespaced_service.return_value = {"spec": {}}
        client.get_service("ns", "name")
        client._core.read_namespaced_service.assert_called_with("name", "ns")

    def test_update_service(self) -> None:
        client = self._make_client()
        body: dict[str, Any] = {"spec": {}}
        client._core.replace_namespaced_service.return_value = body
        client.update_service("ns", "name", body)
        client._core.replace_namespaced_service.assert_called_with("name", "ns", body)

    def test_delete_service(self) -> None:
        client = self._make_client()
        client.delete_service("ns", "name")
        client._core.delete_namespaced_service.assert_called_with("name", "ns")

    def test_list_services(self) -> None:
        client = self._make_client()
        mock_result = MagicMock()
        mock_result.items = []
        client._core.list_namespaced_service.return_value = mock_result
        client.list_services("ns")
        client._core.list_namespaced_service.assert_called_with("ns")

    def test_get_ingress(self) -> None:
        client = self._make_client()
        client._networking.read_namespaced_ingress.return_value = {"spec": {}}
        client.get_ingress("ns", "name")
        client._networking.read_namespaced_ingress.assert_called_with("name", "ns")

    def test_update_ingress(self) -> None:
        client = self._make_client()
        body: dict[str, Any] = {"spec": {}}
        client._networking.replace_namespaced_ingress.return_value = body
        client.update_ingress("ns", "name", body)
        client._networking.replace_namespaced_ingress.assert_called_with("name", "ns", body)

    def test_delete_ingress(self) -> None:
        client = self._make_client()
        client.delete_ingress("ns", "name")
        client._networking.delete_namespaced_ingress.assert_called_with("name", "ns")

    def test_get_statefulset(self) -> None:
        client = self._make_client()
        client._apps.read_namespaced_stateful_set.return_value = {"spec": {}}
        client.get_statefulset("ns", "name")
        client._apps.read_namespaced_stateful_set.assert_called_with("name", "ns")

    def test_update_statefulset(self) -> None:
        client = self._make_client()
        body: dict[str, Any] = {"spec": {}}
        client._apps.replace_namespaced_stateful_set.return_value = body
        client.update_statefulset("ns", "name", body)
        client._apps.replace_namespaced_stateful_set.assert_called()

    def test_delete_statefulset(self) -> None:
        client = self._make_client()
        client.delete_statefulset("ns", "name")
        client._apps.delete_namespaced_stateful_set.assert_called_with("name", "ns")

    def test_list_statefulsets(self) -> None:
        client = self._make_client()
        mock_result = MagicMock()
        mock_result.items = []
        client._apps.list_namespaced_stateful_set.return_value = mock_result
        client.list_statefulsets("ns", labels={"type": "addon"})
        call_kwargs = client._apps.list_namespaced_stateful_set.call_args[1]
        assert "label_selector" in call_kwargs

    def test_create_pvc(self) -> None:
        client = self._make_client()
        body: dict[str, Any] = {"kind": "PVC"}
        client._core.create_namespaced_persistent_volume_claim.return_value = body
        client.create_pvc("ns", body)
        client._core.create_namespaced_persistent_volume_claim.assert_called()

    def test_get_pvc(self) -> None:
        client = self._make_client()
        client._core.read_namespaced_persistent_volume_claim.return_value = {"spec": {}}
        client.get_pvc("ns", "name")
        client._core.read_namespaced_persistent_volume_claim.assert_called()

    def test_delete_pvc(self) -> None:
        client = self._make_client()
        client.delete_pvc("ns", "name")
        client._core.delete_namespaced_persistent_volume_claim.assert_called()

    def test_get_hpa(self) -> None:
        client = self._make_client()
        client._autoscaling.read_namespaced_horizontal_pod_autoscaler.return_value = {}
        client.get_hpa("ns", "name")
        client._autoscaling.read_namespaced_horizontal_pod_autoscaler.assert_called()

    def test_update_hpa(self) -> None:
        client = self._make_client()
        body: dict[str, Any] = {"spec": {}}
        client._autoscaling.replace_namespaced_horizontal_pod_autoscaler.return_value = body
        client.update_hpa("ns", "name", body)
        client._autoscaling.replace_namespaced_horizontal_pod_autoscaler.assert_called()

    def test_delete_hpa(self) -> None:
        client = self._make_client()
        client.delete_hpa("ns", "name")
        client._autoscaling.delete_namespaced_horizontal_pod_autoscaler.assert_called()

    def test_delete_job(self) -> None:
        client = self._make_client()
        client.delete_job("ns", "job-name")
        client._batch.delete_namespaced_job.assert_called()


# ── Auth ─────────────────────────────────────────────────────────


class TestCanI:
    @patch("kuberoku.k8s.client.kubernetes")
    def test_can_i_allowed(self, mock_k8s: MagicMock) -> None:
        client = K8sClient(MagicMock())
        mock_result = MagicMock()
        mock_result.status.allowed = True
        client._auth.create_self_subject_access_review.return_value = mock_result
        assert client.can_i("create", "deployments", "default") is True

    @patch("kuberoku.k8s.client.kubernetes")
    def test_can_i_denied(self, mock_k8s: MagicMock) -> None:
        client = K8sClient(MagicMock())
        mock_result = MagicMock()
        mock_result.status.allowed = False
        client._auth.create_self_subject_access_review.return_value = mock_result
        assert client.can_i("delete", "secrets") is False
