"""Values-safety system: snapshot I/O, drift detection, and diff computation.

Every upgrade/enable/disable operation follows a fetch-merge-diff-apply
pipeline to ensure the CLI never silently discards values that were changed
outside it (e.g. via ``helm upgrade --set custom.value=foo``).
"""

from __future__ import annotations

import copy
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from ruamel.yaml import YAML

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


@dataclass(frozen=True)
class ValuesSnapshot:
    """Point-in-time capture of values the CLI applied."""

    release: str
    namespace: str
    timestamp: str  # ISO 8601
    chart_version: str
    values: dict[str, Any]
    operation: str  # "install" | "upgrade" | "enable" | "disable"


@dataclass
class ValuesDiff:
    """Diff between two values dictionaries (flattened to dot-paths)."""

    added: dict[str, Any] = field(default_factory=dict)
    removed: dict[str, Any] = field(default_factory=dict)
    changed: dict[str, tuple[Any, Any]] = field(default_factory=dict)

    @property
    def is_empty(self) -> bool:
        return not self.added and not self.removed and not self.changed


@dataclass
class DriftResult:
    """Outcome of comparing live values against last CLI snapshot."""

    has_drift: bool
    snapshot_exists: bool
    diff: ValuesDiff | None  # None when no snapshot exists
    live_values: dict[str, Any]


# ---------------------------------------------------------------------------
# Pure functions
# ---------------------------------------------------------------------------


def flatten_dict(d: dict[str, Any], prefix: str = "") -> dict[str, Any]:
    """Flatten a nested dict to dot-path keys.

    >>> flatten_dict({"a": {"b": 1, "c": 2}})
    {'a.b': 1, 'a.c': 2}
    """
    items: dict[str, Any] = {}
    for key, value in d.items():
        new_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
        if isinstance(value, dict) and value:
            items.update(flatten_dict(value, new_key))
        else:
            items[new_key] = value
    return items


def compute_diff(old: dict[str, Any], new: dict[str, Any]) -> ValuesDiff:
    """Compare two flattened dicts and return a :class:`ValuesDiff`."""
    flat_old = flatten_dict(old)
    flat_new = flatten_dict(new)

    all_keys = set(flat_old) | set(flat_new)

    added: dict[str, Any] = {}
    removed: dict[str, Any] = {}
    changed: dict[str, tuple[Any, Any]] = {}

    for key in sorted(all_keys):
        in_old = key in flat_old
        in_new = key in flat_new
        if in_old and not in_new:
            removed[key] = flat_old[key]
        elif not in_old and in_new:
            added[key] = flat_new[key]
        elif flat_old[key] != flat_new[key]:
            changed[key] = (flat_old[key], flat_new[key])

    return ValuesDiff(added=added, removed=removed, changed=changed)


# ---------------------------------------------------------------------------
# Snapshot I/O
# ---------------------------------------------------------------------------

_YAML = YAML()
_YAML.default_flow_style = False


def _sanitize_context(context: str) -> str:
    """Sanitize a kubeconfig context name for use in file paths."""
    return re.sub(r"[^a-zA-Z0-9_-]", "_", context)


def _get_current_context() -> str:
    """Get the current kubeconfig context name, or 'default' if unavailable."""
    try:
        from kubernetes.config import list_kube_config_contexts

        _, active = list_kube_config_contexts()
        return str(active.get("name", "default")) if active else "default"
    except Exception:
        return "default"


def _snapshot_path(release: str, namespace: str, state_dir: Path, context: str = "") -> Path:
    ctx = _sanitize_context(context) if context else _sanitize_context(_get_current_context())
    return state_dir / "snapshots" / f"{ctx}--{release}--{namespace}.yaml"


def _legacy_snapshot_path(release: str, namespace: str, state_dir: Path) -> Path:
    """Legacy path without context prefix (for migration)."""
    return state_dir / "snapshots" / f"{release}--{namespace}.yaml"


def save_snapshot(snapshot: ValuesSnapshot, state_dir: Path, context: str = "") -> None:
    """Persist a :class:`ValuesSnapshot` to the state directory."""
    path = _snapshot_path(snapshot.release, snapshot.namespace, state_dir, context)
    path.parent.mkdir(parents=True, exist_ok=True)

    data = {
        "release": snapshot.release,
        "namespace": snapshot.namespace,
        "timestamp": snapshot.timestamp,
        "chart_version": snapshot.chart_version,
        "operation": snapshot.operation,
        "values": snapshot.values,
    }
    _YAML.dump(data, path)


