"""LLMHosts unified three-tier cache manager.

Orchestrates the cascade:
  Tier 0  -- Exact hash   (always available, zero false positives)
  Tier 1  -- Namespace     (requires ``[smart]`` tier -- numpy)
  Tier 2  -- vCache        (requires ``[smart]`` tier -- numpy)

Lookup order: exact -> namespace -> vCache.
All available tiers are populated on a cache miss that is later stored.

The manager gracefully degrades: if ``[smart]`` dependencies are missing,
only the exact-hash tier is active.
"""

from __future__ import annotations

import hashlib
import json
import logging
import time
from typing import TYPE_CHECKING, Any

from pydantic import BaseModel, ConfigDict

from llmhosts.cache.exact import ExactHashCache
from llmhosts.cache.namespace import NAMESPACE_AVAILABLE, NamespaceFilter
from llmhosts.cache.vcache import VCACHE_AVAILABLE, VCache

if TYPE_CHECKING:
    from pathlib import Path

    pass

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------


class CacheLookup(BaseModel):
    """Result of a three-tier cache lookup."""

    model_config = ConfigDict(frozen=True)

    hit: bool
    tier: str | None  # "exact", "namespace", "vcache", or None
    response_json: str | None
    cache_key: str | None
    similarity: float | None  # for semantic matches only
    latency_ms: float


class CacheManagerStats(BaseModel):
    """Combined statistics from all active cache tiers."""

    tiers_active: list[str]
    exact_stats: dict[str, Any] | None
    namespace_stats: dict[str, Any] | None
    vcache_stats: dict[str, Any] | None
    combined_hit_rate: float


# ---------------------------------------------------------------------------
# Cache Manager
# ---------------------------------------------------------------------------


