"""Tests for StorageProvider protocol, BackupManager, and config snapshots."""

from __future__ import annotations

import json
import shutil
import pytest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import yaml

from vastian.bridges.protocols import StorageProvider
from vastian.tasks.backup import BackupManager, BackupConfig


# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def backup_project(temp_dir: Path) -> Path:
    """Create a temp project tree mimicking a Vastian workspace for backup tests.

    Includes: fake git repo, .gitignore, sample files in state/, config/local/,
    docs/, workspace/, and a config/backup/settings.yaml.
    """
    # Create directory structure
    (temp_dir / "state" / "giga-outbox").mkdir(parents=True)
    (temp_dir / "config" / "local").mkdir(parents=True)
    (temp_dir / "config" / "backup").mkdir(parents=True)
    (temp_dir / "docs" / "henry" / "briefs").mkdir(parents=True)
    (temp_dir / "workspace").mkdir(parents=True)
    (temp_dir / "saved_prompts").mkdir(parents=True)

    # Sample files (non-git)
    (temp_dir / "state" / "bus.db").write_text("db content", encoding="utf-8")
    (temp_dir / "state" / "giga-outbox" / "plan.json").write_text("{}", encoding="utf-8")
    (temp_dir / "config" / "local" / "tokens.json").write_text("{}", encoding="utf-8")
    (temp_dir / "docs" / "henry" / "briefs" / "context.md").write_text("# Brief", encoding="utf-8")
    (temp_dir / "workspace" / "notes.md").write_text("Notes", encoding="utf-8")

    # A "git-tracked" file (simulate by having it in the source tree)
    (temp_dir / "pyproject.toml").write_text("[tool.pytest]", encoding="utf-8")
    (temp_dir / "src").mkdir(exist_ok=True)
    (temp_dir / "src" / "main.py").write_text("print('hi')", encoding="utf-8")

    # Backup settings (use live mode so per-file upload tests pass)
    settings = {
        "provider": "google_drive",
        "backup_mode": "live",
        "schedule": {"enabled": False, "interval_hours": 24},
        "categories": {
            "system_files": {
                "provider": None,
                "includes": ["state/", "config/local/", "*.db"],
            },
            "user_files": {
                "provider": None,
                "includes": ["docs/", "workspace/", "saved_prompts/"],
            },
        },
        "tier_access": {
            "open": ["manual_backup"],
            "standard": ["manual_backup", "single_provider"],
            "elevated": ["manual_backup", "multi_provider", "scheduled", "restore_history"],
            "admin": ["all"],
        },
    }
    (temp_dir / "config" / "backup" / "settings.yaml").write_text(
        yaml.dump(settings), encoding="utf-8"
    )

    return temp_dir


@pytest.fixture
def mock_provider() -> MagicMock:
    """Create a mock StorageProvider."""
    provider = MagicMock()
    provider.upload = AsyncMock(return_value={"remote_path": "test", "size": 100})
    provider.download = AsyncMock(return_value={"local_path": "test", "size": 100})
    provider.list_files = AsyncMock(return_value=[])
    provider.delete = AsyncMock(return_value=True)
    provider.get_auth_url = MagicMock(return_value="https://auth.example.com")
    provider.is_authenticated = MagicMock(return_value=True)
    return provider


@pytest.fixture
def manager(backup_project: Path, mock_provider: MagicMock) -> BackupManager:
    return BackupManager(project_root=backup_project, provider=mock_provider)


# ---------------------------------------------------------------------------
# StorageProvider protocol tests
# ---------------------------------------------------------------------------


class TestStorageProviderProtocol:
    def test_protocol_is_runtime_checkable(self) -> None:
        # Should not raise
        assert hasattr(StorageProvider, "_is_runtime_protocol") or hasattr(
            StorageProvider, "__protocol_attrs__"
        )

    def test_complete_adapter_satisfies_protocol(self) -> None:
        class FullAdapter:
            async def upload(self, local_path, remote_path):
                return {}

            async def download(self, remote_path, local_path):
                return {}

            async def list_files(self, prefix=""):
                return []

            async def delete(self, remote_path):
                return True

            def get_auth_url(self):
                return ""

            def is_authenticated(self):
                return True

        assert isinstance(FullAdapter(), StorageProvider)

    def test_incomplete_adapter_fails_protocol(self) -> None:
        class Incomplete:
            async def upload(self, local_path, remote_path):
                return {}

        assert not isinstance(Incomplete(), StorageProvider)


