"""LLMHosts Tier 0 cache -- SHA-256 exact match on normalised prompts.

Normalisation: lowercase, strip whitespace, remove system prompt, sort
messages by role for stability.  Zero false positives.  Backed by aiosqlite
with WAL mode for concurrent reads.
"""

from __future__ import annotations

import hashlib
import json
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any

import aiosqlite

from llmhosts.cache.models import CacheEntry, CacheStats

if TYPE_CHECKING:
    from pathlib import Path

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# SQL
# ---------------------------------------------------------------------------

_CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS cache_entries (
    cache_key    TEXT PRIMARY KEY,
    model        TEXT NOT NULL,
    prompt_hash  TEXT NOT NULL,
    response_json TEXT NOT NULL,
    created_at   TEXT NOT NULL,
    expires_at   TEXT NOT NULL,
    hit_count    INTEGER NOT NULL DEFAULT 0,
    last_hit_at  TEXT,
    size_bytes   INTEGER NOT NULL
);
"""

_CREATE_INDEXES = [
    "CREATE INDEX IF NOT EXISTS idx_cache_model ON cache_entries (model);",
    "CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache_entries (expires_at);",
    "CREATE INDEX IF NOT EXISTS idx_cache_created ON cache_entries (created_at);",
    "CREATE INDEX IF NOT EXISTS idx_cache_last_hit ON cache_entries (last_hit_at);",
]


# ---------------------------------------------------------------------------
# Exact hash cache
# ---------------------------------------------------------------------------


class ExactHashCache:
    """Tier 0 cache: SHA-256 exact match on normalised prompts.

    Normalisation: lowercase, strip whitespace, remove system prompt,
    sort messages by role for stability.  Zero false positives.
    """

    def __init__(
        self,
        db_path: Path,
        max_size_mb: int = 512,
        ttl_seconds: int = 3600,
        max_entries: int = 10_000,
    ) -> None:
        self._db_path = db_path
        self._max_size_mb = max_size_mb
        self._max_entries = max_entries
        self._ttl_seconds = ttl_seconds
        self._hits = 0
        self._misses = 0
        self._evictions = 0
        self._db: aiosqlite.Connection | None = None

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

    async def initialize(self) -> None:
        """Create cache table if not exists.  Create indexes.  Enable WAL."""
        self._db_path.parent.mkdir(parents=True, exist_ok=True)
        self._db = await aiosqlite.connect(str(self._db_path))
        await self._db.execute("PRAGMA journal_mode=WAL;")
        await self._db.execute("PRAGMA synchronous=NORMAL;")
        await self._db.execute(_CREATE_TABLE)
        for idx_sql in _CREATE_INDEXES:
            await self._db.execute(idx_sql)
        await self._db.commit()
        logger.info(
            "ExactHashCache initialised (db=%s, max=%dMB, ttl=%ds)", self._db_path, self._max_size_mb, self._ttl_seconds
        )

    async def close(self) -> None:
        """Close database connection."""
        if self._db is not None:
            await self._db.close()
            self._db = None
            logger.debug("ExactHashCache closed")

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _ensure_db(self) -> aiosqlite.Connection:
        if self._db is None:
            raise RuntimeError("ExactHashCache not initialised -- call initialize() first")
        return self._db

    @staticmethod
    def _normalize(model: str, messages: list[dict[str, Any]]) -> str:
        """Normalise prompt for hashing.  Deterministic output.

        Steps:
        1. Lowercase model name and strip whitespace.
        2. Filter out system messages (they vary across retries).
        3. For each message: lowercase role, stringify & strip content.
        4. Sort messages by (role, content) for order-independence within
           same-role blocks.
        5. Produce a canonical JSON string (sort_keys, no spaces).
        """
        norm_model = model.strip().lower()

        normalised_msgs: list[dict[str, str]] = []
        for msg in messages:
            role = str(msg.get("role", "")).strip().lower()
            # Skip system messages -- they are instruction context, not user prompt
            if role == "system":
                continue

            content = msg.get("content", "")
            if isinstance(content, list):
                # Multi-modal content blocks -- extract text parts only
                text_parts = []
                for block in content:
                    if isinstance(block, dict) and block.get("type") == "text":
                        text_parts.append(str(block.get("text", "")).strip().lower())
                    elif isinstance(block, str):
                        text_parts.append(block.strip().lower())
                content_str = " ".join(text_parts)
            else:
                content_str = str(content).strip().lower()

            normalised_msgs.append({"role": role, "content": content_str})

        # Sort for determinism: by role first, then by content
        normalised_msgs.sort(key=lambda m: (m["role"], m["content"]))

        canonical = {"model": norm_model, "messages": normalised_msgs}
        return json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=True)

    @staticmethod
    def _hash(normalised: str) -> str:
        """SHA-256 hash of normalised prompt string."""
        return hashlib.sha256(normalised.encode("utf-8")).hexdigest()

    @staticmethod
    def _now() -> datetime:
        return datetime.now(timezone.utc)

    @staticmethod
    def _iso(dt: datetime) -> str:
        return dt.isoformat()

    @staticmethod
    def _parse_dt(val: str | None) -> datetime | None:
        if val is None:
            return None
        return datetime.fromisoformat(val)

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

    async def get(self, model: str, messages: list[dict[str, Any]]) -> CacheEntry | None:
        """Look up cached response.  Returns None on miss.

        Increments hit_count and updates last_hit_at on hit.
        Ignores expired entries (treats them as misses).
        """
        db = self._ensure_db()
        normalised = self._normalize(model, messages)
        cache_key = self._hash(normalised)
        now = self._now()

        async with db.execute(
            "SELECT cache_key, model, prompt_hash, response_json, created_at, "
            "expires_at, hit_count, last_hit_at, size_bytes "
            "FROM cache_entries WHERE cache_key = ?",
            (cache_key,),
        ) as cursor:
            row = await cursor.fetchone()

        if row is None:
            self._misses += 1
            return None

        expires_at = self._parse_dt(row[5])
        if expires_at is not None and expires_at <= now:
            # Entry expired -- treat as miss and remove it
            await db.execute("DELETE FROM cache_entries WHERE cache_key = ?", (cache_key,))
            await db.commit()
            self._misses += 1
            logger.debug("Cache MISS (expired) key=%s", cache_key[:12])
            return None

        # Hit -- update counters
        new_hit_count = row[6] + 1
        await db.execute(
            "UPDATE cache_entries SET hit_count = ?, last_hit_at = ? WHERE cache_key = ?",
            (new_hit_count, self._iso(now), cache_key),
        )
        await db.commit()
        self._hits += 1

        entry = CacheEntry(
            cache_key=row[0],
            model=row[1],
            prompt_hash=row[2],
            response_json=row[3],
            created_at=self._parse_dt(row[4]),  # type: ignore[arg-type]
            expires_at=self._parse_dt(row[5]),  # type: ignore[arg-type]
            hit_count=new_hit_count,
            last_hit_at=now,
            size_bytes=row[8],
        )
        logger.debug("Cache HIT key=%s hits=%d", cache_key[:12], new_hit_count)
        return entry

    async def put(self, model: str, messages: list[dict[str, Any]], response_json: str) -> str:
        """Store response in cache.  Returns cache_key.

        Evicts oldest entries if max_size exceeded (LRU by last_hit_at/created_at).
        Uses INSERT OR REPLACE to handle duplicate keys gracefully.
        """
        db = self._ensure_db()
        normalised = self._normalize(model, messages)
        cache_key = self._hash(normalised)
        now = self._now()

        from datetime import timedelta

        expires_at = now + timedelta(seconds=self._ttl_seconds)
        size_bytes = len(response_json.encode("utf-8"))

        await db.execute(
            "INSERT OR REPLACE INTO cache_entries "
            "(cache_key, model, prompt_hash, response_json, created_at, expires_at, "
            "hit_count, last_hit_at, size_bytes) "
            "VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?)",
            (cache_key, model, cache_key, response_json, self._iso(now), self._iso(expires_at), size_bytes),
        )
        await db.commit()

        # Evict if over size limit
        await self._evict_if_needed()

        logger.debug("Cache PUT key=%s model=%s size=%d", cache_key[:12], model, size_bytes)
        return cache_key

    async def invalidate(self, cache_key: str) -> bool:
        """Remove a specific cache entry.  Returns True if entry existed."""
        db = self._ensure_db()
        cursor = await db.execute("DELETE FROM cache_entries WHERE cache_key = ?", (cache_key,))
        await db.commit()
        removed = cursor.rowcount > 0
        if removed:
            logger.debug("Cache INVALIDATE key=%s", cache_key[:12])
        return removed

    async def clear(self, model: str | None = None) -> int:
        """Clear all or model-specific entries.  Returns count removed."""
        db = self._ensure_db()
        if model is not None:
            cursor = await db.execute("DELETE FROM cache_entries WHERE model = ?", (model,))
        else:
            cursor = await db.execute("DELETE FROM cache_entries")
        await db.commit()
        count = cursor.rowcount
        logger.info("Cache CLEAR model=%s removed=%d", model or "ALL", count)
        return count

    async def stats(self) -> CacheStats:
        """Return cache statistics."""
        db = self._ensure_db()

        # Total entries and size
        async with db.execute("SELECT COUNT(*), COALESCE(SUM(size_bytes), 0) FROM cache_entries") as cur:
            row = await cur.fetchone()
            total_entries = row[0] if row else 0
            total_size = row[1] if row else 0

        # Entries by model
        entries_by_model: dict[str, int] = {}
        async with db.execute("SELECT model, COUNT(*) FROM cache_entries GROUP BY model") as cur:
            async for row in cur:
                entries_by_model[row[0]] = row[1]

        # Oldest / newest
        async with db.execute("SELECT MIN(created_at), MAX(created_at) FROM cache_entries") as cur:
            row = await cur.fetchone()
            oldest = self._parse_dt(row[0]) if row and row[0] else None
            newest = self._parse_dt(row[1]) if row and row[1] else None

        total_lookups = self._hits + self._misses
        hit_rate = self._hits / total_lookups if total_lookups > 0 else 0.0

        return CacheStats(
            total_entries=total_entries,
            total_size_bytes=total_size,
            hit_count=self._hits,
            miss_count=self._misses,
            hit_rate=round(hit_rate, 4),
            eviction_count=self._evictions,
            entries_by_model=entries_by_model,
            oldest_entry=oldest,
            newest_entry=newest,
        )

    async def evict_expired(self) -> int:
        """Remove expired entries.  Returns count removed."""
        db = self._ensure_db()
        now_iso = self._iso(self._now())
        cursor = await db.execute("DELETE FROM cache_entries WHERE expires_at <= ?", (now_iso,))
        await db.commit()
        count = cursor.rowcount
        if count > 0:
            logger.info("Evicted %d expired cache entries", count)
        return count

    # ------------------------------------------------------------------
    # Internal eviction
    # ------------------------------------------------------------------

    async def _evict_if_needed(self) -> None:
        """Evict oldest/least-recently-used entries when size or entry count exceeds limits."""
        db = self._ensure_db()
        max_bytes = self._max_size_mb * 1024 * 1024

        async with db.execute("SELECT COUNT(*), COALESCE(SUM(size_bytes), 0) FROM cache_entries") as cur:
            row = await cur.fetchone()
            entry_count = row[0] if row else 0
            total_size = row[1] if row else 0

        if total_size <= max_bytes and entry_count <= self._max_entries:
            return

        # Evict in batches ordered by (last_hit_at IS NULL first, then oldest last_hit_at, then oldest created_at)
        evicted = 0
        while total_size > max_bytes or entry_count > self._max_entries:
            async with db.execute(
                "SELECT cache_key, size_bytes FROM cache_entries "
                "ORDER BY COALESCE(last_hit_at, '1970-01-01') ASC, created_at ASC "
                "LIMIT 50"
            ) as cur:
                rows = await cur.fetchall()

            if not rows:
                break

            keys = [r[0] for r in rows]
            batch_size = sum(r[1] for r in rows)
            placeholders = ",".join("?" for _ in keys)
            await db.execute(f"DELETE FROM cache_entries WHERE cache_key IN ({placeholders})", keys)  # nosec B608
            total_size -= batch_size
            entry_count -= len(keys)
            evicted += len(keys)

        if evicted > 0:
            self._evictions += evicted
            await db.commit()
            try:
                from llmhosts.metrics.prometheus import record_cache_eviction

                record_cache_eviction("exact", evicted)
            except ImportError:
                pass
            logger.info("Evicted %d exact cache entries (size/entry limit)", evicted)
