"""
tests/test_embedding_cache.py — Tests for EmbeddingCache (v1.2.1).

All tests are self-contained: no external dependencies, no DB, no LLM.
They exercise the full public API of EmbeddingCache:

    - Cache hit returns correct vector
    - Cache miss returns None
    - Cache miss → provider called (integration with _embed)
    - TTL expiry evicts entries
    - LRU eviction at maxsize
    - Thread safety (concurrent gets/sets)
    - Stats accuracy (hits, misses, hit_rate, size)
    - Key normalisation (case/whitespace insensitive)
    - Config disabled = bypass (all ops are no-ops)
    - clear() resets cache and counters
    - RetrievalPipeline._embed() uses cache (unit test with mocked provider)
"""

from __future__ import annotations

import asyncio
import time
import threading
from typing import List, Optional
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from cortex.core.embedding_cache import EmbeddingCache


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

SAMPLE_VEC: List[float] = [0.1, 0.2, 0.3, 0.4]
SAMPLE_TEXT = "What is the API design?"


def _make_cache(maxsize: int = 10, ttl: int = 3600, enabled: bool = True) -> EmbeddingCache:
    return EmbeddingCache(maxsize=maxsize, ttl=ttl, enabled=enabled)


# ---------------------------------------------------------------------------
# Basic get / set
# ---------------------------------------------------------------------------

class TestEmbeddingCacheBasic:
    def test_miss_returns_none(self):
        cache = _make_cache()
        assert cache.get("unknown text") is None

    def test_set_then_get_returns_vector(self):
        cache = _make_cache()
        cache.set(SAMPLE_TEXT, SAMPLE_VEC)
        result = cache.get(SAMPLE_TEXT)
        assert result == SAMPLE_VEC

    def test_set_overwrite(self):
        cache = _make_cache()
        vec1 = [1.0, 2.0]
        vec2 = [3.0, 4.0]
        cache.set(SAMPLE_TEXT, vec1)
        cache.set(SAMPLE_TEXT, vec2)
        assert cache.get(SAMPLE_TEXT) == vec2


# ---------------------------------------------------------------------------
# Key normalisation (case + whitespace)
# ---------------------------------------------------------------------------

class TestKeyNormalisation:
    def test_case_insensitive(self):
        """Different case variants should map to the same cache entry."""
        cache = _make_cache()
        cache.set("What is the API design?", SAMPLE_VEC)
        assert cache.get("what is the api design?") == SAMPLE_VEC
        assert cache.get("WHAT IS THE API DESIGN?") == SAMPLE_VEC

    def test_whitespace_stripped(self):
        """Leading/trailing whitespace should not affect the key."""
        cache = _make_cache()
        cache.set("  hello world  ", SAMPLE_VEC)
        assert cache.get("hello world") == SAMPLE_VEC
        assert cache.get("  hello world") == SAMPLE_VEC

    def test_different_content_different_keys(self):
        """Genuinely different texts should NOT collide."""
        cache = _make_cache()
        vec_a = [1.0]
        vec_b = [2.0]
        cache.set("query A", vec_a)
        cache.set("query B", vec_b)
        assert cache.get("query A") == vec_a
        assert cache.get("query B") == vec_b

    def test_make_key_deterministic(self):
        """Same text always produces same key."""
        key1 = EmbeddingCache._make_key("Hello World")
        key2 = EmbeddingCache._make_key("hello world")
        assert key1 == key2
        assert len(key1) == 64  # SHA-256 hex digest length


# ---------------------------------------------------------------------------
# TTL expiry
# ---------------------------------------------------------------------------

class TestTTLExpiry:
    def test_entry_expires_after_ttl(self):
        """An entry with TTL=1s should not be retrievable after 2s."""
        cache = EmbeddingCache(maxsize=100, ttl=1)
        cache.set(SAMPLE_TEXT, SAMPLE_VEC)
        assert cache.get(SAMPLE_TEXT) == SAMPLE_VEC
        time.sleep(1.1)
        assert cache.get(SAMPLE_TEXT) is None

    def test_entry_within_ttl(self):
        """An entry should remain valid before its TTL expires."""
        cache = EmbeddingCache(maxsize=100, ttl=60)
        cache.set(SAMPLE_TEXT, SAMPLE_VEC)
        time.sleep(0.05)
        assert cache.get(SAMPLE_TEXT) == SAMPLE_VEC


# ---------------------------------------------------------------------------
# LRU eviction
# ---------------------------------------------------------------------------

class TestLRUEviction:
    def test_evicts_oldest_when_full(self):
        """When maxsize is hit, the LRU (least-recently-used) entry is evicted."""
        cache = EmbeddingCache(maxsize=3, ttl=3600)
        cache.set("a", [1.0])
        cache.set("b", [2.0])
        cache.set("c", [3.0])
        # Access "a" to make "b" the LRU entry
        cache.get("a")
        # Add a fourth entry — "b" should be evicted
        cache.set("d", [4.0])
        assert cache.get("b") is None
        assert cache.get("a") == [1.0]
        assert cache.get("c") == [3.0]
        assert cache.get("d") == [4.0]

    def test_size_never_exceeds_maxsize(self):
        """Cache size must stay at or below maxsize at all times."""
        maxsize = 5
        cache = EmbeddingCache(maxsize=maxsize, ttl=3600)
        for i in range(20):
            cache.set(f"text_{i}", [float(i)])
        stats = cache.stats()
        assert stats["size"] <= maxsize