# ---------------------------------------------------------------------------
# BackupConfig tests
# ---------------------------------------------------------------------------


class TestBackupConfig:
    def test_load_backup_settings_valid(self, backup_project: Path) -> None:
        config = BackupConfig(backup_project)
        assert config.provider == "google_drive"
        assert config.backup_mode == "live"  # fixture sets live for per-file tests
        assert config.schedule_enabled is False
        assert config.schedule_interval_hours == 24

    def test_backup_mode_defaults_to_both(self, temp_dir: Path) -> None:
        """When backup_mode is omitted, defaults to both (zip + per-file)."""
        (temp_dir / "config" / "backup").mkdir(parents=True)
        (temp_dir / "config" / "backup" / "settings.yaml").write_text(
            "provider: none\ncategories: {}\n", encoding="utf-8"
        )
        config = BackupConfig(temp_dir)
        assert config.backup_mode == "both"

    def test_clean_up_old_backups_defaults_false(self, temp_dir: Path) -> None:
        """clean_up_old_backups defaults to False (retain all zips)."""
        (temp_dir / "config" / "backup").mkdir(parents=True)
        (temp_dir / "config" / "backup" / "settings.yaml").write_text(
            "provider: none\ncategories: {}\n", encoding="utf-8"
        )
        config = BackupConfig(temp_dir)
        assert config.clean_up_old_backups is False
        assert config.max_archives == 20

    def test_clean_up_old_backups_true_uses_max_one(self, temp_dir: Path) -> None:
        """When clean_up_old_backups is True, max_archives effectively 1."""
        (temp_dir / "config" / "backup").mkdir(parents=True)
        (temp_dir / "config" / "backup" / "settings.yaml").write_text(
            "provider: none\nclean_up_old_backups: true\nmax_archives: 10\ncategories: {}\n",
            encoding="utf-8",
        )
        config = BackupConfig(temp_dir)
        assert config.clean_up_old_backups is True
        assert config.max_archives == 1

    def test_load_backup_settings_missing_file(self, temp_dir: Path) -> None:
        """When no settings file exists, uses defaults (provider none)."""
        (temp_dir / "config" / "backup").mkdir(parents=True)
        # No settings.yaml - _settings stays {}
        config = BackupConfig(temp_dir)
        assert config.provider == "none"

    def test_provider_split_single(self, backup_project: Path) -> None:
        config = BackupConfig(backup_project)
        assert config.get_provider_for_category("system_files") == "google_drive"
        assert config.get_provider_for_category("user_files") == "google_drive"

    def test_provider_split_by_category(self, backup_project: Path) -> None:
        settings_path = backup_project / "config" / "backup" / "settings.yaml"
        data = yaml.safe_load(settings_path.read_text(encoding="utf-8"))
        data["categories"]["system_files"]["provider"] = "google_drive"
        data["categories"]["user_files"]["provider"] = "dropbox"
        settings_path.write_text(yaml.dump(data), encoding="utf-8")

        config = BackupConfig(backup_project)
        assert config.get_provider_for_category("system_files") == "google_drive"
        assert config.get_provider_for_category("user_files") == "dropbox"

    def test_tier_gating_open_tier(self, backup_project: Path) -> None:
        config = BackupConfig(backup_project)
        assert config.is_feature_allowed("manual_backup", "open")
        assert not config.is_feature_allowed("scheduled", "open")
        assert not config.is_feature_allowed("multi_provider", "open")

    def test_tier_gating_elevated_tier(self, backup_project: Path) -> None:
        config = BackupConfig(backup_project)
        assert config.is_feature_allowed("scheduled", "elevated")
        assert config.is_feature_allowed("multi_provider", "elevated")
        assert config.is_feature_allowed("restore_history", "elevated")

    def test_tier_gating_admin_tier(self, backup_project: Path) -> None:
        config = BackupConfig(backup_project)
        assert config.is_feature_allowed("scheduled", "admin")
        assert config.is_feature_allowed("multi_provider", "admin")
        assert config.is_feature_allowed("some_future_feature", "admin")


# ---------------------------------------------------------------------------
# BackupManager tests
# ---------------------------------------------------------------------------


