"""Local-first backup system — scan, upload, restore, config snapshots.

Part of Mercury Wave 3. User data never lives on Vastian servers —
backups go to the user's own cloud storage (Google Drive, Dropbox, Box, Tresorit).

Uses the StorageProvider protocol from bridges/protocols.py.

Backup modes:
- archive: date-stamped zip uploads (backup/YYYY-MM-DD_HHmm.zip), pruned by max_archives
- live: per-file mirror at live/{rel} for incremental sync/restore
- both: archive + live
"""

from __future__ import annotations

import contextlib
import hashlib
import json
import logging
import shutil
import tempfile
import zipfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

import yaml

logger = logging.getLogger(__name__)

# Default max snapshots per config file before pruning
MAX_SNAPSHOTS = 20

# Path segments that are never backed up (code, tooling, caches, dot-dirs).
# Backup is for documents, user data, and agent prompts (agents/*.yaml) only.
BACKUP_EXCLUDE_DIRS = (
    ".venv",
    "venv",
    "env",
    "src",
    "tests",
    ".git",
    "__pycache__",
    "node_modules",
    "vastian-docs",  # project docs (ARCHITECTURE, README) — excluded from user backup
)


class BackupConfig:
    """Reads and interprets config/backup/settings.yaml."""

    def __init__(self, project_root: Path) -> None:
        self.root = project_root
        self._settings: dict[str, Any] = {}
        self._load()

    def _load(self) -> None:
        # Config at root/config/backup/settings.yaml (root = data root or test temp)
        path = self.root / "config" / "backup" / "settings.yaml"
        if path.exists():
            try:
                self._settings = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
            except Exception as e:
                logger.warning("Failed to load backup settings: %s", e)
                self._settings = {}
        else:
            self._settings = {}

    @property
    def provider(self) -> str:
        return self._settings.get("provider", "none")

    @property
    def backup_mode(self) -> str:
        """archive | live | both. Default: both (zip + per-file for recent backup)."""
        return self._settings.get("backup_mode", "both")

    @property
    def max_archives(self) -> int:
        """Max archive zips to keep before pruning. When clean_up_old_backups, effectively 1."""
        if self.clean_up_old_backups:
            return 1
        return self._settings.get("max_archives", 20)

    @property
    def clean_up_old_backups(self) -> bool:
        """When True: delete old zip archives after new backup (keep only latest). Default False."""
        return self._settings.get("clean_up_old_backups", False)

    @property
    def replace_previous(self) -> bool:
        """Deprecated alias for clean_up_old_backups. Kept for settings migration."""
        return self.clean_up_old_backups or self._settings.get("replace_previous", False)

    @property
    def schedule_enabled(self) -> bool:
        return self._settings.get("schedule", {}).get("enabled", False)

    @property
    def schedule_interval_hours(self) -> int:
        return self._settings.get("schedule", {}).get("interval_hours", 24)

    @property
    def categories(self) -> dict[str, Any]:
        """File categories for backup. Defaults to data-root structure when empty."""
        cats = self._settings.get("categories", {})
        if not cats:
            # Default: scan user's vastian data folder (docs, state, config, etc.)
            return {
                "system": {
                    "includes": ["state/", "config/", "data/"],
                },
                "user": {
                    "includes": ["docs/", "workspace/", "saved_prompts/"],
                },
            }
        return cats

    @property
    def tier_access(self) -> dict[str, list[str]]:
        return self._settings.get("tier_access", {})

    def get_provider_for_category(self, category: str) -> str:
        """Return the provider for a file category.

        Falls back to the default provider if the category has no override.
        """
        cat = self.categories.get(category, {})
        return cat.get("provider") or self.provider

    def is_feature_allowed(self, feature: str, tier: str) -> bool:
        """Check if a feature is allowed at the given user tier."""
        allowed = self.tier_access.get(tier, [])
        return "all" in allowed or feature in allowed

    def get_all_includes(self) -> list[str]:
        """Collect all include patterns across categories."""
        includes: list[str] = []
        for cat in self.categories.values():
            includes.extend(cat.get("includes", []))
        return includes


