"""Tests for SQLite-vec semantic cache (Phase 12.2a).

Integration tests using real MemoryStorage with sqlite-vec and
sentence-transformers, verifying cache behavior end-to-end.
"""

from __future__ import annotations

import time
from pathlib import Path

import pytest

from claude_engram.semantic_cache import CacheStats, SemanticCache
from claude_engram.storage import MemoryStorage


@pytest.fixture()
def storage(tmp_path: Path) -> MemoryStorage:
    """Create a MemoryStorage with a temp database."""
    db = tmp_path / "test.db"
    s = MemoryStorage(db_path=db)
    yield s
    s.close()


@pytest.fixture()
def short_ttl_storage(tmp_path: Path) -> MemoryStorage:
    """Create a MemoryStorage with 1-second cache TTL for expiry tests."""
    db = tmp_path / "ttl_test.db"
    s = MemoryStorage(db_path=db, cache_ttl=1)
    yield s
    s.close()


class TestCacheMissThenStore:
    """Cache miss triggers full search, then stores result for future hits."""

    def test_first_search_is_cache_miss(self, storage: MemoryStorage) -> None:
        """First search for a query should be a cache miss."""
        storage.store(content="Python is great for scripting", category="lesson")
        results = storage.search(query="Python scripting")
        assert len(results) > 0
        stats = storage._semantic_cache.stats()
        assert stats.misses >= 1
        assert stats.entries == 1  # Result was cached

    def test_cache_stores_results_after_miss(self, storage: MemoryStorage) -> None:
        """After a cache miss, results should be stored for future lookups."""
        storage.store(content="SQLite is a lightweight database engine", category="lesson")
        storage.search(query="lightweight database")
        stats = storage._semantic_cache.stats()
        assert stats.entries == 1


class TestCacheHit:
    """Cache hit returns same results without full vector search."""

    def test_identical_query_hits_cache(self, storage: MemoryStorage) -> None:
        """Second identical search should return cached results."""
        storage.store(content="SQLite is a lightweight database", category="lesson")

        results1 = storage.search(query="lightweight database")
        miss_count = storage._semantic_cache.stats().misses

        results2 = storage.search(query="lightweight database")
        stats = storage._semantic_cache.stats()

        assert stats.hits >= 1
        assert stats.misses == miss_count  # No new misses
        assert len(results1) == len(results2)
        for r1, r2 in zip(results1, results2, strict=True):
            assert r1["id"] == r2["id"]

    def test_cache_hit_returns_correct_data(self, storage: MemoryStorage) -> None:
        """Cached results should contain all expected fields."""
        mid = storage.store(content="dataclass patterns in Python", category="lesson")
        storage.search(query="dataclass patterns")  # Populate cache

        results2 = storage.search(query="dataclass patterns")
        assert len(results2) > 0
        hit_result = results2[0]
        assert hit_result["id"] == mid
        assert "similarity" in hit_result
        assert "category" in hit_result
        assert "created_at" in hit_result


class TestCacheTTLExpiration:
    """TTL expiration correctly invalidates cache entries."""

    def test_expired_entry_is_cache_miss(self, short_ttl_storage: MemoryStorage) -> None:
        """Cache entries past TTL should be treated as misses."""
        short_ttl_storage.store(content="ephemeral data for testing", category="lesson")

        # First search: miss, populates cache
        short_ttl_storage.search(query="ephemeral data")
        assert short_ttl_storage._semantic_cache.stats().entries == 1

        # Wait for TTL to expire
        time.sleep(1.5)

        # Second search: should be a miss (expired entry)
        short_ttl_storage.search(query="ephemeral data")
        stats = short_ttl_storage._semantic_cache.stats()
        assert stats.misses >= 2


class TestStoreInvalidatesCache:
    """Storing new memories invalidates the semantic cache."""

    def test_store_clears_all_cache_entries(self, storage: MemoryStorage) -> None:
        """New memory store should invalidate all cache entries."""
        storage.store(content="existing knowledge base", category="lesson")
        storage.search(query="existing knowledge")
        assert storage._semantic_cache.stats().entries > 0

        # Store new memory — should invalidate entire cache
        storage.store(content="brand new information", category="lesson")
        assert storage._semantic_cache.stats().entries == 0

    def test_cache_rebuilds_after_invalidation(self, storage: MemoryStorage) -> None:
        """After invalidation, new searches should repopulate cache."""
        storage.store(content="knowledge about testing", category="lesson")
        storage.search(query="testing knowledge")
        storage.store(content="new memory clears cache", category="lesson")
        assert storage._semantic_cache.stats().entries == 0

        # New search repopulates
        storage.search(query="testing knowledge")
        assert storage._semantic_cache.stats().entries == 1