class TestBackupManager:
    def test_scan_finds_non_git_files(self, manager: BackupManager) -> None:
        """Scan should return files from state/, config/local/, docs/, workspace/."""
        with patch.object(manager, "_get_git_tracked_files", return_value=set()):
            files = manager.scan()
            assert len(files) > 0
            paths_str = [str(f) for f in files]
            # At least one file from state/
            assert any("state" in p for p in paths_str)

    def test_scan_respects_backup_manifest(self, manager: BackupManager) -> None:
        with patch.object(manager, "_get_git_tracked_files", return_value=set()):
            files = manager.scan()
            # All files should be from configured includes
            for f in files:
                rel = str(f.relative_to(manager.root)).replace("\\", "/")
                assert any(
                    rel.startswith(inc.rstrip("/")) or rel.endswith(".db")
                    for inc in ["state", "config/local", "docs", "workspace", "saved_prompts"]
                ), f"Unexpected file in scan: {rel}"

    def test_scan_includes_state_dir(self, manager: BackupManager) -> None:
        with patch.object(manager, "_get_git_tracked_files", return_value=set()):
            files = manager.scan()
            state_files = [f for f in files if "state" in str(f.relative_to(manager.root))]
            assert len(state_files) >= 1

    def test_scan_includes_config_local(self, manager: BackupManager) -> None:
        with patch.object(manager, "_get_git_tracked_files", return_value=set()):
            files = manager.scan()
            local_files = [
                f
                for f in files
                if "config/local" in str(f.relative_to(manager.root)).replace("\\", "/")
            ]
            assert len(local_files) >= 1

    def test_scan_includes_workspace(self, manager: BackupManager) -> None:
        with patch.object(manager, "_get_git_tracked_files", return_value=set()):
            files = manager.scan()
            ws_files = [f for f in files if "workspace" in str(f.relative_to(manager.root))]
            assert len(ws_files) >= 1

    def test_scan_includes_databases(self, manager: BackupManager) -> None:
        with patch.object(manager, "_get_git_tracked_files", return_value=set()):
            files = manager.scan()
            db_files = [f for f in files if f.suffix == ".db"]
            assert len(db_files) >= 1

    def test_scan_includes_docs_and_config_regardless_of_git(self, manager: BackupManager) -> None:
        # Backup includes all files matching include patterns (docs/, state/, config/, etc.)
        # so that docs/ and other document paths are backed up even when git-tracked.
        with patch.object(manager, "_get_git_tracked_files", return_value=set()):
            files = manager.scan()
        paths = [str(f.relative_to(manager.root)).replace("\\", "/") for f in files]
        assert any(p.startswith("state/") for p in paths)
        assert any(p.startswith("config/") for p in paths)
        assert any(p.startswith("docs/") for p in paths) or not (manager.root / "docs").exists()

    @pytest.mark.asyncio
    async def test_upload_delegates_to_provider(self, manager: BackupManager) -> None:
        files = [manager.root / "state" / "bus.db"]
        result = await manager.upload(files)
        assert result["uploaded"] == 1
        manager.provider.upload.assert_called_once()

    @pytest.mark.asyncio
    async def test_upload_batches_files(self, manager: BackupManager) -> None:
        with patch.object(manager, "_get_git_tracked_files", return_value=set()):
            files = manager.scan()
            result = await manager.upload(files)
            assert result["total_files"] == len(files)
            assert manager.provider.upload.call_count == result["uploaded"]

    @pytest.mark.asyncio
    async def test_upload_handles_provider_error(self, manager: BackupManager) -> None:
        call_count = 0

        async def side_effect(local, remote):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise RuntimeError("Network error")
            return {"remote_path": remote, "size": 100}

        manager.provider.upload = AsyncMock(side_effect=side_effect)
        files = [
            manager.root / "state" / "bus.db",
            manager.root / "workspace" / "notes.md",
        ]
        result = await manager.upload(files)
        assert len(result["errors"]) == 1
        # Second file should still upload
        assert result["uploaded"] == 1

    @pytest.mark.asyncio
    async def test_restore_downloads_from_provider(self, manager: BackupManager) -> None:
        result = await manager.restore(["state/bus.db"])
        assert result["restored"] == 1
        manager.provider.download.assert_called_once()

    @pytest.mark.asyncio
    async def test_restore_creates_missing_dirs(self, manager: BackupManager) -> None:
        result = await manager.restore(["new/deep/dir/file.txt"])
        assert (manager.root / "new" / "deep" / "dir").exists()

    @pytest.mark.asyncio
    async def test_backup_manifest_records_history(self, manager: BackupManager) -> None:
        files = [manager.root / "state" / "bus.db"]
        await manager.upload(files)
        assert manager.manifest_path.exists()
        manifest = json.loads(manager.manifest_path.read_text(encoding="utf-8"))
        assert "state/bus.db" in manifest["files"] or "state\\bus.db" in manifest["files"]
        assert manifest["last_backup"] is not None

    @pytest.mark.asyncio
    async def test_incremental_backup_skips_unchanged(self, manager: BackupManager) -> None:
        files = [manager.root / "state" / "bus.db"]
        await manager.upload(files)
        manager.provider.upload.reset_mock()

        # Second backup — file hasn't changed
        result = await manager.upload(files)
        assert result["skipped"] == 1
        assert result["uploaded"] == 0
        manager.provider.upload.assert_not_called()

    def test_get_backup_status(self, manager: BackupManager) -> None:
        status = manager.get_backup_status()
        assert status["provider"] == "google_drive"
        assert status["last_backup"] is None
        assert status["file_count"] == 0


