"""Tests for Prefix Cache (Task 2.1)"""

import pytest

from sagellm_kv_cache.prefix_cache import PrefixCache


class TestPrefixCache:
    """Test suite for PrefixCache."""

    def test_initialization(self):
        """Test basic initialization."""
        cache = PrefixCache(block_size=16)
        assert cache.block_size == 16
        assert len(cache) == 0

        with pytest.raises(ValueError):
            PrefixCache(block_size=0)

    def test_compute_block_hash_deterministic(self):
        """Test that block hash is deterministic."""
        cache = PrefixCache(block_size=16)
        tokens = list(range(16))

        hash1 = cache.compute_block_hash(tokens)
        hash2 = cache.compute_block_hash(tokens)

        assert hash1 == hash2
        assert isinstance(hash1, bytes)
        assert len(hash1) == 32  # SHA256 = 32 bytes

    def test_compute_block_hash_different_tokens(self):
        """Test that different tokens produce different hashes."""
        cache = PrefixCache(block_size=16)
        tokens1 = list(range(16))
        tokens2 = list(range(1, 17))

        hash1 = cache.compute_block_hash(tokens1)
        hash2 = cache.compute_block_hash(tokens2)

        assert hash1 != hash2

    def test_compute_block_hash_wrong_size(self):
        """Test error when token count doesn't match block_size."""
        cache = PrefixCache(block_size=16)
        tokens = list(range(10))  # Wrong size

        with pytest.raises(ValueError, match="doesn't match block_size"):
            cache.compute_block_hash(tokens)

    def test_compute_block_hashes(self):
        """Test computing hashes for multiple blocks."""
        cache = PrefixCache(block_size=16)
        tokens = list(range(50))  # 3 complete blocks + 2 tokens

        hashes = cache.compute_block_hashes(tokens)

        assert len(hashes) == 3  # Only complete blocks
        assert all(isinstance(h, bytes) for h in hashes)
        assert all(len(h) == 32 for h in hashes)

    def test_insert_and_lookup_hit(self):
        """Test successful cache lookup."""
        cache = PrefixCache(block_size=16)
        tokens = list(range(48))
        hashes = cache.compute_block_hashes(tokens)
        blocks = [{"block_id": i, "tokens": 16} for i in range(3)]

        # Insert
        cache.insert(hashes, blocks)
        assert len(cache) == 3

        # Lookup - full match
        hit_blocks, num_tokens = cache.lookup(hashes)
        assert len(hit_blocks) == 3
        assert num_tokens == 48

        # Check stats
        stats = cache.get_stats()
        assert stats["hit_count"] == 1
        assert stats["miss_count"] == 0
        assert stats["hit_rate"] == 1.0

    def test_lookup_partial_match(self):
        """Test partial prefix match."""
        cache = PrefixCache(block_size=16)
        tokens = list(range(48))
        hashes = cache.compute_block_hashes(tokens)
        blocks = [{"block_id": i} for i in range(3)]

        # Insert only first 2 blocks
        cache.insert(hashes[:2], blocks[:2])

        # Lookup with 3 blocks - should match first 2
        hit_blocks, num_tokens = cache.lookup(hashes)
        assert len(hit_blocks) == 2
        assert num_tokens == 32

    def test_lookup_miss(self):
        """Test cache miss."""
        cache = PrefixCache(block_size=16)
        hashes = [b"hash1", b"hash2"]

        # Lookup without insert
        hit_blocks, num_tokens = cache.lookup(hashes)
        assert len(hit_blocks) == 0
        assert num_tokens == 0

        stats = cache.get_stats()
        assert stats["miss_count"] == 1

    def test_invalidate(self):
        """Test cache invalidation."""
        from uuid import uuid4

        cache = PrefixCache(block_size=16)
        handle_id = uuid4()
        hashes = [b"hash1", b"hash2"]
        blocks = [
            {"block_id": 0, "handle_id": handle_id},
            {"block_id": 1, "handle_id": handle_id},
        ]

        cache.insert(hashes, blocks)
        assert len(cache) == 2

        # Invalidate
        removed = cache.invalidate(handle_id)
        assert removed == 2
        assert len(cache) == 0

    def test_invalidate_partial(self):
        """Test invalidating some but not all blocks."""
        from uuid import uuid4

        cache = PrefixCache(block_size=16)
        handle_id1 = uuid4()
        handle_id2 = uuid4()

        hashes = [b"hash1", b"hash2", b"hash3"]
        blocks = [
            {"block_id": 0, "handle_id": handle_id1},
            {"block_id": 1, "handle_id": handle_id2},
            {"block_id": 2, "handle_id": handle_id1},
        ]

        cache.insert(hashes, blocks)

        # Invalidate handle_id1
        removed = cache.invalidate(handle_id1)
        assert removed == 2

        # hash2 should still be cached
        hit_blocks, _ = cache.lookup([hashes[1]])
        assert len(hit_blocks) == 1

    def test_clear(self):
        """Test clearing entire cache."""
        cache = PrefixCache(block_size=16)
        hashes = [b"hash1", b"hash2"]
        blocks = [{"block_id": 0}, {"block_id": 1}]

        cache.insert(hashes, blocks)
        assert len(cache) == 2

        cache.clear()
        assert len(cache) == 0

    def test_insert_duplicate_hash(self):
        """Test inserting multiple blocks with same hash."""
        cache = PrefixCache(block_size=16)
        hash1 = b"hash1"

        # Insert twice with same hash
        cache.insert([hash1], [{"block_id": 0}])
        cache.insert([hash1], [{"block_id": 1}])

        # Should have 1 entry but 2 blocks
        stats = cache.get_stats()
        assert stats["num_entries"] == 1
        assert stats["num_blocks"] == 2

    def test_stats_tracking(self):
        """Test statistics tracking."""
        cache = PrefixCache(block_size=16)
        hashes = [b"hash1", b"hash2"]
        blocks = [{"block_id": 0}, {"block_id": 1}]

        cache.insert(hashes, blocks)

        # Hit
        cache.lookup(hashes)
        # Miss
        cache.lookup([b"hash3"])

        stats = cache.get_stats()
        assert stats["hit_count"] == 1
        assert stats["miss_count"] == 1
        assert stats["hit_rate"] == 0.5
        assert stats["insert_count"] == 2

    def test_repr(self):
        """Test string representation."""
        cache = PrefixCache(block_size=16)
        repr_str = repr(cache)

        assert "PrefixCache" in repr_str
        assert "block_size=16" in repr_str
        assert "hit_rate" in repr_str


