# tests/unit/services/memory/cache/test_embedding_cache.py
"""Tests for EmbeddingCache."""

import time
from unittest.mock import AsyncMock, MagicMock

import pytest

from app.services.memory.cache.embedding_cache import (
    CachedEmbeddingGenerator,
    EmbeddingCache,
    EmbeddingCacheStats,
    EmbeddingEntry,
    create_embedding_cache,
)

pytestmark = pytest.mark.asyncio(loop_scope="function")


class TestEmbeddingEntry:
    """Tests for EmbeddingEntry."""

    def test_creates_entry(self) -> None:
        """Should create entry with embedding."""
        from datetime import UTC, datetime

        entry = EmbeddingEntry(
            embedding=[0.1, 0.2, 0.3],
            content_hash="abc123",
            model="text-embedding-3-small",
            created_at=datetime.now(UTC),
        )

        assert entry.embedding == [0.1, 0.2, 0.3]
        assert entry.content_hash == "abc123"
        assert entry.ttl_seconds == 3600.0

    def test_is_expired(self) -> None:
        """Should detect expired entries."""
        from datetime import UTC, datetime, timedelta

        old_time = datetime.now(UTC) - timedelta(seconds=4000)
        entry = EmbeddingEntry(
            embedding=[0.1],
            content_hash="abc",
            model="default",
            created_at=old_time,
            ttl_seconds=3600.0,
        )

        assert entry.is_expired() is True

    def test_not_expired(self) -> None:
        """Should detect non-expired entries."""
        from datetime import UTC, datetime

        entry = EmbeddingEntry(
            embedding=[0.1],
            content_hash="abc",
            model="default",
            created_at=datetime.now(UTC),
        )

        assert entry.is_expired() is False


class TestEmbeddingCacheStats:
    """Tests for EmbeddingCacheStats."""

    def test_hit_rate_calculation(self) -> None:
        """Should calculate hit rate correctly."""
        stats = EmbeddingCacheStats(hits=90, misses=10)

        assert stats.hit_rate == 0.9

    def test_hit_rate_zero_requests(self) -> None:
        """Should return 0 for no requests."""
        stats = EmbeddingCacheStats()

        assert stats.hit_rate == 0.0

    def test_to_dict(self) -> None:
        """Should convert to dictionary."""
        stats = EmbeddingCacheStats(hits=10, misses=5, bytes_saved=1000)

        result = stats.to_dict()

        assert result["hits"] == 10
        assert result["bytes_saved"] == 1000


