"""AppsService — business logic for app lifecycle operations.

Handles: create, destroy, info, list, rename.
"""

from __future__ import annotations

import contextlib
import getpass
import json
import re
import socket
from collections.abc import Callable
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

from kuberoku.exceptions import (
    AppAlreadyExistsError,
    AppNotFoundError,
    InvalidAppNameError,
)
from kuberoku.k8s import resources
from kuberoku.models import App, AppStatus, MaintenanceInfo, PortMapping, ProcessFormation

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

_APP_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,28}[a-z0-9])?$")
_APP_NAME_MAX = 30
_RETRY_ATTEMPTS = 3


def validate_app_name(name: str) -> None:
    """Validate an app name against the naming rules.

    Rules: max 30 chars, lowercase alphanumeric + hyphens,
    must start and end with alphanumeric.
    """
    if not name:
        raise InvalidAppNameError(name, "name cannot be empty.")
    if len(name) > _APP_NAME_MAX:
        raise InvalidAppNameError(
            name, f"must be at most {_APP_NAME_MAX} characters (got {len(name)})."
        )
    if not _APP_NAME_RE.match(name):
        raise InvalidAppNameError(
            name,
            "must contain only lowercase letters, digits, and hyphens, "
            "and must start and end with a letter or digit.",
        )


