"""Snapshot Manager — capture and restore database state.

Supports SQLite backup (native) and Django serialization for cross-db portability.
Snapshots are tagged and stored for test isolation, demo state management,
and reproducible development environments.
"""

from __future__ import annotations

import gzip
import json
import shutil
import sqlite3
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any

from django.conf import settings
from django.core import serializers
from django.db import transaction

from simorgh.apps.provisioning.models import FixtureRun

if TYPE_CHECKING:

    from django.db.models import Model


SNAPSHOT_DIR = Path(settings.BASE_DIR) / "var" / "snapshots"


class SnapshotManager:
    """Create, list, restore, and delete database snapshots.

    For SQLite, uses sqlite3's built-in backup API for fast binary snapshots.
    For other databases, falls back to Django serialization.

    Usage:
        mgr = SnapshotManager()
        mgr.create("before_migration")
        mgr.list_all()
        mgr.restore("before_migration")
        mgr.delete("before_migration")
    """

    def __init__(self, storage_dir: Path | None = None) -> None:
        self._dir = storage_dir or SNAPSHOT_DIR
        self._dir.mkdir(parents=True, exist_ok=True)

    @property
    def storage_path(self) -> Path:
        return self._dir

    def create(
        self,
        name: str,
        tags: list[str] | None = None,
        description: str = "",
        models: list[type[Model] | str] | None = None,
    ) -> Path:
        """Create a named snapshot of the current database state.

        Args:
            name: Unique snapshot name (slug-friendly).
            tags: Optional tags for categorization.
            description: Human-readable description.
            models: Optional list of model classes to snapshot (all if None).

        Returns:
            Path to the created snapshot file.
        """
        timestamp = int(time.time())
        db_engine = settings.DATABASES["default"]["ENGINE"]

        if "sqlite3" in db_engine:
            return self._create_sqlite_backup(name, timestamp, tags, description)
        return self._create_serialized(name, timestamp, tags, description, models)

    def _create_sqlite_backup(
        self,
        name: str,
        timestamp: int,
        tags: list[str] | None,
        description: str,
    ) -> Path:
        """Native SQLite backup."""
        db_path = settings.DATABASES["default"]["NAME"]
        snapshot_path = self._dir / f"{name}_{timestamp}.sqlite3.gz"

        with sqlite3.connect(db_path) as src, gzip.open(snapshot_path, "wb") as dst:
            src.backup(dst)

        self._write_meta(name, timestamp, tags, description, snapshot_path)
        self._record_run(name, "snapshot_create", snapshot_path)
        return snapshot_path

    def _create_serialized(
        self,
        name: str,
        timestamp: int,
        tags: list[str] | None,
        description: str,
        models: list[type[Model] | str] | None,
    ) -> Path:
        """Django serialization fallback for non-SQLite databases."""
        snapshot_path = self._dir / f"{name}_{timestamp}.json.gz"

        if models is None:
            from django.apps import apps as django_apps
            models = list(django_apps.get_models())

        all_objects: list[str] = []
        for model in models:
            data = serializers.serialize("json", model.objects.all())
            all_objects.append(data)

        with gzip.open(snapshot_path, "wt", encoding="utf-8") as f:
            json.dump({"models": all_objects, "meta": {"name": name, "timestamp": timestamp}}, f)

        self._write_meta(name, timestamp, tags, description, snapshot_path)
        self._record_run(name, "snapshot_create", snapshot_path)
        return snapshot_path

    def restore(self, name: str) -> bool:
        """Restore a named snapshot, replacing the current database state.

        Args:
            name: Snapshot name (or exact filename stem).

        Returns:
            True if restore succeeded.

        Raises:
            FileNotFoundError: If the snapshot does not exist.
        """
        snapshot_path = self._find_snapshot(name)
        if snapshot_path is None:
            raise FileNotFoundError(f"Snapshot {name!r} not found in {self._dir}")

        db_engine = settings.DATABASES["default"]["ENGINE"]

        if "sqlite3" in db_engine:
            return self._restore_sqlite(snapshot_path)
        return self._restore_serialized(snapshot_path)

    def _restore_sqlite(self, path: Path) -> bool:
        db_path = settings.DATABASES["default"]["NAME"]
        backup_path = path.with_suffix("")  # Remove .gz

        if path.suffix == ".gz":
            with gzip.open(path, "rb") as src, open(backup_path, "wb") as dst:
                shutil.copyfileobj(src, dst, length=64 * 1024)
        else:
            backup_path = path

        with sqlite3.connect(backup_path) as src, sqlite3.connect(db_path) as dst:
            src.backup(dst)

        if path.suffix == ".gz":
            backup_path.unlink(missing_ok=True)

        self._record_run(path.stem, "snapshot_restore", path)
        return True

    def _restore_serialized(self, path: Path) -> bool:

        with gzip.open(path, "rt", encoding="utf-8") as f:
            data = json.load(f)

        with transaction.atomic():
            for model_data in data["models"]:
                for obj in serializers.deserialize("json", model_data):
                    obj.save()

        self._record_run(path.stem, "snapshot_restore", path)
        return True

    def list_all(self) -> list[dict[str, Any]]:
        """List all available snapshots with metadata."""
        snapshots: list[dict[str, Any]] = []
        for meta_file in sorted(self._dir.glob("*.meta.json"), reverse=True):
            with open(meta_file) as f:
                meta = json.load(f)
            snapshots.append(meta)
        return snapshots

    def delete(self, name: str) -> int:
        """Delete a snapshot and its metadata. Returns number of files removed."""
        count = 0
        for pattern in [f"{name}_*.sqlite3.gz", f"{name}_*.json.gz", f"{name}_*.meta.json"]:
            for p in self._dir.glob(pattern):
                p.unlink()
                count += 1
        return count

    def _find_snapshot(self, name: str) -> Path | None:
        """Find the most recent snapshot file matching the name."""
        candidates = sorted(
            list(self._dir.glob(f"{name}_*.sqlite3.gz"))
            + list(self._dir.glob(f"{name}_*.json.gz")),
            reverse=True,
        )
        return candidates[0] if candidates else None

    def _write_meta(
        self,
        name: str,
        timestamp: int,
        tags: list[str] | None,
        description: str,
        path: Path,
    ) -> None:
        meta = {
            "name": name,
            "timestamp": timestamp,
            "tags": tags or [],
            "description": description,
            "file": str(path.name),
            "size_bytes": path.stat().st_size,
        }
        meta_path = self._dir / f"{name}_{timestamp}.meta.json"
        with open(meta_path, "w") as f:
            json.dump(meta, f, indent=2)

    def _record_run(
        self,
        name: str,
        kind: str,
        path: Path,
    ) -> None:
        FixtureRun.objects.create(
            kind=kind,
            fixture_names=[name],
            success=True,
            meta={"file": str(path)},
        )

    def cleanup(self, keep_latest: int = 10) -> int:
        """Remove old snapshots, keeping only the most recent N."""
        all_snapshots = self.list_all()
        if len(all_snapshots) <= keep_latest:
            return 0
        removed = 0
        for snap in all_snapshots[keep_latest:]:
            removed += self.delete(snap["name"])
        return removed
