"""
cortex/core/embedding_cache.py — In-Memory Embedding Cache (v1.2.1).

Caches Voyage embedding vectors in memory to eliminate redundant API calls
on repeated or similar queries. Typical conversational workloads have 60-80%
hit rates within a session, reducing retrieval latency from ~300ms to ~150ms.

Design:
    - cachetools.TTLCache: LRU eviction at maxsize + TTL expiry per entry.
    - Cache key: sha256(text.lower().strip()) — content-addressed, no leaks.
    - Thread-safe: threading.Lock wraps all cache mutations.
    - Stats: hit/miss counters for observability.
    - Singleton usage: one instance per RetrievalPipeline (passed at init).

Memory footprint:
    - Voyage voyage-3: 1024-dim float32 = ~4KB per entry.
    - 1,000 entries → ~4MB RAM (negligible).

Dependencies: cachetools (pip install cachetools). Pure stdlib otherwise.
Dependents: cortex.core.retrieval.RetrievalPipeline._embed()
"""

from __future__ import annotations

import hashlib
import logging
from threading import Lock
from typing import List, Optional

try:
    from cachetools import TTLCache
except ImportError as _cachetools_err:  # pragma: no cover
    raise ImportError(
        "cortex.core.embedding_cache requires 'cachetools'. "
        "Install it with: pip install cachetools"
    ) from _cachetools_err

logger = logging.getLogger(__name__)


class EmbeddingCache:
    """In-memory LRU + TTL cache for embedding vectors.

    Thread-safe wrapper around ``cachetools.TTLCache``. Intended to be
    instantiated once per ``RetrievalPipeline`` and reused across calls.

    Args:
        maxsize: Maximum number of entries before LRU eviction (default 1000).
        ttl: Time-to-live in seconds for each entry (default 3600 = 1 hour).
        enabled: When False, all get/set operations are no-ops. Useful for
                 testing or deployments where caching is explicitly disabled.

    Example::

        cache = EmbeddingCache(maxsize=1000, ttl=3600)
        vec = cache.get("what is the API design?")
        if vec is None:
            vec = await provider.embed(["what is the API design?"])
            cache.set("what is the API design?", vec)
    """

    def __init__(
        self,
        maxsize: int = 1000,
        ttl: int = 3600,
        enabled: bool = True,
    ) -> None:
        self.enabled = enabled
        self._maxsize = maxsize
        self._ttl = ttl
        self._lock = Lock()
        self._hits: int = 0
        self._misses: int = 0

        if enabled:
            # TTLCache provides LRU eviction (oldest entry dropped at maxsize)
            # plus per-entry TTL expiry — exactly what we need.
            self._cache: TTLCache = TTLCache(maxsize=maxsize, ttl=ttl)
        else:
            self._cache = None  # type: ignore[assignment]

    # ------------------------------------------------------------------
    # Key normalisation
    # ------------------------------------------------------------------

    @staticmethod
    def _make_key(text: str) -> str:
        """Normalise *text* and return its SHA-256 hex digest as the cache key.

        Normalisation: lowercase + strip whitespace.  This ensures that
        "What is the API design?" and "what is the api design?" hit the same
        cache entry, which is correct for embedding models that are largely
        case-insensitive.

        Args:
            text: Raw query or document text.

        Returns:
            64-character lowercase hexadecimal SHA-256 digest.
        """
        normalised = text.lower().strip()
        return hashlib.sha256(normalised.encode("utf-8")).hexdigest()

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def get(self, text: str) -> Optional[List[float]]:
        """Look up a cached embedding for *text*.

        Args:
            text: The text whose embedding to look up.

        Returns:
            The cached embedding vector, or ``None`` on a cache miss.
        """
        if not self.enabled:
            return None

        key = self._make_key(text)
        with self._lock:
            result = self._cache.get(key)
            if result is not None:
                self._hits += 1
                logger.debug("embedding_cache: HIT key=%s...", key[:8])
                return result
            self._misses += 1
            logger.debug("embedding_cache: MISS key=%s...", key[:8])
            return None

    def set(self, text: str, embedding: List[float]) -> None:
        """Store an embedding vector for *text*.

        Silently overwrites any existing entry for the same (normalised) key.
        Evicts the least-recently-used entry when the cache is at capacity.

        Args:
            text: The source text used to generate the embedding.
            embedding: The float vector to cache.
        """
        if not self.enabled:
            return

        key = self._make_key(text)
        with self._lock:
            self._cache[key] = embedding
            logger.debug(
                "embedding_cache: SET key=%s... dim=%d",
                key[:8],
                len(embedding),
            )

    def stats(self) -> dict:
        """Return current cache statistics.

        Returns a dict with the following keys:

        - ``hits``: Total cache hits since creation (or last ``clear()``).
        - ``misses``: Total cache misses.
        - ``hit_rate``: Float in [0.0, 1.0]; 0.0 when no requests yet.
        - ``size``: Current number of entries in the cache.
        - ``maxsize``: Configured maximum size.
        - ``ttl``: Configured TTL in seconds.
        - ``enabled``: Whether the cache is active.
        """
        with self._lock:
            total = self._hits + self._misses
            hit_rate = self._hits / total if total > 0 else 0.0
            size = len(self._cache) if self.enabled else 0
        return {
            "hits": self._hits,
            "misses": self._misses,
            "hit_rate": round(hit_rate, 4),
            "size": size,
            "maxsize": self._maxsize,
            "ttl": self._ttl,
            "enabled": self.enabled,
        }

    def clear(self) -> None:
        """Evict all entries and reset hit/miss counters.

        Useful for testing and for forced invalidation (e.g., after a model
        change that would make all cached vectors stale).
        """
        if not self.enabled:
            return

        with self._lock:
            self._cache.clear()
            self._hits = 0
            self._misses = 0
        logger.debug("embedding_cache: cleared")

    def __repr__(self) -> str:
        s = self.stats()
        return (
            f"EmbeddingCache(enabled={s['enabled']}, "
            f"size={s['size']}/{s['maxsize']}, "
            f"ttl={s['ttl']}s, "
            f"hit_rate={s['hit_rate']:.1%})"
        )
