"""
tests/test_db_manager.py — Unit tests for cortex.storage.db_manager.DBManager.

All tests use temporary directories and/or :memory: equivalents.
No real /data directory is touched.
"""
from __future__ import annotations

import asyncio
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
import pytest_asyncio

from cortex.storage.db_manager import DBManager


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def make_manager(tmp_path: Path, max_open: int = 5, idle_timeout: float = 300.0) -> DBManager:
    """Create a DBManager pointing at a temp directory."""
    return DBManager(data_dir=str(tmp_path), max_open=max_open, idle_timeout=idle_timeout)


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


class TestPathTraversalPrevention:
    """owner_id must match ^[a-zA-Z0-9_-]+$ to prevent path traversal."""

    @pytest.mark.asyncio
    async def test_dot_dot_raises(self, tmp_path):
        mgr = make_manager(tmp_path)
        await mgr.initialize()
        with pytest.raises(ValueError, match="invalid characters"):
            await mgr.get_adapter("../etc/passwd")

    @pytest.mark.asyncio
    async def test_slash_raises(self, tmp_path):
        mgr = make_manager(tmp_path)
        await mgr.initialize()
        with pytest.raises(ValueError, match="invalid characters"):
            await mgr.get_adapter("foo/bar")

    @pytest.mark.asyncio
    async def test_empty_raises(self, tmp_path):
        mgr = make_manager(tmp_path)
        await mgr.initialize()
        with pytest.raises(ValueError):
            await mgr.get_adapter("")

    @pytest.mark.asyncio
    async def test_valid_owner_ids(self, tmp_path):
        mgr = make_manager(tmp_path)
        await mgr.initialize()
        # These should all succeed (no exception)
        adapter = await mgr.get_adapter("user-uuid-123")
        assert adapter is not None
        adapter2 = await mgr.get_adapter("abc_XYZ-0123456789")
        assert adapter2 is not None
        await mgr.close_all()


class TestCacheHit:
    """Same owner_id should return the same adapter instance."""

    @pytest.mark.asyncio
    async def test_same_adapter_returned(self, tmp_path):
        mgr = make_manager(tmp_path)
        await mgr.initialize()

        adapter_a = await mgr.get_adapter("alice")
        adapter_b = await mgr.get_adapter("alice")

        assert adapter_a is adapter_b
        assert mgr.hit_count == 1
        assert mgr.miss_count == 1
        await mgr.close_all()

    @pytest.mark.asyncio
    async def test_different_owners_different_adapters(self, tmp_path):
        mgr = make_manager(tmp_path)
        await mgr.initialize()

        alice = await mgr.get_adapter("alice")
        bob = await mgr.get_adapter("bob")

        assert alice is not bob
        assert mgr.miss_count == 2
        await mgr.close_all()


class TestLRUEviction:
    """When max_open is exceeded, the least-recently-used entry is evicted."""

    @pytest.mark.asyncio
    async def test_lru_eviction_on_overflow(self, tmp_path):
        mgr = make_manager(tmp_path, max_open=3)
        await mgr.initialize()

        # Fill cache to capacity
        a1 = await mgr.get_adapter("user1")
        a2 = await mgr.get_adapter("user2")
        a3 = await mgr.get_adapter("user3")  # cache is now full

        assert mgr.cached_count == 3
        assert mgr.eviction_count == 0

        # Adding a 4th evicts user1 (LRU)
        a4 = await mgr.get_adapter("user4")

        assert mgr.cached_count == 3
        assert mgr.eviction_count == 1

        # user1 is gone — fetching it again is a miss and creates a new adapter
        a1_new = await mgr.get_adapter("user1")
        assert a1_new is not a1  # new connection, not the evicted one

        await mgr.close_all()

    @pytest.mark.asyncio
    async def test_access_refreshes_lru_order(self, tmp_path):
        mgr = make_manager(tmp_path, max_open=2)
        await mgr.initialize()

        a1 = await mgr.get_adapter("user1")
        a2 = await mgr.get_adapter("user2")

        # Re-access user1, making user2 the LRU
        _ = await mgr.get_adapter("user1")

        # Adding user3 should evict user2 (now LRU), not user1
        a3 = await mgr.get_adapter("user3")

        assert mgr.eviction_count == 1
        # user1 should still be in cache (not evicted)
        a1_again = await mgr.get_adapter("user1")
        assert a1_again is a1  # cache hit, same object

        await mgr.close_all()


