"""FakeK8sClient — in-memory implementation of K8sClientProtocol.

Models: CRUD, resourceVersion, 404/409 errors, label selectors,
namespace scoping. Does NOT model: pods, rolling updates, RBAC,
watches, delete cascade, field validation.

Operations are instant (no I/O). Used for unit tests.
"""

from __future__ import annotations

import copy
from collections.abc import Iterator
from typing import Any


class FakeNotFoundError(Exception):
    """Raised when a resource is not found in the fake store."""

    def __init__(self, kind: str, namespace: str, name: str) -> None:
        super().__init__(f"{kind} '{name}' not found in namespace '{namespace}'")
        self.kind = kind
        self.namespace = namespace
        self.name = name


class FakeConflictError(Exception):
    """Raised on 409 Conflict (create existing or stale resourceVersion)."""

    def __init__(self, message: str) -> None:
        super().__init__(message)
        self.status = 409


class _Store:
    """In-memory resource store with namespace scoping and resourceVersion."""

    def __init__(self) -> None:
        # {kind: {namespace: {name: resource_dict}}}
        self._data: dict[str, dict[str, dict[str, dict[str, Any]]]] = {}
        self._version_counter = 0
        # {(kind, namespace, name): remaining_conflict_count}
        self._conflict_counters: dict[tuple[str, str, str], int] = {}

    def _next_version(self) -> str:
        self._version_counter += 1
        return str(self._version_counter)

    def create(self, kind: str, namespace: str, body: dict[str, Any]) -> dict[str, Any]:
        name = body.get("metadata", {}).get("name", "")
        ns_store = self._data.setdefault(kind, {}).setdefault(namespace, {})
        if name in ns_store:
            raise FakeConflictError(f"{kind} '{name}' already exists in namespace '{namespace}'")
        resource = copy.deepcopy(body)
        resource.setdefault("metadata", {})["resourceVersion"] = self._next_version()
        resource["metadata"]["namespace"] = namespace
        ns_store[name] = resource
        return copy.deepcopy(resource)

    def get(self, kind: str, namespace: str, name: str) -> dict[str, Any]:
        try:
            return copy.deepcopy(self._data[kind][namespace][name])
        except KeyError:
            raise FakeNotFoundError(kind, namespace, name) from None

    def inject_conflict(self, kind: str, namespace: str, name: str, count: int = 1) -> None:
        """Force the next `count` update calls for this resource to raise 409."""
        self._conflict_counters[(kind, namespace, name)] = count

    def update(self, kind: str, namespace: str, name: str, body: dict[str, Any]) -> dict[str, Any]:
        try:
            existing = self._data[kind][namespace][name]
        except KeyError:
            raise FakeNotFoundError(kind, namespace, name) from None

        # Injected conflict simulation (for testing retry logic)
        key = (kind, namespace, name)
        if self._conflict_counters.get(key, 0) > 0:
            self._conflict_counters[key] -= 1
            # Bump the stored resourceVersion so the retry re-reads fresh data
            existing["metadata"]["resourceVersion"] = self._next_version()
            raise FakeConflictError(f"Conflict: injected 409 for {kind} '{name}'")

        # Optimistic concurrency: check resourceVersion
        incoming_rv = body.get("metadata", {}).get("resourceVersion")
        existing_rv = existing.get("metadata", {}).get("resourceVersion")
        if incoming_rv is not None and incoming_rv != existing_rv:
            raise FakeConflictError(f"Conflict: resourceVersion {incoming_rv} != {existing_rv}")

        resource = copy.deepcopy(body)
        resource.setdefault("metadata", {})["resourceVersion"] = self._next_version()
        resource["metadata"]["namespace"] = namespace
        resource["metadata"]["name"] = name
        self._data[kind][namespace][name] = resource
        return copy.deepcopy(resource)

    def delete(self, kind: str, namespace: str, name: str) -> None:
        try:
            del self._data[kind][namespace][name]
        except KeyError:
            raise FakeNotFoundError(kind, namespace, name) from None

    def list(
        self,
        kind: str,
        namespace: str,
        labels: dict[str, str] | None = None,
    ) -> list[dict[str, Any]]:
        ns_store = self._data.get(kind, {}).get(namespace, {})
        results: list[dict[str, Any]] = []
        for resource in ns_store.values():
            if labels:
                res_labels = resource.get("metadata", {}).get("labels", {})
                if not all(res_labels.get(k) == v for k, v in labels.items()):
                    continue
            results.append(copy.deepcopy(resource))
        return results


