"""Tests for idempotent cleanup — SDK tolerates partially-deleted resources.

Real-world scenarios these cover:
- User retries ``apps:destroy`` after a network interruption
- Admin ``kubectl delete``'d some resources manually, then runs SDK destroy
- Auto-scaler terminated pods between list and delete (TOCTOU race)
- Re-deploy when Service/NetworkPolicy already exist from a previous deploy
- Addon destroy after partial creation failure (e.g., STS created but PVC failed)

These verify that the "try delete → verify gone" pattern works correctly
across all SDK services.
"""

from __future__ import annotations

from unittest.mock import patch

import pytest

from kuberoku.addons import get_addon_def
from kuberoku.factory import KuberokuFactory
from kuberoku.k8s import resources
from tests.backends.fake import FakeK8sClient

# ── Addon destroy: sub-resources already gone ──────────────────────


class TestAddonDestroyIdempotent:
    """Addon destroy tolerates already-deleted sub-resources.

    Scenario: admin ``kubectl delete sts/svc/secret`` to debug, then runs
    ``addons:destroy`` through the SDK. The destroy must complete cleanly.
    """

    @pytest.fixture()
    def addon_factory(self) -> KuberokuFactory:
        k8s = FakeK8sClient()
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")
        factory.addons.create("myapp", "postgres")
        return factory

    def _resource_names(self, factory: KuberokuFactory) -> dict[str, str]:
        """Compute all K8s resource names for the postgres addon."""
        p = factory.prefix
        addon_def = get_addon_def("postgres")
        return {
            "sts": resources.safe_name(p, "addon", "myapp-postgres"),
            "svc": resources.safe_name(p, "addon", "myapp-postgres"),
            "netpol": resources.safe_name(p, "netpol-allow", "myapp-postgres"),
            "secret": resources.safe_name(p, "addon-secret", "myapp-postgres"),
            "pvc": resources.addon_pvc_name(p, "myapp", "postgres", addon_def.volume_name),
            "meta_cm": resources.safe_name(p, "addon-meta", "myapp-postgres"),
        }

    def test_destroy_when_sts_already_deleted(self, addon_factory: KuberokuFactory) -> None:
        k8s = addon_factory.k8s
        ns = addon_factory.namespace
        names = self._resource_names(addon_factory)
        k8s.delete_statefulset(ns, names["sts"])

        addon_factory.addons.destroy("myapp", "postgres", delete_data=True)
        assert addon_factory.addons.list("myapp") == []

    def test_destroy_when_service_already_deleted(self, addon_factory: KuberokuFactory) -> None:
        k8s = addon_factory.k8s
        ns = addon_factory.namespace
        names = self._resource_names(addon_factory)
        k8s.delete_service(ns, names["svc"])

        addon_factory.addons.destroy("myapp", "postgres", delete_data=True)
        assert addon_factory.addons.list("myapp") == []

    def test_destroy_when_netpol_already_deleted(self, addon_factory: KuberokuFactory) -> None:
        k8s = addon_factory.k8s
        ns = addon_factory.namespace
        names = self._resource_names(addon_factory)
        k8s.delete_network_policy(ns, names["netpol"])

        addon_factory.addons.destroy("myapp", "postgres", delete_data=True)
        assert addon_factory.addons.list("myapp") == []

    def test_destroy_when_secret_already_deleted(self, addon_factory: KuberokuFactory) -> None:
        k8s = addon_factory.k8s
        ns = addon_factory.namespace
        names = self._resource_names(addon_factory)
        k8s.delete_secret(ns, names["secret"])

        addon_factory.addons.destroy("myapp", "postgres", delete_data=True)
        assert addon_factory.addons.list("myapp") == []

    def test_destroy_when_pvc_never_existed(self, addon_factory: KuberokuFactory) -> None:
        """PVC was never created (FakeK8sClient doesn't auto-create from STS templates).

        On real K8s the PVC could have been manually deleted.
        destroy() with delete_data=True should tolerate the missing PVC.
        """
        # PVC is auto-created by K8s from StatefulSet volumeClaimTemplates.
        # FakeK8sClient doesn't simulate this, so the PVC never exists.
        # destroy(delete_data=True) should handle this gracefully.
        addon_factory.addons.destroy("myapp", "postgres", delete_data=True)
        assert addon_factory.addons.list("myapp") == []

    def test_destroy_when_all_sub_resources_gone(self, addon_factory: KuberokuFactory) -> None:
        """Total wipeout: only metadata CM remains. Destroy should recover."""
        k8s = addon_factory.k8s
        ns = addon_factory.namespace
        names = self._resource_names(addon_factory)

        # Delete everything except the metadata ConfigMap.
        # Each resource kind has its own store — names don't conflict across types.
        k8s.delete_statefulset(ns, names["sts"])
        k8s.delete_service(ns, names["svc"])
        k8s.delete_network_policy(ns, names["netpol"])
        k8s.delete_secret(ns, names["secret"])

        addon_factory.addons.destroy("myapp", "postgres", delete_data=True)
        assert addon_factory.addons.list("myapp") == []


