"""
tests/test_audit_v5_fixes.py — Regression tests for audit V5 security fixes.

Fixes R-CRIT-1, R-HIGH-1, R-HIGH-2, R-HIGH-3 from LAUNCH_READINESS_AUDIT_V5.md
"""
from __future__ import annotations

import asyncio
import inspect
import io
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

pytest.importorskip("fastapi", reason="fastapi not installed")
pytest.importorskip("httpx", reason="httpx not installed")

from fastapi.testclient import TestClient  # noqa: E402


# ---------------------------------------------------------------------------
# Shared stubs (mirrors test_http_endpoints.py pattern exactly)
# ---------------------------------------------------------------------------

@dataclass
class _SleepReport:
    session_id: str = "sess-sleep"
    memories_summarised: int = 1
    errors: List[str] = field(default_factory=list)


def _make_mock_plugin(sleep_fn=None) -> MagicMock:
    plugin = MagicMock()
    plugin.config = MagicMock()
    plugin.config.owner_id = "default"
    plugin.storage = MagicMock()
    plugin.storage.initialize = AsyncMock()
    plugin.storage.close = AsyncMock()
    plugin.storage.create_claim = AsyncMock(return_value=MagicMock(id="c1"))

    async def _default_sleep(session_id=None, consolidate=False):
        return _SleepReport(session_id=session_id or "sess-sleep")

    plugin.sleep = AsyncMock(side_effect=sleep_fn or _default_sleep)
    plugin.wake = AsyncMock(return_value=MagicMock(session_id="s", cornerstones_loaded=0, errors=[]))
    plugin.remember = AsyncMock(return_value=MagicMock(memories_stored=0, errors=[]))
    return plugin


@contextmanager
def _make_client(api_key: Optional[str] = None, plugin=None):
    """Context manager matching the pattern in test_http_endpoints.py."""
    from cortex.api.http_server import create_app
    mock_plugin = plugin or _make_mock_plugin()
    app = create_app(api_key=api_key)
    with patch("cortex.api.plugin.CortexPlugin") as MockClass:
        MockClass.return_value = mock_plugin
        with TestClient(app, raise_server_exceptions=False) as tc:
            tc._mock_plugin = mock_plugin
            yield tc, mock_plugin


PREFIX = "/api/v1"


# ---------------------------------------------------------------------------
# R-HIGH-1: Timing side-channel — hmac.compare_digest
# ---------------------------------------------------------------------------

class TestHmacAuth:
    """R-HIGH-1: API key comparison uses constant-time hmac.compare_digest."""

    def test_hmac_used_in_deps_source(self):
        """Verify deps.py actually calls hmac.compare_digest (not ==)."""
        from cortex.api import deps
        src = inspect.getsource(deps.make_auth_dependency)
        assert "hmac.compare_digest" in src, \
            "R-HIGH-1: Should use hmac.compare_digest for constant-time comparison"
        assert " != api_key" not in src, \
            "R-HIGH-1: Should not use != for API key comparison (timing attack)"

    def test_correct_key_passes(self):
        with _make_client(api_key="secret-key-123") as (tc, _):
            resp = tc.post(
                f"{PREFIX}/session/sleep",
                json={"session_id": "s1", "consolidate": False},
                headers={"X-API-Key": "secret-key-123"},
            )
            assert resp.status_code == 200

    def test_wrong_key_rejected(self):
        with _make_client(api_key="secret-key-123") as (tc, _):
            resp = tc.post(
                f"{PREFIX}/session/sleep",
                json={"session_id": "s1", "consolidate": False},
                headers={"X-API-Key": "wrong-key"},
            )
            assert resp.status_code == 401

    def test_missing_key_rejected(self):
        with _make_client(api_key="secret-key-123") as (tc, _):
            resp = tc.post(
                f"{PREFIX}/session/sleep",
                json={"session_id": "s1", "consolidate": False},
            )
            assert resp.status_code == 401

    def test_no_auth_configured_allows_any(self):
        """When api_key is None, all requests pass through."""
        with _make_client(api_key=None) as (tc, _):
            resp = tc.post(
                f"{PREFIX}/session/sleep",
                json={"session_id": "s1", "consolidate": False},
            )
            assert resp.status_code == 200


# ---------------------------------------------------------------------------
# R-CRIT-1: Concurrent sleep guard
# ---------------------------------------------------------------------------

