"""tests/test_admin_backup_hardened.py — Tests for #1552, #1556, #1557.

Covers:
    #1552: Admin backup/restore routes use hardened cortex.storage.backup module
    #1556: No duplicate CLI backup command (io_cmds vs backup_cmds)
    #1557: Webhook delivery uses asyncio.Semaphore for backpressure
"""
from __future__ import annotations

import asyncio
import os
import sqlite3
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch, call

import pytest


# ===========================================================================
# #1552: Admin backup route uses hardened backup module
# ===========================================================================

class TestAdminBackupUsesHardenedModule:
    """Verify admin backup/restore routes delegate to cortex.storage.backup."""

    def test_admin_backup_calls_backup_database(self, tmp_path):
        """POST /admin/backup should call backup_database, not shutil.copy2."""
        pytest.importorskip("fastapi", reason="fastapi not installed")
        pytest.importorskip("httpx", reason="httpx not installed")

        from cortex.api.routes.admin.backup import _register
        # Check that backup_database is imported at module level
        import cortex.api.routes.admin.backup as backup_mod
        assert hasattr(backup_mod, 'backup_database'), \
            "backup.py should import backup_database from cortex.storage.backup"

    def test_admin_backup_no_inline_shutil(self):
        """The admin backup route should NOT contain inline shutil.copy2 calls."""
        import inspect
        import cortex.api.routes.admin.backup as backup_mod
        source = inspect.getsource(backup_mod)
        # The module should import from cortex.storage.backup
        assert "from cortex.storage.backup import" in source
        # The backup endpoint should not use shutil.copy2 directly
        # (restore may still use aiosqlite for counting, which is fine)
        # Count occurrences of shutil.copy2 — should be zero
        assert "shutil.copy2" not in source, \
            "Admin backup module should not use inline shutil.copy2"

    def test_admin_restore_imports_hardened_module(self):
        """Admin restore should import restore_database from cortex.storage.backup."""
        import inspect
        import cortex.api.routes.admin.backup as backup_mod
        source = inspect.getsource(backup_mod)
        assert "restore_database" in source, \
            "Admin backup module should import restore_database"

    def test_admin_backup_calls_backup_database_function(self, tmp_path):
        """Integration: verify backup_database is actually called when backup endpoint runs."""
        pytest.importorskip("fastapi", reason="fastapi not installed")
        pytest.importorskip("httpx", reason="httpx not installed")

        # Create a real DB
        db_file = tmp_path / "test.db"
        conn = sqlite3.connect(str(db_file))
        conn.execute("CREATE TABLE test (id INTEGER)")
        conn.commit()
        conn.close()

        with patch("cortex.api.routes.admin.backup.backup_database") as mock_backup:
            # Make backup_database return mock paths
            mock_archive = tmp_path / "backups" / "cortex_test.tar.gz"
            mock_checksum = tmp_path / "backups" / "cortex_test.tar.gz.sha256"
            mock_archive.parent.mkdir(parents=True, exist_ok=True)
            mock_archive.write_bytes(b"fake archive")
            mock_backup.return_value = (mock_archive, mock_checksum)

            from cortex.api.http_server import create_app
            from fastapi.testclient import TestClient

            app = create_app()
            mock_plugin = MagicMock()
            mock_plugin.storage = MagicMock()
            mock_plugin.storage.initialize = AsyncMock()
            mock_plugin.storage._get_conn = AsyncMock()
            mock_plugin.config.storage_db_path = str(db_file)
            mock_plugin.config.db_path_resolved.return_value = str(db_file)

            with patch("cortex.api.plugin.CortexPlugin") as MockClass:
                MockClass.return_value = mock_plugin
                with TestClient(app, raise_server_exceptions=True) as tc:
                    resp = tc.post("/api/v1/admin/backup", json={})

            # backup_database should have been called
            assert mock_backup.called, \
                "backup_database from cortex.storage.backup should be called"


# ===========================================================================
# #1556: No duplicate CLI backup command
# ===========================================================================

class TestNoDuplicateBackupCLI:
    """Verify there's no ambiguous backup command from io_cmds."""

    def test_io_cmds_has_no_backup_command(self):
        """io_cmds.py should NOT register a 'backup' Click command."""
        import cortex.cli.io_cmds as io_mod
        # Get all Click commands registered on the main group by io_cmds
        import inspect
        source = inspect.getsource(io_mod)
        # There should be no @main.command("backup") decorator
        assert '@main.command("backup")' not in source, \
            "io_cmds.py should not have a backup command (use backup_cmds.py)"

    def test_backup_cmds_has_backup_command(self):
        """backup_cmds.py should have the hardened backup command."""
        import cortex.cli.backup_cmds as backup_mod
        import inspect
        source = inspect.getsource(backup_mod)
        assert '@main.command("backup")' in source, \
            "backup_cmds.py should register the backup command"
        assert "from cortex.storage.backup import backup_database" in source, \
            "backup_cmds.py should use the hardened backup_database function"

    def test_single_backup_command_registered(self):
        """Only one 'backup' command should be registered on the CLI group."""
        import cortex.cli.app as app_mod
        # Import both modules to trigger registration
        import cortex.cli.io_cmds  # noqa: F401
        import cortex.cli.backup_cmds  # noqa: F401

        main = app_mod.main
        # Click groups store commands in a dict — check that 'backup' exists once
        if hasattr(main, 'commands'):
            cmds = main.commands
        elif hasattr(main, 'list_commands'):
            cmds = {name: main.get_command(None, name) for name in main.list_commands(None)}
        else:
            pytest.skip("Cannot inspect Click commands")

        assert "backup" in cmds, "backup command should exist"
        # The key point: it should come from backup_cmds, not io_cmds
        cmd = cmds["backup"]
        # The hardened backup command has --output-dir option
        param_names = [p.name for p in cmd.params]
        assert "output_dir" in param_names, \
            "The registered backup command should be the hardened one from backup_cmds.py (has --output-dir)"