# ── Services remove_port: Service/NetworkPolicy already gone ───────


class TestServicesRemovePortIdempotent:
    """remove_port tolerates missing Service/NetworkPolicy when removing last port.

    Scenario: Service was manually kubectl-deleted while debugging, user then
    removes the port declaration through the SDK.
    """

    def test_remove_port_with_service_recreated(self) -> None:
        """add_port after deploy, then remove_port — exercises update path."""
        k8s = FakeK8sClient()
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")
        factory.deploy.deploy("myapp", image="nginx:latest", wait=False)

        # Add a second port
        factory.services.add_port("myapp", "9090/TCP", "web")

        # Verify both ports exist on the deployment
        dep_name = resources.safe_name(factory.prefix, "myapp", "web")
        dep = k8s.get_deployment(factory.namespace, dep_name)
        container_ports = dep["spec"]["template"]["spec"]["containers"][0].get("ports", [])
        assert len(container_ports) == 2

        # Remove the second port
        factory.services.remove_port("myapp", "9090/TCP", "web")

        # Verify only original port remains
        dep = k8s.get_deployment(factory.namespace, dep_name)
        container_ports = dep["spec"]["template"]["spec"]["containers"][0].get("ports", [])
        assert len(container_ports) == 1


class TestServicesUpdateProcessAllowPolicyIdempotent:
    """_update_process_allow_policy does delete→create. Tolerates missing policy."""

    def test_add_port_when_netpol_missing(self) -> None:
        """add_port calls _update_process_allow_policy which deletes old policy first.

        If the old policy was already deleted, it should tolerate and create the new one.
        """
        k8s = FakeK8sClient()
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")
        factory.deploy.deploy("myapp", image="nginx:latest", wait=False)

        # Delete the process-allow netpol that deploy created
        np_name = resources.safe_name(factory.prefix, "netpol-allow", "myapp-web")
        k8s.delete_network_policy(factory.namespace, np_name)

        # add_port calls _update_process_allow_policy which tries to delete
        # the old netpol (gone) → tolerate, then creates new one
        factory.services.add_port("myapp", "9090/TCP", "web")

        # Verify the new policy was created
        np = k8s.get_network_policy(factory.namespace, np_name)
        assert np is not None


# ── Domains clear: Ingress already gone ────────────────────────────


class TestDomainsClearIdempotent:
    """domains.clear tolerates already-deleted Ingress.

    Scenario: user ran ``kubectl delete ingress`` manually, then runs
    ``domains:clear`` through the SDK.
    """

    def test_clear_when_ingress_gone(self) -> None:
        k8s = FakeK8sClient()
        k8s.create_ingress_class(
            {
                "apiVersion": "networking.k8s.io/v1",
                "kind": "IngressClass",
                "metadata": {"name": "nginx"},
                "spec": {"controller": "k8s.io/ingress-nginx"},
            }
        )
        k8s.create_deployment(
            "cert-manager",
            {
                "apiVersion": "apps/v1",
                "kind": "Deployment",
                "metadata": {"name": "cert-manager"},
                "spec": {
                    "replicas": 1,
                    "selector": {"matchLabels": {"app": "cert-manager"}},
                    "template": {
                        "metadata": {"labels": {"app": "cert-manager"}},
                        "spec": {"containers": [{"name": "cm", "image": "cert-manager:v1.14"}]},
                    },
                },
            },
        )
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")
        factory.deploy.deploy("myapp", image="nginx:latest", wait=False)
        factory.domains.add("myapp", "api.example.com")

        # Delete ingress manually
        ingress_name = resources.safe_name(factory.prefix, "ingress", "myapp")
        k8s.delete_ingress(factory.namespace, ingress_name)

        # clear should tolerate the missing ingress
        factory.domains.clear("myapp")

        # Verify no error and domain list is empty
        assert factory.domains.list("myapp") == []

    def test_clear_when_no_domains_ever_added(self) -> None:
        """Clear on app that never had domains — ingress never existed."""
        k8s = FakeK8sClient()
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")

        factory.domains.clear("myapp")  # Should not raise