class TestConcurrentSleep:
    """R-CRIT-1: Concurrent /session/sleep calls are serialised via per-session lock."""

    def test_sleep_lock_dict_exists(self):
        """Module-level _sleep_locks dict is present."""
        from cortex.api.routes import sessions as sess_module
        assert hasattr(sess_module, "_sleep_locks"), \
            "R-CRIT-1: _sleep_locks dict must exist in sessions module"
        assert isinstance(sess_module._sleep_locks, dict)

    def test_get_sleep_lock_returns_lock(self):
        """_get_sleep_lock returns an asyncio.Lock and caches it."""
        from cortex.api.routes import sessions as sess_module
        sess_module._sleep_locks.clear()
        lock1 = sess_module._get_sleep_lock("session-abc")
        lock2 = sess_module._get_sleep_lock("session-abc")
        assert isinstance(lock1, asyncio.Lock)
        assert lock1 is lock2, "Same session should get the same lock"
        sess_module._sleep_locks.clear()

    def test_sleep_lock_cleaned_up_after_completion(self):
        """Lock entry is removed from _sleep_locks after session_sleep completes."""
        from cortex.api.routes import sessions as sess_module
        sess_module._sleep_locks.clear()

        with _make_client() as (tc, _):
            resp = tc.post(
                f"{PREFIX}/session/sleep",
                json={"session_id": "cleanup-sess", "consolidate": False},
            )
            assert resp.status_code == 200
            assert "cleanup-sess" not in sess_module._sleep_locks

    @pytest.mark.asyncio
    async def test_concurrent_sleep_calls_serialised(self):
        """Two concurrent tasks using the same session lock are serialised.

        Tests the lock mechanism directly (unit test) — simulates what happens
        inside the session_sleep route handler.
        """
        from cortex.api.routes import sessions as sess_module
        sess_module._sleep_locks.clear()

        call_order: List[str] = []

        async def simulate_sleep_handler(session_id: str):
            """Mirrors the logic added by R-CRIT-1 in session_sleep."""
            lock = sess_module._get_sleep_lock(session_id)
            async with lock:
                call_order.append("start")
                await asyncio.sleep(0.02)  # simulate consolidation work
                call_order.append("end")
            sess_module._sleep_locks.pop(session_id, None)

        # Run two concurrent "requests" on the same session
        await asyncio.gather(
            simulate_sleep_handler("shared-sess"),
            simulate_sleep_handler("shared-sess"),
        )

        assert call_order.count("start") == 2
        assert call_order.count("end") == 2
        # Serialised: first "end" must come before second "start"
        first_end = next(i for i, v in enumerate(call_order) if v == "end")
        second_start = next(
            i for i, v in enumerate(call_order[first_end + 1:], first_end + 1) if v == "start"
        )
        assert first_end < second_start, \
            f"R-CRIT-1: Calls should be serialised, got order: {call_order}"


# ---------------------------------------------------------------------------
# R-HIGH-2: Import file size limit
# ---------------------------------------------------------------------------

class TestImportFileSizeLimit:
    """R-HIGH-2: /import rejects files larger than 10MB."""

    def test_import_large_file_returns_413(self):
        """Upload > 10MB → 413."""
        with _make_client() as (tc, _):
            oversized = b"x" * 10_000_001
            resp = tc.post(
                f"{PREFIX}/import",
                data={"format": "evaos", "owner_id": "test"},
                files={"file": ("big.json", io.BytesIO(oversized), "application/json")},
            )
            assert resp.status_code == 413, \
                f"R-HIGH-2: Expected 413 for oversized file, got {resp.status_code}: {resp.text}"
            assert "10MB" in resp.json().get("detail", "")

    def test_import_small_file_accepted(self):
        """Small file passes size check (not 413)."""
        with _make_client() as (tc, _):
            small = b'[{"subject":"Eva","predicate":"is","object_value":"cool"}]'
            resp = tc.post(
                f"{PREFIX}/import",
                data={"format": "evaos", "owner_id": "test"},
                files={"file": ("small.json", io.BytesIO(small), "application/json")},
            )
            assert resp.status_code != 413

    def test_import_exactly_10mb_accepted(self):
        """Exactly 10MB is within limit — not 413."""
        with _make_client() as (tc, _):
            exact = b"x" * 10_000_000
            resp = tc.post(
                f"{PREFIX}/import",
                data={"format": "evaos", "owner_id": "test"},
                files={"file": ("exact.json", io.BytesIO(exact), "application/json")},
            )
            assert resp.status_code != 413

    def test_size_check_in_source(self):
        """Verify export_import.py has the size limit guard in source."""
        from cortex.api.routes import export_import
        src = inspect.getsource(export_import)
        assert "10_000_000" in src or "10000000" in src, \
            "R-HIGH-2: File size limit constant not found in export_import.py"
        assert "413" in src or "HTTP_413_REQUEST_ENTITY_TOO_LARGE" in src, \
            "R-HIGH-2: 413 status not found in export_import.py"