class TestPrefixCacheLRU:
    """Test suite for Prefix Cache LRU eviction (issue #55)."""

    def test_lru_initialization(self):
        """Test LRU initialization."""
        cache = PrefixCache(block_size=16, max_cached_blocks=10, enable_lru=True)
        assert cache.block_size == 16
        assert cache.max_cached_blocks == 10
        assert cache.enable_lru is True

        stats = cache.get_stats()
        assert stats["enable_lru"] is True
        assert stats["max_cached_blocks"] == 10

    def test_lru_disabled(self):
        """Test cache with LRU disabled."""
        cache = PrefixCache(block_size=16, enable_lru=False)
        assert cache.enable_lru is False

        # evict_by_count should fail when LRU is disabled
        with pytest.raises(RuntimeError, match="LRU eviction not enabled"):
            cache.evict_by_count(1)

    def test_lru_access_updates_order(self):
        """Test that lookup updates LRU order."""
        import time

        cache = PrefixCache(block_size=16, max_cached_blocks=3, enable_lru=True)
        hashes = [b"hash1", b"hash2", b"hash3"]
        blocks = [{"block_id": i} for i in range(3)]

        # Insert all blocks
        for h, b in zip(hashes, blocks):
            cache.insert([h], [b])
            time.sleep(0.01)  # Ensure different timestamps

        # Access hash1 to make it most recent
        cache.lookup([hashes[0]])

        # Now hash2 should be least recently used
        # Insert a new block - should evict hash2
        cache.insert([b"hash4"], [{"block_id": 4}])

        # hash1 and hash3 should still exist
        hit_blocks1, _ = cache.lookup([hashes[0]])
        assert len(hit_blocks1) == 1

        hit_blocks3, _ = cache.lookup([hashes[2]])
        assert len(hit_blocks3) == 1

        # hash2 should be evicted
        hit_blocks2, _ = cache.lookup([hashes[1]])
        assert len(hit_blocks2) == 0

    def test_lru_eviction_on_capacity(self):
        """Test automatic eviction when capacity is reached."""
        cache = PrefixCache(block_size=16, max_cached_blocks=2, enable_lru=True)
        hashes = [b"hash1", b"hash2", b"hash3"]
        blocks = [{"block_id": i} for i in range(3)]

        # Insert 2 blocks - should be fine
        cache.insert(hashes[:2], blocks[:2])
        assert len(cache) == 2

        # Insert 3rd block - should trigger eviction of hash1 (oldest)
        cache.insert([hashes[2]], [blocks[2]])

        # hash1 should be evicted (oldest)
        hit_blocks1, _ = cache.lookup([hashes[0]])
        assert len(hit_blocks1) == 0

        # hash2 and hash3 should still exist
        hit_blocks2, _ = cache.lookup([hashes[1]])
        assert len(hit_blocks2) == 1

        hit_blocks3, _ = cache.lookup([hashes[2]])
        assert len(hit_blocks3) == 1

        # Check eviction count
        stats = cache.get_stats()
        assert stats["evict_count"] >= 1

    def test_manual_eviction(self):
        """Test manual eviction via evict_by_count."""
        cache = PrefixCache(block_size=16, max_cached_blocks=10, enable_lru=True)
        hashes = [b"hash1", b"hash2", b"hash3"]
        blocks = [{"block_id": i} for i in range(3)]

        cache.insert(hashes, blocks)
        assert len(cache) == 3

        # Manually evict 2 blocks
        evicted = cache.evict_by_count(2)
        assert evicted >= 2

        # Should have at most 1 block left
        stats = cache.get_stats()
        assert stats["num_blocks"] <= 1

    def test_lru_stats(self):
        """Test that LRU stats are properly tracked."""
        cache = PrefixCache(block_size=16, max_cached_blocks=2, enable_lru=True)
        hashes = [b"hash1", b"hash2", b"hash3"]
        blocks = [{"block_id": i} for i in range(3)]

        # Insert 3 blocks - should trigger eviction
        for h, b in zip(hashes, blocks):
            cache.insert([h], [b])

        stats = cache.get_stats()
        assert stats["insert_count"] == 3
        assert stats["evict_count"] >= 1
        assert stats["enable_lru"] is True

    def test_lru_clear(self):
        """Test that clear removes LRU tracker entries."""
        cache = PrefixCache(block_size=16, max_cached_blocks=10, enable_lru=True)
        hashes = [b"hash1", b"hash2"]
        blocks = [{"block_id": i} for i in range(2)]

        cache.insert(hashes, blocks)
        assert len(cache) == 2
        assert len(cache._lru_tracker) == 2

        cache.clear()
        assert len(cache) == 0
        assert len(cache._lru_tracker) == 0

    def test_lru_invalidate(self):
        """Test that invalidate also updates LRU tracker."""
        from uuid import uuid4

        cache = PrefixCache(block_size=16, max_cached_blocks=10, enable_lru=True)
        handle_id = uuid4()
        hashes = [b"hash1", b"hash2"]
        blocks = [
            {"block_id": 0, "handle_id": handle_id},
            {"block_id": 1, "handle_id": handle_id},
        ]

        cache.insert(hashes, blocks)
        assert len(cache) == 2
        assert len(cache._lru_tracker) == 2

        # Invalidate
        cache.invalidate(handle_id)
        assert len(cache) == 0
        assert len(cache._lru_tracker) == 0