class BackupManager:
    """Scans for non-git files, uploads to StorageProvider, handles restores.

    Also manages config snapshots (save/restore individual config files).
    """

    def __init__(
        self,
        project_root: Path,
        provider: Any = None,
        config: BackupConfig | None = None,
    ) -> None:
        self.root = project_root
        self.provider = provider
        self.config = config or BackupConfig(project_root)
        # Manifest lives in root/state (caller passes data root; tests pass temp project)
        self.manifest_path = project_root / "state" / "backup-manifest.json"
        self._manifest: dict[str, Any] | None = None

    # ------------------------------------------------------------------
    # Manifest
    # ------------------------------------------------------------------

    def _load_manifest(self) -> dict[str, Any]:
        if self._manifest is not None:
            return self._manifest
        if self.manifest_path.exists():
            try:
                self._manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
            except (json.JSONDecodeError, OSError):
                self._manifest = {"files": {}, "last_backup": None}
        else:
            self._manifest = {"files": {}, "last_backup": None}
        return self._manifest

    def _save_manifest(self) -> None:
        manifest = self._load_manifest()
        self.manifest_path.parent.mkdir(parents=True, exist_ok=True)
        self.manifest_path.write_text(json.dumps(manifest, indent=2, default=str), encoding="utf-8")

    @staticmethod
    def _file_hash(path: Path) -> str:
        h = hashlib.sha256()
        h.update(path.read_bytes())
        return h.hexdigest()

    # ------------------------------------------------------------------
    # Scan
    # ------------------------------------------------------------------

    def _get_git_tracked_files(self) -> set[str]:
        """Return set of absolute paths tracked by git."""
        import subprocess

        try:
            result = subprocess.run(
                ["git", "ls-files", "--full-name"],
                capture_output=True,
                text=True,
                cwd=str(self.root),
            )
            if result.returncode == 0:
                return {
                    str(self.root / line.strip())
                    for line in result.stdout.strip().splitlines()
                    if line.strip()
                }
        except FileNotFoundError:
            pass  # git not available
        return set()

    def _is_under_excluded_dir(self, path: Path) -> bool:
        """True if path is under an excluded dir or any segment starts with a dot."""
        try:
            rel = path.relative_to(self.root)
        except ValueError:
            return False
        parts = rel.parts
        if not parts:
            return False
        # Exclude top-level and any path segment that starts with a dot (.cursor, .pytest_cache, etc.)
        if any(p.startswith(".") for p in parts):
            return True
        return parts[0] in BACKUP_EXCLUDE_DIRS

    def scan(self) -> list[Path]:
        """Scan for files that should be backed up.

        Returns files matching the configured include patterns (docs/, state/, config/,
        agents/, workspace/, saved_prompts/, *.db, *.md) that are NOT under excluded
        dirs (src, tests, .venv, etc.). Git-tracked files are included so that docs/
        and other document paths are backed up even when committed.
        """
        includes = self.config.get_all_includes()
        files: list[Path] = []

        for pattern in includes:
            if pattern.endswith("/"):
                # Directory pattern (e.g. docs/, state/, config/)
                dir_path = self.root / pattern.rstrip("/")
                if dir_path.exists():
                    for f in dir_path.rglob("*"):
                        if f.is_file() and not self._is_under_excluded_dir(f):
                            files.append(f)
            elif pattern.startswith("*."):
                # Extension pattern
                for f in self.root.rglob(pattern):
                    if f.is_file() and not self._is_under_excluded_dir(f):
                        files.append(f)
            else:
                # Exact path
                p = self.root / pattern
                if p.is_file() and not self._is_under_excluded_dir(p):
                    files.append(p)

        return sorted(set(files))

    # ------------------------------------------------------------------
    # Upload / backup
    # ------------------------------------------------------------------

    async def _upload_archive(self, files: list[Path]) -> dict[str, Any]:
        """Build zip archive, validate file count, upload, then prune old (only after success)."""
        if not files:
            return {"uploaded": 0, "skipped": 0, "errors": []}
        timestamp = datetime.now(UTC).strftime("%Y-%m-%d_%H%M")
        archive_name = f"backup-{timestamp}.zip"
        remote_path = archive_name

        with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
            tmp_path = Path(tmp.name)
        try:
            with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
                for f in files:
                    rel = str(f.relative_to(self.root)).replace("\\", "/")
                    zf.write(f, rel)
            # Validate: zip must contain expected file count before we upload
            with zipfile.ZipFile(tmp_path, "r") as zf:
                names = [n for n in zf.namelist() if not n.endswith("/")]
            if len(names) != len(files):
                return {
                    "uploaded": 0,
                    "skipped": 0,
                    "errors": [
                        {
                            "file": archive_name,
                            "error": f"Zip validation failed: expected {len(files)} files, got {len(names)}",
                        }
                    ],
                }
            await self.provider.upload(str(tmp_path), remote_path)
            # Only delete old zip archives when user opted in (clean_up_old_backups)
            if self.config.clean_up_old_backups:
                await self._prune_old_archives()
            return {"uploaded": 1, "skipped": 0, "errors": []}
        except Exception as e:
            logger.error("Failed to upload archive: %s", e)
            return {
                "uploaded": 0,
                "skipped": 0,
                "errors": [{"file": archive_name, "error": str(e)}],
            }
        finally:
            tmp_path.unlink(missing_ok=True)

    async def _clear_live_files(self) -> None:
        """Remove all live/* files from remote (so new backup replaces, doesn't accumulate)."""
        if self.provider is None:
            return
        try:
            listed = await self.provider.list_files(prefix="live/")
            for entry in listed:
                rp = entry.get("remote_path", "")
                if rp and rp.startswith("live/"):
                    await self.provider.delete(rp)
                    logger.debug("Cleared previous live file: %s", rp)
        except Exception as e:
            logger.warning("Could not clear live files: %s", e)

    async def _prune_old_archives(self) -> None:
        """Remove oldest archive zips beyond max_archives (only when clean_up_old_backups)."""
        try:
            listed = await self.provider.list_files(prefix="backup-")
            archives = sorted(
                [f["remote_path"] for f in listed if f["remote_path"].startswith("backup-")],
                reverse=True,
            )
            for old in archives[self.config.max_archives :]:
                await self.provider.delete(old)
                logger.info("Pruned old archive: %s", old)
        except Exception as e:
            logger.warning("Could not prune old archives: %s", e)

    async def upload(self, files: list[Path], force_full: bool = False) -> dict[str, Any]:
        """Upload files to the configured StorageProvider.

        Mode archive: build zip, upload to backup-YYYY-MM-DD_HHmm.zip, prune old.
        Mode live: per-file upload to live/{rel}.
        Mode both: do both.
        """
        if self.provider is None:
            return {
                "uploaded": 0,
                "skipped": 0,
                "total_files": len(files),
                "errors": [
                    {
                        "file": "",
                        "error": "No storage provider configured. Set provider in config/backup/settings.yaml and run 'vastian setup gdrive' (or the provider you chose).",
                    }
                ],
                "message": "No storage provider configured. Run 'vastian setup gdrive' (or your provider) to authenticate.",
            }
        manifest = self._load_manifest()
        uploaded = 0
        skipped = 0
        errors: list[dict[str, str]] = []
        mode = self.config.backup_mode

        # Archive mode: zip and upload
        if mode in ("archive", "both"):
            result = await self._upload_archive(files)
            uploaded += result["uploaded"]
            errors.extend(result["errors"])
            if result["uploaded"] > 0:
                manifest["last_backup"] = datetime.now(UTC).isoformat()
                self._save_manifest()

        # Live mode: per-file upload. Remove previous live files first (keep only current).
        if mode in ("live", "both"):
            await self._clear_live_files()
            for f in files:
                try:
                    rel = str(f.relative_to(self.root)).replace("\\", "/")
                    current_hash = self._file_hash(f)
                    if (
                        not force_full
                        and manifest["files"].get(rel, {}).get("hash") == current_hash
                    ):
                        skipped += 1
                        continue
                    remote_path = f"live/{rel}"
                    await self.provider.upload(str(f), remote_path)
                    manifest["files"][rel] = {
                        "hash": current_hash,
                        "size": f.stat().st_size,
                        "backed_up_at": datetime.now(UTC).isoformat(),
                    }
                    uploaded += 1
                except Exception as e:
                    logger.error("Failed to upload %s: %s", f, e)
                    errors.append({"file": str(f), "error": str(e)})
            if uploaded > 0 or skipped > 0:
                manifest["last_backup"] = datetime.now(UTC).isoformat()
                self._save_manifest()

        message = None
        if len(files) == 0:
            message = (
                "No files to back up. Scan found 0 files matching config/backup/settings.yaml. "
                "Check that you're running from the project root and that include patterns (state/, docs/, etc.) contain non–git-tracked files."
            )
        elif errors:
            message = f"{len(errors)} file(s) failed to upload. See errors for details."

        return {
            "uploaded": uploaded,
            "skipped": skipped,
            "total_files": len(files),
            "errors": errors,
            "message": message,
        }

    # ------------------------------------------------------------------
    # Restore
    # ------------------------------------------------------------------

    async def restore(self, remote_paths: list[str]) -> dict[str, Any]:
        """Restore files from the StorageProvider.

        Supports:
        - Archive: backup-YYYY-MM-DD_HHmm.zip — downloads and extracts to root
        - Live: rel path (e.g. docs/charles/foo.md) — downloads from live/{rel}
        """
        restored = 0
        errors: list[dict[str, str]] = []

        for item in remote_paths:
            try:
                if item.startswith("backup-") and item.endswith(".zip"):
                    # Archive restore: download zip, extract
                    with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
                        tmp_path = Path(tmp.name)
                    try:
                        await self.provider.download(item, str(tmp_path))
                        with zipfile.ZipFile(tmp_path, "r") as zf:
                            for name in zf.namelist():
                                target = self.root / name
                                target.parent.mkdir(parents=True, exist_ok=True)
                                if not name.endswith("/"):
                                    target.write_bytes(zf.read(name))
                                    restored += 1
                    finally:
                        tmp_path.unlink(missing_ok=True)
                else:
                    # Live restore: single file from live/{rel} or vastian-backup/{rel} (legacy)
                    local_path = self.root / item
                    local_path.parent.mkdir(parents=True, exist_ok=True)
                    err = None
                    for prefix in ("live/", "vastian-backup/"):
                        try:
                            await self.provider.download(f"{prefix}{item}", str(local_path))
                            restored += 1
                            break
                        except Exception as e:
                            err = e
                    else:
                        if err:
                            raise err
            except Exception as e:
                logger.error("Failed to restore %s: %s", item, e)
                errors.append({"file": item, "error": str(e)})

        return {"restored": restored, "errors": errors}

    # ------------------------------------------------------------------
    # Backup listing (for restore UI)
    # ------------------------------------------------------------------

    def list_backups(self) -> list[str]:
        """Return backup IDs available for restore: archives (backup-*.zip) and live manifest paths."""
        result: list[str] = []
        manifest = self._load_manifest()
        result.extend(manifest.get("files", {}).keys())
        if self.provider:
            try:
                import asyncio

                async def _list_archives() -> list[str]:
                    listed = await self.provider.list_files(prefix="backup-")
                    return [
                        f["remote_path"] for f in listed if f["remote_path"].startswith("backup-")
                    ]

                loop = asyncio.new_event_loop()
                try:
                    archives = loop.run_until_complete(_list_archives())
                    # Prepend archives (newest-first by name) so they appear first in restore UI
                    result[:0] = sorted(archives, reverse=True)
                finally:
                    loop.close()
            except Exception as e:
                logger.debug("Could not list remote archives: %s", e)
        return result

    # ------------------------------------------------------------------
    # Status
    # ------------------------------------------------------------------

    def get_backup_status(self) -> dict[str, Any]:
        """Return current backup status."""
        manifest = self._load_manifest()
        provider_ready = self.provider is not None
        if provider_ready and hasattr(self.provider, "is_authenticated"):
            provider_ready = self.provider.is_authenticated()
        return {
            "provider": self.config.provider,
            "provider_ready": provider_ready,
            "last_backup": manifest.get("last_backup"),
            "file_count": len(manifest.get("files", {})),
            "schedule_enabled": self.config.schedule_enabled,
            "schedule_interval_hours": self.config.schedule_interval_hours,
            "backup_mode": self.config.backup_mode,
            "replace_previous": self.config.replace_previous,
            "clean_up_old_backups": self.config.clean_up_old_backups,
        }

    # ------------------------------------------------------------------
    # Config snapshots
    # ------------------------------------------------------------------

    def _snapshot_dir(self, config_file: str) -> Path:
        """Return the snapshot directory for a config file."""
        safe_name = config_file.replace("/", "__").replace("\\", "__")
        return self.root / "state" / "config-snapshots" / safe_name

    def save_config_snapshot(self, config_file: str, name: str | None = None) -> str:
        """Save a snapshot of a config file.

        Returns the path to the snapshot file.
        """
        src = self.root / config_file
        if not src.exists():
            raise FileNotFoundError(f"Config file not found: {config_file}")

        snap_dir = self._snapshot_dir(config_file)
        snap_dir.mkdir(parents=True, exist_ok=True)

        timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S")
        filename = f"{timestamp}_{name}{src.suffix}" if name else f"{timestamp}{src.suffix}"

        snap_path = snap_dir / filename
        shutil.copy2(str(src), str(snap_path))

        # Prune old snapshots
        self._prune_snapshots(config_file)

        logger.info("Config snapshot saved: %s -> %s", config_file, snap_path)
        return str(snap_path)

    def list_config_snapshots(self, config_file: str) -> list[dict[str, Any]]:
        """List all snapshots for a config file, newest first."""
        snap_dir = self._snapshot_dir(config_file)
        if not snap_dir.exists():
            return []

        src = self.root / config_file
        suffix = src.suffix or ""
        snapshots: list[dict[str, Any]] = []

        for f in sorted(snap_dir.glob(f"*{suffix}"), reverse=True):
            stem = f.stem
            # Parse name from timestamp_name or just timestamp
            parts = stem.split("_", 1)
            if len(parts) == 2 and len(parts[0]) >= 15:  # timestamp + name  # noqa: SIM108
                snap_name = parts[1]
            else:
                snap_name = stem

            snapshots.append(
                {
                    "name": snap_name,
                    "path": str(f),
                    "timestamp": parts[0] if parts else stem,
                    "size": f.stat().st_size,
                }
            )

        return snapshots

    def restore_config_snapshot(self, config_file: str, snapshot_name: str | None = None) -> None:
        """Restore a config file from a snapshot.

        Creates a pre-restore backup of the current file first.
        If no snapshot_name is given, restores the most recent snapshot.
        """
        target = self.root / config_file
        snapshots = self.list_config_snapshots(config_file)

        if not snapshots:
            raise FileNotFoundError(f"No snapshots found for {config_file}")

        # Save current state as pre-restore backup
        if target.exists():
            self.save_config_snapshot(config_file, "pre-restore")

        # Find the snapshot to restore
        if snapshot_name:
            snap = next((s for s in snapshots if s["name"] == snapshot_name), None)
            if not snap:
                raise FileNotFoundError(f"Snapshot '{snapshot_name}' not found for {config_file}")
        else:
            # Most recent (skip any pre-restore we just created)
            snap = next((s for s in snapshots if s["name"] != "pre-restore"), None)
            if not snap:
                raise FileNotFoundError(f"No non-pre-restore snapshots for {config_file}")

        shutil.copy2(snap["path"], str(target))
        logger.info("Config restored: %s from snapshot %s", config_file, snap["name"])

    def reset_config_to_default(self, config_file: str) -> None:
        """Restore a config file from its tracked template/example.

        Looks for {filename}.example.{ext} or .example.{ext} in the same dir.
        """
        target = self.root / config_file
        parent = target.parent
        stem = target.stem
        suffix = target.suffix

        # Try common template patterns
        candidates = [
            parent / f"{stem}.example{suffix}",
            parent / f".example{suffix}",
            parent / f"{stem}.default{suffix}",
        ]

        for template in candidates:
            if template.exists():
                # Save pre-reset backup
                if target.exists():
                    self.save_config_snapshot(config_file, "pre-reset")
                shutil.copy2(str(template), str(target))
                logger.info("Config reset to default: %s from %s", config_file, template)
                return

        raise FileNotFoundError(
            f"No template found for {config_file}. Tried: {[str(c) for c in candidates]}"
        )

    def _prune_snapshots(self, config_file: str) -> None:
        """Remove oldest snapshots beyond the max limit."""
        snapshots = self.list_config_snapshots(config_file)
        if len(snapshots) <= MAX_SNAPSHOTS:
            return

        # Snapshots are already newest-first; remove oldest
        for snap in snapshots[MAX_SNAPSHOTS:]:
            with contextlib.suppress(OSError):
                Path(snap["path"]).unlink()