# ---------------------------------------------------------------------------
# Stats
# ---------------------------------------------------------------------------

class TestStats:
    def test_initial_stats(self):
        cache = _make_cache()
        s = cache.stats()
        assert s["hits"] == 0
        assert s["misses"] == 0
        assert s["hit_rate"] == 0.0
        assert s["size"] == 0
        assert s["enabled"] is True

    def test_hit_increments_hits(self):
        cache = _make_cache()
        cache.set(SAMPLE_TEXT, SAMPLE_VEC)
        cache.get(SAMPLE_TEXT)
        s = cache.stats()
        assert s["hits"] == 1
        assert s["misses"] == 0

    def test_miss_increments_misses(self):
        cache = _make_cache()
        cache.get("not in cache")
        s = cache.stats()
        assert s["hits"] == 0
        assert s["misses"] == 1

    def test_hit_rate_calculation(self):
        cache = _make_cache()
        cache.set(SAMPLE_TEXT, SAMPLE_VEC)
        cache.get(SAMPLE_TEXT)   # hit
        cache.get(SAMPLE_TEXT)   # hit
        cache.get("missing")     # miss
        s = cache.stats()
        assert s["hit_rate"] == pytest.approx(2 / 3, abs=0.001)

    def test_size_reflects_contents(self):
        cache = _make_cache()
        cache.set("a", [1.0])
        cache.set("b", [2.0])
        assert cache.stats()["size"] == 2

    def test_stats_config_fields(self):
        cache = EmbeddingCache(maxsize=500, ttl=7200)
        s = cache.stats()
        assert s["maxsize"] == 500
        assert s["ttl"] == 7200


# ---------------------------------------------------------------------------
# clear()
# ---------------------------------------------------------------------------

class TestClear:
    def test_clear_removes_entries(self):
        cache = _make_cache()
        cache.set(SAMPLE_TEXT, SAMPLE_VEC)
        cache.clear()
        assert cache.get(SAMPLE_TEXT) is None

    def test_clear_resets_counters(self):
        cache = _make_cache()
        cache.set(SAMPLE_TEXT, SAMPLE_VEC)
        cache.get(SAMPLE_TEXT)
        cache.get("miss")
        cache.clear()
        s = cache.stats()
        assert s["hits"] == 0
        assert s["misses"] == 0
        assert s["size"] == 0


# ---------------------------------------------------------------------------
# Disabled cache (enabled=False)
# ---------------------------------------------------------------------------

class TestDisabledCache:
    def test_get_returns_none_when_disabled(self):
        cache = EmbeddingCache(enabled=False)
        assert cache.get(SAMPLE_TEXT) is None

    def test_set_is_noop_when_disabled(self):
        cache = EmbeddingCache(enabled=False)
        cache.set(SAMPLE_TEXT, SAMPLE_VEC)
        assert cache.get(SAMPLE_TEXT) is None

    def test_stats_size_zero_when_disabled(self):
        cache = EmbeddingCache(enabled=False)
        cache.set(SAMPLE_TEXT, SAMPLE_VEC)
        assert cache.stats()["size"] == 0
        assert cache.stats()["enabled"] is False

    def test_clear_noop_when_disabled(self):
        """clear() on a disabled cache should not raise."""
        cache = EmbeddingCache(enabled=False)
        cache.clear()  # should not raise

    def test_hits_not_counted_when_disabled(self):
        """Stats counters stay zero when disabled (no-ops)."""
        cache = EmbeddingCache(enabled=False)
        cache.get("anything")
        s = cache.stats()
        assert s["hits"] == 0
        assert s["misses"] == 0


# ---------------------------------------------------------------------------
# Thread safety
# ---------------------------------------------------------------------------