# ── Gateway: backend LB Service already gone ───────────────────────


class TestGatewayIdempotent:
    """Gateway deallocate tolerates already-deleted LB Service.

    Scenario: LoadBalancer Service was manually deleted, then user runs
    ``services:expose off`` which deallocates the gateway port.
    """

    def test_deallocate_when_backend_svc_gone(self) -> None:
        k8s = FakeK8sClient()
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")
        factory.deploy.deploy("myapp", image="nginx:latest", wait=False)

        # Allocate a gateway port
        alloc = factory.gateway.allocate("myapp", f"{factory.prefix}-myapp-web", 80)
        assert alloc.external_port >= 10000

        # Delete the LB Service manually
        gw_svc_name = f"{factory.prefix}-gw-{alloc.external_port}"
        k8s.delete_service(factory.namespace, gw_svc_name)

        # Deallocate should tolerate the missing service
        factory.gateway.deallocate("myapp", f"{factory.prefix}-myapp-web")
        assert factory.gateway.list_allocations() == []

    def test_allocate_when_backend_svc_already_exists(self) -> None:
        """Re-allocate same port — backend Service already exists.

        Scenario: allocation succeeds on first attempt, user somehow re-allocates
        (idempotent). The _apply_backend create should tolerate existing service.
        """
        k8s = FakeK8sClient()
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")
        factory.deploy.deploy("myapp", image="nginx:latest", wait=False)

        alloc1 = factory.gateway.allocate("myapp", f"{factory.prefix}-myapp-web", 80)
        # Allocating same service again — should return same allocation
        alloc2 = factory.gateway.allocate("myapp", f"{factory.prefix}-myapp-web", 80)
        assert alloc1.external_port == alloc2.external_port


# ── Apps destroy: Ingress already gone ─────────────────────────────


class TestAppsDestroyIngressIdempotent:
    """apps.destroy catches domains.clear() failure when Ingress is already gone.

    Scenario: User ran ``domains:clear`` separately, then ``apps:destroy``.
    The destroy calls domains.clear() again, which tries to delete the
    already-gone Ingress. The apps.destroy wrapper tolerates this.
    """

    def test_destroy_after_domains_cleared(self) -> None:
        k8s = FakeK8sClient()
        k8s.create_ingress_class(
            {
                "apiVersion": "networking.k8s.io/v1",
                "kind": "IngressClass",
                "metadata": {"name": "nginx"},
                "spec": {"controller": "k8s.io/ingress-nginx"},
            }
        )
        k8s.create_deployment(
            "cert-manager",
            {
                "apiVersion": "apps/v1",
                "kind": "Deployment",
                "metadata": {"name": "cert-manager"},
                "spec": {
                    "replicas": 1,
                    "selector": {"matchLabels": {"app": "cert-manager"}},
                    "template": {
                        "metadata": {"labels": {"app": "cert-manager"}},
                        "spec": {"containers": [{"name": "cm", "image": "cert-manager:v1.14"}]},
                    },
                },
            },
        )
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")
        factory.deploy.deploy("myapp", image="nginx:latest", wait=False)
        factory.domains.add("myapp", "api.example.com")

        # Clear domains first — removes Ingress
        factory.domains.clear("myapp")

        # Now destroy the app — domains.clear() is called again internally
        factory.apps.destroy("myapp")
        assert factory.apps.list() == []


# ── PS restart: pods vanish during restart loop ────────────────────