class CacheManager:
    """Orchestrates the three-tier cache: exact -> namespace -> vCache.

    Tier 0: Exact hash (always available, zero false positives)
    Tier 1: Namespace filter (requires [smart] tier)
    Tier 2: vCache semantic (requires [smart] tier)

    Lookup order: exact -> namespace -> vCache.
    All tiers populated on miss.
    """

    def __init__(
        self,
        db_path: Path,
        embedding_pipeline: Any | None = None,
        max_size_mb: int = 512,
        ttl_seconds: int = 3600,
        delta: float = 0.01,
        initial_threshold: float = 0.85,
        embedding_dim: int = 384,
        exact_max_entries: int = 10_000,
        namespace_max_namespaces: int = 500,
        vcache_max_bytes: int = 256 * 1024 * 1024,
    ) -> None:
        self._db_path = db_path
        self._max_size_mb = max_size_mb
        self._ttl_seconds = ttl_seconds
        self._pipeline = embedding_pipeline

        # Tier 0: always available
        self._exact = ExactHashCache(
            db_path=db_path / "exact_cache.db",
            max_size_mb=max_size_mb,
            ttl_seconds=ttl_seconds,
            max_entries=exact_max_entries,
        )

        # Tier 1: namespace filter (requires numpy)
        self._namespace: NamespaceFilter | None = None
        if NAMESPACE_AVAILABLE:
            self._namespace = NamespaceFilter(
                db_path=db_path / "namespace_cache.db",
                ttl_seconds=ttl_seconds,
                max_namespaces=namespace_max_namespaces,
            )

        # Tier 2: vCache semantic (requires numpy)
        self._vcache: VCache | None = None
        if VCACHE_AVAILABLE:
            self._vcache = VCache(
                db_path=db_path / "vcache.db",
                embedding_dim=embedding_dim,
                delta=delta,
                initial_threshold=initial_threshold,
                ttl_seconds=ttl_seconds,
                max_bytes=vcache_max_bytes,
            )

        # Aggregate counters
        self._total_lookups = 0
        self._total_hits = 0

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    async def initialize(self) -> None:
        """Initialize all available cache tiers."""
        self._db_path.mkdir(parents=True, exist_ok=True)

        await self._exact.initialize()
        logger.info("Cache tier 0 (exact hash) initialised")

        if self._namespace is not None:
            await self._namespace.initialize()
            logger.info("Cache tier 1 (namespace filter) initialised")

        if self._vcache is not None:
            await self._vcache.initialize()
            logger.info("Cache tier 2 (vCache semantic) initialised")

        logger.info("CacheManager ready: %s", self.tier_description())

    async def close(self) -> None:
        """Close all cache tier connections."""
        await self._exact.close()
        if self._namespace is not None:
            await self._namespace.close()
        if self._vcache is not None:
            await self._vcache.close()
        logger.debug("CacheManager closed")

    # ------------------------------------------------------------------
    # Embedding helper
    # ------------------------------------------------------------------

    async def _get_embedding(self, messages: list[dict[str, Any]]) -> Any | None:
        """Compute embedding for messages using the pipeline if available.

        Returns None if the pipeline isn't configured or deps are missing.
        """
        if self._pipeline is None:
            return None
        try:
            # Extract text from messages for embedding
            text_parts: list[str] = []
            for msg in messages:
                content = msg.get("content", "")
                if isinstance(content, list):
                    for block in content:
                        if isinstance(block, dict) and block.get("type") == "text":
                            text_parts.append(str(block.get("text", "")))
                        elif isinstance(block, str):
                            text_parts.append(block)
                else:
                    text_parts.append(str(content))
            text = " ".join(text_parts).strip()
            if not text:
                return None
            # EmbeddingPipeline.embed() returns np.ndarray
            return await self._pipeline.embed(text)
        except Exception:
            logger.debug("Embedding generation failed", exc_info=True)
            return None

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

    async def get(self, model: str, messages: list[dict[str, Any]]) -> CacheLookup:
        """Three-tier cache lookup. Returns first hit.

        Order: exact hash -> namespace filter -> vCache semantic.
        """
        start = time.monotonic()
        self._total_lookups += 1

        # --- Tier 0: Exact hash ---
        exact_hit = await self._exact.get(model, messages)
        if exact_hit is not None:
            elapsed = (time.monotonic() - start) * 1000
            self._total_hits += 1
            logger.debug("CacheManager HIT tier=exact latency=%.1fms", elapsed)
            return CacheLookup(
                hit=True,
                tier="exact",
                response_json=exact_hit.response_json,
                cache_key=exact_hit.cache_key,
                similarity=1.0,
                latency_ms=round(elapsed, 2),
            )

        # Compute embedding once for tiers 1 & 2
        embedding = await self._get_embedding(messages)

        # --- Tier 1: Namespace filter ---
        if self._namespace is not None:
            ns_hit = await self._namespace.get(model, messages, embedding=embedding)
            if ns_hit is not None:
                elapsed = (time.monotonic() - start) * 1000
                self._total_hits += 1
                logger.debug("CacheManager HIT tier=namespace latency=%.1fms", elapsed)
                return CacheLookup(
                    hit=True,
                    tier="namespace",
                    response_json=ns_hit.response_json,
                    cache_key=ns_hit.cache_key,
                    similarity=None,  # namespace may or may not use similarity
                    latency_ms=round(elapsed, 2),
                )

        # --- Tier 2: vCache semantic ---
        if self._vcache is not None and embedding is not None:
            # Determine namespace key for scoping
            ns_key: str | None = None
            if self._namespace is not None:
                ns_obj = self._namespace.extract_entities(messages)
                ns_key = ns_obj.key

            vc_hit = await self._vcache.get(embedding, model, namespace=ns_key)
            if vc_hit is not None:
                elapsed = (time.monotonic() - start) * 1000
                self._total_hits += 1
                logger.debug(
                    "CacheManager HIT tier=vcache sim=%.4f latency=%.1fms",
                    vc_hit.similarity,
                    elapsed,
                )
                return CacheLookup(
                    hit=True,
                    tier="vcache",
                    response_json=vc_hit.response_json,
                    cache_key=vc_hit.cache_key,
                    similarity=vc_hit.similarity,
                    latency_ms=round(elapsed, 2),
                )

        # --- Miss ---
        elapsed = (time.monotonic() - start) * 1000
        logger.debug("CacheManager MISS latency=%.1fms", elapsed)
        return CacheLookup(
            hit=False,
            tier=None,
            response_json=None,
            cache_key=None,
            similarity=None,
            latency_ms=round(elapsed, 2),
        )

    async def put(self, model: str, messages: list[dict[str, Any]], response_json: str) -> None:
        """Store in all available tiers.

        - Tier 0 (exact hash): always
        - Tier 1 (namespace): if available
        - Tier 2 (vCache): if available + embedding pipeline present
        """
        # Tier 0: Exact hash
        await self._exact.put(model, messages, response_json)

        # Compute embedding once for tiers 1 & 2
        embedding = await self._get_embedding(messages)

        # Tier 1: Namespace
        if self._namespace is not None:
            await self._namespace.put(model, messages, response_json, embedding=embedding)

        # Tier 2: vCache
        if self._vcache is not None and embedding is not None:
            ns_key: str | None = None
            if self._namespace is not None:
                ns_obj = self._namespace.extract_entities(messages)
                ns_key = ns_obj.key

            # Generate messages hash for vCache dedup key
            normalised_msgs: list[dict[str, str]] = []
            for msg in messages:
                role = str(msg.get("role", "")).strip().lower()
                if role == "system":
                    continue
                content = msg.get("content", "")
                content_str = str(content).strip().lower() if not isinstance(content, list) else str(content)
                normalised_msgs.append({"role": role, "content": content_str})
            normalised_msgs.sort(key=lambda m: (m["role"], m["content"]))
            messages_hash = hashlib.sha256(
                json.dumps(normalised_msgs, sort_keys=True, separators=(",", ":")).encode("utf-8"),
            ).hexdigest()

            await self._vcache.put(
                embedding=embedding,
                model=model,
                messages_hash=messages_hash,
                response_json=response_json,
                namespace=ns_key,
            )

        logger.debug("CacheManager PUT model=%s tiers=%s", model, self.tier_description())

    async def stats(self) -> CacheManagerStats:
        """Combined stats from all tiers."""
        exact_stats = (await self._exact.stats()).model_dump()

        namespace_stats: dict[str, Any] | None = None
        if self._namespace is not None:
            namespace_stats = (await self._namespace.stats()).model_dump()

        vcache_stats: dict[str, Any] | None = None
        if self._vcache is not None:
            vcache_stats = (await self._vcache.stats()).model_dump()

        combined_rate = self._total_hits / self._total_lookups if self._total_lookups > 0 else 0.0

        return CacheManagerStats(
            tiers_active=self._active_tiers(),
            exact_stats=exact_stats,
            namespace_stats=namespace_stats,
            vcache_stats=vcache_stats,
            combined_hit_rate=round(combined_rate, 4),
        )

    def tier_description(self) -> str:
        """Return human-readable tier description."""
        tiers = self._active_tiers()
        descriptions = {
            "exact": "Tier 0: exact hash",
            "namespace": "Tier 1: namespace filter",
            "vcache": "Tier 2: vCache semantic",
        }
        parts = [descriptions.get(t, t) for t in tiers]
        return " -> ".join(parts) if parts else "no cache tiers"

    # ------------------------------------------------------------------
    # Internals
    # ------------------------------------------------------------------

    def _active_tiers(self) -> list[str]:
        tiers: list[str] = ["exact"]
        if self._namespace is not None:
            tiers.append("namespace")
        if self._vcache is not None:
            tiers.append("vcache")
        return tiers

    @property
    def exact(self) -> ExactHashCache:
        """Direct access to exact hash cache tier."""
        return self._exact

    @property
    def namespace(self) -> NamespaceFilter | None:
        """Direct access to namespace filter tier (None if unavailable)."""
        return self._namespace

    @property
    def vcache(self) -> VCache | None:
        """Direct access to vCache tier (None if unavailable)."""
        return self._vcache