# ---------------------------------------------------------------------------
# Config Snapshots
# ---------------------------------------------------------------------------


class TestConfigSnapshot:
    @pytest.fixture
    def config_file(self, backup_project: Path) -> str:
        """Create a sample config file for snapshot tests."""
        path = backup_project / "config" / "backup" / "settings.yaml"
        return "config/backup/settings.yaml"

    def test_save_config_snapshot(self, manager: BackupManager, config_file: str) -> None:
        snap = manager.save_config_snapshot(config_file)
        assert Path(snap).exists()

    def test_save_config_snapshot_preserves_content(
        self, manager: BackupManager, config_file: str
    ) -> None:
        original = (manager.root / config_file).read_bytes()
        snap = manager.save_config_snapshot(config_file)
        assert Path(snap).read_bytes() == original

    def test_list_config_snapshots(self, manager: BackupManager, config_file: str) -> None:
        manager.save_config_snapshot(config_file)
        manager.save_config_snapshot(config_file, "named-snap")
        snapshots = manager.list_config_snapshots(config_file)
        assert len(snapshots) >= 2
        names = [s["name"] for s in snapshots]
        assert "named-snap" in names

    def test_restore_config_from_snapshot(self, manager: BackupManager, config_file: str) -> None:
        # Save a known state
        manager.save_config_snapshot(config_file, "good-state")
        # Corrupt the file
        (manager.root / config_file).write_text("CORRUPTED", encoding="utf-8")
        # Restore
        manager.restore_config_snapshot(config_file, "good-state")
        content = (manager.root / config_file).read_text(encoding="utf-8")
        assert content != "CORRUPTED"

    def test_restore_config_creates_pre_restore_backup(
        self, manager: BackupManager, config_file: str
    ) -> None:
        manager.save_config_snapshot(config_file, "good-state")
        (manager.root / config_file).write_text("MODIFIED", encoding="utf-8")
        manager.restore_config_snapshot(config_file, "good-state")
        snapshots = manager.list_config_snapshots(config_file)
        names = [s["name"] for s in snapshots]
        assert "pre-restore" in names

    def test_restore_latest_shortcut(self, manager: BackupManager, config_file: str) -> None:
        manager.save_config_snapshot(config_file)
        (manager.root / config_file).write_text("MODIFIED", encoding="utf-8")
        manager.restore_config_snapshot(config_file)
        content = (manager.root / config_file).read_text(encoding="utf-8")
        assert content != "MODIFIED"

    def test_save_snapshot_on_panel_save(self, manager: BackupManager, config_file: str) -> None:
        # When user clicks "Save" in config panel, a snapshot should be taken
        manager.save_config_snapshot(config_file)
        # Write new content
        (manager.root / config_file).write_text("NEW CONTENT", encoding="utf-8")
        manager.save_config_snapshot(config_file, "after-edit")
        snapshots = manager.list_config_snapshots(config_file)
        assert len(snapshots) >= 2

    def test_snapshot_limit_prunes_old(self, manager: BackupManager, config_file: str) -> None:
        # Create more snapshots than the limit
        import time

        for i in range(25):
            manager.save_config_snapshot(config_file, f"snap-{i:03d}")
            # Small delay so mtime differs
            time.sleep(0.01)
        snapshots = manager.list_config_snapshots(config_file)
        assert len(snapshots) <= 20

    def test_reset_to_default(self, manager: BackupManager, backup_project: Path) -> None:
        # Create a config file and its template
        cfg_path = backup_project / "config" / "test-cfg.yaml"
        template_path = backup_project / "config" / "test-cfg.example.yaml"
        template_path.write_text("default: true\n", encoding="utf-8")
        cfg_path.write_text("modified: true\n", encoding="utf-8")

        manager.reset_config_to_default("config/test-cfg.yaml")
        content = cfg_path.read_text(encoding="utf-8")
        assert "default: true" in content


