"""ReleasesService — business logic for release operations.

Handles: create_release, list, info, rollback, prune.
Releases are stored as ConfigMaps — one per release.
Version counter lives on the app manifest ConfigMap.
"""

from __future__ import annotations

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

from kuberoku.exceptions import AppNotFoundError, ReleaseNotFoundError
from kuberoku.k8s import resources
from kuberoku.models import ProcessFormation, Release

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

_MAX_RETRIES = 5


class ReleasesService:
    """Business logic for release 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_release(
        self,
        app: str,
        images: dict[str, str],
        formation: dict[str, ProcessFormation],
        description: str,
        commit: str | None = None,
        ref: str | None = None,
        env_diff: dict[str, str | None] | None = None,
        on_step: Callable[[str], None] | None = None,
    ) -> Release:
        """Create a new release with optimistic concurrency on version counter."""
        for _attempt in range(_MAX_RETRIES):
            manifest = self._get_manifest(app)
            current_version = int(manifest.get("data", {}).get("current_release_version", "0"))
            new_version = current_version + 1

            # Build release data
            created_by = _get_identity()
            created_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
            formation_data: dict[str, str] = {}
            for ptype, pf in formation.items():
                formation_data[ptype] = json.dumps(
                    {
                        "replicas": pf.replicas,
                        "command": pf.command,
                        "command_source": pf.command_source,
                    }
                )

            data: dict[str, str] = {
                "version": str(new_version),
                "app": app,
                "description": description,
                "images": json.dumps(images),
                "formation": json.dumps(formation_data),
                "created_at": created_at,
                "created_by": created_by,
            }
            if commit is not None:
                data["commit"] = commit
            if ref is not None:
                data["ref"] = ref
            if env_diff is not None:
                data["env_diff"] = json.dumps(env_diff)

            # Create release ConfigMap
            cm = resources.build_release_configmap(
                self._prefix, self._domain, app, new_version, data
            )
            self._k8s.create_configmap(self._ns, cm)

            # Update version counter on manifest
            manifest["data"]["current_release_version"] = str(new_version)
            try:
                self._k8s.update_configmap(self._ns, manifest["metadata"]["name"], manifest)
            except Exception as e:
                if "conflict" in str(e).lower() or "409" in str(e):
                    # Delete the release CM we just created and retry
                    release_cm_name = resources.safe_name(
                        self._prefix, "release", f"{app}-v{new_version}"
                    )
                    try:
                        self._k8s.delete_configmap(self._ns, release_cm_name)
                    except Exception:
                        try:
                            self._k8s.get_configmap(self._ns, release_cm_name)
                        except Exception:
                            pass  # Confirmed gone
                        else:
                            raise
                    continue
                raise

            if on_step:
                on_step(f"Created release v{new_version}")

            return Release(
                version=new_version,
                app=app,
                description=description,
                images=images,
                formation=formation,
                commit=commit,
                ref=ref,
                created_at=datetime.fromisoformat(created_at.replace("Z", "+00:00")),
                created_by=created_by,
                env_diff=env_diff or {},
            )

        # Should not reach here, but satisfy type checker
        raise RuntimeError("Failed to create release after retries")  # pragma: no cover

    def list(
        self,
        app: str,
        num: int = 10,
    ) -> list[Release]:
        """List releases for an app, newest first."""
        self._check_app_exists(app)
        release_labels = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": app,
            f"{self._domain}/resource-type": "release",
        }
        cms = self._k8s.list_configmaps(self._ns, labels=release_labels)
        releases: list[Release] = []
        for cm in cms:
            release = self._parse_release_cm(cm)
            if release is not None:
                releases.append(release)
        releases.sort(key=lambda r: r.version, reverse=True)
        return releases[:num]

    def info(self, app: str, version: int) -> Release:
        """Get a specific release by version."""
        self._check_app_exists(app)
        cm_name = resources.safe_name(self._prefix, "release", f"{app}-v{version}")
        try:
            cm = self._k8s.get_configmap(self._ns, cm_name)
        except Exception:
            raise ReleaseNotFoundError(f"Release v{version} not found for app '{app}'.") from None
        release = self._parse_release_cm(cm)
        if release is None:
            raise ReleaseNotFoundError(f"Release v{version} not found for app '{app}'.")
        return release

    def rollback(
        self,
        app: str,
        version: int | None = None,
        on_step: Callable[[str], None] | None = None,
    ) -> Release:
        """Roll back to a previous release. Creates a NEW release."""
        self._check_app_exists(app)

        if version is None:
            # Roll back to previous version
            releases = self.list(app, num=2)
            if len(releases) < 2:
                raise ReleaseNotFoundError("No previous release to roll back to.")
            target = releases[1]  # Previous (index 0 is current)
        else:
            target = self.info(app, version)

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

        # Apply images from target release
        self._factory.deploy._apply_images(app, target.images, target.formation, on_step=on_step)

        # Save formation from target release
        self._factory.deploy._save_formation(app, target.formation)

        # Create new release
        description = f"Rollback to v{target.version}"
        return self.create_release(
            app=app,
            images=target.images,
            formation=target.formation,
            description=description,
            commit=target.commit,
            ref=target.ref,
            on_step=on_step,
        )

    def prune(
        self,
        app: str,
        keep: int = 50,
        on_step: Callable[[str], None] | None = None,
    ) -> int:
        """Delete old releases, keeping the newest `keep`."""
        self._check_app_exists(app)
        releases = self.list(app, num=10000)
        if len(releases) <= keep:
            return 0

        to_delete = releases[keep:]
        deleted = 0
        for release in to_delete:
            cm_name = resources.safe_name(self._prefix, "release", f"{app}-v{release.version}")
            try:
                self._k8s.delete_configmap(self._ns, cm_name)
                deleted += 1
            except Exception:
                pass
        if on_step and deleted > 0:
            on_step(f"Pruned {deleted} old releases")
        return deleted

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

    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 _get_manifest(self, app: str) -> dict[str, Any]:
        cm_name = resources.safe_name(self._prefix, "app", app)
        try:
            return self._k8s.get_configmap(self._ns, cm_name)
        except Exception:
            raise AppNotFoundError(app) from None

    def _parse_release_cm(self, cm: dict[str, Any]) -> Release | None:
        """Parse a release ConfigMap into a Release model."""
        data = cm.get("data", {})
        try:
            version = int(data.get("version", "0"))
        except (ValueError, TypeError):
            return None
        if version == 0:
            return None

        images_str = data.get("images", "{}")
        formation_str = data.get("formation", "{}")
        env_diff_str = data.get("env_diff", "{}")

        try:
            images: dict[str, str] = json.loads(images_str)
        except (json.JSONDecodeError, TypeError):
            images = {}
        try:
            formation_raw: dict[str, str] = json.loads(formation_str)
        except (json.JSONDecodeError, TypeError):
            formation_raw = {}
        try:
            env_diff: dict[str, str | None] = json.loads(env_diff_str)
        except (json.JSONDecodeError, TypeError):
            env_diff = {}

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

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

        return Release(
            version=version,
            app=data.get("app", ""),
            description=data.get("description", ""),
            images=images,
            formation=formation,
            commit=data.get("commit"),
            ref=data.get("ref"),
            created_at=created_at,
            created_by=data.get("created_by", "unknown"),
            env_diff=env_diff,
        )


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"