class AppsService:
    """Business logic for app 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,
        name: str,
        on_step: Callable[[str], None] | None = None,
    ) -> App:
        """Create a new app with manifest, env, and formation ConfigMaps."""
        validate_app_name(name)
        if self._exists(name):
            raise AppAlreadyExistsError(name)

        self._factory.ensure_namespace()

        created_by = _get_identity()

        if on_step:
            on_step("Creating app manifest...")
        self._k8s.create_configmap(
            self._ns,
            resources.build_app_manifest(self._prefix, self._domain, name, created_by),
        )
        if on_step:
            on_step("Creating env config...")
        self._k8s.create_configmap(
            self._ns,
            resources.build_env_configmap(self._prefix, self._domain, name),
        )
        if on_step:
            on_step("Creating formation...")
        self._k8s.create_configmap(
            self._ns,
            resources.build_formation_configmap(self._prefix, self._domain, name),
        )
        if on_step:
            on_step("Creating network isolation policy...")
        self._k8s.create_network_policy(
            self._ns,
            resources.build_app_deny_policy(self._prefix, self._domain, name),
        )

        return App(
            name=name,
            cluster="",
            created_at=datetime.now(UTC),
            created_by=created_by,
            process_types={},
            release_version=0,
            maintenance=False,
        )

    def destroy(
        self,
        name: str,
        on_step: Callable[[str], None] | None = None,
    ) -> None:
        """Delete an app and ALL its resources.

        Cleanup order (reverse of creation):
          1. Deployments    (created by deploy)
          2. Services       (created by deploy/expose)
          3. Secrets        (created by config:set --secret)
          4. NetworkPolicies(created by apps:create)
          5. ConfigMaps     (created by apps:create — manifest, env, formation)

        IMPORTANT: When adding new resource types that apps:create or other
        commands create, add the corresponding cleanup here in reverse order.
        """
        if not self._exists(name):
            raise AppNotFoundError(name)

        match_labels = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": name,
        }

        # 0. Ingress (domains)
        if on_step:
            on_step("Deleting Ingress...")
        try:
            self._factory.domains.clear(name)
        except Exception:
            # Only tolerate if Ingress is already gone
            ingress_name = resources.safe_name(self._prefix, "ingress", name)
            try:
                self._k8s.get_ingress(self._ns, ingress_name)
            except Exception:
                pass  # Confirmed gone
            else:
                raise

        # 1. StatefulSets (addons)
        if on_step:
            on_step("Deleting addon StatefulSets...")
        for sts in self._k8s.list_statefulsets(self._ns, labels=match_labels):
            sts_name = sts["metadata"]["name"]
            self._k8s.delete_statefulset(self._ns, sts_name)

        # 1b. Warn about retained PVCs (addon data not deleted by default)
        addon_pvcs = self._k8s.list_pvcs(self._ns, labels=match_labels)
        if addon_pvcs and on_step:
            pvc_names = [p["metadata"]["name"] for p in addon_pvcs]
            on_step(
                f"Warning: {len(pvc_names)} PVC(s) retained: "
                f"{', '.join(pvc_names)}. Delete manually if not needed."
            )

        # 2. Deployments (created last during deploy)
        if on_step:
            on_step("Deleting deployments...")
        for dep in self._k8s.list_deployments(self._ns, labels=match_labels):
            dep_name = dep["metadata"]["name"]
            self._k8s.delete_deployment(self._ns, dep_name)

        # 3. Services (created during deploy/expose + addons)
        if on_step:
            on_step("Deleting services...")
        for svc in self._k8s.list_services(self._ns, labels=match_labels):
            svc_name = svc["metadata"]["name"]
            self._k8s.delete_service(self._ns, svc_name)

        # 4. Secrets (app secret + addon credential secrets)
        if on_step:
            on_step("Deleting secrets...")
        for secret in self._k8s.list_secrets(self._ns, labels=match_labels):
            secret_name = secret["metadata"]["name"]
            self._k8s.delete_secret(self._ns, secret_name)

        # 5. NetworkPolicies (created by apps:create + addons)
        if on_step:
            on_step("Deleting network policies...")
        for np in self._k8s.list_network_policies(self._ns, labels=match_labels):
            np_name = np["metadata"]["name"]
            self._k8s.delete_network_policy(self._ns, np_name)

        # 6. ConfigMaps — manifest, env, formation, addon metadata
        if on_step:
            on_step("Deleting config...")
        for cm in self._k8s.list_configmaps(self._ns, labels=match_labels):
            cm_name = cm["metadata"]["name"]
            self._k8s.delete_configmap(self._ns, cm_name)

    def info(self, name: str) -> App:
        """Get detailed app information."""
        manifest = self._get_manifest(name)
        data = manifest.get("data", {})
        annotations = manifest.get("metadata", {}).get("annotations", {})

        created_at_str = annotations.get(f"{self._domain}/created-at", "")
        try:
            created_at = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
        except (ValueError, AttributeError):
            created_at = datetime.now(UTC)

        created_by = annotations.get(f"{self._domain}/created-by", "unknown")
        release_version = int(data.get("current_release_version", "0"))
        maintenance = annotations.get(f"{self._domain}/maintenance") == "true"

        formation = self._get_formation(name)

        return App(
            name=name,
            cluster="",
            created_at=created_at,
            created_by=created_by,
            process_types=formation,
            release_version=release_version,
            maintenance=maintenance,
        )

    def list(self) -> list[App]:
        """List all apps in the current namespace."""
        cms = self._k8s.list_configmaps(
            self._ns,
            labels={
                f"{self._domain}/managed-by": self._prefix,
                f"{self._domain}/resource-type": "app-manifest",
            },
        )
        apps: list[App] = []
        for cm in cms:
            app = self._app_from_manifest(cm)
            apps.append(app)
        return sorted(apps, key=lambda a: a.name)

    def status(self, name: str) -> AppStatus:
        """Get aggregated status view for an app."""
        app = self.info(name)

        # Dynos
        dynos = self._factory.ps.list_dynos(name)

        # Config counts
        config_with_sources = self._factory.config.list_with_sources(name)
        config_count = sum(1 for _, src in config_with_sources.values() if src == "configmap")
        secret_count = sum(1 for _, src in config_with_sources.values() if src == "secret")

        # Addons
        addons = self._factory.addons.list(name)

        # Domains
        domains = self._factory.domains.list(name)

        # Latest release
        releases = self._factory.releases.list(name, num=1)
        latest_release = releases[0] if releases else None

        # Ports
        port_map = self._factory.services.list_ports(name)
        ports: dict[str, list[PortMapping]] = {k: list(v) for k, v in port_map.items()}

        return AppStatus(
            app=app,
            dynos=dynos,
            config_count=config_count,
            secret_count=secret_count,
            addons=addons,
            domains=domains,
            latest_release=latest_release,
            ports=ports,
        )

    def maintenance_status(self, name: str) -> MaintenanceInfo:
        """Get the maintenance state for an app."""
        manifest = self._get_manifest(name)
        return self._read_maintenance_info(manifest)

    def maintenance_on(
        self,
        name: str,
        on_step: Callable[[str], None] | None = None,
    ) -> MaintenanceInfo:
        """Put ALL processes into maintenance (scale to 0, save replicas)."""
        manifest = self._get_manifest(name)
        annotations = manifest.get("metadata", {}).get("annotations", {})

        already_on = annotations.get(f"{self._domain}/maintenance") == "true"

        # Collect current replicas from all deployments
        match_labels = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": name,
        }
        deps = self._k8s.list_deployments(self._ns, labels=match_labels)

        if already_on:
            # Already in maintenance — keep existing saved replicas,
            # just ensure deployments are at 0 (idempotent)
            if on_step:
                on_step("Already in maintenance, ensuring scale is 0...")
            for dep in deps:
                if dep.get("spec", {}).get("replicas", 0) != 0:
                    self._scale_deployment_with_retry(dep["metadata"]["name"], 0)
            return self._read_maintenance_info(manifest)

        saved: dict[str, int] = {}
        for dep in deps:
            dep_labels = dep.get("metadata", {}).get("labels", {})
            ptype = dep_labels.get(f"{self._domain}/process-type")
            if ptype:
                replicas = dep.get("spec", {}).get("replicas", 1)
                saved[ptype] = replicas

        if on_step:
            on_step("Saving current replicas...")

        # Save replicas and set maintenance flag
        annotations[f"{self._domain}/maintenance"] = "true"
        annotations[f"{self._domain}/maintenance-since"] = datetime.now(UTC).isoformat()
        annotations[f"{self._domain}/maintenance-saved"] = json.dumps(saved)
        # Clear per-service list (app-wide overrides)
        annotations.pop(f"{self._domain}/maintenance-services", None)
        manifest["metadata"]["annotations"] = annotations
        self._k8s.update_configmap(self._ns, manifest["metadata"]["name"], manifest)

        # Scale all deployments to 0
        if on_step:
            on_step("Scaling all processes to 0...")
        for dep in deps:
            self._scale_deployment_with_retry(dep["metadata"]["name"], 0)

        return self._read_maintenance_info(self._get_manifest(name))

    def maintenance_off(
        self,
        name: str,
        on_step: Callable[[str], None] | None = None,
    ) -> MaintenanceInfo:
        """Restore ALL processes from maintenance."""
        manifest = self._get_manifest(name)
        annotations = manifest.get("metadata", {}).get("annotations", {})

        saved_json = annotations.get(f"{self._domain}/maintenance-saved", "{}")
        saved: dict[str, int] = json.loads(saved_json)

        if on_step:
            on_step("Restoring process replicas...")

        # Restore replicas
        match_labels = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": name,
        }
        for dep in self._k8s.list_deployments(self._ns, labels=match_labels):
            dep_labels = dep.get("metadata", {}).get("labels", {})
            ptype = dep_labels.get(f"{self._domain}/process-type")
            if ptype and ptype in saved:
                self._scale_deployment_with_retry(
                    dep["metadata"]["name"],
                    saved[ptype],
                )

        # Clear all maintenance annotations
        if on_step:
            on_step("Clearing maintenance flag...")
        for key in [
            f"{self._domain}/maintenance",
            f"{self._domain}/maintenance-since",
            f"{self._domain}/maintenance-saved",
            f"{self._domain}/maintenance-services",
        ]:
            annotations.pop(key, None)
        manifest["metadata"]["annotations"] = annotations
        self._k8s.update_configmap(self._ns, manifest["metadata"]["name"], manifest)

        return MaintenanceInfo(
            enabled=False,
            app_wide=False,
            since=None,
            services=(),
            saved_replicas={},
        )

    def rename(
        self,
        old_name: str,
        new_name: str,
        on_step: Callable[[str], None] | None = None,
    ) -> App:
        """Rename an app. Updates labels on all resources."""
        validate_app_name(new_name)
        if not self._exists(old_name):
            raise AppNotFoundError(old_name)
        if self._exists(new_name):
            raise AppAlreadyExistsError(new_name)

        old_labels = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": old_name,
        }

        # Fetch old manifest BEFORE updating labels (it won't match after)
        old_manifest = self._get_manifest_by_label(old_name)

        if on_step:
            on_step("Updating configmaps...")
        for cm in self._k8s.list_configmaps(self._ns, labels=old_labels):
            cm["metadata"]["labels"][f"{self._domain}/app"] = new_name
            self._k8s.update_configmap(self._ns, cm["metadata"]["name"], cm)

        if on_step:
            on_step("Updating deployments...")
        for dep in self._k8s.list_deployments(self._ns, labels=old_labels):
            dep_name = dep["metadata"]["name"]
            self._rename_update_deployment(dep_name, new_name)

        if on_step:
            on_step("Updating services...")
        for svc in self._k8s.list_services(self._ns, labels=old_labels):
            svc["metadata"]["labels"][f"{self._domain}/app"] = new_name
            self._k8s.update_service(self._ns, svc["metadata"]["name"], svc)

        if on_step:
            on_step("Updating network policies...")
        for np in self._k8s.list_network_policies(self._ns, labels=old_labels):
            np["metadata"]["labels"][f"{self._domain}/app"] = new_name
            self._k8s.update_network_policy(self._ns, np["metadata"]["name"], np)

        # Recreate name-addressed resources (env, formation, secret) with new names.
        # These are looked up by resource name ({prefix}-{type}-{app}), not by label,
        # so just updating labels is not enough — we must create new resources with
        # the correct name, copy the data, and delete the old ones.
        if on_step:
            on_step("Renaming env config...")
        self._recreate_configmap(
            old_name=resources.safe_name(self._prefix, "env", old_name),
            new_body=resources.build_env_configmap(self._prefix, self._domain, new_name),
        )

        if on_step:
            on_step("Renaming formation config...")
        self._recreate_configmap(
            old_name=resources.safe_name(self._prefix, "formation", old_name),
            new_body=resources.build_formation_configmap(self._prefix, self._domain, new_name),
        )

        if on_step:
            on_step("Renaming secrets...")
        self._recreate_secret(old_name, new_name)

        if on_step:
            on_step("Creating new app manifest...")
        created_by = (
            old_manifest.get("metadata", {})
            .get("annotations", {})
            .get(f"{self._domain}/created-by", "unknown")
        )
        new_manifest = resources.build_app_manifest(
            self._prefix, self._domain, new_name, created_by
        )
        # Copy over release version
        old_data = old_manifest.get("data", {})
        new_manifest["data"]["current_release_version"] = old_data.get(
            "current_release_version", "0"
        )
        self._k8s.create_configmap(self._ns, new_manifest)

        # Delete old manifest
        old_manifest_name = old_manifest["metadata"]["name"]
        self._k8s.delete_configmap(self._ns, old_manifest_name)

        return self.info(new_name)

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

    def _recreate_configmap(
        self,
        old_name: str,
        new_body: dict[str, Any],
    ) -> None:
        """Copy data from an old ConfigMap into a new one, then delete the old.

        Used during rename to move name-addressed resources (env, formation)
        to their new resource name while preserving all data.
        """
        try:
            old_cm = self._k8s.get_configmap(self._ns, old_name)
        except Exception:
            # Old CM doesn't exist (e.g. freshly created app) — just create the new one
            self._k8s.create_configmap(self._ns, new_body)
            return
        # Copy data from old CM into new body
        old_data = old_cm.get("data") or {}
        if old_data:
            new_body["data"] = dict(old_data)
        self._k8s.create_configmap(self._ns, new_body)
        self._k8s.delete_configmap(self._ns, old_name)

    def _recreate_secret(self, old_app: str, new_app: str) -> None:
        """Copy data from the old app Secret to a new one, then delete the old.

        If the old secret doesn't exist, this is a no-op (secrets are created
        lazily on first `config:set --secret`).
        """
        old_secret_name = resources.safe_name(self._prefix, "secret", old_app)
        try:
            old_secret = self._k8s.get_secret(self._ns, old_secret_name)
        except Exception:
            return  # No secret to migrate
        new_body = resources.build_secret(self._prefix, self._domain, new_app)
        old_data = old_secret.get("data") or {}
        if old_data:
            # Use stringData so K8s handles encoding; existing `data` is already
            # plain-text in FakeK8sClient and base64-decoded by real K8s client.
            new_body["stringData"] = dict(old_data)
        self._k8s.create_secret(self._ns, new_body)
        self._k8s.delete_secret(self._ns, old_secret_name)

    def _read_maintenance_info(self, manifest: dict[str, Any]) -> MaintenanceInfo:
        """Build a MaintenanceInfo from the manifest annotations."""
        annotations = manifest.get("metadata", {}).get("annotations", {})
        enabled = annotations.get(f"{self._domain}/maintenance") == "true"
        since_str = annotations.get(f"{self._domain}/maintenance-since")
        since: datetime | None = None
        if since_str:
            with contextlib.suppress(ValueError):
                since = datetime.fromisoformat(since_str)

        saved_json = annotations.get(f"{self._domain}/maintenance-saved", "{}")
        try:
            saved: dict[str, int] = json.loads(saved_json)
        except (json.JSONDecodeError, TypeError):
            saved = {}

        services_json = annotations.get(f"{self._domain}/maintenance-services", "[]")
        try:
            services_list: list[str] = json.loads(services_json)
        except (json.JSONDecodeError, TypeError):
            services_list = []

        # App-wide = enabled AND no per-service list
        app_wide = enabled and not services_list

        return MaintenanceInfo(
            enabled=enabled,
            app_wide=app_wide,
            since=since,
            services=tuple(services_list),
            saved_replicas=saved,
        )

    def _exists(self, name: str) -> bool:
        """Check if an app exists by looking for its manifest ConfigMap."""
        cm_name = resources.safe_name(self._prefix, "app", name)
        try:
            self._k8s.get_configmap(self._ns, cm_name)
            return True
        except Exception:
            return False

    def _rename_update_deployment(self, dep_name: str, new_app_name: str) -> None:
        """Update deployment's app label during rename, with retry on 409 Conflict.

        The deployment controller continuously reconciles deployments, which
        changes resourceVersion between our list and update. We re-read on 409.
        """
        for _ in range(_RETRY_ATTEMPTS):
            dep = self._k8s.get_deployment(self._ns, dep_name)
            dep["metadata"]["labels"][f"{self._domain}/app"] = new_app_name
            try:
                self._k8s.update_deployment(self._ns, dep_name, dep)
                return
            except Exception as e:
                if "conflict" in str(e).lower() or "409" in str(e):
                    continue
                raise
        # Final attempt — let it raise
        dep = self._k8s.get_deployment(self._ns, dep_name)
        dep["metadata"]["labels"][f"{self._domain}/app"] = new_app_name
        self._k8s.update_deployment(self._ns, dep_name, dep)

    def _scale_deployment_with_retry(
        self,
        dep_name: str,
        replicas: int,
    ) -> None:
        """Scale a deployment with retry on 409 conflict."""
        for _ in range(_RETRY_ATTEMPTS):
            dep = self._k8s.get_deployment(self._ns, dep_name)
            dep["spec"]["replicas"] = replicas
            try:
                self._k8s.update_deployment(self._ns, dep_name, dep)
                return
            except Exception as e:
                if "conflict" in str(e).lower() or "409" in str(e):
                    continue
                raise
        # Final attempt — let it raise
        dep = self._k8s.get_deployment(self._ns, dep_name)
        dep["spec"]["replicas"] = replicas
        self._k8s.update_deployment(self._ns, dep_name, dep)

    def _get_manifest(self, name: str) -> dict[str, Any]:
        """Get the app manifest ConfigMap or raise AppNotFoundError."""
        cm_name = resources.safe_name(self._prefix, "app", name)
        try:
            return self._k8s.get_configmap(self._ns, cm_name)
        except Exception:
            raise AppNotFoundError(name) from None

    def _get_manifest_by_label(self, name: str) -> dict[str, Any]:
        """Get manifest by label query (for rename where name is in labels)."""
        cms = self._k8s.list_configmaps(
            self._ns,
            labels={
                f"{self._domain}/managed-by": self._prefix,
                f"{self._domain}/app": name,
                f"{self._domain}/resource-type": "app-manifest",
            },
        )
        if not cms:
            raise AppNotFoundError(name)
        return cms[0]

    def _get_formation(self, name: str) -> dict[str, ProcessFormation]:
        """Get process formation from the formation ConfigMap."""
        cm_name = resources.safe_name(self._prefix, "formation", name)
        try:
            cm = self._k8s.get_configmap(self._ns, cm_name)
        except Exception:
            return {}

        formation: dict[str, ProcessFormation] = {}
        for key, value in cm.get("data", {}).items():
            try:
                entry = json.loads(value)
                formation[key] = ProcessFormation(
                    replicas=entry.get("replicas", 1),
                    command=entry.get("command"),
                    command_source=entry.get("command_source", "default"),
                )
            except (json.JSONDecodeError, TypeError):
                continue
        return formation

    def _app_from_manifest(self, cm: dict[str, Any]) -> App:
        """Build an App model from a manifest ConfigMap dict."""
        data = cm.get("data", {})
        annotations = cm.get("metadata", {}).get("annotations", {})
        app_label = cm.get("metadata", {}).get("labels", {}).get(f"{self._domain}/app", "")

        created_at_str = annotations.get(f"{self._domain}/created-at", "")
        try:
            created_at = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
        except (ValueError, AttributeError):
            created_at = datetime.now(UTC)

        maintenance = annotations.get(f"{self._domain}/maintenance") == "true"
        return App(
            name=app_label,
            cluster="",
            created_at=created_at,
            created_by=annotations.get(f"{self._domain}/created-by", "unknown"),
            process_types={},
            release_version=int(data.get("current_release_version", "0")),
            maintenance=maintenance,
        )


def _get_identity() -> str:
    """Get the current user identity for created_by."""
    try:
        user = getpass.getuser()
        host = socket.gethostname()
        return f"{user}@{host}"
    except Exception:
        return "unknown"