class TestEmbeddingCache:
    """Tests for EmbeddingCache."""

    @pytest.fixture
    def cache(self) -> EmbeddingCache:
        """Create an embedding cache."""
        return EmbeddingCache(max_size=100, default_ttl_seconds=300.0)

    async def test_put_and_get(self, cache: EmbeddingCache) -> None:
        """Should store and retrieve embeddings."""
        content = "Hello world"
        embedding = [0.1, 0.2, 0.3, 0.4]

        content_hash = await cache.put(content, embedding)
        result = await cache.get(content)

        assert result == embedding
        assert len(content_hash) == 32

    async def test_get_missing(self, cache: EmbeddingCache) -> None:
        """Should return None for missing content."""
        result = await cache.get("nonexistent content")

        assert result is None

    async def test_get_by_hash(self, cache: EmbeddingCache) -> None:
        """Should get by content hash."""
        content = "Test content"
        embedding = [0.1, 0.2]

        content_hash = await cache.put(content, embedding)
        result = await cache.get_by_hash(content_hash)

        assert result == embedding

    async def test_model_separation(self, cache: EmbeddingCache) -> None:
        """Should separate embeddings by model."""
        content = "Same content"
        emb1 = [0.1, 0.2]
        emb2 = [0.3, 0.4]

        await cache.put(content, emb1, model="model-a")
        await cache.put(content, emb2, model="model-b")

        result1 = await cache.get(content, model="model-a")
        result2 = await cache.get(content, model="model-b")

        assert result1 == emb1
        assert result2 == emb2

    async def test_lru_eviction(self) -> None:
        """Should evict LRU entries when at capacity."""
        cache = EmbeddingCache(max_size=3)

        await cache.put("content1", [0.1])
        await cache.put("content2", [0.2])
        await cache.put("content3", [0.3])

        # Access first to make it recent
        await cache.get("content1")

        # Add fourth, should evict second (LRU)
        await cache.put("content4", [0.4])

        assert await cache.get("content1") is not None
        assert await cache.get("content2") is None  # Evicted
        assert await cache.get("content3") is not None
        assert await cache.get("content4") is not None

    async def test_ttl_expiration(self) -> None:
        """Should expire entries after TTL."""
        cache = EmbeddingCache(max_size=100, default_ttl_seconds=0.05)

        await cache.put("content", [0.1, 0.2])

        time.sleep(0.06)

        result = await cache.get("content")

        assert result is None

    async def test_put_batch(self, cache: EmbeddingCache) -> None:
        """Should cache multiple embeddings."""
        items = [
            ("content1", [0.1]),
            ("content2", [0.2]),
            ("content3", [0.3]),
        ]

        hashes = await cache.put_batch(items)

        assert len(hashes) == 3
        assert await cache.get("content1") == [0.1]
        assert await cache.get("content2") == [0.2]

    async def test_invalidate(self, cache: EmbeddingCache) -> None:
        """Should invalidate cached embedding."""
        await cache.put("content", [0.1, 0.2])

        result = await cache.invalidate("content")

        assert result is True
        assert await cache.get("content") is None

    async def test_invalidate_by_hash(self, cache: EmbeddingCache) -> None:
        """Should invalidate by hash."""
        content_hash = await cache.put("content", [0.1, 0.2])

        result = await cache.invalidate_by_hash(content_hash)

        assert result is True
        assert await cache.get("content") is None

    async def test_invalidate_by_model(self, cache: EmbeddingCache) -> None:
        """Should invalidate all embeddings for a model."""
        await cache.put("content1", [0.1], model="model-a")
        await cache.put("content2", [0.2], model="model-a")
        await cache.put("content3", [0.3], model="model-b")

        count = await cache.invalidate_by_model("model-a")

        assert count == 2
        assert await cache.get("content1", model="model-a") is None
        assert await cache.get("content3", model="model-b") is not None

    async def test_clear(self, cache: EmbeddingCache) -> None:
        """Should clear all entries."""
        await cache.put("content1", [0.1])
        await cache.put("content2", [0.2])

        count = await cache.clear()

        assert count == 2
        assert cache.size == 0

    def test_cleanup_expired(self) -> None:
        """Should remove expired entries."""
        cache = EmbeddingCache(max_size=100, default_ttl_seconds=0.05)

        # Use synchronous put for setup
        cache._put_memory("hash1", "default", [0.1])
        cache._put_memory("hash2", "default", [0.2], ttl_seconds=10)

        time.sleep(0.06)

        count = cache.cleanup_expired()

        assert count == 1

    def test_get_stats(self, cache: EmbeddingCache) -> None:
        """Should return accurate statistics."""
        # Put synchronously for setup
        cache._put_memory("hash1", "default", [0.1])

        stats = cache.get_stats()

        assert stats.current_size == 1

    def test_hash_content(self) -> None:
        """Should produce consistent hashes."""
        hash1 = EmbeddingCache.hash_content("test content")
        hash2 = EmbeddingCache.hash_content("test content")
        hash3 = EmbeddingCache.hash_content("different content")

        assert hash1 == hash2
        assert hash1 != hash3
        assert len(hash1) == 32