# ---------------------------------------------------------------------------
# R-HIGH-3: Consistent owner_id validation in Pydantic models
# ---------------------------------------------------------------------------

class TestOwnerIdValidation:
    """R-HIGH-3: All request models with owner_id enforce the same safe pattern."""

    INVALID_OWNER_IDS = [
        "owner id",           # space
        "../etc/passwd",      # path traversal
        "'; DROP TABLE--",    # SQL injection
        "owner<script>",      # XSS attempt
        "a" * 201,            # too long (> max_length=200)
    ]

    VALID_OWNER_IDS = [
        "eva",
        "andrew-main",
        "user.123",
        "agent:eva@100yen",
        "a1_b2-c3.d4:e5@f6",
    ]

    @pytest.mark.parametrize("bad_id", INVALID_OWNER_IDS)
    def test_remember_request_rejects_invalid(self, bad_id):
        from pydantic import ValidationError
        from cortex.api.models import RememberRequest
        with pytest.raises(ValidationError):
            RememberRequest(conversation="test msg", owner_id=bad_id)

    @pytest.mark.parametrize("good_id", VALID_OWNER_IDS)
    def test_remember_request_accepts_valid(self, good_id):
        from cortex.api.models import RememberRequest
        m = RememberRequest(conversation="test msg", owner_id=good_id)
        assert m.owner_id == good_id

    @pytest.mark.parametrize("bad_id", INVALID_OWNER_IDS)
    def test_retrieve_request_rejects_invalid(self, bad_id):
        from pydantic import ValidationError
        from cortex.api.models import RetrieveRequest
        with pytest.raises(ValidationError):
            RetrieveRequest(query="what is eva?", owner_id=bad_id)

    @pytest.mark.parametrize("bad_id", INVALID_OWNER_IDS)
    def test_dream_request_rejects_invalid(self, bad_id):
        from pydantic import ValidationError
        from cortex.api.models import DreamRequest
        with pytest.raises(ValidationError):
            DreamRequest(owner_id=bad_id)

    @pytest.mark.parametrize("bad_id", INVALID_OWNER_IDS)
    def test_ask_request_still_rejects_invalid(self, bad_id):
        """AskRequest already had the pattern — verify no regression."""
        from pydantic import ValidationError
        from cortex.api.models import AskRequest
        with pytest.raises(ValidationError):
            AskRequest(query="test?", owner_id=bad_id)

    @pytest.mark.parametrize("bad_id", INVALID_OWNER_IDS)
    def test_enhanced_retrieve_request_rejects_invalid(self, bad_id):
        from pydantic import ValidationError
        from cortex.api.models import EnhancedRetrieveRequest
        with pytest.raises(ValidationError):
            EnhancedRetrieveRequest(query="test?", owner_id=bad_id)

    @pytest.mark.parametrize("bad_id", INVALID_OWNER_IDS)
    def test_enhanced_remember_request_rejects_invalid(self, bad_id):
        from pydantic import ValidationError
        from cortex.api.models import EnhancedRememberRequest
        with pytest.raises(ValidationError):
            EnhancedRememberRequest(conversation="test msg", owner_id=bad_id)

    def test_none_owner_id_always_allowed(self):
        """None is valid — these are Optional[str] fields."""
        from cortex.api.models import RememberRequest, RetrieveRequest, DreamRequest
        assert RememberRequest(conversation="x", owner_id=None).owner_id is None
        assert RetrieveRequest(query="x", owner_id=None).owner_id is None
        assert DreamRequest(owner_id=None).owner_id is None

    def test_via_http_remember_rejects_sql_injection(self):
        """End-to-end: POST /remember with malicious owner_id → 422."""
        with _make_client() as (tc, _):
            resp = tc.post(
                f"{PREFIX}/remember",
                json={
                    "conversation": "test",
                    "owner_id": "'; DROP TABLE memories; --",
                },
            )
            assert resp.status_code == 422, \
                f"R-HIGH-3: SQL injection in owner_id should → 422, got {resp.status_code}"

    def test_via_http_remember_accepts_valid_owner_id(self):
        """End-to-end: POST /remember with valid owner_id → not 422."""
        with _make_client() as (tc, _):
            resp = tc.post(
                f"{PREFIX}/remember",
                json={
                    "conversation": "test",
                    "owner_id": "andrew-main",
                },
            )
            assert resp.status_code != 422
