"""AddonsService — business logic for addon lifecycle operations.

Handles: create, destroy, list, info, scale, migrate, credentials, rotate,
exec_command, backup, restore, expose_on, expose_off, connect.
"""

from __future__ import annotations

import builtins
import secrets
import time
from collections.abc import Callable
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

from kuberoku.addons import get_addon_def
from kuberoku.addons._types import AddonDef
from kuberoku.branding import TOOL_NAME
from kuberoku.exceptions import (
    AddonAlreadyExistsError,
    AddonNotFoundError,
    AppNotFoundError,
    KuberokuError,
    NoPortsError,
    NotExposedError,
)
from kuberoku.k8s import labels, resources
from kuberoku.models import Addon, ConnectionTarget, ExposedEndpoint, PortMapping
from kuberoku.sdk.ps import parse_resources

if TYPE_CHECKING:
    from kuberoku.factory import KuberokuFactory
    from kuberoku.k8s.protocols import K8sClientProtocol


class AddonsService:
    """Business logic for addon operations."""

    def __init__(self, factory: KuberokuFactory) -> None:
        self._factory = factory

    @property
    def _k8s(self) -> K8sClientProtocol:
        return self._factory.k8s

    @property
    def _ns(self) -> str:
        return self._factory.namespace

    @property
    def _prefix(self) -> str:
        return self._factory.prefix

    @property
    def _domain(self) -> str:
        return self._factory.domain

    def create(
        self,
        app: str,
        addon_type: str,
        instance_name: str | None = None,
        cpu: str | None = None,
        memory: str | None = None,
        storage: str | None = None,
        ephemeral: bool = False,
        external_url: str | None = None,
        version: str | None = None,
        on_step: Callable[[str], None] | None = None,
    ) -> Addon:
        """Create a new addon instance attached to an app."""
        self._check_app_exists(app)
        addon_def = get_addon_def(addon_type)

        # Resolve version → image
        resolved_image, resolved_version = self._resolve_version(addon_def, version)

        # Resolve instance name
        instance = instance_name or addon_type

        # Check for duplicate
        if self._instance_exists(app, instance):
            raise AddonAlreadyExistsError(instance, app)

        # Validate ephemeral
        if ephemeral and not addon_def.storage_optional:
            raise KuberokuError(
                f"Addon type '{addon_type}' requires persistent storage. Cannot use --ephemeral."
            )

        # Resolve resources
        resolved_cpu = cpu or addon_def.default_cpu
        resolved_memory = memory or addon_def.default_memory
        resolved_storage = None if ephemeral else (storage or addon_def.default_storage)

        # Resolve env_key: first instance = default, named = {NAME_UPPER}_URL
        env_key = self._resolve_env_key(app, addon_def.env_key, instance, addon_type)

        is_external = external_url is not None
        now_str = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")

        # Build metadata
        meta_data: dict[str, str] = {
            "type": addon_type,
            "status": "creating",
            "image": resolved_image,
            "cpu": resolved_cpu,
            "memory": resolved_memory,
            "env_key": env_key,
            "external": str(is_external),
            "ephemeral": str(ephemeral),
            "created_at": now_str,
        }
        if resolved_version:
            meta_data["version"] = resolved_version
        if resolved_storage is not None:
            meta_data["storage"] = resolved_storage

        if on_step:
            on_step("Creating addon metadata...")
        self._k8s.create_configmap(
            self._ns,
            resources.build_addon_metadata_configmap(
                self._prefix,
                self._domain,
                app,
                instance,
                addon_type,
                meta_data,
            ),
        )

        if is_external:
            # External addon: just set the URL in config
            assert external_url is not None
            if on_step:
                on_step("Setting external URL...")
            self._factory.config.set(
                app,
                {env_key: external_url},
                no_restart=False,
                on_step=on_step,
            )
            self._update_metadata_status(app, instance, addon_type, "running")
            return Addon(
                instance_name=instance,
                addon_type=addon_type,
                app=app,
                status="running",
                image=resolved_image,
                cpu=resolved_cpu,
                memory=resolved_memory,
                storage=resolved_storage,
                env_key=env_key,
                external=True,
                ephemeral=ephemeral,
                created_at=datetime.now(UTC),
            )

        # Generate credentials
        user = TOOL_NAME
        password = secrets.token_urlsafe(32)
        db = app.replace("-", "_")

        if on_step:
            on_step("Creating addon credentials...")
        secret_name = resources.safe_name(self._prefix, "addon-secret", f"{app}-{instance}")
        self._k8s.create_secret(
            self._ns,
            resources.build_addon_credential_secret(
                self._prefix,
                self._domain,
                app,
                instance,
                addon_type,
                {"username": user, "password": password, "database": db},
            ),
        )

        # Build container env from Secret refs
        container_env: list[dict[str, Any]] | None = None
        if addon_def.container_env_builder:
            container_env = addon_def.container_env_builder(
                secret_name,
                user,
                password,
                db,
            )

        # Build resources spec (guaranteed QoS: requests = limits)
        resources_spec = self._build_resources_spec(resolved_cpu, resolved_memory)

        # Build volumes
        volumes: dict[str, str] | None = None
        if not ephemeral and addon_def.data_dir:
            volumes = {addon_def.data_dir: addon_def.volume_name}

        # Redis: add --requirepass via command
        command: list[str] | None = None
        if addon_type == "redis":
            command = ["redis-server", "--requirepass", password]

        if on_step:
            on_step("Creating StatefulSet...")
        self._k8s.create_statefulset(
            self._ns,
            resources.build_statefulset(
                self._prefix,
                self._domain,
                app,
                instance,
                addon_type,
                resolved_image,
                addon_def.port,
                command=command,
                storage_size=resolved_storage or "1Gi",
                volumes=volumes,
                container_env=container_env,
                resources_spec=resources_spec,
                readiness_probe=addon_def.readiness_probe,
            ),
        )

        # Create Service
        if on_step:
            on_step("Creating addon service...")
        svc_name = resources.safe_name(self._prefix, "addon", f"{app}-{instance}")
        addon_lbl = labels.for_addon(
            self._domain,
            self._prefix,
            app,
            instance,
            addon_type,
        )
        svc_body: dict[str, Any] = {
            "apiVersion": "v1",
            "kind": "Service",
            "metadata": {
                "name": svc_name,
                "labels": addon_lbl,
            },
            "spec": {
                "type": "ClusterIP",
                "selector": {
                    f"{self._domain}/app": app,
                    f"{self._domain}/addon-instance": instance,
                },
                "ports": [
                    {
                        "port": addon_def.port,
                        "targetPort": addon_def.port,
                        "protocol": addon_def.protocol,
                    }
                ],
            },
        }
        self._k8s.create_service(self._ns, svc_body)

        # Create NetworkPolicy
        if on_step:
            on_step("Creating network policy...")
        self._k8s.create_network_policy(
            self._ns,
            resources.build_addon_allow_policy(
                self._prefix,
                self._domain,
                app,
                instance,
                addon_type,
                addon_def.port,
            ),
        )

        # Build URL and inject via config
        host = f"{svc_name}.{self._ns}.svc.cluster.local"
        url = addon_def.url_template.format(
            user=user,
            password=password,
            host=host,
            port=addon_def.port,
            db=db,
        )
        if on_step:
            on_step("Injecting connection URL...")
        self._factory.config.set(
            app,
            {env_key: url},
            no_restart=False,
            on_step=on_step,
        )

        # Update status to running
        self._update_metadata_status(app, instance, addon_type, "running")

        return Addon(
            instance_name=instance,
            addon_type=addon_type,
            app=app,
            status="running",
            image=resolved_image,
            cpu=resolved_cpu,
            memory=resolved_memory,
            storage=resolved_storage,
            env_key=env_key,
            external=False,
            ephemeral=ephemeral,
            created_at=datetime.now(UTC),
        )

    def list(self, app: str) -> builtins.list[Addon]:
        """List all addon instances for an app."""
        self._check_app_exists(app)
        match_labels = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": app,
            f"{self._domain}/resource-type": "addon",
        }
        cms = self._k8s.list_configmaps(self._ns, labels=match_labels)
        addons: builtins.list[Addon] = []
        for cm in cms:
            data = cm.get("data", {})
            created_str = data.get("created_at", "")
            try:
                created_at = datetime.fromisoformat(created_str.replace("Z", "+00:00"))
            except (ValueError, AttributeError):
                created_at = datetime.now(UTC)
            addons.append(
                Addon(
                    instance_name=cm.get("metadata", {})
                    .get("labels", {})
                    .get(f"{self._domain}/addon-instance", ""),
                    addon_type=data.get("type", ""),
                    app=app,
                    status=data.get("status", "unknown"),
                    image=data.get("image", ""),
                    cpu=data.get("cpu", ""),
                    memory=data.get("memory", ""),
                    storage=data.get("storage"),
                    env_key=data.get("env_key", ""),
                    external=data.get("external", "False") == "True",
                    ephemeral=data.get("ephemeral", "False") == "True",
                    created_at=created_at,
                )
            )
        return sorted(addons, key=lambda a: a.instance_name)

    def info(self, app: str, instance_name: str) -> Addon:
        """Get addon instance details."""
        self._check_app_exists(app)
        meta_cm_name = resources.safe_name(self._prefix, "addon-meta", f"{app}-{instance_name}")
        try:
            cm = self._k8s.get_configmap(self._ns, meta_cm_name)
        except Exception:
            raise AddonNotFoundError(instance_name, app) from None

        data = cm.get("data", {})
        created_str = data.get("created_at", "")
        try:
            created_at = datetime.fromisoformat(created_str.replace("Z", "+00:00"))
        except (ValueError, AttributeError):
            created_at = datetime.now(UTC)

        return Addon(
            instance_name=instance_name,
            addon_type=data.get("type", ""),
            app=app,
            status=data.get("status", "unknown"),
            image=data.get("image", ""),
            cpu=data.get("cpu", ""),
            memory=data.get("memory", ""),
            storage=data.get("storage"),
            env_key=data.get("env_key", ""),
            external=data.get("external", "False") == "True",
            ephemeral=data.get("ephemeral", "False") == "True",
            created_at=created_at,
        )

    def destroy(
        self,
        app: str,
        instance_name: str,
        delete_data: bool = False,
        on_step: Callable[[str], None] | None = None,
    ) -> None:
        """Destroy an addon instance."""
        self._check_app_exists(app)

        # Read metadata to get details
        addon = self.info(app, instance_name)

        if not addon.external:
            # Delete StatefulSet
            sts_name = resources.safe_name(self._prefix, "addon", f"{app}-{instance_name}")
            if on_step:
                on_step("Deleting StatefulSet...")
            try:
                self._k8s.delete_statefulset(self._ns, sts_name)
            except Exception:
                try:
                    self._k8s.get_statefulset(self._ns, sts_name)
                except Exception:
                    pass  # Confirmed gone
                else:
                    raise

            # Wait for STS pod to terminate before deleting PVC
            # PVCs have finalizers — can't be deleted while the pod still exists.
            pod_name = f"{sts_name}-0"
            if on_step:
                on_step("Waiting for pod termination...")
            self._wait_for_pod_gone(pod_name)

            # Delete Service
            if on_step:
                on_step("Deleting service...")
            try:
                self._k8s.delete_service(self._ns, sts_name)
            except Exception:
                try:
                    self._k8s.get_service(self._ns, sts_name)
                except Exception:
                    pass  # Confirmed gone
                else:
                    raise

            # Delete NetworkPolicy
            np_name = resources.safe_name(self._prefix, "netpol-allow", f"{app}-{instance_name}")
            if on_step:
                on_step("Deleting network policy...")
            try:
                self._k8s.delete_network_policy(self._ns, np_name)
            except Exception:
                try:
                    self._k8s.get_network_policy(self._ns, np_name)
                except Exception:
                    pass  # Confirmed gone
                else:
                    raise

            # Delete credential Secret
            secret_name = resources.safe_name(
                self._prefix, "addon-secret", f"{app}-{instance_name}"
            )
            if on_step:
                on_step("Deleting credentials...")
            try:
                self._k8s.delete_secret(self._ns, secret_name)
            except Exception:
                try:
                    self._k8s.get_secret(self._ns, secret_name)
                except Exception:
                    pass  # Confirmed gone
                else:
                    raise

            # Handle PVC
            if delete_data and not addon.ephemeral and addon.addon_type:
                addon_def = get_addon_def(addon.addon_type)
                pvc_name = resources.addon_pvc_name(
                    self._prefix,
                    app,
                    instance_name,
                    addon_def.volume_name,
                )
                if on_step:
                    on_step("Deleting persistent data...")
                try:
                    self._k8s.delete_pvc(self._ns, pvc_name)
                except Exception:
                    try:
                        self._k8s.get_pvc(self._ns, pvc_name)
                    except Exception:
                        pass  # Confirmed gone
                    else:
                        raise

        # Unset env var
        if addon.env_key:
            if on_step:
                on_step("Removing connection URL...")
            try:
                self._factory.config.unset(
                    app,
                    [addon.env_key],
                    no_restart=False,
                    on_step=on_step,
                )
            except KeyError:
                pass  # Config var already absent — OK
            except Exception:
                # Verify the key is actually gone (may have been removed earlier)
                existing = self._factory.config.list(app)
                if addon.env_key in existing:
                    raise

        # Delete metadata ConfigMap
        meta_cm_name = resources.safe_name(self._prefix, "addon-meta", f"{app}-{instance_name}")
        if on_step:
            on_step("Deleting addon metadata...")
        try:
            self._k8s.delete_configmap(self._ns, meta_cm_name)
        except Exception:
            try:
                self._k8s.get_configmap(self._ns, meta_cm_name)
            except Exception:
                pass  # Confirmed gone
            else:
                raise

    def scale(
        self,
        app: str,
        instance_name: str,
        cpu: str | None = None,
        memory: str | None = None,
        storage: str | None = None,
        image: str | None = None,
        on_step: Callable[[str], None] | None = None,
    ) -> Addon:
        """Scale addon resources (cpu/memory/storage)."""
        self._check_app_exists(app)
        addon = self.info(app, instance_name)

        if addon.external:
            raise KuberokuError(f"Cannot scale external addon '{instance_name}'.")

        sts_name = resources.safe_name(self._prefix, "addon", f"{app}-{instance_name}")

        if on_step:
            on_step("Updating StatefulSet...")

        new_cpu = cpu or addon.cpu
        new_memory = memory or addon.memory

        def _apply_scale(sts: dict[str, Any]) -> None:
            container = sts["spec"]["template"]["spec"]["containers"][0]
            if image:
                container["image"] = image
            container["resources"] = self._build_resources_spec(new_cpu, new_memory)

        self._update_sts_with_retry(sts_name, _apply_scale)

        # Update metadata CM
        meta_cm_name = resources.safe_name(self._prefix, "addon-meta", f"{app}-{instance_name}")
        cm = self._k8s.get_configmap(self._ns, meta_cm_name)
        if cpu:
            cm["data"]["cpu"] = cpu
        if memory:
            cm["data"]["memory"] = memory
        if image:
            cm["data"]["image"] = image
        self._k8s.update_configmap(self._ns, meta_cm_name, cm)

        return self.info(app, instance_name)

    def credentials(self, app: str, instance_name: str) -> dict[str, str]:
        """Get addon credentials."""
        self._check_app_exists(app)
        addon = self.info(app, instance_name)
        if addon.external:
            raise KuberokuError(f"External addon '{instance_name}' has no managed credentials.")
        secret_name = resources.safe_name(self._prefix, "addon-secret", f"{app}-{instance_name}")
        try:
            secret = self._k8s.get_secret(self._ns, secret_name)
        except Exception:
            raise AddonNotFoundError(instance_name, app) from None
        data = secret.get("data", {})

        # Build URL
        addon_def = get_addon_def(addon.addon_type)
        svc_name = resources.safe_name(self._prefix, "addon", f"{app}-{instance_name}")
        host = f"{svc_name}.{self._ns}.svc.cluster.local"
        url = addon_def.url_template.format(
            user=data.get("username", ""),
            password=data.get("password", ""),
            host=host,
            port=addon_def.port,
            db=data.get("database", ""),
        )
        return {
            "username": data.get("username", ""),
            "password": data.get("password", ""),
            "database": data.get("database", ""),
            "url": url,
        }

    def credentials_rotate(
        self,
        app: str,
        instance_name: str,
        on_step: Callable[[str], None] | None = None,
    ) -> dict[str, str]:
        """Rotate addon credentials (new password)."""
        self._check_app_exists(app)
        addon = self.info(app, instance_name)
        if addon.external:
            raise KuberokuError(f"External addon '{instance_name}' has no managed credentials.")

        secret_name = resources.safe_name(self._prefix, "addon-secret", f"{app}-{instance_name}")
        secret = self._k8s.get_secret(self._ns, secret_name)
        data = dict(secret.get("data") or {})

        new_password = secrets.token_urlsafe(32)
        data["password"] = new_password
        secret.pop("data", None)
        secret["stringData"] = data

        if on_step:
            on_step("Updating credentials...")
        self._k8s.update_secret(self._ns, secret_name, secret)

        # Update config URL
        addon_def = get_addon_def(addon.addon_type)
        svc_name = resources.safe_name(self._prefix, "addon", f"{app}-{instance_name}")
        host = f"{svc_name}.{self._ns}.svc.cluster.local"
        new_url = addon_def.url_template.format(
            user=data.get("username", ""),
            password=new_password,
            host=host,
            port=addon_def.port,
            db=data.get("database", ""),
        )
        if on_step:
            on_step("Updating connection URL...")
        self._factory.config.set(
            app,
            {addon.env_key: new_url},
            no_restart=False,
            on_step=on_step,
        )

        return {
            "username": data.get("username", ""),
            "password": new_password,
            "database": data.get("database", ""),
            "url": new_url,
        }

    def exec_command(
        self,
        app: str,
        instance_name: str,
    ) -> tuple[str, builtins.list[str]]:
        """Get the pod name and exec command for interactive access."""
        self._check_app_exists(app)
        addon = self.info(app, instance_name)
        addon_def = get_addon_def(addon.addon_type)

        if not addon_def.exec_command:
            raise KuberokuError(f"Addon type '{addon.addon_type}' has no exec command.")

        creds = self.credentials(app, instance_name)
        cmd = [
            part.format(
                user=creds.get("username", ""),
                password=creds.get("password", ""),
                db=creds.get("database", ""),
            )
            for part in addon_def.exec_command
        ]

        sts_name = resources.safe_name(self._prefix, "addon", f"{app}-{instance_name}")
        pod_name = f"{sts_name}-0"
        return pod_name, cmd

    def backup(self, app: str, instance_name: str) -> str:
        """Run backup command in addon pod and return output."""
        self._check_app_exists(app)
        addon = self.info(app, instance_name)
        addon_def = get_addon_def(addon.addon_type)

        if not addon_def.backup_command:
            raise KuberokuError(f"Addon type '{addon.addon_type}' does not support backup.")

        creds = self.credentials(app, instance_name)
        cmd = [
            part.format(
                user=creds.get("username", ""),
                password=creds.get("password", ""),
                db=creds.get("database", ""),
            )
            for part in addon_def.backup_command
        ]

        sts_name = resources.safe_name(self._prefix, "addon", f"{app}-{instance_name}")
        pod_name = f"{sts_name}-0"
        return self._k8s.exec_in_pod(self._ns, pod_name, cmd, container=instance_name)

    # ── Expose / Connect ────────────────────────────────────────

    def expose_on(
        self,
        app: str,
        instance_name: str,
        method: str = "gateway",
        port: int | None = None,
        wait: bool = True,
        timeout: int = 60,
        on_step: Callable[[str], None] | None = None,
    ) -> ExposedEndpoint:
        """Expose an addon Service externally.

        method="gateway" (default): allocate a gateway port (10000-10999).
        method="loadbalancer"/"nodeport": patch Service type directly.
        Creates an external-allow NetworkPolicy in all cases.
        """
        self._check_app_exists(app)
        addon = self.info(app, instance_name)

        svc_name = resources.safe_name(
            self._prefix,
            "addon",
            f"{app}-{instance_name}",
        )
        try:
            svc = self._k8s.get_service(self._ns, svc_name)
        except Exception:
            raise AddonNotFoundError(instance_name, app) from None

        if method == "gateway":
            # Get the first port from the addon service
            svc_ports = svc.get("spec", {}).get("ports", [])
            target_port = port or (svc_ports[0]["port"] if svc_ports else 0)

            alloc = self._factory.gateway.allocate(
                app,
                svc_name,
                target_port,
                on_step=on_step,
            )

            # Create external-allow NetworkPolicy
            if on_step:
                on_step("Creating external network policy...")
            netpol_ports = [
                {"port": p["port"], "protocol": p.get("protocol", "TCP")} for p in svc_ports
            ]
            self._create_addon_external_policy(
                app,
                instance_name,
                addon.addon_type,
                netpol_ports,
            )

            return ExposedEndpoint(
                name=instance_name,
                app=app,
                kind="addon",
                service_type="LoadBalancer",
                method="gateway",
                external_ip=self._factory.gateway.get_gateway_ip(),
                hostname=None,
                ports=(alloc.external_port,),
                gateway_port=alloc.external_port,
            )

        target_type = "LoadBalancer" if method == "loadbalancer" else "NodePort"

        # Idempotent: if already the target type, return current state
        current_type = svc.get("spec", {}).get("type", "ClusterIP")
        if current_type == target_type:
            return self._build_endpoint(instance_name, app, svc, method)

        # Patch Service type
        if on_step:
            on_step(f"Patching addon Service to {target_type}...")
        svc["spec"]["type"] = target_type
        svc = self._k8s.update_service(self._ns, svc_name, svc)

        # Create external-allow NetworkPolicy
        if on_step:
            on_step("Creating external network policy...")
        svc_ports = svc.get("spec", {}).get("ports", [])
        netpol_ports = [
            {"port": p["port"], "protocol": p.get("protocol", "TCP")} for p in svc_ports
        ]
        self._create_addon_external_policy(
            app,
            instance_name,
            addon.addon_type,
            netpol_ports,
        )

        return self._build_endpoint(instance_name, app, svc, method)

    def expose_off(
        self,
        app: str,
        instance_name: str,
        on_step: Callable[[str], None] | None = None,
    ) -> ExposedEndpoint:
        """Revert an exposed addon back to internal-only.

        Checks gateway first — if allocated, deallocates.
        Otherwise reverts Service type from LB/NodePort to ClusterIP.
        Removes the external-allow NetworkPolicy (if not owned by other sources).
        """
        self._check_app_exists(app)

        svc_name = resources.safe_name(
            self._prefix,
            "addon",
            f"{app}-{instance_name}",
        )
        try:
            svc = self._k8s.get_service(self._ns, svc_name)
        except Exception:
            raise AddonNotFoundError(instance_name, app) from None

        # Check if exposed via gateway
        alloc = self._factory.gateway.get_allocation(app, svc_name)
        if alloc is not None:
            if on_step:
                on_step("Deallocating gateway port...")
            self._factory.gateway.deallocate(app, svc_name, on_step=on_step)

            # Delete external-allow NetworkPolicy
            if on_step:
                on_step("Removing external network policy...")
            self._delete_addon_external_policy(app, instance_name)

            return ExposedEndpoint(
                name=instance_name,
                app=app,
                kind="addon",
                service_type="ClusterIP",
                method="clusterip",
                external_ip=None,
                hostname=None,
                ports=tuple(p.get("port", 0) for p in svc.get("spec", {}).get("ports", [])),
            )

        # Not gateway — check LB/NodePort
        current_type = svc.get("spec", {}).get("type", "ClusterIP")
        if current_type == "ClusterIP":
            raise NotExposedError(instance_name, app)

        # Patch Service back to ClusterIP
        if on_step:
            on_step("Reverting addon Service to ClusterIP...")
        svc["spec"]["type"] = "ClusterIP"
        for port_spec in svc.get("spec", {}).get("ports", []):
            port_spec.pop("nodePort", None)
        svc = self._k8s.update_service(self._ns, svc_name, svc)

        # Delete external-allow NetworkPolicy
        if on_step:
            on_step("Removing external network policy...")
        self._delete_addon_external_policy(app, instance_name)

        return self._build_endpoint(instance_name, app, svc, "clusterip")

    def connect(
        self,
        app: str,
        instance_name: str,
    ) -> ConnectionTarget:
        """Resolve an addon connect target for port-forwarding.

        If the addon is exposed via gateway, returns the gateway LB service.
        Otherwise returns the addon's ClusterIP service for kubectl port-forward.
        """
        self._check_app_exists(app)

        svc_name = resources.safe_name(
            self._prefix,
            "addon",
            f"{app}-{instance_name}",
        )
        try:
            self._k8s.get_service(self._ns, svc_name)
        except Exception:
            raise AddonNotFoundError(instance_name, app) from None

        # Check if exposed via gateway
        alloc = self._factory.gateway.get_allocation(app, svc_name)
        if alloc is not None:
            gw_svc_name = f"{self._prefix}-gw-{alloc.external_port}"
            return ConnectionTarget(
                name=instance_name,
                app=app,
                kind="addon",
                service_name=gw_svc_name,
                namespace=self._ns,
                ports=(PortMapping(port=alloc.external_port, protocol="TCP"),),
            )

        # Direct service path
        svc = self._k8s.get_service(self._ns, svc_name)
        svc_ports = svc.get("spec", {}).get("ports", [])
        if not svc_ports:
            raise NoPortsError(instance_name, app)

        port_mappings = tuple(
            PortMapping(
                port=p.get("port", 0),
                protocol=p.get("protocol", "TCP"),
            )
            for p in svc_ports
        )

        return ConnectionTarget(
            name=instance_name,
            app=app,
            kind="addon",
            service_name=svc_name,
            namespace=self._ns,
            ports=port_mappings,
        )

    def migrate(
        self,
        app: str,
        instance_name: str,
        target_version: str,
        force: bool = False,
        on_step: Callable[[str], None] | None = None,
    ) -> Addon:
        """Migrate an addon to a new version with file-based binary dump.

        Strategy per addon type:
        - Addons with migrate_dump_command (postgres): service freeze →
          binary dump to file → read as base64 → PVC wipe → image swap →
          write dump via chunked transfer → restore from file → service restore
        - Addons without migrate commands:
          - Upgrade: simple image swap (forward compatible)
          - Downgrade: PVC wipe only (data lost, documented)

        Steps:
        1. Validate target version and migration path
        2. Freeze service (set selector to match nothing)
        3. Binary dump to file + read as base64 (if migrate_dump_command)
        4. Scale STS to 0, wait for pod gone
        5. Delete PVC if needed
        6. Swap image + scale to 1
        7. Wait for pod ready + service readiness
        8. Write dump via chunked base64 + restore from file
        9. Restore service
        10. Update metadata
        """
        self._check_app_exists(app)
        addon = self.info(app, instance_name)

        if addon.external:
            raise KuberokuError(f"Cannot migrate external addon '{instance_name}'.")

        addon_def = get_addon_def(addon.addon_type)

        # Validate target version
        if addon_def.versions is None:
            raise KuberokuError(f"Addon type '{addon.addon_type}' does not support versioning.")

        if target_version not in addon_def.versions:
            available = ", ".join(sorted(addon_def.versions.keys()))
            raise KuberokuError(
                f"Version '{target_version}' not available for {addon.addon_type}. "
                f"Available: {available}"
            )

        # Determine current version from metadata
        current_version = self._get_addon_version(app, instance_name, addon_def)

        if current_version == target_version:
            raise KuberokuError(f"Addon '{instance_name}' is already at version {target_version}.")

        # Validate migration path
        if not force and addon_def.migration_paths is not None:
            allowed = addon_def.migration_paths.get(current_version, ())
            if target_version not in allowed:
                path = self._suggest_migration_path(addon_def, current_version, target_version)
                raise KuberokuError(
                    f"Cannot migrate {addon.addon_type} directly from "
                    f"v{current_version} to v{target_version}.\n"
                    f"Supported path: {path}\n"
                    f"Migrate one version at a time, or use --force to bypass."
                )

        target_image = addon_def.versions[target_version]
        sts_name = resources.safe_name(self._prefix, "addon", f"{app}-{instance_name}")
        pod_name = f"{sts_name}-0"
        svc_name = resources.safe_name(self._prefix, "addon", f"{app}-{instance_name}")

        # Determine migration strategy
        has_file_migrate = addon_def.migrate_dump_command is not None
        is_downgrade = self._is_downgrade(current_version, target_version)
        needs_pvc_wipe = has_file_migrate or (not has_file_migrate and is_downgrade)

        # Step 1: Freeze service (prevent writes during migration)
        original_selector: dict[str, str] | None = None
        if on_step:
            on_step("Freezing service to prevent writes...")
        try:
            svc = self._k8s.get_service(self._ns, svc_name)
            original_selector = dict(svc["spec"]["selector"])
            svc["spec"]["selector"] = {f"{self._domain}/migration-freeze": "true"}
            self._k8s.update_service(self._ns, svc_name, svc)
        except Exception:
            pass  # Service may not exist (ephemeral addons)

        try:
            # Step 2: Binary dump to file + read as base64
            backup_b64 = ""
            if has_file_migrate:
                backup_b64 = self._migrate_dump(
                    app,
                    instance_name,
                    addon_def,
                    pod_name,
                    force,
                    on_step,
                )

            # Step 3: Scale STS to 0
            if on_step:
                on_step("Scaling down addon...")
            old_image = ""

            def _scale_down(sts: dict[str, Any]) -> None:
                nonlocal old_image
                old_image = sts["spec"]["template"]["spec"]["containers"][0]["image"]
                sts["spec"]["replicas"] = 0

            self._update_sts_with_retry(sts_name, _scale_down)

            # Step 4: Wait for pod gone
            if on_step:
                on_step("Waiting for pod termination...")
            self._wait_for_pod_gone(pod_name, timeout=120)

            # Step 5: Delete PVC if needed
            if needs_pvc_wipe and not addon.ephemeral and addon_def.data_dir:
                pvc_name = resources.addon_pvc_name(
                    self._prefix, app, instance_name, addon_def.volume_name
                )
                if on_step:
                    on_step("Deleting persistent volume for clean start...")
                try:
                    self._k8s.delete_pvc(self._ns, pvc_name)
                except Exception:
                    try:
                        self._k8s.get_pvc(self._ns, pvc_name)
                    except Exception:
                        pass  # Confirmed gone
                    else:
                        raise
                self._wait_for_pvc_gone(pvc_name)

            # Step 6: Swap image + scale to 1
            if on_step:
                on_step(f"Updating image to {target_image}...")

            def _swap_image(sts: dict[str, Any]) -> None:
                sts["spec"]["template"]["spec"]["containers"][0]["image"] = target_image
                sts["spec"]["replicas"] = 1

            self._update_sts_with_retry(sts_name, _swap_image)

            # Step 7: Wait for pod ready + service readiness probe
            if on_step:
                on_step("Waiting for new pod to be ready...")
            self._wait_for_pod_ready(pod_name, timeout=180)

            probe = addon_def.readiness_probe
            pods_with_status = any(p.get("status") for p in self._k8s.list_pods(self._ns))
            if probe and "exec" in probe and pods_with_status:
                ready_cmd: list[str] = probe["exec"]["command"]
                for _ in range(60):
                    try:
                        self._k8s.exec_in_pod(
                            self._ns,
                            pod_name,
                            ready_cmd,
                            container=instance_name,
                        )
                        break
                    except Exception:
                        time.sleep(2)

            # Step 8: Write dump + restore from file
            if backup_b64 and has_file_migrate:
                self._migrate_restore(
                    app,
                    instance_name,
                    addon_def,
                    pod_name,
                    backup_b64,
                    on_step,
                )

            # Step 9: Update metadata
            if on_step:
                on_step("Updating addon metadata...")
            meta_cm_name = resources.safe_name(
                self._prefix,
                "addon-meta",
                f"{app}-{instance_name}",
            )
            cm = self._k8s.get_configmap(self._ns, meta_cm_name)
            cm["data"]["image"] = target_image
            cm["data"]["version"] = target_version
            cm["data"]["previous_version"] = current_version
            cm["data"]["previous_image"] = old_image
            cm["data"]["migration_date"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
            if backup_b64:
                cm["data"]["last_backup"] = "file-based"
            if needs_pvc_wipe and not backup_b64:
                cm["data"]["data_wiped"] = "true"
            self._k8s.update_configmap(self._ns, meta_cm_name, cm)

        finally:
            # Step 10: Restore service (always, even on failure)
            if original_selector is not None:
                if on_step:
                    on_step("Restoring addon service...")
                svc = self._k8s.get_service(self._ns, svc_name)
                svc["spec"]["selector"] = original_selector
                self._k8s.update_service(self._ns, svc_name, svc)

        return self.info(app, instance_name)

    def _migrate_dump(
        self,
        app: str,
        instance_name: str,
        addon_def: Any,
        pod_name: str,
        force: bool,
        on_step: Callable[[str], None] | None,
    ) -> str:
        """Dump addon data to file and read as base64.

        Returns base64-encoded dump data, or empty string on failure
        when force=True.
        """
        assert addon_def.migrate_dump_command is not None
        assert addon_def.migrate_dump_file is not None

        if on_step:
            on_step("Creating binary backup before migration...")
        creds = self.credentials(app, instance_name)
        dump_cmd = [
            part.format(
                user=creds.get("username", ""),
                password=creds.get("password", ""),
                db=creds.get("database", ""),
            )
            for part in addon_def.migrate_dump_command
        ]

        if force:
            # Tolerate failure when force=True (pod may be crashing)
            try:
                self._k8s.exec_in_pod(
                    self._ns,
                    pod_name,
                    dump_cmd,
                    container=instance_name,
                )
                if on_step:
                    on_step("Reading backup data...")
                return self._k8s.exec_in_pod(
                    self._ns,
                    pod_name,
                    ["base64", "-w0", addon_def.migrate_dump_file],
                    container=instance_name,
                )
            except Exception:
                return ""

        self._k8s.exec_in_pod(
            self._ns,
            pod_name,
            dump_cmd,
            container=instance_name,
        )
        if on_step:
            on_step("Reading backup data...")
        return self._k8s.exec_in_pod(
            self._ns,
            pod_name,
            ["base64", "-w0", addon_def.migrate_dump_file],
            container=instance_name,
        )

    def _migrate_restore(
        self,
        app: str,
        instance_name: str,
        addon_def: Any,
        pod_name: str,
        backup_b64: str,
        on_step: Callable[[str], None] | None,
    ) -> None:
        """Write base64-encoded dump to new pod and restore from file.

        Transfers data via chunked exec commands (no stdin piping) to
        avoid websocket EOF issues, then restores from the local file.
        """
        assert addon_def.migrate_dump_file is not None

        dump_file = addon_def.migrate_dump_file
        b64_file = f"{dump_file}.b64"
        chunk_size = 500_000  # ~500KB per exec call, safe for command args

        if on_step:
            on_step("Transferring backup data to new instance...")

        # Write base64 data in chunks via command arguments (no stdin)
        for i in range(0, len(backup_b64), chunk_size):
            chunk = backup_b64[i : i + chunk_size]
            op = ">" if i == 0 else ">>"
            self._k8s.exec_in_pod(
                self._ns,
                pod_name,
                ["sh", "-c", f"printf '%s' '{chunk}' {op} {b64_file}"],
                container=instance_name,
            )

        # Decode base64 to binary dump file
        self._k8s.exec_in_pod(
            self._ns,
            pod_name,
            ["sh", "-c", f"base64 -d {b64_file} > {dump_file} && rm {b64_file}"],
            container=instance_name,
        )

        # Restore from file
        if addon_def.migrate_restore_command:
            if on_step:
                on_step("Restoring data from backup...")
            creds = self.credentials(app, instance_name)
            restore_cmd = [
                part.format(
                    user=creds.get("username", ""),
                    password=creds.get("password", ""),
                    db=creds.get("database", ""),
                )
                for part in addon_def.migrate_restore_command
            ]
            # Retry restore — after PVC wipe, postgres may still be initializing
            last_err: Exception | None = None
            for _attempt in range(30):
                try:
                    self._k8s.exec_in_pod(
                        self._ns,
                        pod_name,
                        restore_cmd,
                        container=instance_name,
                    )
                    last_err = None
                    break
                except Exception as exc:
                    last_err = exc
                    time.sleep(3)
            if last_err is not None:
                raise last_err

    def migrate_rollback(
        self,
        app: str,
        instance_name: str,
        on_step: Callable[[str], None] | None = None,
    ) -> Addon:
        """Rollback a version migration to previous version."""
        self._check_app_exists(app)

        # Read metadata for previous version
        meta_cm_name = resources.safe_name(self._prefix, "addon-meta", f"{app}-{instance_name}")
        try:
            cm = self._k8s.get_configmap(self._ns, meta_cm_name)
        except Exception:
            raise AddonNotFoundError(instance_name, app) from None

        data = cm.get("data", {})
        prev_version = data.get("previous_version")
        if not prev_version:
            raise KuberokuError(
                f"No previous version found for addon '{instance_name}'. No migration to rollback."
            )

        if on_step:
            on_step(f"Rolling back to version {prev_version}...")

        return self.migrate(
            app,
            instance_name,
            target_version=prev_version,
            force=True,
            on_step=on_step,
        )

    # ── Private helpers ──────────────────────────────────────────

    @staticmethod
    def _is_downgrade(current_version: str, target_version: str) -> bool:
        """Check if target_version is lower than current_version.

        Uses integer comparison when both versions are numeric,
        falls back to string comparison otherwise.
        """
        try:
            return int(target_version) < int(current_version)
        except ValueError:
            return target_version < current_version

    def _wait_for_pod_ready(self, pod_name: str, timeout: int = 30) -> None:
        """Poll until a pod is Running+Ready.

        On FakeK8sClient pods have no status fields. If no pods in the
        namespace have status, we're on a fake backend — return immediately.

        Raises TimeoutError if the pod is not ready within the timeout.
        """
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            pods = self._k8s.list_pods(self._ns)
            # FakeK8sClient: pods never have status fields — skip wait
            if not any(p.get("status") for p in pods):
                return
            for pod in pods:
                if pod.get("metadata", {}).get("name") != pod_name:
                    continue
                phase = pod.get("status", {}).get("phase", "")
                statuses = pod.get("status", {}).get("containerStatuses", [])
                if (
                    phase == "Running"
                    and statuses
                    and all(s.get("ready", False) for s in statuses)
                ):
                    return
            time.sleep(2)
        raise TimeoutError(f"Pod {pod_name} not ready after {timeout}s")

    def _update_sts_with_retry(
        self,
        sts_name: str,
        mutate: Callable[[dict[str, Any]], None],
        max_retries: int = 5,
    ) -> dict[str, Any]:
        """Read-modify-update a StatefulSet with retry on 409 Conflict.

        ``mutate`` is called with the STS dict and should modify it in place.
        On 409, re-read and re-apply the mutation.
        """
        for attempt in range(max_retries):
            sts = self._k8s.get_statefulset(self._ns, sts_name)
            mutate(sts)
            try:
                self._k8s.update_statefulset(self._ns, sts_name, sts)
                return sts
            except Exception as exc:
                status = getattr(exc, "status", None)
                if status == 409 and attempt < max_retries - 1:
                    time.sleep(0.5 * (attempt + 1))
                    continue
                raise
        # Unreachable, but satisfies type checker
        raise RuntimeError("_update_sts_with_retry: exhausted retries")  # pragma: no cover

    def _wait_for_pod_gone(self, pod_name: str, timeout: int = 10) -> None:
        """Poll until a pod no longer appears in list_pods.

        PVCs with finalizers cannot be deleted while the pod still exists.
        On FakeK8sClient pods have no status fields and the fake doesn't
        model STS→Pod lifecycle, so we return immediately.
        """
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            pods = self._k8s.list_pods(self._ns)
            found = False
            for p in pods:
                if p.get("metadata", {}).get("name") == pod_name:
                    # FakeK8sClient: pods have no status fields — return
                    # immediately (fake doesn't model STS→Pod lifecycle)
                    if not p.get("status"):
                        return
                    found = True
                    break
            if not found:
                return
            time.sleep(2)
        # Best-effort: if timeout expires, continue anyway

    def _wait_for_pvc_gone(self, pvc_name: str, timeout: int = 120) -> None:
        """Poll until a PVC is fully deleted (no longer returns from get_pvc).

        After delete_pvc(), the PVC may linger in Terminating state due to
        finalizers.  If we scale up the STS before the PVC is gone, the new
        pod cannot be created because the PVC name is still occupied.

        On FakeK8sClient delete_pvc removes immediately, so this returns fast.
        """
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            try:
                self._k8s.get_pvc(self._ns, pvc_name)
            except Exception:
                # PVC is gone (404) — success
                return
            time.sleep(2)

    def _create_addon_external_policy(
        self,
        app: str,
        instance_name: str,
        addon_type: str,
        ports: builtins.list[dict[str, Any]],
    ) -> None:
        """Create the external-allow NetworkPolicy for an addon."""
        np_name = resources.safe_name(
            self._prefix,
            "netpol-external",
            f"{app}-{instance_name}",
        )
        try:
            self._k8s.delete_network_policy(self._ns, np_name)
        except Exception:
            try:
                self._k8s.get_network_policy(self._ns, np_name)
            except Exception:
                pass  # Confirmed gone — OK for delete-before-create
            else:
                raise

        body = resources.build_addon_external_allow_policy(
            self._prefix,
            self._domain,
            app,
            instance_name,
            addon_type,
            ports,
        )
        self._k8s.create_network_policy(self._ns, body)

    def _delete_addon_external_policy(self, app: str, instance_name: str) -> None:
        """Delete the external-allow NetworkPolicy for an addon.

        Only deletes if the policy's exposed-by annotation is "expose" only.
        """
        np_name = resources.safe_name(
            self._prefix,
            "netpol-external",
            f"{app}-{instance_name}",
        )
        try:
            np = self._k8s.get_network_policy(self._ns, np_name)
        except Exception:
            return

        annotations = np.get("metadata", {}).get("annotations", {})
        exposed_by = annotations.get(f"{self._domain}/exposed-by", "")
        owners = {o.strip() for o in exposed_by.split(",") if o.strip()}
        owners.discard("expose")

        if owners:
            np["metadata"].setdefault("annotations", {})[f"{self._domain}/exposed-by"] = ",".join(
                sorted(owners)
            )
            self._k8s.update_network_policy(self._ns, np_name, np)
        else:
            self._k8s.delete_network_policy(self._ns, np_name)

    @staticmethod
    def _build_endpoint(
        name: str,
        app: str,
        svc: dict[str, Any],
        method: str,
    ) -> ExposedEndpoint:
        """Build an ExposedEndpoint from an addon Service dict."""
        svc_type = svc.get("spec", {}).get("type", "ClusterIP")
        svc_ports = svc.get("spec", {}).get("ports", [])
        port_nums = tuple(p.get("port", 0) for p in svc_ports)

        external_ip: str | None = None
        hostname: str | None = None
        status = svc.get("status", {})
        lb = status.get("loadBalancer", {})
        for ingress in lb.get("ingress", []):
            if ingress.get("ip"):
                external_ip = ingress["ip"]
                break
            if ingress.get("hostname"):
                hostname = ingress["hostname"]
                break

        return ExposedEndpoint(
            name=name,
            app=app,
            kind="addon",
            service_type=svc_type,
            method=method,
            external_ip=external_ip,
            hostname=hostname,
            ports=port_nums,
        )

    def _check_app_exists(self, app: str) -> None:
        cm_name = resources.safe_name(self._prefix, "app", app)
        try:
            self._k8s.get_configmap(self._ns, cm_name)
        except Exception:
            raise AppNotFoundError(app) from None

    def _instance_exists(self, app: str, instance: str) -> bool:
        meta_cm_name = resources.safe_name(self._prefix, "addon-meta", f"{app}-{instance}")
        try:
            self._k8s.get_configmap(self._ns, meta_cm_name)
            return True
        except Exception:
            return False

    def _resolve_env_key(
        self,
        app: str,
        default_key: str,
        instance: str,
        addon_type: str,
    ) -> str:
        """Resolve the env key for an addon instance.

        First instance of a type uses the default key (DATABASE_URL).
        Named instances use {NAME_UPPER}_URL.
        """
        if instance == addon_type:
            return default_key
        return f"{instance.upper().replace('-', '_')}_URL"

    def _update_metadata_status(
        self,
        app: str,
        instance: str,
        addon_type: str,
        status: str,
    ) -> None:
        meta_cm_name = resources.safe_name(self._prefix, "addon-meta", f"{app}-{instance}")
        cm = self._k8s.get_configmap(self._ns, meta_cm_name)
        cm["data"]["status"] = status
        self._k8s.update_configmap(self._ns, meta_cm_name, cm)

    @staticmethod
    def _build_resources_spec(cpu: str, memory: str) -> dict[str, Any]:
        """Build a K8s resources spec from cpu/memory strings.

        Guaranteed QoS by default: requests = limits.
        Supports '250m/500m' format for different request/limit.
        """
        cpu_parsed = parse_resources(cpu)
        mem_parsed = parse_resources(memory)

        result: dict[str, Any] = {
            "requests": {
                "cpu": cpu_parsed["requests"],
                "memory": mem_parsed["requests"],
            },
            "limits": {
                "cpu": cpu_parsed.get("limits", cpu_parsed["requests"]),
                "memory": mem_parsed.get("limits", mem_parsed["requests"]),
            },
        }
        return result

    @staticmethod
    def _resolve_version(
        addon_def: Any,
        version: str | None,
    ) -> tuple[str, str | None]:
        """Resolve version → (image, version_str).

        Returns (addon_def.image, None) if no versioning configured.
        """
        assert isinstance(addon_def, AddonDef)

        if version is not None:
            if addon_def.versions is None:
                raise KuberokuError(
                    f"Addon type '{addon_def.type_name}' does not support "
                    "versioning. Remove --version."
                )
            if version not in addon_def.versions:
                available = ", ".join(sorted(addon_def.versions.keys()))
                raise KuberokuError(
                    f"Version '{version}' not available for "
                    f"{addon_def.type_name}. Available: {available}"
                )
            return addon_def.versions[version], version

        if addon_def.versions and addon_def.default_version:
            return (
                addon_def.versions[addon_def.default_version],
                addon_def.default_version,
            )

        return addon_def.image, None

    def _get_addon_version(
        self,
        app: str,
        instance_name: str,
        addon_def: Any,
    ) -> str:
        """Get current version of an addon from metadata."""
        meta_cm_name = resources.safe_name(self._prefix, "addon-meta", f"{app}-{instance_name}")
        cm = self._k8s.get_configmap(self._ns, meta_cm_name)
        stored: str = cm.get("data", {}).get("version", "")
        if stored:
            return stored

        # Infer from image if no version stored
        image: str = cm.get("data", {}).get("image", "")
        if addon_def.versions:
            for ver, img in addon_def.versions.items():
                if img == image:
                    return str(ver)

        # Fallback: default version
        if addon_def.default_version:
            return str(addon_def.default_version)
        return "unknown"

    @staticmethod
    def _suggest_migration_path(
        addon_def: Any,
        from_version: str,
        to_version: str,
    ) -> str:
        """Build a suggested migration path string like '15 -> 16 -> 17'."""
        if addon_def.migration_paths is None:
            return f"{from_version} -> {to_version}"

        # BFS to find shortest path
        visited: set[str] = {from_version}
        queue: builtins.list[builtins.list[str]] = [[from_version]]
        while queue:
            path = queue.pop(0)
            current = path[-1]
            if current == to_version:
                return " -> ".join(path)
            neighbors: tuple[str, ...] = addon_def.migration_paths.get(current, ())
            for neighbor in neighbors:
                if neighbor not in visited:
                    visited.add(neighbor)
                    queue.append([*path, neighbor])

        return f"{from_version} -> ... -> {to_version}"