class TestPsRestartIdempotent:
    """ps.restart tolerates pods that terminate between list and delete.

    Scenario: auto-scaler or another operator terminates a pod at the same
    moment the user runs ``ps:restart``. The pod appears in the list but is
    gone by the time delete is called.
    """

    def test_restart_process_type_tolerates_vanished_pod(self) -> None:
        """Pod vanishes between list_pods and delete_pod."""
        k8s = FakeK8sClient()
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")
        factory.deploy.deploy("myapp", image="nginx:latest", wait=False)

        # Manually create pods (FakeK8sClient doesn't model pod lifecycle)
        pod_labels = {
            f"{factory.domain}/managed-by": factory.prefix,
            f"{factory.domain}/app": "myapp",
            f"{factory.domain}/process-type": "web",
        }
        for i in range(3):
            k8s.create_pod(
                factory.namespace,
                {
                    "apiVersion": "v1",
                    "kind": "Pod",
                    "metadata": {
                        "name": f"myapp-web-pod-{i}",
                        "labels": dict(pod_labels),
                    },
                    "spec": {"containers": [{"name": "web", "image": "nginx"}]},
                },
            )

        # Make delete_pod flaky for pod-1: the pod terminates (gets removed from
        # store) between list and delete. We simulate this by having delete_pod
        # remove the pod AND raise an error on the first call.
        original_delete = k8s.delete_pod
        vanished_pods: set[str] = set()

        def flaky_delete_pod(ns: str, name: str) -> None:
            if name == "myapp-web-pod-1" and name not in vanished_pods:
                vanished_pods.add(name)
                original_delete(ns, name)  # Actually remove it
                raise RuntimeError("connection reset by peer")
            return original_delete(ns, name)

        with patch.object(k8s, "delete_pod", side_effect=flaky_delete_pod):
            count = factory.ps.restart("myapp", process_type="web")

        assert count == 3  # All 3 counted even though pod-1 had a transient error

    def test_restart_all_tolerates_vanished_pod(self) -> None:
        """Same TOCTOU race but for restart-all (no process_type filter)."""
        k8s = FakeK8sClient()
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")
        factory.deploy.deploy("myapp", image="nginx:latest", wait=False)

        pod_labels = {
            f"{factory.domain}/managed-by": factory.prefix,
            f"{factory.domain}/app": "myapp",
            f"{factory.domain}/process-type": "web",
        }
        for i in range(2):
            k8s.create_pod(
                factory.namespace,
                {
                    "apiVersion": "v1",
                    "kind": "Pod",
                    "metadata": {
                        "name": f"myapp-web-pod-{i}",
                        "labels": dict(pod_labels),
                    },
                    "spec": {"containers": [{"name": "web", "image": "nginx"}]},
                },
            )

        original_delete = k8s.delete_pod
        vanished: set[str] = set()

        def flaky_delete(ns: str, name: str) -> None:
            if name == "myapp-web-pod-0" and name not in vanished:
                vanished.add(name)
                original_delete(ns, name)
                raise RuntimeError("connection reset by peer")
            return original_delete(ns, name)

        with patch.object(k8s, "delete_pod", side_effect=flaky_delete):
            count = factory.ps.restart("myapp")

        assert count == 2


# ── Deploy re-deploy: Service/NetworkPolicy already exist ──────────


class TestDeployIdempotentResources:
    """Re-deploying when Service or NetworkPolicy already exists.

    Scenario: user deploys an app, then deploys again with a new image.
    The Service and NetworkPolicy from the first deploy are still there.
    The second deploy's create calls should tolerate "already exists".

    Note: FakeK8sClient.create() doesn't reject duplicates (unlike real K8s),
    so we inject a create error to simulate the real K8s 409 Conflict on create.
    """

    def test_redeploy_tolerates_existing_service(self) -> None:
        k8s = FakeK8sClient()
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")
        factory.deploy.deploy("myapp", image="nginx:v1", wait=False)

        # Verify service exists
        svc_name = resources.safe_name(factory.prefix, "myapp", "web")
        svc = k8s.get_service(factory.namespace, svc_name)
        assert svc is not None

        # Inject create_service to fail on next call (simulates real K8s 409)
        original_create = k8s.create_service
        create_errors: dict[str, int] = {svc_name: 1}

        def failing_create(ns: str, body: dict) -> dict:  # type: ignore[type-arg]
            name = body.get("metadata", {}).get("name", "")
            if create_errors.get(name, 0) > 0:
                create_errors[name] -= 1
                raise RuntimeError("AlreadyExists: service already exists")
            return original_create(ns, body)

        with patch.object(k8s, "create_service", side_effect=failing_create):
            release = factory.deploy.deploy("myapp", image="nginx:v2", wait=False)

        assert release.version == 2

    def test_redeploy_tolerates_existing_netpol(self) -> None:
        k8s = FakeK8sClient()
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")
        factory.deploy.deploy("myapp", image="nginx:v1", wait=False)

        np_name = resources.safe_name(factory.prefix, "netpol-allow", "myapp-web")
        np = k8s.get_network_policy(factory.namespace, np_name)
        assert np is not None

        original_create = k8s.create_network_policy
        create_errors: dict[str, int] = {np_name: 1}

        def failing_create(ns: str, body: dict) -> dict:  # type: ignore[type-arg]
            name = body.get("metadata", {}).get("name", "")
            if create_errors.get(name, 0) > 0:
                create_errors[name] -= 1
                raise RuntimeError("AlreadyExists: network policy already exists")
            return original_create(ns, body)

        with patch.object(k8s, "create_network_policy", side_effect=failing_create):
            release = factory.deploy.deploy("myapp", image="nginx:v2", wait=False)

        assert release.version == 2


