# platform/deskclaw/backup/engine.py
# ═══════════════════════════════════════════════════════════════
# Phase 1: Backup Engine — Atomic Snapshots & Environment Backup
# Creates tar.gz snapshots with SHA256 integrity, manifest.json,
# and supports list / prune / restore operations.
# ═══════════════════════════════════════════════════════════════

from __future__ import annotations

import hashlib
import json
import os
import shutil
import tarfile
import tempfile
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path

# ── Defaults ──

_DEFAULT_EXCLUDE = frozenset({".git", "__pycache__", "node_modules", "*.pyc"})
_DESKCLAW_VERSION = "0.5.0"


# ── Data Models ──


@dataclass(frozen=True)
class SnapshotManifest:
    """Immutable record of a single backup snapshot."""

    snapshot_id: str
    agent: str
    timestamp: str  # ISO-8601
    paths_backed_up: list[str]
    sha256: str
    size_bytes: int
    deskclaw_version: str = _DESKCLAW_VERSION

    def to_dict(self) -> dict:
        return asdict(self)

    @classmethod
    def from_dict(cls, data: dict) -> SnapshotManifest:
        return cls(**data)


class BackupError(Exception):
    """Raised when a backup operation fails."""


class IntegrityError(BackupError):
    """Snapshot SHA256 integrity check failed."""


# ── Core Engine ──