# ===========================================================================
# #1557: Webhook delivery semaphore
# ===========================================================================

class TestWebhookDeliverySemaphore:
    """Verify webhook delivery uses asyncio.Semaphore for backpressure."""

    def test_dispatcher_has_semaphore(self):
        """WebhookDispatcher should have a _delivery_semaphore attribute."""
        from cortex.events.webhook import WebhookDispatcher
        dispatcher = WebhookDispatcher(storage=None)
        assert hasattr(dispatcher, '_delivery_semaphore'), \
            "WebhookDispatcher should have _delivery_semaphore"
        assert isinstance(dispatcher._delivery_semaphore, asyncio.Semaphore)

    def test_default_semaphore_limit(self):
        """Default semaphore limit should be 10."""
        from cortex.events.webhook import WebhookDispatcher, _DEFAULT_MAX_CONCURRENT_DELIVERIES
        dispatcher = WebhookDispatcher(storage=None)
        # asyncio.Semaphore stores the initial value in _value
        assert dispatcher._delivery_semaphore._value == _DEFAULT_MAX_CONCURRENT_DELIVERIES

    def test_custom_semaphore_limit(self):
        """max_concurrent parameter should set the semaphore limit."""
        from cortex.events.webhook import WebhookDispatcher
        dispatcher = WebhookDispatcher(storage=None, max_concurrent=5)
        assert dispatcher._delivery_semaphore._value == 5

    def test_config_semaphore_limit(self):
        """Config webhook_max_concurrent should set the semaphore limit."""
        from cortex.events.webhook import WebhookDispatcher
        config = MagicMock()
        config.webhook_max_concurrent = 20
        dispatcher = WebhookDispatcher(storage=None, config=config)
        assert dispatcher._delivery_semaphore._value == 20

    def test_explicit_overrides_config(self):
        """Explicit max_concurrent should override config."""
        from cortex.events.webhook import WebhookDispatcher
        config = MagicMock()
        config.webhook_max_concurrent = 20
        dispatcher = WebhookDispatcher(storage=None, config=config, max_concurrent=3)
        assert dispatcher._delivery_semaphore._value == 3

    @pytest.mark.asyncio
    async def test_semaphore_limits_concurrency(self):
        """Verify that the semaphore actually limits concurrent deliveries."""
        from cortex.events.webhook import WebhookDispatcher, Webhook, _make_payload_bytes
        from cortex.events.emitter import CortexEvent, EventType

        max_concurrent = 2
        dispatcher = WebhookDispatcher(storage=None, max_concurrent=max_concurrent)

        # Track concurrent execution
        concurrent_count = 0
        max_observed_concurrent = 0
        lock = asyncio.Lock()

        original_inner = dispatcher._deliver_with_retry_inner

        async def mock_deliver_inner(webhook, event, event_type_str):
            nonlocal concurrent_count, max_observed_concurrent
            async with lock:
                concurrent_count += 1
                if concurrent_count > max_observed_concurrent:
                    max_observed_concurrent = concurrent_count
            # Simulate some work
            await asyncio.sleep(0.05)
            async with lock:
                concurrent_count -= 1

        dispatcher._deliver_with_retry_inner = mock_deliver_inner

        # Create test event and webhooks
        event = CortexEvent(
            event_type=EventType.MEMORY_CREATED,
            owner_id="test",
            timestamp="2026-01-01T00:00:00Z",
            payload={"test": True},
        )

        webhook = Webhook(
            id="wh-1",
            owner_id="test",
            url="https://example.com/hook",
            events=[EventType.MEMORY_CREATED],
            secret=None,
        )

        # Launch 5 concurrent deliveries with max_concurrent=2
        tasks = [
            asyncio.create_task(
                dispatcher._deliver_with_retry(webhook, event, "memory.created")
            )
            for _ in range(5)
        ]
        await asyncio.gather(*tasks)

        assert max_observed_concurrent <= max_concurrent, \
            f"Max concurrent was {max_observed_concurrent}, expected <= {max_concurrent}"
        assert max_observed_concurrent > 0, "At least one delivery should have run"

    def test_deliver_with_retry_has_semaphore_in_source(self):
        """The _deliver_with_retry method should use self._delivery_semaphore."""
        import inspect
        from cortex.events.webhook import WebhookDispatcher
        source = inspect.getsource(WebhookDispatcher._deliver_with_retry)
        assert "_delivery_semaphore" in source, \
            "_deliver_with_retry should use _delivery_semaphore"