class TestIdleEviction:
    """Entries idle longer than idle_timeout are lazily evicted."""

    @pytest.mark.asyncio
    async def test_idle_entries_evicted_on_next_access(self, tmp_path):
        mgr = make_manager(tmp_path, max_open=10, idle_timeout=60.0)
        await mgr.initialize()

        adapter_old = await mgr.get_adapter("stale-user")
        assert mgr.cached_count == 1

        # Simulate time passing beyond idle_timeout
        fake_time = time.monotonic() + 120.0  # 2 minutes later
        with patch("cortex.storage.db_manager.time") as mock_time:
            mock_time.monotonic.return_value = fake_time
            # Trigger lazy eviction by fetching any adapter
            await mgr.get_adapter("active-user")

        # stale-user should have been evicted
        assert mgr.eviction_count >= 1
        await mgr.close_all()

    @pytest.mark.asyncio
    async def test_non_idle_entries_not_evicted(self, tmp_path):
        mgr = make_manager(tmp_path, max_open=10, idle_timeout=300.0)
        await mgr.initialize()

        adapter_a = await mgr.get_adapter("alice")
        # Access immediately (no time passed) — should stay in cache
        adapter_a2 = await mgr.get_adapter("alice")

        assert adapter_a is adapter_a2
        assert mgr.eviction_count == 0
        await mgr.close_all()

    @pytest.mark.asyncio
    async def test_zero_idle_timeout_disables_age_eviction(self, tmp_path):
        mgr = make_manager(tmp_path, max_open=10, idle_timeout=0.0)
        await mgr.initialize()

        await mgr.get_adapter("user1")

        # Even with time far in the future, no age-based eviction
        fake_time = time.monotonic() + 99999.0
        with patch("cortex.storage.db_manager.time") as mock_time:
            mock_time.monotonic.return_value = fake_time
            await mgr.get_adapter("user2")

        assert mgr.eviction_count == 0
        await mgr.close_all()


class TestStatsTracking:
    """Stats counters: open_count, hit_count, miss_count, eviction_count."""

    @pytest.mark.asyncio
    async def test_initial_stats_zero(self, tmp_path):
        mgr = make_manager(tmp_path)
        assert mgr.open_count == 0
        assert mgr.hit_count == 0
        assert mgr.miss_count == 0
        assert mgr.eviction_count == 0

    @pytest.mark.asyncio
    async def test_stats_after_misses_and_hits(self, tmp_path):
        mgr = make_manager(tmp_path, max_open=10)
        await mgr.initialize()

        await mgr.get_adapter("user1")   # miss
        await mgr.get_adapter("user2")   # miss
        await mgr.get_adapter("user1")   # hit
        await mgr.get_adapter("user2")   # hit
        await mgr.get_adapter("user2")   # hit

        assert mgr.open_count == 2
        assert mgr.miss_count == 2
        assert mgr.hit_count == 3
        assert mgr.eviction_count == 0
        await mgr.close_all()

    @pytest.mark.asyncio
    async def test_stats_dict(self, tmp_path):
        mgr = make_manager(tmp_path, max_open=2)
        await mgr.initialize()

        await mgr.get_adapter("user1")
        await mgr.get_adapter("user2")
        await mgr.get_adapter("user3")  # evicts user1

        s = mgr.stats()
        assert s["open_count"] == 3
        assert s["miss_count"] == 3
        assert s["hit_count"] == 0
        assert s["eviction_count"] == 1
        assert s["cached"] == 2
        await mgr.close_all()


class TestCloseAll:
    """close_all() should close every open connection and empty the cache."""

    @pytest.mark.asyncio
    async def test_close_all_empties_cache(self, tmp_path):
        mgr = make_manager(tmp_path)
        await mgr.initialize()

        await mgr.get_adapter("alice")
        await mgr.get_adapter("bob")
        await mgr.get_adapter("carol")

        assert mgr.cached_count == 3

        await mgr.close_all()

        assert mgr.cached_count == 0

    @pytest.mark.asyncio
    async def test_close_all_idempotent(self, tmp_path):
        mgr = make_manager(tmp_path)
        await mgr.initialize()
        await mgr.get_adapter("user1")
        await mgr.close_all()
        # Second call should not raise
        await mgr.close_all()
        assert mgr.cached_count == 0

    @pytest.mark.asyncio
    async def test_adapters_usable_after_close_all(self, tmp_path):
        """After close_all, new get_adapter() calls should work fine."""
        mgr = make_manager(tmp_path)
        await mgr.initialize()
        await mgr.get_adapter("user1")
        await mgr.close_all()

        # Should open a fresh connection
        adapter_new = await mgr.get_adapter("user1")
        assert adapter_new is not None
        await mgr.close_all()


class TestConcurrentAccess:
    """Multiple async tasks hitting the same owner_id should get the same adapter."""

    @pytest.mark.asyncio
    async def test_concurrent_same_owner(self, tmp_path):
        mgr = make_manager(tmp_path, max_open=10)
        await mgr.initialize()

        async def fetch():
            return await mgr.get_adapter("shared-user")

        results = await asyncio.gather(*[fetch() for _ in range(20)])

        # All should return the same adapter instance
        assert all(r is results[0] for r in results)
        # Only one miss (first open), 19 hits
        assert mgr.miss_count == 1
        assert mgr.hit_count == 19
        await mgr.close_all()

    @pytest.mark.asyncio
    async def test_concurrent_different_owners(self, tmp_path):
        mgr = make_manager(tmp_path, max_open=50)
        await mgr.initialize()

        async def fetch(uid: str):
            return await mgr.get_adapter(uid)

        owner_ids = [f"user{i}" for i in range(20)]
        tasks = [fetch(uid) for uid in owner_ids]
        adapters = await asyncio.gather(*tasks)

        # All should be distinct
        assert len(set(id(a) for a in adapters)) == 20
        assert mgr.miss_count == 20
        await mgr.close_all()


class TestInitialize:
    """initialize() creates the data directory if it doesn't exist."""

    @pytest.mark.asyncio
    async def test_creates_data_dir(self, tmp_path):
        new_dir = tmp_path / "nested" / "data"
        mgr = DBManager(data_dir=str(new_dir), max_open=5)
        assert not new_dir.exists()
        await mgr.initialize()
        assert new_dir.exists()