class BackupEngine:
    """
    Atomic snapshot engine for DeskClaw Phase 1.

    Responsibilities:
    - Create tar.gz snapshots from plugin-declared backup paths
    - Generate SHA256 checksum + manifest.json per snapshot
    - List, prune (keep-N), and restore snapshots
    """

    def __init__(self, snapshot_dir: str | Path = "./snapshots") -> None:
        self.snapshot_dir = Path(snapshot_dir)
        self.snapshot_dir.mkdir(parents=True, exist_ok=True)

    # ── Create ──

    def create(
        self,
        agent_name: str,
        paths: list[str],
        *,
        exclude: set[str] | None = None,
        timestamp: datetime | None = None,
    ) -> SnapshotManifest:
        """
        Create an atomic tar.gz snapshot for *agent_name*.

        Args:
            agent_name: Agent identifier (e.g. ``nanobot-v1``).
            paths:      Directories / files to include (supports ``~`` expansion).
            exclude:    Glob patterns to skip (defaults to .git, __pycache__, etc.).
            timestamp:  Override for testing; defaults to ``datetime.now(timezone.utc)``.

        Returns:
            :class:`SnapshotManifest` describing the new snapshot.

        Raises:
            BackupError: If no valid paths are found or archive creation fails.
        """
        exclude = set(exclude) if exclude else set(_DEFAULT_EXCLUDE)
        ts = timestamp or datetime.now(timezone.utc)
        ts_tag = ts.strftime("%Y%m%dT%H%M%S")
        snapshot_id = f"{ts_tag}_{agent_name}"

        archive_name = f"{snapshot_id}.tar.gz"
        archive_path = self.snapshot_dir / archive_name
        manifest_name = f"{snapshot_id}_manifest.json"
        manifest_path = self.snapshot_dir / manifest_name

        expanded_paths = [Path(p).expanduser() for p in paths]
        valid_paths = [p for p in expanded_paths if p.exists()]
        if not valid_paths:
            raise BackupError(
                f"No valid backup paths found. Checked: {[str(p) for p in expanded_paths]}"
            )

        # Build tar.gz
        self._create_archive(archive_path, valid_paths, exclude)

        # SHA256
        sha256 = _sha256_file(archive_path)
        size_bytes = archive_path.stat().st_size

        manifest = SnapshotManifest(
            snapshot_id=snapshot_id,
            agent=agent_name,
            timestamp=ts.isoformat(),
            paths_backed_up=[str(p) for p in valid_paths],
            sha256=sha256,
            size_bytes=size_bytes,
            deskclaw_version=_DESKCLAW_VERSION,
        )

        manifest_path.write_text(json.dumps(manifest.to_dict(), indent=2))
        return manifest

    # ── List ──

    def list_snapshots(self, *, agent: str | None = None) -> list[SnapshotManifest]:
        """Return all manifests, optionally filtered by *agent*."""
        manifests: list[SnapshotManifest] = []
        for path in sorted(self.snapshot_dir.glob("*_manifest.json")):
            try:
                data = json.loads(path.read_text())
                m = SnapshotManifest.from_dict(data)
                if agent is None or m.agent == agent:
                    manifests.append(m)
            except (json.JSONDecodeError, TypeError, KeyError):
                continue
        return manifests

    # ── Prune ──

    def prune(self, *, keep: int = 10, agent: str | None = None) -> list[str]:
        """
        Delete old snapshots, keeping the most recent *keep*.

        Returns list of pruned snapshot IDs.
        """
        manifests = self.list_snapshots(agent=agent)
        # Sort newest-first by timestamp
        manifests.sort(key=lambda m: m.timestamp, reverse=True)
        to_prune = manifests[keep:]
        pruned_ids: list[str] = []
        for m in to_prune:
            self._delete_snapshot_files(m.snapshot_id)
            pruned_ids.append(m.snapshot_id)
        return pruned_ids

    # ── Restore ──

    def restore(
        self,
        snapshot_id: str | None = None,
        *,
        latest: bool = False,
    ) -> SnapshotManifest:
        """
        Restore environment from a snapshot.

        Args:
            snapshot_id: Explicit snapshot to restore.
            latest:      If ``True``, restore the most recent snapshot.

        Returns:
            The manifest of the restored snapshot.

        Raises:
            BackupError:     Snapshot not found.
            IntegrityError:  SHA256 mismatch.
        """
        manifest = self._resolve_manifest(snapshot_id=snapshot_id, latest=latest)
        archive_path = self.snapshot_dir / f"{manifest.snapshot_id}.tar.gz"

        if not archive_path.exists():
            raise BackupError(f"Archive not found: {archive_path}")

        # Integrity check
        verify_integrity(archive_path, manifest.sha256)

        # Extract to original paths
        self._extract_archive(archive_path, manifest.paths_backed_up)
        return manifest

    # ── Verify (public) ──

    def verify(self, snapshot_id: str) -> bool:
        """Verify SHA256 integrity of *snapshot_id* without restoring."""
        manifest = self._resolve_manifest(snapshot_id=snapshot_id)
        archive_path = self.snapshot_dir / f"{manifest.snapshot_id}.tar.gz"
        if not archive_path.exists():
            raise BackupError(f"Archive not found: {archive_path}")
        verify_integrity(archive_path, manifest.sha256)
        return True

    # ── Private helpers ──

    def _create_archive(
        self, archive_path: Path, paths: list[Path], exclude: set[str]
    ) -> None:
        """Create a tar.gz from *paths*, applying *exclude* filters."""

        def _filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None:
            name = tarinfo.name
            for pattern in exclude:
                if pattern.startswith("*"):
                    # Suffix match (e.g. *.pyc)
                    if name.endswith(pattern[1:]):
                        return None
                elif f"/{pattern}" in f"/{name}" or name == pattern:
                    return None
            return tarinfo

        with tarfile.open(archive_path, "w:gz") as tar:
            for p in paths:
                arcname = str(p)
                tar.add(str(p), arcname=arcname, filter=_filter)

    @staticmethod
    def _extract_archive(archive_path: Path, original_paths: list[str]) -> None:
        """Extract tar.gz, restoring files to their original locations."""
        # We extract into a temporary directory first, then move into place
        with tempfile.TemporaryDirectory() as tmpdir:
            with tarfile.open(archive_path, "r:gz") as tar:
                tar.extractall(tmpdir)  # noqa: S202 — paths are trusted (our own snapshots)

            # For each originally-backed-up path, replace with extracted version
            for orig in original_paths:
                orig_path = Path(orig)
                extracted = Path(tmpdir) / orig.lstrip("/")
                if not extracted.exists():
                    continue
                if orig_path.exists():
                    if orig_path.is_dir():
                        shutil.rmtree(orig_path)
                    else:
                        orig_path.unlink()
                orig_path.parent.mkdir(parents=True, exist_ok=True)
                shutil.move(str(extracted), str(orig_path))

    def _resolve_manifest(
        self,
        snapshot_id: str | None = None,
        latest: bool = False,
    ) -> SnapshotManifest:
        """Look up a manifest by ID or pick the latest."""
        if latest:
            manifests = self.list_snapshots()
            if not manifests:
                raise BackupError("No snapshots available.")
            manifests.sort(key=lambda m: m.timestamp, reverse=True)
            return manifests[0]
        if snapshot_id is None:
            raise BackupError("Provide snapshot_id or latest=True.")
        manifest_path = self.snapshot_dir / f"{snapshot_id}_manifest.json"
        if not manifest_path.exists():
            raise BackupError(f"Manifest not found: {manifest_path}")
        data = json.loads(manifest_path.read_text())
        return SnapshotManifest.from_dict(data)

    def _delete_snapshot_files(self, snapshot_id: str) -> None:
        """Remove archive + manifest for *snapshot_id*."""
        for suffix in (".tar.gz", "_manifest.json"):
            path = self.snapshot_dir / f"{snapshot_id}{suffix}"
            if path.exists():
                path.unlink()


# ── Standalone utilities ──


def _sha256_file(filepath: Path) -> str:
    """Compute hex SHA256 digest of *filepath*."""
    sha = hashlib.sha256()
    with open(filepath, "rb") as f:
        for chunk in iter(lambda: f.read(8192), b""):
            sha.update(chunk)
    return sha.hexdigest()


def verify_integrity(snapshot_path: Path, expected_sha256: str) -> bool:
    """
    Verify SHA256 of *snapshot_path*.

    Raises:
        IntegrityError: On mismatch.
    """
    actual = _sha256_file(snapshot_path)
    if actual != expected_sha256:
        raise IntegrityError(
            f"Snapshot integrity check FAILED.\n"
            f"  Expected: {expected_sha256}\n"
            f"  Actual:   {actual}\n"
            f"  File:     {snapshot_path}"
        )
    return True