def load_snapshot(
    release: str,
    namespace: str,
    state_dir: Path,
    context: str = "",
) -> ValuesSnapshot | None:
    """Load a snapshot from disk, or ``None`` if it does not exist."""
    path = _snapshot_path(release, namespace, state_dir, context)
    if not path.exists():
        # Try legacy path and migrate if found
        legacy = _legacy_snapshot_path(release, namespace, state_dir)
        if legacy.exists():
            import shutil

            path.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(str(legacy), str(path))
        else:
            return None

    data = _YAML.load(path)
    if data is None:
        return None

    return ValuesSnapshot(
        release=data["release"],
        namespace=data["namespace"],
        timestamp=data.get("timestamp", ""),
        chart_version=data.get("chart_version", ""),
        values=dict(data.get("values") or {}),
        operation=data.get("operation", "unknown"),
    )


def delete_snapshot(release: str, namespace: str, state_dir: Path, context: str = "") -> None:
    """Remove a snapshot file if it exists."""
    path = _snapshot_path(release, namespace, state_dir, context)
    if path.exists():
        path.unlink()


# ---------------------------------------------------------------------------
# Drift detection
# ---------------------------------------------------------------------------


def detect_drift(
    release: str,
    namespace: str,
    live_values: dict[str, Any],
    state_dir: Path,
    context: str = "",
) -> DriftResult:
    """Compare *live_values* against the last CLI snapshot.

    Returns a :class:`DriftResult` indicating whether external changes
    were detected.
    """
    snapshot = load_snapshot(release, namespace, state_dir, context)

    if snapshot is None:
        return DriftResult(
            has_drift=False,
            snapshot_exists=False,
            diff=None,
            live_values=live_values,
        )

    diff = compute_diff(snapshot.values, live_values)
    return DriftResult(
        has_drift=not diff.is_empty,
        snapshot_exists=True,
        diff=diff,
        live_values=live_values,
    )


def make_snapshot(
    release: str,
    namespace: str,
    chart_version: str,
    values: dict[str, Any],
    operation: str,
) -> ValuesSnapshot:
    """Create a new :class:`ValuesSnapshot` with the current timestamp."""
    return ValuesSnapshot(
        release=release,
        namespace=namespace,
        timestamp=datetime.now(UTC).isoformat(),
        chart_version=chart_version,
        values=copy.deepcopy(values),
        operation=operation,
    )


def format_diff_table(diff: ValuesDiff) -> list[list[str]]:
    """Return rows suitable for :meth:`IlumConsole.table`."""
    rows: list[list[str]] = []
    for key, value in sorted(diff.added.items()):
        rows.append(["+ added", key, "\u2014", str(value)])
    for key, (old, new) in sorted(diff.changed.items()):
        rows.append(["~ changed", key, str(old), str(new)])
    for key, value in sorted(diff.removed.items()):
        rows.append(["- removed", key, str(value), "\u2014"])
    return rows


# ---------------------------------------------------------------------------
# Backups
# ---------------------------------------------------------------------------

_MAX_BACKUPS = 10


def _backup_dir(state_dir: Path) -> Path:
    return state_dir / "backups"


def save_backup(
    release: str,
    namespace: str,
    values: dict[str, Any],
    state_dir: Path,
    context: str = "",
) -> Path:
    """Save a values backup before destructive operations.

    Returns the path to the created backup file. Caps at _MAX_BACKUPS per release.
    """
    ctx = _sanitize_context(context) if context else _sanitize_context(_get_current_context())
    bak_dir = _backup_dir(state_dir)
    bak_dir.mkdir(parents=True, exist_ok=True)

    timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S")
    filename = f"{ctx}--{release}--{namespace}--{timestamp}.yaml"
    path = bak_dir / filename
    _YAML.dump({"values": values}, path)

    # Prune old backups
    _prune_backups(bak_dir, ctx, release, namespace)
    return path


def _prune_backups(bak_dir: Path, context: str, release: str, namespace: str) -> None:
    """Keep only the most recent _MAX_BACKUPS files per release."""
    prefix = f"{context}--{release}--{namespace}--"
    backups = sorted(
        [f for f in bak_dir.iterdir() if f.name.startswith(prefix) and f.suffix == ".yaml"],
        key=lambda f: f.name,
    )
    while len(backups) > _MAX_BACKUPS:
        oldest = backups.pop(0)
        oldest.unlink()


def list_backups(
    release: str,
    namespace: str,
    state_dir: Path,
    context: str = "",
) -> list[Path]:
    """Return backup paths sorted newest-first, capped at _MAX_BACKUPS."""
    ctx = _sanitize_context(context) if context else _sanitize_context(_get_current_context())
    bak_dir = _backup_dir(state_dir)
    if not bak_dir.exists():
        return []

    prefix = f"{ctx}--{release}--{namespace}--"
    backups = sorted(
        [f for f in bak_dir.iterdir() if f.name.startswith(prefix) and f.suffix == ".yaml"],
        key=lambda f: f.name,
        reverse=True,
    )
    return backups[:_MAX_BACKUPS]


def load_backup(path: Path) -> dict[str, Any]:
    """Load a backup file and return the values dict."""
    data = _YAML.load(path)
    return dict(data.get("values") or {}) if data else {}