# ── Releases: cleanup on 409 conflict ──────────────────────────────


class TestReleasesConflictCleanup:
    """Release creation retries on 409 — cleans up orphaned release CM.

    Scenario: two concurrent deploys race. The manifest update gets a 409
    Conflict. The release CM that was optimistically created must be cleaned
    up before retrying.
    """

    def test_release_creation_retries_on_conflict(self) -> None:
        k8s = FakeK8sClient()
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")
        factory.deploy.deploy("myapp", image="nginx:v1", wait=False)

        # Inject a conflict on the app manifest update (used during release creation)
        manifest_name = resources.safe_name(factory.prefix, "app", "myapp")
        k8s.inject_conflict("ConfigMap", factory.namespace, manifest_name, count=1)

        # Second deploy triggers a new release — gets 409, retries
        release = factory.deploy.deploy("myapp", image="nginx:v2", wait=False)
        assert release.version == 2

    def test_release_cleanup_when_cm_already_gone(self) -> None:
        """Release CM was already deleted when we try to clean up after 409.

        Simulates: two processes race — one creates release CM, gets 409 on
        the manifest update, but by the time it cleans up the CM, another
        process already deleted it.
        """
        k8s = FakeK8sClient()
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")
        factory.deploy.deploy("myapp", image="nginx:v1", wait=False)

        # Inject 409 on manifest update to trigger cleanup path
        manifest_name = resources.safe_name(factory.prefix, "app", "myapp")
        k8s.inject_conflict("ConfigMap", factory.namespace, manifest_name, count=1)

        original_delete_cm = k8s.delete_configmap
        intercepted = {"done": False}

        def intercepting_delete(ns: str, name: str) -> None:
            # First time SDK tries to clean up a release CM, pre-delete it
            # so the SDK's own delete hits "not found" → verified gone path
            if "release" in name and not intercepted["done"]:
                intercepted["done"] = True
                original_delete_cm(ns, name)  # Actually deletes it
                # Fall through — SDK's call to delete_configmap will now
                # call original_delete_cm again on an already-gone CM
            return original_delete_cm(ns, name)

        with patch.object(k8s, "delete_configmap", side_effect=intercepting_delete):
            release = factory.deploy.deploy("myapp", image="nginx:v2", wait=False)

        assert release.version == 2


# ── Services expose_off: external policy already gone ──────────────


class TestServicesExposeOffIdempotent:
    """expose_off tolerates already-gone external NetworkPolicy."""

    def test_expose_off_when_external_policy_gone(self) -> None:
        k8s = FakeK8sClient()
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")
        factory.deploy.deploy("myapp", image="nginx:latest", wait=False)
        factory.services.expose_on("myapp", "web", method="loadbalancer")

        # Verify the external policy was created by expose_on
        np_name = resources.safe_name(factory.prefix, "netpol-external", "myapp-web")
        np = k8s.get_network_policy(factory.namespace, np_name)
        assert np is not None, "expose_on should have created the external policy"

        # Delete the external policy manually (simulating kubectl delete)
        k8s.delete_network_policy(factory.namespace, np_name)

        # expose_off should tolerate the missing policy
        factory.services.expose_off("myapp", "web")


# ── PS scale before deploy: Deployment doesn't exist yet ───────────


class TestPsScaleDeploymentMissing:
    """Scaling when Deployment was manually deleted.

    Scenario: user ``kubectl delete deployment`` and then scales through the
    SDK. The Deployment update should be tolerated (ps.py lines 158-159).
    """

    def test_scale_tolerates_missing_deployment(self) -> None:
        k8s = FakeK8sClient()
        factory = KuberokuFactory(k8s_client=k8s, namespace="test-ns")
        factory.apps.create("myapp")
        factory.deploy.deploy("myapp", image="nginx:latest", wait=False)

        # Delete the deployment manually (simulating kubectl delete)
        dep_name = resources.safe_name(factory.prefix, "myapp", "web")
        k8s.delete_deployment(factory.namespace, dep_name)

        # Scale should tolerate the missing deployment — updates formation only
        formation = factory.ps.scale("myapp", {"web": 3})
        assert formation["web"].replicas == 3