class FakeK8sClient:
    """In-memory K8s client implementing K8sClientProtocol."""

    def __init__(self) -> None:
        self._store = _Store()
        self._namespaces: dict[str, dict[str, Any]] = {}
        # {pod_name: [line1, line2, ...]} — injected log lines for testing
        self._pod_logs: dict[str, list[str]] = {}
        # {pod_name: output_string} — injected exec outputs for testing
        self._exec_outputs: dict[str, str] = {}
        # Call recordings for assertions
        self.exec_calls: list[dict[str, Any]] = []
        self.log_calls: list[dict[str, Any]] = []
        # IngressClass resources (cluster-scoped, not namespace-scoped)
        self._ingress_classes: list[dict[str, Any]] = []
        # Injected nodes (overrides default when set)
        self._custom_nodes: list[dict[str, Any]] | None = None

    def inject_conflict(self, kind: str, namespace: str, name: str, count: int = 1) -> None:
        """Force the next `count` updates for a resource to raise 409 Conflict.

        Use this to test optimistic concurrency retry logic.
        """
        self._store.inject_conflict(kind, namespace, name, count)

    def inject_pod_logs(self, pod_name: str, lines: list[str]) -> None:
        """Inject log lines for a pod. Used by tests to simulate log output."""
        self._pod_logs[pod_name] = lines

    def inject_exec_output(self, pod_name: str, output: str) -> None:
        """Inject exec output for a pod. Used by tests to simulate exec results."""
        self._exec_outputs[pod_name] = output

    # ── Namespaces ──────────────────────────────────────────────

    def get_namespace(self, name: str) -> dict[str, Any]:
        if name not in self._namespaces:
            raise FakeNotFoundError("Namespace", "", name)
        return copy.deepcopy(self._namespaces[name])

    def namespace_exists(self, name: str) -> bool:
        return name in self._namespaces

    def create_namespace(self, name: str) -> dict[str, Any]:
        if name in self._namespaces:
            raise FakeConflictError(f"Namespace '{name}' already exists")
        ns = {
            "apiVersion": "v1",
            "kind": "Namespace",
            "metadata": {"name": name},
        }
        self._namespaces[name] = ns
        return copy.deepcopy(ns)

    # ── Generic CRUD helpers ────────────────────────────────────

    def _create(self, kind: str, namespace: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._store.create(kind, namespace, body)

    def _get(self, kind: str, namespace: str, name: str) -> dict[str, Any]:
        return self._store.get(kind, namespace, name)

    def _update(
        self, kind: str, namespace: str, name: str, body: dict[str, Any]
    ) -> dict[str, Any]:
        return self._store.update(kind, namespace, name, body)

    def _delete(self, kind: str, namespace: str, name: str) -> None:
        self._store.delete(kind, namespace, name)

    def _list(
        self,
        kind: str,
        namespace: str,
        labels: dict[str, str] | None = None,
    ) -> list[dict[str, Any]]:
        return self._store.list(kind, namespace, labels)

    # ── Deployments ─────────────────────────────────────────────

    def create_deployment(self, namespace: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._create("Deployment", namespace, body)

    def get_deployment(self, namespace: str, name: str) -> dict[str, Any]:
        return self._get("Deployment", namespace, name)

    def update_deployment(self, namespace: str, name: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._update("Deployment", namespace, name, body)

    def delete_deployment(self, namespace: str, name: str) -> None:
        self._delete("Deployment", namespace, name)

    def list_deployments(
        self, namespace: str, labels: dict[str, str] | None = None
    ) -> list[dict[str, Any]]:
        return self._list("Deployment", namespace, labels)

    # ── Pods (limited — no scheduling) ──────────────────────────

    def create_pod(self, namespace: str, body: dict[str, Any]) -> dict[str, Any]:
        """Create a pod (for test setup — real K8s creates pods from Deployments)."""
        return self._create("Pod", namespace, body)

    def list_pods(
        self, namespace: str, labels: dict[str, str] | None = None
    ) -> list[dict[str, Any]]:
        return self._list("Pod", namespace, labels)

    def delete_pod(self, namespace: str, name: str) -> None:
        self._delete("Pod", namespace, name)

    def exec_in_pod(
        self,
        namespace: str,
        name: str,
        command: list[str],
        tty: bool = False,
        container: str | None = None,
        stdin_data: str | None = None,
    ) -> str:
        # Record call for assertions
        self.exec_calls.append(
            {
                "namespace": namespace,
                "name": name,
                "command": command,
                "tty": tty,
                "container": container,
                "stdin_data": stdin_data,
            }
        )
        # Fake: if exec output is injected, return it directly (pod may not exist
        # in fake store — real K8s manages pods via STS/Deployment controllers).
        # Otherwise verify the pod exists.
        if name in self._exec_outputs:
            return self._exec_outputs[name]
        self._get("Pod", namespace, name)
        return ""

    def stream_pod_logs(
        self,
        namespace: str,
        name: str,
        follow: bool = False,
        tail_lines: int | None = None,
        since_seconds: int | None = None,
        previous: bool = False,
    ) -> Iterator[str]:
        # Record call for assertions
        self.log_calls.append(
            {
                "namespace": namespace,
                "name": name,
                "follow": follow,
                "tail_lines": tail_lines,
                "since_seconds": since_seconds,
                "previous": previous,
            }
        )
        # Fake: return injected log lines, respecting tail_lines
        lines = self._pod_logs.get(name, [])
        if tail_lines is not None and tail_lines < len(lines):
            lines = lines[-tail_lines:]
        return iter(lines)

    # ── ConfigMaps ──────────────────────────────────────────────

    def create_configmap(self, namespace: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._create("ConfigMap", namespace, body)

    def get_configmap(self, namespace: str, name: str) -> dict[str, Any]:
        return self._get("ConfigMap", namespace, name)

    def update_configmap(self, namespace: str, name: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._update("ConfigMap", namespace, name, body)

    def delete_configmap(self, namespace: str, name: str) -> None:
        self._delete("ConfigMap", namespace, name)

    def list_configmaps(
        self, namespace: str, labels: dict[str, str] | None = None
    ) -> list[dict[str, Any]]:
        return self._list("ConfigMap", namespace, labels)

    # ── Secrets ─────────────────────────────────────────────────

    @staticmethod
    def _merge_string_data(body: dict[str, Any]) -> dict[str, Any]:
        """Simulate K8s stringData→data merge.

        Real K8s base64-encodes stringData values and merges into data.
        We skip the encoding (plain-text store) but match the merge behavior
        so that SDK code reads from 'data' consistently.
        """
        if "stringData" in body:
            body = copy.deepcopy(body)
            sd = body.pop("stringData")
            body.setdefault("data", {}).update(sd)
        return body

    def create_secret(self, namespace: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._create("Secret", namespace, self._merge_string_data(body))

    def get_secret(self, namespace: str, name: str) -> dict[str, Any]:
        return self._get("Secret", namespace, name)

    def update_secret(self, namespace: str, name: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._update("Secret", namespace, name, self._merge_string_data(body))

    def delete_secret(self, namespace: str, name: str) -> None:
        self._delete("Secret", namespace, name)

    def list_secrets(
        self, namespace: str, labels: dict[str, str] | None = None
    ) -> list[dict[str, Any]]:
        return self._list("Secret", namespace, labels)

    # ── Services ────────────────────────────────────────────────

    def create_service(self, namespace: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._create("Service", namespace, body)

    def get_service(self, namespace: str, name: str) -> dict[str, Any]:
        return self._get("Service", namespace, name)

    def update_service(self, namespace: str, name: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._update("Service", namespace, name, body)

    def delete_service(self, namespace: str, name: str) -> None:
        self._delete("Service", namespace, name)

    def list_services(
        self, namespace: str, labels: dict[str, str] | None = None
    ) -> list[dict[str, Any]]:
        return self._list("Service", namespace, labels)

    # ── Ingress ─────────────────────────────────────────────────

    def create_ingress(self, namespace: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._create("Ingress", namespace, body)

    def get_ingress(self, namespace: str, name: str) -> dict[str, Any]:
        return self._get("Ingress", namespace, name)

    def update_ingress(self, namespace: str, name: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._update("Ingress", namespace, name, body)

    def delete_ingress(self, namespace: str, name: str) -> None:
        self._delete("Ingress", namespace, name)

    def list_ingresses(
        self, namespace: str, labels: dict[str, str] | None = None
    ) -> list[dict[str, Any]]:
        return self._list("Ingress", namespace, labels)

    def list_ingress_classes(self) -> list[dict[str, Any]]:
        return self._ingress_classes

    def create_ingress_class(self, body: dict[str, Any]) -> None:
        """Inject an IngressClass for testing. Not part of the protocol."""
        self._ingress_classes.append(copy.deepcopy(body))

    # ── StatefulSets ────────────────────────────────────────────

    def create_statefulset(self, namespace: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._create("StatefulSet", namespace, body)

    def get_statefulset(self, namespace: str, name: str) -> dict[str, Any]:
        return self._get("StatefulSet", namespace, name)

    def update_statefulset(
        self, namespace: str, name: str, body: dict[str, Any]
    ) -> dict[str, Any]:
        return self._update("StatefulSet", namespace, name, body)

    def delete_statefulset(self, namespace: str, name: str) -> None:
        self._delete("StatefulSet", namespace, name)

    def list_statefulsets(
        self, namespace: str, labels: dict[str, str] | None = None
    ) -> list[dict[str, Any]]:
        return self._list("StatefulSet", namespace, labels)

    # ── PVCs ────────────────────────────────────────────────────

    def create_pvc(self, namespace: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._create("PVC", namespace, body)

    def get_pvc(self, namespace: str, name: str) -> dict[str, Any]:
        return self._get("PVC", namespace, name)

    def delete_pvc(self, namespace: str, name: str) -> None:
        self._delete("PVC", namespace, name)

    def list_pvcs(
        self, namespace: str, labels: dict[str, str] | None = None
    ) -> list[dict[str, Any]]:
        return self._list("PVC", namespace, labels)

    # ── Jobs ────────────────────────────────────────────────────

    def create_job(self, namespace: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._create("Job", namespace, body)

    def wait_for_job(self, namespace: str, name: str, timeout: int = 300) -> dict[str, Any]:
        # Fake: jobs complete instantly
        job = self._get("Job", namespace, name)
        job.setdefault("status", {})["succeeded"] = 1
        return job

    def delete_job(self, namespace: str, name: str) -> None:
        self._delete("Job", namespace, name)

    # ── HPA ─────────────────────────────────────────────────────

    def create_hpa(self, namespace: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._create("HPA", namespace, body)

    def get_hpa(self, namespace: str, name: str) -> dict[str, Any]:
        return self._get("HPA", namespace, name)

    def update_hpa(self, namespace: str, name: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._update("HPA", namespace, name, body)

    def delete_hpa(self, namespace: str, name: str) -> None:
        self._delete("HPA", namespace, name)

    # ── NetworkPolicies ────────────────────────────────────────

    def create_network_policy(self, namespace: str, body: dict[str, Any]) -> dict[str, Any]:
        return self._create("NetworkPolicy", namespace, body)

    def get_network_policy(self, namespace: str, name: str) -> dict[str, Any]:
        return self._get("NetworkPolicy", namespace, name)

    def update_network_policy(
        self, namespace: str, name: str, body: dict[str, Any]
    ) -> dict[str, Any]:
        return self._update("NetworkPolicy", namespace, name, body)

    def delete_network_policy(self, namespace: str, name: str) -> None:
        self._delete("NetworkPolicy", namespace, name)

    def list_network_policies(
        self, namespace: str, labels: dict[str, str] | None = None
    ) -> list[dict[str, Any]]:
        return self._list("NetworkPolicy", namespace, labels)

    # ── DaemonSets ────────────────────────────────────────────────

    def create_daemonset(self, namespace: str, body: dict[str, Any]) -> dict[str, Any]:
        """Create a DaemonSet (for test setup)."""
        return self._create("DaemonSet", namespace, body)

    def list_daemonsets(
        self, namespace: str, labels: dict[str, str] | None = None
    ) -> list[dict[str, Any]]:
        return self._list("DaemonSet", namespace, labels)

    # ── Nodes ────────────────────────────────────────────────────

    def inject_nodes(self, nodes: list[dict[str, Any]]) -> None:
        """Inject custom node data. Used by tests for environment detection."""
        self._custom_nodes = copy.deepcopy(nodes)

    def list_nodes(self) -> list[dict[str, Any]]:
        if self._custom_nodes is not None:
            return copy.deepcopy(self._custom_nodes)
        return [
            {
                "apiVersion": "v1",
                "kind": "Node",
                "metadata": {"name": "fake-node-1"},
                "status": {
                    "addresses": [
                        {"type": "InternalIP", "address": "10.0.0.1"},
                    ],
                },
            }
        ]

    # ── Cluster info ───────────────────────────────────────────

    @property
    def cluster_endpoint(self) -> str:
        return ""

    def server_version(self) -> str:
        return "1.31"

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

    def inject_denied_permission(
        self,
        verb: str,
        resource: str,
        api_group: str = "",
        subresource: str = "",
    ) -> None:
        """Mark a (verb, resource, api_group, subresource) tuple as denied."""
        if not hasattr(self, "_denied_permissions"):
            self._denied_permissions: set[tuple[str, str, str, str]] = set()
        self._denied_permissions.add((verb, resource, api_group, subresource))

    def can_i(
        self,
        verb: str,
        resource: str,
        namespace: str = "",
        api_group: str = "",
        subresource: str = "",
    ) -> bool:
        denied: set[tuple[str, str, str, str]] = getattr(self, "_denied_permissions", set())
        return (verb, resource, api_group, subresource) not in denied

    # ── StorageClasses ────────────────────────────────────────

    def list_storage_classes(self) -> list[dict[str, Any]]:
        # Fake: return a default local-path StorageClass
        return [
            {
                "apiVersion": "storage.k8s.io/v1",
                "kind": "StorageClass",
                "metadata": {
                    "name": "local-path",
                    "annotations": {
                        "storageclass.kubernetes.io/is-default-class": "true",
                    },
                },
                "provisioner": "rancher.io/local-path",
            }
        ]