class TestDeleteInvalidatesCache:
    """Deleting memories invalidates relevant cache entries."""

    def test_delete_invalidates_cache_entry(self, storage: MemoryStorage) -> None:
        """Deleting a memory should invalidate cache entries containing it."""
        mid = storage.store(content="deletable content for cache test", category="lesson")
        storage.search(query="deletable content")
        assert storage._semantic_cache.stats().entries > 0

        storage.delete(mid)
        assert storage._semantic_cache.stats().entries == 0


class TestSimilarityThreshold:
    """Similarity threshold correctly filters cache hits vs misses."""

    def test_dissimilar_query_misses_cache(self, storage: MemoryStorage) -> None:
        """Very different query should not hit cache for a previous search."""
        storage.store(content="Python is great for scripting", category="lesson")
        storage.search(query="Python scripting")  # Populate cache

        # Very different query should miss
        storage.search(query="database management SQL optimization")
        stats = storage._semantic_cache.stats()
        assert stats.misses >= 2  # Both are misses (or first miss + dissimilar miss)
        assert stats.hits == 0


class TestCacheStatsAccuracy:
    """Stats tracking reflects actual cache usage."""

    def test_hit_miss_tracking(self, storage: MemoryStorage) -> None:
        """Stats should accurately count hits and misses."""
        storage.store(content="stats tracking test", category="lesson")

        # Miss
        storage.search(query="stats tracking test")
        stats = storage._semantic_cache.stats()
        assert stats.misses == 1
        assert stats.hits == 0

        # Hit
        storage.search(query="stats tracking test")
        stats = storage._semantic_cache.stats()
        assert stats.hits == 1
        assert stats.misses == 1

    def test_hit_rate_calculation(self, storage: MemoryStorage) -> None:
        """Hit rate should be hits / (hits + misses)."""
        storage.store(content="hit rate calculation test", category="lesson")
        storage.search(query="hit rate calculation test")  # miss
        storage.search(query="hit rate calculation test")  # hit
        stats = storage._semantic_cache.stats()
        assert stats.hit_rate == pytest.approx(0.5)

    def test_entry_count(self, storage: MemoryStorage) -> None:
        """Entry count should reflect stored cache entries."""
        stats = storage._semantic_cache.stats()
        assert stats.entries == 0

        storage.store(content="entry count test data", category="lesson")
        storage.search(query="entry count test")
        stats = storage._semantic_cache.stats()
        assert stats.entries == 1

    def test_stats_returns_dataclass(self, storage: MemoryStorage) -> None:
        """Stats should return a CacheStats dataclass."""
        stats = storage._semantic_cache.stats()
        assert isinstance(stats, CacheStats)
        assert hasattr(stats, "hits")
        assert hasattr(stats, "misses")
        assert hasattr(stats, "entries")
        assert hasattr(stats, "avg_age_seconds")
        assert hasattr(stats, "hit_rate")


class TestEnableCacheBypass:
    """enable_cache=False bypasses the cache entirely."""

    def test_bypass_does_not_read_cache(self, storage: MemoryStorage) -> None:
        """Search with enable_cache=False should not read from cache."""
        storage.store(content="bypass test content", category="lesson")
        storage.search(query="bypass test content")  # Populate cache
        initial_hits = storage._semantic_cache.stats().hits

        results = storage.search(query="bypass test content", enable_cache=False)
        assert storage._semantic_cache.stats().hits == initial_hits  # No new hits
        assert len(results) > 0  # Still returns results from full search

    def test_bypass_does_not_write_cache(self, storage: MemoryStorage) -> None:
        """Search with enable_cache=False should not store results."""
        storage.store(content="no cache write test", category="lesson")
        storage.search(query="no cache write test", enable_cache=False)
        assert storage._semantic_cache.stats().entries == 0