def create_backup_manager(project_root: Path | None = None) -> BackupManager:
    """Factory that creates a BackupManager with the configured StorageProvider.

    Reads the provider name from config/backup/settings.yaml, looks it up
    in the plugin loader, and instantiates the adapter. If the provider is
    "none" or not registered, creates a BackupManager with no provider
    (scan-only mode). Uses get_data_root() for scan root when VASTIAN_DATA_ROOT is set.
    """
    proj = project_root or Path(__file__).resolve().parents[3]
    from vastian.storage.data_root import get_data_root

    scan_root = get_data_root(proj)
    config = BackupConfig(scan_root)  # Config lives in data root
    provider_name = config.provider

    if provider_name == "none":
        return BackupManager(scan_root, provider=None, config=config)

    # Map settings.yaml names to loader registration keys
    _provider_map = {
        "google_drive": "gdrive_storage",
        "dropbox": "dropbox_storage",
        "box": "box_storage",
        "tresorit": "tresorit_storage",
    }
    loader_key = _provider_map.get(provider_name, provider_name)

    try:
        from vastian.loader import get as loader_get
        from vastian.storage.data_root import get_config_dir

        adapter_cls = loader_get(loader_key)
        adapter = adapter_cls(config_dir=get_config_dir(proj) / "local")
        logger.info(
            "Backup provider: config=%s, adapter=%s",
            provider_name,
            adapter_cls.__name__,
        )
        return BackupManager(scan_root, provider=adapter, config=config)
    except (KeyError, Exception) as e:
        logger.warning(
            "Could not load storage provider '%s' (%s): %s. Running in scan-only mode.",
            provider_name,
            loader_key,
            e,
        )
        return BackupManager(scan_root, provider=None, config=config)


__all__ = ["BackupManager", "BackupConfig", "create_backup_manager"]