class TestThreadSafety:
    def test_concurrent_set_get(self):
        """Multiple threads writing and reading should not raise or corrupt data."""
        cache = EmbeddingCache(maxsize=200, ttl=3600)
        errors: List[Exception] = []

        def writer(i: int) -> None:
            try:
                for j in range(10):
                    cache.set(f"key_{i}_{j}", [float(i), float(j)])
            except Exception as exc:
                errors.append(exc)

        def reader(i: int) -> None:
            try:
                for j in range(10):
                    cache.get(f"key_{i}_{j}")
            except Exception as exc:
                errors.append(exc)

        threads = []
        for i in range(5):
            threads.append(threading.Thread(target=writer, args=(i,)))
            threads.append(threading.Thread(target=reader, args=(i,)))

        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert errors == [], f"Thread errors: {errors}"

    def test_stats_consistent_under_concurrency(self):
        """Hit + miss counters should not go negative or overflow."""
        cache = EmbeddingCache(maxsize=100, ttl=3600)
        cache.set("shared", [1.0, 2.0])

        def worker() -> None:
            for _ in range(50):
                cache.get("shared")
                cache.get("absent")

        threads = [threading.Thread(target=worker) for _ in range(4)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        s = cache.stats()
        total = s["hits"] + s["misses"]
        assert total == 4 * 2 * 50  # 4 threads × 2 gets × 50 loops
        assert s["hit_rate"] == pytest.approx(0.5, abs=0.01)


# ---------------------------------------------------------------------------
# RetrievalPipeline._embed() integration
# ---------------------------------------------------------------------------

class TestRetrievalPipelineEmbedIntegration:
    """Verify that RetrievalPipeline._embed() honours the cache contract."""

    def _make_pipeline(self, cache: EmbeddingCache) -> "Any":
        """Build a minimal RetrievalPipeline with a mocked storage + LLM."""
        from cortex.core.retrieval import RetrievalPipeline

        storage = MagicMock()
        # storage.embed is not present → pipeline will fall through to llm
        del storage.embed

        llm = MagicMock()
        llm.embed = AsyncMock(
            return_value=MagicMock(embeddings=[SAMPLE_VEC])
        )

        config = MagicMock()
        config.embedding_cache_enabled = cache.enabled
        config.embedding_cache_max_size = cache._maxsize
        config.embedding_cache_ttl_seconds = cache._ttl
        # Other config attrs accessed in __init__
        config.retrieval_recency_weight = 0.0

        pipeline = RetrievalPipeline(
            storage=storage,
            config=config,
            llm=llm,
            embedding_cache=cache,
        )
        return pipeline, llm

    @pytest.mark.asyncio
    async def test_cache_miss_calls_provider(self):
        """On cache miss, the LLM provider is called exactly once."""
        cache = _make_cache()
        pipeline, llm = self._make_pipeline(cache)

        result = await pipeline._embed(SAMPLE_TEXT)
        assert result == SAMPLE_VEC
        llm.embed.assert_called_once()

    @pytest.mark.asyncio
    async def test_cache_hit_skips_provider(self):
        """On cache hit, the LLM provider is NOT called."""
        cache = _make_cache()
        cache.set(SAMPLE_TEXT, SAMPLE_VEC)

        pipeline, llm = self._make_pipeline(cache)

        result = await pipeline._embed(SAMPLE_TEXT)
        assert result == SAMPLE_VEC
        llm.embed.assert_not_called()

    @pytest.mark.asyncio
    async def test_cache_populated_after_miss(self):
        """After a miss, the result is stored so the next call is a hit."""
        cache = _make_cache()
        pipeline, llm = self._make_pipeline(cache)

        # First call → miss, should populate cache
        await pipeline._embed(SAMPLE_TEXT)
        assert llm.embed.call_count == 1

        # Second call → should hit cache
        result = await pipeline._embed(SAMPLE_TEXT)
        assert result == SAMPLE_VEC
        assert llm.embed.call_count == 1  # NOT called again

    @pytest.mark.asyncio
    async def test_disabled_cache_always_calls_provider(self):
        """When cache is disabled, every call goes to the provider."""
        cache = EmbeddingCache(enabled=False)
        pipeline, llm = self._make_pipeline(cache)

        await pipeline._embed(SAMPLE_TEXT)
        await pipeline._embed(SAMPLE_TEXT)
        assert llm.embed.call_count == 2

    @pytest.mark.asyncio
    async def test_pipeline_builds_cache_from_config(self):
        """RetrievalPipeline auto-builds EmbeddingCache from config when none is injected."""
        from cortex.core.retrieval import RetrievalPipeline

        storage = MagicMock()
        del storage.embed

        config = MagicMock()
        config.embedding_cache_enabled = True
        config.embedding_cache_max_size = 50
        config.embedding_cache_ttl_seconds = 600
        config.retrieval_recency_weight = 0.0

        pipeline = RetrievalPipeline(storage=storage, config=config)
        assert pipeline.embedding_cache is not None
        assert pipeline.embedding_cache.enabled is True
        assert pipeline.embedding_cache._maxsize == 50
        assert pipeline.embedding_cache._ttl == 600

    @pytest.mark.asyncio
    async def test_pipeline_disables_cache_when_config_false(self):
        """When embedding_cache_enabled=False in config, cache is built disabled."""
        from cortex.core.retrieval import RetrievalPipeline

        storage = MagicMock()
        del storage.embed

        config = MagicMock()
        config.embedding_cache_enabled = False
        config.embedding_cache_max_size = 1000
        config.embedding_cache_ttl_seconds = 3600
        config.retrieval_recency_weight = 0.0

        pipeline = RetrievalPipeline(storage=storage, config=config)
        assert pipeline.embedding_cache.enabled is False


# ---------------------------------------------------------------------------
# repr / str
# ---------------------------------------------------------------------------

class TestRepr:
    def test_repr_contains_key_info(self):
        cache = EmbeddingCache(maxsize=500, ttl=7200, enabled=True)
        r = repr(cache)
        assert "500" in r
        assert "7200" in r
        assert "True" in r or "enabled=True" in r
