"""Orchestration layer between CLI commands and infrastructure clients.

``ReleaseManager`` sits between the CLI and the low-level clients
(HelmClient, KubeClient, ModuleResolver, ConfigManager) and implements
the fetch-merge-diff-apply pipeline described in the values-safety
design.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from ilum.config.manager import ConfigManager
from ilum.config.paths import IlumPaths
from ilum.constants import CHART_REPO_NAME, CHART_REPO_URL
from ilum.core.helm import HelmClient, HelmResult
from ilum.core.kubernetes import KubeClient
from ilum.core.modules import ModuleResolver
from ilum.core.safety import (
    DriftResult,
    ValuesDiff,
    compute_diff,
    delete_snapshot,
    detect_drift,
    make_snapshot,
    save_snapshot,
)
from ilum.core.values import apply_set_flags
from ilum.core.versioning import parse_version
from ilum.errors import HelmError, ReleaseExistsError, ReleaseNotFoundError

# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class BreakingChange:
    """Describes a breaking change between chart versions."""

    from_version: str
    to_version: str
    description: str
    migration_hint: str = ""


@dataclass
class ReleaseInfo:
    """Summary of an existing Helm release."""

    name: str
    namespace: str
    status: str
    chart: str
    chart_version: str
    revision: int
    last_deployed: str


@dataclass
class ReleasePlan:
    """Describes a planned Helm operation before execution."""

    action: str  # "install" | "upgrade" | "enable" | "disable" | "uninstall"
    release: str
    namespace: str
    chart: str
    chart_version: str = ""
    set_flags: list[str] = field(default_factory=list)
    values_files: list[Path] = field(default_factory=list)
    modules: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)
    computed_values: dict[str, Any] = field(default_factory=dict)
    effective_diff: ValuesDiff | None = None
    drift: DriftResult | None = None
    atomic: bool = False
    devel: bool = False
    reset_defaults: bool = False


# ---------------------------------------------------------------------------
# Known breaking changes
# ---------------------------------------------------------------------------

UPGRADE_NOTES_URL = "https://ilum.cloud/docs/upgrade-notes/"

BREAKING_CHANGES: tuple[BreakingChange, ...] = (
    BreakingChange(
        from_version="6.5",
        to_version="6.6",
        description=(
            "MongoDB chart upgraded from bitnami/mongodb 13.x to 14.x — auth key format changed."
        ),
        migration_hint="Back up MongoDB data before upgrading.",
    ),
    BreakingChange(
        from_version="6.6",
        to_version="6.7",
        description="Langfuse requires custom Docker image with NEXT_PUBLIC_BASE_PATH baked in.",
        migration_hint="Build custom Langfuse image or disable Langfuse module.",
    ),
)


# ---------------------------------------------------------------------------
# ReleaseManager
# ---------------------------------------------------------------------------


class ReleaseManager:
    """Facade that coordinates Helm operations with values safety."""

    def __init__(
        self,
        helm: HelmClient,
        k8s: KubeClient,
        resolver: ModuleResolver,
        config_mgr: ConfigManager,
        paths: IlumPaths,
    ) -> None:
        self.helm = helm
        self.k8s = k8s
        self.resolver = resolver
        self.config_mgr = config_mgr
        self.paths = paths

    # -- Repo management ---------------------------------------------------

    def ensure_repo(self) -> None:
        """Add the Ilum Helm repo if it is not already present."""
        try:
            result = self.helm._run(["repo", "list", "--output", "json"], capture_json=True)
            if result.json_data:
                for repo in result.json_data:
                    if repo.get("name") == CHART_REPO_NAME or "ilum.cloud" in repo.get("url", ""):
                        return
        except HelmError:
            pass  # No repos configured yet — fall through to add

        self.helm.repo_add(CHART_REPO_NAME, CHART_REPO_URL)
        self.helm.repo_update()

    def resolve_latest_version(self, chart: str, devel: bool = False) -> str:
        """Query the Helm repo for the latest version of *chart*.

        Returns the version string, or ``""`` if it cannot be determined.
        When *devel* is True, includes pre-release versions.
        """
        try:
            result = self.helm.search_repo(chart, devel=devel)
            if result.json_data:
                return str(result.json_data[0].get("version", ""))
        except HelmError:
            pass
        return ""

    # -- Release queries ---------------------------------------------------

    def release_exists(self, release: str) -> bool:
        """Return ``True`` if *release* exists in the current namespace."""
        try:
            result = self.helm.list_releases()
        except HelmError:
            return False

        if result.json_data:
            for rel in result.json_data:
                if rel.get("name") == release:
                    return True
        return False

    def get_release_info(self, release: str) -> ReleaseInfo:
        """Fetch metadata about *release*, raising if not found."""
        try:
            result = self.helm.history(release)
        except HelmError as exc:
            raise ReleaseNotFoundError(
                f"Release '{release}' not found.",
                suggestion="Use 'ilum install' to create a new release.",
                error_code="ILUM-023",
            ) from exc

        if not result.json_data:
            raise ReleaseNotFoundError(
                f"No history for release '{release}'.",
                suggestion="Use 'ilum install' to create a new release.",
                error_code="ILUM-023",
            )

        latest = result.json_data[-1]
        chart_full = latest.get("chart", "")
        # chart field is like "ilum-6.7.0" or "ilum-6.7.0-RC1"
        # Split on the first hyphen followed by a digit to handle
        # versions with hyphens (e.g. 6.7.0-RC1, 6.7.0-beta.1)
        m = re.search(r"-(\d)", chart_full)
        if m:
            split_pos = m.start()
            chart_name = chart_full[:split_pos]
            chart_ver = chart_full[split_pos + 1 :]
        else:
            chart_name = chart_full
            chart_ver = ""

        return ReleaseInfo(
            name=release,
            namespace=self.helm.namespace,
            status=latest.get("status", "unknown"),
            chart=chart_name,
            chart_version=chart_ver,
            revision=int(latest.get("revision", 0)),
            last_deployed=latest.get("updated", ""),
        )

    def is_stuck(self, release: str) -> bool:
        """Return ``True`` if *release* is in a stuck state."""
        try:
            info = self.get_release_info(release)
        except ReleaseNotFoundError:
            return False
        return info.status in (
            "pending-install",
            "pending-upgrade",
            "pending-rollback",
        )

    def scan_releases(self, namespace: str = "") -> list[ReleaseInfo]:
        """Scan for Ilum Helm releases, optionally filtered to *namespace*.

        When *namespace* is empty, scans all namespaces.  Returns only
        releases whose chart name starts with ``ilum``.
        """
        try:
            all_ns = not namespace
            result = self.helm.list_releases(all_namespaces=all_ns)
        except HelmError:
            return []

        releases: list[ReleaseInfo] = []
        for rel in result.json_data or []:
            chart_full = rel.get("chart", "")
            # Chart field from `helm list` is like "ilum-6.7.0"
            m = re.search(r"-(\d)", chart_full)
            if m:
                chart_name = chart_full[: m.start()]
                chart_ver = chart_full[m.start() + 1 :]
            else:
                chart_name = chart_full
                chart_ver = ""

            if not (chart_name == "ilum" or chart_name.startswith("ilum-")):
                continue

            # Filter by exact chart name "ilum" (not sub-charts like "ilum-core")
            # The umbrella chart name in `helm list` is just "ilum"
            if chart_name != "ilum":
                continue

            releases.append(
                ReleaseInfo(
                    name=rel.get("name", ""),
                    namespace=rel.get("namespace", namespace or self.helm.namespace),
                    status=rel.get("status", "unknown"),
                    chart=chart_name,
                    chart_version=chart_ver,
                    revision=int(rel.get("revision", "0")),
                    last_deployed=rel.get("updated", ""),
                )
            )

        return releases

    # -- Live values -------------------------------------------------------

    def fetch_live_values(self, release: str) -> dict[str, Any]:
        """Fetch the user-supplied values from a live release."""
        result = self.helm.get_values(release)
        return dict(result.json_data or {})

    def fetch_computed_values(self, release: str) -> dict[str, Any]:
        """Fetch computed values (user-supplied + chart defaults) from a live release."""
        result = self.helm.get_values_all(release)
        return dict(result.json_data or {})

    def check_drift(self, release: str) -> DriftResult:
        """Detect external changes to *release* values."""
        live = self.fetch_live_values(release)
        return detect_drift(
            release=release,
            namespace=self.helm.namespace,
            live_values=live,
            state_dir=self.paths.state_dir,
        )

    # -- CRD management ----------------------------------------------------

    def ensure_module_crds(self, module_names: list[str]) -> None:
        """Install CRD charts required by the given modules."""
        for mod_name in module_names:
            mod = self.resolver.get(mod_name)
            if not mod.crd_chart:
                continue
            crd_release = f"{mod_name}-crds"
            if not self.helm.release_exists(crd_release):
                # Ensure the chart repo is available
                repo_name = mod.crd_chart.split("/")[0]
                if repo_name == "prometheus-community":
                    self.helm.repo_add(
                        repo_name,
                        "https://prometheus-community.github.io/helm-charts",
                    )
                self.helm.install(
                    release=crd_release,
                    chart=mod.crd_chart,
                )

    # -- Plan builders -----------------------------------------------------

    def plan_install(
        self,
        release: str,
        chart: str,
        modules: list[str],
        values_files: list[Path] | None = None,
        set_flags: list[str] | None = None,
        version: str = "",
        atomic: bool = False,
        devel: bool = False,
    ) -> ReleasePlan:
        """Build a :class:`ReleasePlan` for a fresh installation."""
        if self.release_exists(release):
            raise ReleaseExistsError(
                f"Release '{release}' already exists.",
                suggestion="Use 'ilum upgrade' to modify an existing release.",
                error_code="ILUM-024",
            )

        # Resolve module flags (dependencies first, then user --set last)
        module_flags: list[str] = []
        for mod_name in modules:
            module_flags.extend(self.resolver.resolve_enables(mod_name))

        # Validate combination
        all_modules = set(modules)
        for mod_name in modules:
            deps: set[str] = set()
            self.resolver._collect_all_deps(mod_name, deps)
            all_modules.update(deps)
        warnings = self.resolver.validate_combination(frozenset(all_modules))

        # Module flags go first, then user --set flags override
        combined_flags = module_flags + (set_flags or [])

        # Compute values for preview
        computed: dict[str, Any] = {}
        computed = apply_set_flags(computed, combined_flags)

        return ReleasePlan(
            action="install",
            release=release,
            namespace=self.helm.namespace,
            chart=chart,
            chart_version=version,
            set_flags=combined_flags,
            values_files=list(values_files or []),
            modules=modules,
            warnings=warnings,
            computed_values=computed,
            atomic=atomic,
            devel=devel,
        )

    def plan_upgrade(
        self,
        release: str,
        chart: str,
        modules: list[str] | None = None,
        values_files: list[Path] | None = None,
        set_flags: list[str] | None = None,
        version: str = "",
        atomic: bool = False,
        devel: bool = False,
        reset_defaults: bool = False,
    ) -> ReleasePlan:
        """Build a :class:`ReleasePlan` for upgrading an existing release."""
        if not self.release_exists(release):
            raise ReleaseNotFoundError(
                f"Release '{release}' not found.",
                suggestion="Use 'ilum install' to create a new release.",
                error_code="ILUM-023",
            )

        # Fetch live values and detect drift
        drift = self.check_drift(release)
        live_values = drift.live_values

        # Build overlay from modules + set_flags
        module_flags: list[str] = []
        if modules:
            for mod_name in modules:
                module_flags.extend(self.resolver.resolve_enables(mod_name))

        combined_flags = module_flags + (set_flags or [])

        # Compute desired values: deep_merge(live, overlay) + apply_set_flags
        computed = apply_set_flags(dict(live_values), combined_flags)

        # Compute diff
        effective_diff = compute_diff(live_values, computed)

        # Check for breaking changes
        warnings: list[str] = []
        if version:
            info = self.get_release_info(release)
            warnings.extend(self._check_breaking_changes(info.chart_version, version))

        return ReleasePlan(
            action="upgrade",
            release=release,
            namespace=self.helm.namespace,
            chart=chart,
            chart_version=version,
            set_flags=combined_flags,
            values_files=list(values_files or []),
            modules=modules or [],
            warnings=warnings,
            computed_values=computed,
            effective_diff=effective_diff,
            drift=drift,
            atomic=atomic,
            devel=devel,
            reset_defaults=reset_defaults,
        )

    def plan_enable(
        self,
        release: str,
        chart: str,
        module_names: list[str],
        set_flags: list[str] | None = None,
        devel: bool = False,
        reset_defaults: bool = False,
    ) -> ReleasePlan:
        """Build a plan to enable modules on an existing release."""
        if not self.release_exists(release):
            raise ReleaseNotFoundError(
                f"Release '{release}' not found.",
                suggestion="Use 'ilum install' to create a new release.",
                error_code="ILUM-023",
            )

        drift = self.check_drift(release)
        live_values = drift.live_values

        module_flags: list[str] = []
        for mod_name in module_names:
            module_flags.extend(self.resolver.resolve_enables(mod_name))

        combined_flags = module_flags + (set_flags or [])
        computed = apply_set_flags(dict(live_values), combined_flags)
        effective_diff = compute_diff(live_values, computed)

        # Validate combination (requested + already enabled + their deps).
        # Use computed values (user-supplied + chart defaults) so that modules
        # enabled by chart defaults (e.g. core, mongodb) are detected even when
        # the user-supplied values are empty (typical after ``ilum connect``).
        computed_values = self.fetch_computed_values(release)
        live_modules = self.resolver.detect_enabled_modules(computed_values)
        all_modules = set(module_names) | set(live_modules)
        for mod_name in module_names:
            deps: set[str] = set()
            self.resolver._collect_all_deps(mod_name, deps)
            all_modules.update(deps)
        warnings = self.resolver.validate_combination(frozenset(all_modules))

        return ReleasePlan(
            action="enable",
            release=release,
            namespace=self.helm.namespace,
            chart=chart,
            set_flags=combined_flags,
            modules=module_names,
            computed_values=computed,
            effective_diff=effective_diff,
            drift=drift,
            warnings=warnings,
            devel=devel,
            reset_defaults=reset_defaults,
        )

    def plan_disable(
        self,
        release: str,
        chart: str,
        module_names: list[str],
        active_modules: frozenset[str],
        set_flags: list[str] | None = None,
        devel: bool = False,
        reset_defaults: bool = False,
    ) -> ReleasePlan:
        """Build a plan to disable modules on an existing release."""
        if not self.release_exists(release):
            raise ReleaseNotFoundError(
                f"Release '{release}' not found.",
                suggestion="Use 'ilum install' to create a new release.",
                error_code="ILUM-023",
            )

        drift = self.check_drift(release)
        live_values = drift.live_values

        module_flags: list[str] = []
        for mod_name in module_names:
            module_flags.extend(self.resolver.resolve_disables(mod_name, active_modules))

        combined_flags = module_flags + (set_flags or [])
        computed = apply_set_flags(dict(live_values), combined_flags)
        effective_diff = compute_diff(live_values, computed)

        # Warn if disabling core modules
        warnings: list[str] = []
        for mod_name in module_names:
            mod = self.resolver.get(mod_name)
            if mod.default_enabled:
                warnings.append(
                    f"Module '{mod_name}' is a default module."
                    " Disabling it may affect core functionality."
                )

        return ReleasePlan(
            action="disable",
            release=release,
            namespace=self.helm.namespace,
            chart=chart,
            set_flags=combined_flags,
            modules=module_names,
            computed_values=computed,
            effective_diff=effective_diff,
            drift=drift,
            warnings=warnings,
            devel=devel,
            reset_defaults=reset_defaults,
        )

    # -- Preview -----------------------------------------------------------

    def preview_command(self, plan: ReleasePlan) -> list[str]:
        """Return the full Helm command for *plan* without executing."""
        if plan.action == "install":
            return self.helm.preview_install(
                release=plan.release,
                chart=plan.chart,
                values_files=plan.values_files,
                set_flags=plan.set_flags,
                atomic=plan.atomic,
                version=plan.chart_version,
                devel=plan.devel,
            )
        if plan.action in ("upgrade", "enable", "disable"):
            return self.helm.preview_upgrade(
                release=plan.release,
                chart=plan.chart,
                values_files=plan.values_files,
                set_flags=plan.set_flags,
                atomic=plan.atomic,
                version=plan.chart_version,
                reuse_values=not plan.reset_defaults,
                reset_then_reuse_values=plan.reset_defaults,
                devel=plan.devel,
            )
        if plan.action == "uninstall":
            return self.helm.preview_uninstall(release=plan.release)
        raise ValueError(f"Unknown plan action: {plan.action}")

    # -- Execution ---------------------------------------------------------

    def execute(self, plan: ReleasePlan) -> HelmResult:
        """Execute a :class:`ReleasePlan` and save a snapshot on success."""
        import time

        start_time = time.monotonic()
        success = True
        error_code = ""

        # Save backup before destructive operations
        if plan.action in ("upgrade", "enable", "disable"):
            try:
                from ilum.core.safety import save_backup

                live = self.fetch_live_values(plan.release)
                save_backup(
                    release=plan.release,
                    namespace=plan.namespace,
                    values=live,
                    state_dir=self.paths.state_dir,
                )
            except Exception:
                pass  # Best-effort backup — don't block the operation

        try:
            if plan.action == "install":
                result = self.helm.install(
                    release=plan.release,
                    chart=plan.chart,
                    values_files=plan.values_files,
                    set_flags=plan.set_flags,
                    atomic=plan.atomic,
                    version=plan.chart_version,
                    devel=plan.devel,
                )
            elif plan.action in ("upgrade", "enable", "disable"):
                result = self.helm.upgrade(
                    release=plan.release,
                    chart=plan.chart,
                    values_files=plan.values_files,
                    set_flags=plan.set_flags,
                    atomic=plan.atomic,
                    version=plan.chart_version,
                    reuse_values=not plan.reset_defaults,
                    reset_then_reuse_values=plan.reset_defaults,
                    devel=plan.devel,
                )
            elif plan.action == "uninstall":
                result = self.helm.uninstall(release=plan.release)
                delete_snapshot(plan.release, plan.namespace, self.paths.state_dir)
                return result
            else:
                raise ValueError(f"Unknown plan action: {plan.action}")

            # Save snapshot on success
            snapshot = make_snapshot(
                release=plan.release,
                namespace=plan.namespace,
                chart_version=plan.chart_version,
                values=plan.computed_values,
                operation=plan.action,
            )
            save_snapshot(snapshot, self.paths.state_dir)

            return result
        except Exception as exc:
            success = False
            if hasattr(exc, "error_code"):
                error_code = exc.error_code
            raise
        finally:
            duration = time.monotonic() - start_time
            try:
                from datetime import UTC, datetime

                from ilum.core.audit import AuditEntry, AuditLog, _get_user

                audit = AuditLog(self.paths.state_dir)
                audit.append(
                    AuditEntry(
                        timestamp=datetime.now(UTC).isoformat(),
                        operation=plan.action,
                        release=plan.release,
                        namespace=plan.namespace,
                        context=self.helm.kubecontext or "",
                        user=_get_user(),
                        chart_version=plan.chart_version,
                        modules=plan.modules,
                        set_flags=plan.set_flags,
                        success=success,
                        duration_seconds=round(duration, 2),
                        error_code=error_code,
                    )
                )
            except Exception:
                pass  # Best-effort audit — don't break the operation

    def rollback_if_stuck(self, release: str) -> bool:
        """Rollback *release* if it is stuck. Returns ``True`` if rollback was performed."""
        if not self.is_stuck(release):
            return False
        self.helm.rollback(release)
        return True

    # -- Breaking changes --------------------------------------------------

    @staticmethod
    def _check_breaking_changes(current_version: str, target_version: str) -> list[str]:
        """Return human-readable warnings for known breaking changes."""
        current = parse_version(current_version)
        target = parse_version(target_version)

        # Guard: unparseable, downgrade, or same version
        if current == (0, 0, 0) or target == (0, 0, 0):
            return []
        if target <= current:
            return []

        warnings: list[str] = []
        for bc in BREAKING_CHANGES:
            bc_from = parse_version(bc.from_version)
            bc_to = parse_version(bc.to_version)
            if current[:2] <= bc_from[:2] and target[:2] >= bc_to[:2]:
                msg = f"Breaking change ({bc.from_version} -> {bc.to_version}): {bc.description}"
                if bc.migration_hint:
                    msg += f"\n  Hint: {bc.migration_hint}"
                warnings.append(msg)

        # Generic minor-version bump notice
        if current[0] == target[0] and current[1] != target[1]:
            warnings.append(
                f"Upgrading across minor versions ({current_version} → {target_version}). "
                f"Review upgrade notes: {UPGRADE_NOTES_URL}"
            )
            warnings.append(
                "Consider using --reset-defaults to start from new chart defaults. "
                "This avoids stale sub-chart values breaking the upgrade."
            )

        return warnings

    # -- Config helpers ----------------------------------------------------

    def _find_profile_for_release(self, release: str) -> str | None:
        """Return the profile name whose ``release_name`` matches *release*.

        Returns ``None`` if no profile tracks this release.
        """
        config = self.config_mgr.load()
        for name, profile in config.profiles.items():
            if profile.release_name == release:
                return name
        return None

    def save_enabled_modules(self, modules: list[str], release: str = "") -> None:
        """Persist *modules* to the profile matching *release*.

        Falls back to the active profile when *release* is empty or no
        profile tracks the given release.
        """
        config = self.config_mgr.load()
        profile_name = (
            self._find_profile_for_release(release) if release else None
        ) or config.active_profile
        if profile_name in config.profiles:
            config.profiles[profile_name].enabled_modules = modules
            self.config_mgr.save(config)

    def get_enabled_modules(self, release: str = "") -> list[str]:
        """Return ``enabled_modules`` from the profile matching *release*.

        Falls back to the active profile when *release* is empty or no
        profile tracks the given release.
        """
        config = self.config_mgr.load()
        profile_name = (
            self._find_profile_for_release(release) if release else None
        ) or config.active_profile
        if profile_name in config.profiles:
            return config.profiles[profile_name].enabled_modules
        return []