class TestEmbeddingCacheWithRedis:
    """Tests for EmbeddingCache with Redis."""

    @pytest.fixture
    def mock_redis(self) -> MagicMock:
        """Create mock Redis."""
        redis = MagicMock()
        redis.get = AsyncMock(return_value=None)
        redis.setex = AsyncMock()
        redis.delete = AsyncMock()
        redis.scan_iter = MagicMock(return_value=iter([]))
        return redis

    @pytest.fixture
    def cache_with_redis(self, mock_redis: MagicMock) -> EmbeddingCache:
        """Create cache with mock Redis."""
        return EmbeddingCache(
            max_size=100,
            default_ttl_seconds=300.0,
            redis=mock_redis,
        )

    async def test_put_stores_in_redis(
        self,
        cache_with_redis: EmbeddingCache,
        mock_redis: MagicMock,
    ) -> None:
        """Should store in Redis when available."""
        await cache_with_redis.put("content", [0.1, 0.2])

        mock_redis.setex.assert_called_once()

    async def test_get_checks_redis_on_miss(
        self,
        cache_with_redis: EmbeddingCache,
        mock_redis: MagicMock,
    ) -> None:
        """Should check Redis when memory cache misses."""
        import json

        mock_redis.get.return_value = json.dumps([0.1, 0.2])

        result = await cache_with_redis.get("content")

        assert result == [0.1, 0.2]
        mock_redis.get.assert_called_once()


class TestCachedEmbeddingGenerator:
    """Tests for CachedEmbeddingGenerator."""

    @pytest.fixture
    def mock_generator(self) -> MagicMock:
        """Create mock embedding generator."""
        gen = MagicMock()
        gen.generate = AsyncMock(return_value=[0.1, 0.2, 0.3])
        gen.generate_batch = AsyncMock(return_value=[[0.1], [0.2], [0.3]])
        return gen

    @pytest.fixture
    def cache(self) -> EmbeddingCache:
        """Create embedding cache."""
        return EmbeddingCache(max_size=100)

    @pytest.fixture
    def cached_gen(
        self,
        mock_generator: MagicMock,
        cache: EmbeddingCache,
    ) -> CachedEmbeddingGenerator:
        """Create cached generator."""
        return CachedEmbeddingGenerator(mock_generator, cache)

    async def test_generate_caches_result(
        self,
        cached_gen: CachedEmbeddingGenerator,
        mock_generator: MagicMock,
    ) -> None:
        """Should cache generated embedding."""
        result1 = await cached_gen.generate("test text")
        result2 = await cached_gen.generate("test text")

        assert result1 == [0.1, 0.2, 0.3]
        assert result2 == [0.1, 0.2, 0.3]
        mock_generator.generate.assert_called_once()  # Only called once

    async def test_generate_batch_uses_cache(
        self,
        cached_gen: CachedEmbeddingGenerator,
        mock_generator: MagicMock,
        cache: EmbeddingCache,
    ) -> None:
        """Should use cache for batch generation."""
        # Pre-cache one embedding
        await cache.put("text1", [0.5])

        # Mock returns 2 embeddings for the 2 uncached texts
        mock_generator.generate_batch = AsyncMock(return_value=[[0.2], [0.3]])

        results = await cached_gen.generate_batch(["text1", "text2", "text3"])

        assert len(results) == 3
        assert results[0] == [0.5]  # From cache
        assert results[1] == [0.2]  # Generated
        assert results[2] == [0.3]  # Generated

    async def test_get_stats(self, cached_gen: CachedEmbeddingGenerator) -> None:
        """Should return generator statistics."""
        await cached_gen.generate("text1")
        await cached_gen.generate("text1")  # Cache hit

        stats = cached_gen.get_stats()

        assert stats["call_count"] == 2
        assert stats["cache_hit_count"] == 1


class TestCreateEmbeddingCache:
    """Tests for factory function."""

    def test_creates_cache(self) -> None:
        """Should create cache with defaults."""
        cache = create_embedding_cache()

        assert cache.max_size == 50000

    def test_creates_cache_with_options(self) -> None:
        """Should create cache with custom options."""
        cache = create_embedding_cache(max_size=1000, default_ttl_seconds=600.0)

        assert cache.max_size == 1000