class TestCacheCleanup:
    """Cleanup removes expired entries."""

    def test_cleanup_removes_expired(self, short_ttl_storage: MemoryStorage) -> None:
        """cleanup() should remove entries past TTL."""
        short_ttl_storage.store(content="cleanup test data", category="lesson")
        short_ttl_storage.search(query="cleanup test")
        assert short_ttl_storage._semantic_cache.stats().entries == 1

        time.sleep(1.5)
        removed = short_ttl_storage._semantic_cache.cleanup()
        assert removed == 1
        assert short_ttl_storage._semantic_cache.stats().entries == 0

    def test_cleanup_preserves_fresh_entries(self, storage: MemoryStorage) -> None:
        """cleanup() should not remove entries within TTL."""
        storage.store(content="fresh entry data", category="lesson")
        storage.search(query="fresh entry")
        removed = storage._semantic_cache.cleanup()
        assert removed == 0
        assert storage._semantic_cache.stats().entries == 1


class TestCacheWithBatchStore:
    """Batch store invalidates cache correctly."""

    def test_batch_store_invalidates_cache(self, storage: MemoryStorage) -> None:
        """batch_store should invalidate cache once after all items stored."""
        storage.store(content="pre-batch content", category="lesson")
        storage.search(query="pre-batch content")
        assert storage._semantic_cache.stats().entries > 0

        items = [
            {"content": "batch item alpha", "category": "lesson"},
            {"content": "batch item beta", "category": "lesson"},
        ]
        storage.batch_store(items)
        assert storage._semantic_cache.stats().entries == 0


class TestSemanticCacheUnit:
    """Unit tests for SemanticCache class directly."""

    def test_init_creates_tables(self, tmp_path: Path) -> None:
        """SemanticCache should create cache_entries and cache_vectors tables."""
        import sqlite3

        import sqlite_vec

        db = tmp_path / "cache_unit.db"
        cache = SemanticCache(db_path=str(db))
        try:
            conn = sqlite3.connect(str(db))
            conn.enable_load_extension(True)
            sqlite_vec.load(conn)
            conn.enable_load_extension(False)

            # Check tables exist
            tables = conn.execute(
                "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
            ).fetchall()
            table_names = [t[0] for t in tables]
            assert "cache_entries" in table_names
            # vec0 tables show up differently in sqlite_master
        finally:
            cache.close()
            conn.close()

    def test_invalidate_all_returns_count(self, tmp_path: Path) -> None:
        """invalidate() with no args should return count of cleared entries."""
        db = tmp_path / "inv_test.db"
        cache = SemanticCache(db_path=str(db))
        try:
            # Store a dummy entry
            embedding = [0.1] * 384
            cache.store(embedding, "test query", [{"id": 1, "content": "test"}])
            assert cache.stats().entries == 1

            count = cache.invalidate()
            assert count == 1
            assert cache.stats().entries == 0
        finally:
            cache.close()

    def test_invalidate_by_memory_id(self, tmp_path: Path) -> None:
        """invalidate(memory_id) should only remove entries containing that ID."""
        db = tmp_path / "inv_id_test.db"
        cache = SemanticCache(db_path=str(db))
        try:
            emb1 = [0.1] * 384
            emb2 = [0.9] * 384
            cache.store(emb1, "query A", [{"id": 10, "content": "A"}])
            cache.store(emb2, "query B", [{"id": 20, "content": "B"}])
            assert cache.stats().entries == 2

            # Invalidate only entries containing memory 10
            count = cache.invalidate(memory_id=10)
            assert count == 1
            assert cache.stats().entries == 1
        finally:
            cache.close()

    def test_configurable_threshold(self, tmp_path: Path) -> None:
        """SemanticCache should respect the configured similarity threshold."""
        db = tmp_path / "thresh_test.db"
        cache = SemanticCache(db_path=str(db), similarity_threshold=0.99)
        try:
            assert cache.similarity_threshold == 0.99
        finally:
            cache.close()

    def test_configurable_ttl(self, tmp_path: Path) -> None:
        """SemanticCache should respect the configured TTL."""
        db = tmp_path / "ttl_cfg_test.db"
        cache = SemanticCache(db_path=str(db), ttl_seconds=60)
        try:
            assert cache.ttl_seconds == 60
        finally:
            cache.close()