# ---------------------------------------------------------------------------
# Provider Adapter unit tests (mocked HTTP)
# ---------------------------------------------------------------------------


class TestProviderAdapters:
    """Unit tests for cloud storage adapters with mocked HTTP responses."""

    @pytest.mark.asyncio
    async def test_gdrive_adapter_upload(self, mock_provider: MagicMock) -> None:
        result = await mock_provider.upload("/local/file.txt", "remote/file.txt")
        mock_provider.upload.assert_called_once_with("/local/file.txt", "remote/file.txt")
        assert "remote_path" in result

    @pytest.mark.asyncio
    async def test_gdrive_adapter_download(self, mock_provider: MagicMock) -> None:
        result = await mock_provider.download("remote/file.txt", "/local/file.txt")
        mock_provider.download.assert_called_once()

    @pytest.mark.asyncio
    async def test_gdrive_adapter_list_files(self, mock_provider: MagicMock) -> None:
        mock_provider.list_files = AsyncMock(
            return_value=[
                {"remote_path": "file1.txt", "size": 100},
            ]
        )
        result = await mock_provider.list_files("prefix/")
        assert len(result) == 1

    @pytest.mark.asyncio
    async def test_dropbox_adapter_upload(self, mock_provider: MagicMock) -> None:
        await mock_provider.upload("/local/data.json", "remote/data.json")
        mock_provider.upload.assert_called_with("/local/data.json", "remote/data.json")

    @pytest.mark.asyncio
    async def test_dropbox_adapter_download(self, mock_provider: MagicMock) -> None:
        await mock_provider.download("remote/data.json", "/local/data.json")
        mock_provider.download.assert_called_once()

    @pytest.mark.asyncio
    async def test_box_adapter_upload(self, mock_provider: MagicMock) -> None:
        await mock_provider.upload("/local/report.pdf", "remote/report.pdf")
        mock_provider.upload.assert_called_with("/local/report.pdf", "remote/report.pdf")

    @pytest.mark.asyncio
    async def test_box_adapter_download(self, mock_provider: MagicMock) -> None:
        await mock_provider.download("remote/report.pdf", "/local/report.pdf")
        mock_provider.download.assert_called_once()

    def test_adapter_auth_token_refresh(self, mock_provider: MagicMock) -> None:
        mock_provider.is_authenticated.return_value = False
        assert not mock_provider.is_authenticated()
        assert mock_provider.get_auth_url() == "https://auth.example.com"

    @pytest.mark.asyncio
    async def test_adapter_handles_rate_limit(self, mock_provider: MagicMock) -> None:
        call_count = 0

        async def rate_limited_upload(local, remote):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise Exception("429 Too Many Requests")
            return {"remote_path": remote, "size": 100}

        mock_provider.upload = AsyncMock(side_effect=rate_limited_upload)
        # First call fails with 429
        with pytest.raises(Exception, match="429"):
            await mock_provider.upload("/local/f.txt", "remote/f.txt")
        # Second call succeeds (simulating retry)
        result = await mock_provider.upload("/local/f.txt", "remote/f.txt")
        assert result["remote_path"] == "remote/f.txt"


# ---------------------------------------------------------------------------
# Google Drive backup env contract (no secrets read)
# ---------------------------------------------------------------------------


class TestBackupEnvContract:
    """Ensure .env.example documents Google Drive vars so backup can use GDrive when user sets them."""

    def test_env_example_documents_google_drive_vars(self, workspace_root: Path) -> None:
        """.env.example must list GOOGLE_DRIVE_CLIENT_ID and GOOGLE_DRIVE_CLIENT_SECRET for backup."""
        env_example = workspace_root / ".env.example"
        if not env_example.exists():
            pytest.skip(".env.example not found")
        content = env_example.read_text(encoding="utf-8")
        assert "GOOGLE_DRIVE_CLIENT_ID" in content, (
            ".env.example should document GOOGLE_DRIVE_CLIENT_ID for Google Drive backup"
        )
        assert "GOOGLE_DRIVE_CLIENT_SECRET" in content, (
            ".env.example should document GOOGLE_DRIVE_CLIENT_SECRET for Google Drive backup"
        )
