"""ConfigService — business logic for config var operations.

Handles: set, get, unset, list. Stores plain vars in the env ConfigMap
and sensitive vars in the app Secret.
"""

from __future__ import annotations

import builtins
import json
import re
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING, Any

from kuberoku import branding
from kuberoku.exceptions import (
    AppNotFoundError,
    ConfigLimitError,
    ConfigVarNotFoundError,
    InvalidConfigKeyError,
)
from kuberoku.k8s import resources
from kuberoku.models import ProcessFormation as PF

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

_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")

_MAX_RETRIES = 3


def validate_config_key(key: str) -> None:
    """Validate a config key name.

    Rules: uppercase letters, digits, underscores only.
    Must start with a letter or underscore. Max 256 chars.
    """
    if not key:
        raise InvalidConfigKeyError(key, "key cannot be empty.")
    if len(key) > branding.CONFIG_MAX_KEY_LENGTH:
        raise InvalidConfigKeyError(
            key,
            f"must be at most {branding.CONFIG_MAX_KEY_LENGTH} characters (got {len(key)}).",
        )
    if not _KEY_RE.match(key):
        raise InvalidConfigKeyError(
            key,
            "must contain only uppercase letters, digits, and underscores, "
            "and must start with a letter or underscore.",
        )


class ConfigService:
    """Business logic for config var 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 list(self, app: str) -> dict[str, str]:
        """List all config vars (merged ConfigMap + Secret). Secret wins on conflict."""
        self._check_app_exists(app)
        cm_data = self._read_env_data(app)
        secret_data = self._read_secret_data(app)
        merged: dict[str, str] = {}
        merged.update(cm_data)
        merged.update(secret_data)
        return merged

    def list_with_sources(self, app: str) -> dict[str, tuple[str, str]]:
        """List all config vars with their source (configmap/secret)."""
        self._check_app_exists(app)
        cm_data = self._read_env_data(app)
        secret_data = self._read_secret_data(app)
        result: dict[str, tuple[str, str]] = {}
        for key, value in cm_data.items():
            result[key] = (value, "configmap")
        for key, value in secret_data.items():
            result[key] = (value, "secret")
        return result

    def get(self, app: str, key: str) -> tuple[str, str]:
        """Get a single config var. Returns (value, source).

        Source is 'configmap' or 'secret'. Secret takes precedence.
        """
        self._check_app_exists(app)
        secret_data = self._read_secret_data(app)
        if key in secret_data:
            return secret_data[key], "secret"
        cm_data = self._read_env_data(app)
        if key in cm_data:
            return cm_data[key], "configmap"
        raise ConfigVarNotFoundError(key, app)

    def set(
        self,
        app: str,
        vars: dict[str, str],
        secret: bool = False,
        no_restart: bool = False,
        on_step: Callable[[str], None] | None = None,
    ) -> dict[str, str]:
        """Set one or more config vars. Returns all vars in the target source."""
        self._check_app_exists(app)

        # Validate all keys first
        for key in vars:
            validate_config_key(key)

        # Validate value sizes
        for key, value in vars.items():
            if len(value.encode("utf-8")) > branding.CONFIG_MAX_VALUE_SIZE:
                raise ConfigLimitError(
                    f"Value for '{key}' exceeds "
                    f"{branding.CONFIG_MAX_VALUE_SIZE // 1024} KiB limit."
                )

        if secret:
            # Remove these keys from ConfigMap so a key only lives in one source
            self._remove_keys_from_configmap(app, builtins.list(vars.keys()))
            result = self._set_secret(app, vars, on_step)
        else:
            # Remove these keys from Secret so a key only lives in one source
            self._remove_keys_from_secret(app, builtins.list(vars.keys()))
            result = self._set_configmap(app, vars, on_step)

        # Trigger rolling restart + create release
        self._post_config_change(
            app,
            env_diff=dict(vars.items()),
            no_restart=no_restart,
            on_step=on_step,
        )

        return result

    def unset(
        self,
        app: str,
        keys: Sequence[str],
        no_restart: bool = False,
        on_step: Callable[[str], None] | None = None,
    ) -> dict[str, str]:
        """Remove one or more config vars. Returns merged remaining vars."""
        self._check_app_exists(app)

        # Find which source each key lives in
        cm_data = self._read_env_data(app)
        secret_data = self._read_secret_data(app)

        cm_keys_to_remove: builtins.list[str] = []
        secret_keys_to_remove: builtins.list[str] = []

        for key in keys:
            found = False
            if key in secret_data:
                secret_keys_to_remove.append(key)
                found = True
            if key in cm_data:
                cm_keys_to_remove.append(key)
                found = True
            if not found:
                raise ConfigVarNotFoundError(key, app)

        if cm_keys_to_remove:
            if on_step:
                on_step("Unsetting config vars...")
            self._unset_from_configmap(app, cm_keys_to_remove)

        if secret_keys_to_remove:
            if on_step:
                on_step("Unsetting secret vars...")
            self._unset_from_secret(app, secret_keys_to_remove)

        # Trigger rolling restart + create release
        self._post_config_change(
            app,
            env_diff=dict.fromkeys(keys),
            no_restart=no_restart,
            on_step=on_step,
        )

        return self.list(app)

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

    def _post_config_change(
        self,
        app: str,
        env_diff: dict[str, str | None],
        no_restart: bool,
        on_step: Callable[[str], None] | None,
    ) -> None:
        """After config set/unset: restart Deployments and create release."""
        # Rolling restart (unless no_restart)
        if not no_restart:
            self._factory.deploy._restart_deployments(app, on_step=on_step)

        # Create release with env_diff
        images = self._collect_current_images(app)
        formation = self._get_formation(app)
        keys = builtins.list(env_diff.keys())
        description = f"Config change: {', '.join(keys)}"
        self._factory.releases.create_release(
            app=app,
            images=images,
            formation=formation,
            description=description,
            env_diff=env_diff,
            on_step=on_step,
        )

    def _collect_current_images(self, app: str) -> dict[str, str]:
        """Collect current images from Deployments."""
        match_labels = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": app,
        }
        images: dict[str, str] = {}
        for dep in self._k8s.list_deployments(self._ns, labels=match_labels):
            ptype = (
                dep.get("metadata", {}).get("labels", {}).get(f"{self._domain}/process-type", "")
            )
            if ptype:
                container = dep["spec"]["template"]["spec"]["containers"][0]
                images[ptype] = container.get("image", "")
        return images

    def _get_formation(self, app: str) -> dict[str, PF]:
        """Read formation from the formation ConfigMap."""
        cm_name = resources.safe_name(self._prefix, "formation", app)
        try:
            cm = self._k8s.get_configmap(self._ns, cm_name)
        except Exception:
            return {}
        formation: dict[str, PF] = {}
        for key, value in cm.get("data", {}).items():
            try:
                entry = json.loads(value)
                formation[key] = PF(
                    replicas=entry.get("replicas", 1),
                    command=entry.get("command"),
                    command_source=entry.get("command_source", "default"),
                )
            except (json.JSONDecodeError, TypeError):
                continue
        return formation

    def _remove_keys_from_configmap(self, app: str, keys: builtins.list[str]) -> None:
        """Silently remove keys from the env ConfigMap (cross-source cleanup)."""
        cm_data = self._read_env_data(app)
        to_remove = [k for k in keys if k in cm_data]
        if to_remove:
            self._unset_from_configmap(app, to_remove)

    def _remove_keys_from_secret(self, app: str, keys: builtins.list[str]) -> None:
        """Silently remove keys from the Secret (cross-source cleanup)."""
        secret_data = self._read_secret_data(app)
        to_remove = [k for k in keys if k in secret_data]
        if to_remove:
            self._unset_from_secret(app, to_remove)

    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 _env_cm_name(self, app: str) -> str:
        return resources.safe_name(self._prefix, "env", app)

    def _secret_name(self, app: str) -> str:
        return resources.safe_name(self._prefix, "secret", app)

    def _read_env_data(self, app: str) -> dict[str, str]:
        """Read the env ConfigMap data dict."""
        try:
            cm = self._k8s.get_configmap(self._ns, self._env_cm_name(app))
            return dict(cm.get("data") or {})
        except Exception:
            return {}

    def _read_secret_data(self, app: str) -> dict[str, str]:
        """Read the Secret stringData/data dict."""
        try:
            secret = self._k8s.get_secret(self._ns, self._secret_name(app))
            return dict(secret.get("data") or {})
        except Exception:
            return {}

    def _check_var_limits(self, existing: dict[str, str], new_vars: dict[str, str]) -> None:
        """Check that adding new_vars won't exceed limits."""
        merged = dict(existing)
        merged.update(new_vars)
        if len(merged) > branding.CONFIG_MAX_VARS:
            raise ConfigLimitError(
                f"Cannot exceed {branding.CONFIG_MAX_VARS} config vars (would have {len(merged)})."
            )
        total_size = sum(
            len(k.encode("utf-8")) + len(v.encode("utf-8")) for k, v in merged.items()
        )
        if total_size > branding.CONFIG_MAX_TOTAL_SIZE:
            raise ConfigLimitError(
                f"Total config size would exceed "
                f"{branding.CONFIG_MAX_TOTAL_SIZE // 1024} KiB limit."
            )

    def _set_configmap(
        self,
        app: str,
        vars: dict[str, str],
        on_step: Callable[[str], None] | None,
    ) -> dict[str, str]:
        """Set vars in the env ConfigMap with optimistic concurrency."""
        cm_name = self._env_cm_name(app)
        for _attempt in range(_MAX_RETRIES):
            cm = self._k8s.get_configmap(self._ns, cm_name)
            current_data: dict[str, str] = dict(cm.get("data") or {})
            # Also count secret vars toward the total limit
            secret_data = self._read_secret_data(app)
            all_existing = dict(current_data)
            all_existing.update(secret_data)
            self._check_var_limits(all_existing, vars)
            current_data.update(vars)
            cm["data"] = current_data
            if on_step:
                on_step("Setting config vars...")
                on_step = None  # only fire once
            try:
                self._k8s.update_configmap(self._ns, cm_name, cm)
                return current_data
            except Exception as e:
                if "conflict" in str(e).lower() or "409" in str(e):
                    continue
                raise
        # Final attempt — let it raise
        cm = self._k8s.get_configmap(self._ns, cm_name)
        current_data = dict(cm.get("data") or {})
        current_data.update(vars)
        cm["data"] = current_data
        self._k8s.update_configmap(self._ns, cm_name, cm)
        return current_data

    def _set_secret(
        self,
        app: str,
        vars: dict[str, str],
        on_step: Callable[[str], None] | None,
    ) -> dict[str, str]:
        """Set vars in the Secret, creating it if needed."""
        secret_name = self._secret_name(app)
        secret = self._ensure_secret(app)
        for _attempt in range(_MAX_RETRIES):
            current_data: dict[str, str] = dict(secret.get("data") or {})
            # Also count CM vars toward the total limit
            cm_data = self._read_env_data(app)
            all_existing = dict(cm_data)
            all_existing.update(current_data)
            self._check_var_limits(all_existing, vars)
            current_data.update(vars)
            secret.pop("data", None)
            secret["stringData"] = current_data
            if on_step:
                on_step("Setting secret vars...")
                on_step = None
            try:
                self._k8s.update_secret(self._ns, secret_name, secret)
                return current_data
            except Exception as e:
                if "conflict" in str(e).lower() or "409" in str(e):
                    secret = self._k8s.get_secret(self._ns, secret_name)
                    continue
                raise
        # Final attempt
        secret = self._k8s.get_secret(self._ns, secret_name)
        current_data = dict(secret.get("data") or {})
        current_data.update(vars)
        secret.pop("data", None)
        secret["stringData"] = current_data
        self._k8s.update_secret(self._ns, secret_name, secret)
        return current_data

    def _ensure_secret(self, app: str) -> dict[str, Any]:
        """Get or create the app Secret."""
        secret_name = self._secret_name(app)
        try:
            return self._k8s.get_secret(self._ns, secret_name)
        except Exception:
            body = resources.build_secret(self._prefix, self._domain, app)
            return self._k8s.create_secret(self._ns, body)

    def _unset_from_configmap(self, app: str, keys: Sequence[str]) -> None:
        """Remove keys from the env ConfigMap with optimistic concurrency."""
        cm_name = self._env_cm_name(app)
        for _attempt in range(_MAX_RETRIES):
            cm = self._k8s.get_configmap(self._ns, cm_name)
            data: dict[str, str] = dict(cm.get("data") or {})
            for key in keys:
                data.pop(key, None)
            cm["data"] = data
            try:
                self._k8s.update_configmap(self._ns, cm_name, cm)
                return
            except Exception as e:
                if "conflict" in str(e).lower() or "409" in str(e):
                    continue
                raise
        # Final attempt
        cm = self._k8s.get_configmap(self._ns, cm_name)
        data = dict(cm.get("data") or {})
        for key in keys:
            data.pop(key, None)
        cm["data"] = data
        self._k8s.update_configmap(self._ns, cm_name, cm)

    def _unset_from_secret(self, app: str, keys: Sequence[str]) -> None:
        """Remove keys from the Secret with optimistic concurrency."""
        secret_name = self._secret_name(app)
        for _attempt in range(_MAX_RETRIES):
            try:
                secret = self._k8s.get_secret(self._ns, secret_name)
            except Exception:
                return  # Secret doesn't exist, nothing to unset
            data: dict[str, str] = dict(secret.get("data") or {})
            for key in keys:
                data.pop(key, None)
            secret.pop("data", None)
            secret["stringData"] = data
            try:
                self._k8s.update_secret(self._ns, secret_name, secret)
                return
            except Exception as e:
                if "conflict" in str(e).lower() or "409" in str(e):
                    continue
                raise
        # Final attempt
        secret = self._k8s.get_secret(self._ns, secret_name)
        data = dict(secret.get("data") or {})
        for key in keys:
            data.pop(key, None)
        secret.pop("data", None)
        secret["stringData"] = data
        self._k8s.update_secret(self._ns, secret_name, secret)
