"""SQLite-vec semantic cache for search acceleration (Phase 12.2a).

Caches search results keyed by query embedding vector. Before running a full
vector search (~50-100ms), checks if a semantically similar query was made
recently via vec_distance_cosine (~7ms). If similarity > threshold and entry
is not expired, returns cached results directly.

Complementary to the daemon-level QueryCache (exact hash match, sub-μs):
    QueryCache  → exact string match     → ~0.001ms
    SemanticCache → vector similarity hit → ~7ms
    Full search   → exhaustive vec search → ~50-100ms
"""

from __future__ import annotations

import contextlib
import json
import logging
import sqlite3
import struct
import threading
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any

logger = logging.getLogger(__name__)


@dataclass
class CacheStats:
    """Cache performance metrics."""

    hits: int
    misses: int
    entries: int
    avg_age_seconds: float

    @property
    def hit_rate(self) -> float:
        """Fraction of lookups that were cache hits."""
        total = self.hits + self.misses
        return self.hits / total if total > 0 else 0.0


def _serialize_f32(vector: list[float]) -> bytes:
    """Serialize float vector to sqlite-vec binary format."""
    return struct.pack(f"{len(vector)}f", *vector)


class SemanticCache:
    """SQLite-vec based semantic cache for search results.

    Uses a separate pair of tables (cache_entries + cache_vectors) in the
    same database as the main memory storage. Thread-safe via thread-local
    connections mirroring MemoryStorage's pattern.
    """

    def __init__(
        self,
        db_path: str,
        ttl_seconds: int = 300,
        similarity_threshold: float = 0.92,
    ) -> None:
        self.db_path = db_path
        self.ttl_seconds = ttl_seconds
        self.similarity_threshold = similarity_threshold
        self._hits = 0
        self._misses = 0
        self._local = threading.local()
        self._conn_lock = threading.Lock()
        self._all_conns: set[sqlite3.Connection] = set()
        self._init_tables()

    def _get_conn(self) -> sqlite3.Connection:
        """Get or create a thread-local connection with sqlite-vec loaded."""
        conn = getattr(self._local, "conn", None)
        if conn is None:
            conn = sqlite3.connect(self.db_path)
            conn.row_factory = sqlite3.Row
            conn.execute("PRAGMA journal_mode=WAL")
            conn.execute("PRAGMA synchronous=NORMAL")
            conn.enable_load_extension(True)
            import sqlite_vec

            sqlite_vec.load(conn)
            conn.enable_load_extension(False)
            self._local.conn = conn
            with self._conn_lock:
                self._all_conns.add(conn)
        return conn

    def _init_tables(self) -> None:
        """Create cache tables if they don't exist."""
        conn = self._get_conn()
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS cache_entries (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                query_text TEXT NOT NULL,
                results_json TEXT NOT NULL,
                created_at TEXT NOT NULL,
                hit_count INTEGER DEFAULT 0
            );

            CREATE VIRTUAL TABLE IF NOT EXISTS cache_vectors USING vec0(
                id INTEGER PRIMARY KEY,
                embedding FLOAT[384]
            );
        """)
        conn.commit()

    def lookup(self, query_embedding: list[float]) -> list[dict[str, Any]] | None:
        """Search cache for a semantically similar query.

        Finds the closest cached embedding via vec_distance_cosine. Returns
        cached results only if similarity > threshold AND entry is not expired.

        Args:
            query_embedding: 384d embedding of the search query.

        Returns:
            Cached results list if hit, None if miss.
        """
        conn = self._get_conn()
        serialized = _serialize_f32(query_embedding)

        row = conn.execute(
            """
            SELECT ce.id, ce.results_json, ce.created_at,
                   vec_distance_cosine(cv.embedding, ?) as distance
            FROM cache_vectors cv
            JOIN cache_entries ce ON cv.id = ce.id
            ORDER BY distance ASC
            LIMIT 1
            """,
            (serialized,),
        ).fetchone()

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

        similarity = 1.0 - row["distance"]
        if similarity < self.similarity_threshold:
            self._misses += 1
            return None

        # Check TTL
        created = datetime.fromisoformat(row["created_at"])
        age = (datetime.now(tz=UTC) - created).total_seconds()
        if age > self.ttl_seconds:
            # Expired — delete and return miss
            entry_id = row["id"]
            conn.execute("DELETE FROM cache_vectors WHERE id = ?", (entry_id,))
            conn.execute("DELETE FROM cache_entries WHERE id = ?", (entry_id,))
            conn.commit()
            self._misses += 1
            return None

        # Cache hit — increment counter
        conn.execute(
            "UPDATE cache_entries SET hit_count = hit_count + 1 WHERE id = ?",
            (row["id"],),
        )
        conn.commit()
        self._hits += 1
        result: list[dict[str, Any]] = json.loads(row["results_json"])
        return result

    def store(
        self,
        query_embedding: list[float],
        query_text: str,
        results: list[dict[str, Any]],
    ) -> None:
        """Store search results in cache.

        Args:
            query_embedding: 384d embedding of the query.
            query_text: Original query text (for debugging/inspection).
            results: Search results to cache.
        """
        conn = self._get_conn()
        now = datetime.now(tz=UTC).isoformat()
        serialized = _serialize_f32(query_embedding)

        cursor = conn.execute(
            "INSERT INTO cache_entries (query_text, results_json, created_at) VALUES (?, ?, ?)",
            (query_text, json.dumps(results), now),
        )
        entry_id = cursor.lastrowid

        conn.execute(
            "INSERT INTO cache_vectors (id, embedding) VALUES (?, ?)",
            (entry_id, serialized),
        )
        conn.commit()

    def invalidate(self, memory_id: int | None = None) -> int:
        """Invalidate cache entries.

        Args:
            memory_id: If given, invalidate entries whose cached results
                       contain this memory ID. If None, clear entire cache.

        Returns:
            Number of entries invalidated.
        """
        conn = self._get_conn()

        if memory_id is None:
            count: int = conn.execute("SELECT COUNT(*) FROM cache_entries").fetchone()[0]
            conn.execute("DELETE FROM cache_vectors")
            conn.execute("DELETE FROM cache_entries")
            conn.commit()
            return count

        # Find entries containing this memory_id in their results
        rows = conn.execute("SELECT id, results_json FROM cache_entries").fetchall()
        to_delete = []
        for row in rows:
            results = json.loads(row["results_json"])
            if any(r.get("id") == memory_id for r in results):
                to_delete.append(row["id"])

        if to_delete:
            placeholders = ",".join("?" * len(to_delete))
            # nosemgrep: sql-injection - placeholders is "?,?,?" built from safe data
            conn.execute(
                f"DELETE FROM cache_vectors WHERE id IN ({placeholders})",  # noqa: S608
                to_delete,
            )
            # nosemgrep: sql-injection - placeholders is "?,?,?" built from safe data
            conn.execute(
                f"DELETE FROM cache_entries WHERE id IN ({placeholders})",  # noqa: S608
                to_delete,
            )
            conn.commit()

        return len(to_delete)

    def stats(self) -> CacheStats:
        """Get cache performance statistics."""
        conn = self._get_conn()
        entries = conn.execute("SELECT COUNT(*) FROM cache_entries").fetchone()[0]

        avg_age = 0.0
        if entries > 0:
            now = datetime.now(tz=UTC).isoformat()
            row = conn.execute(
                "SELECT AVG(julianday(?) - julianday(created_at)) * 86400 FROM cache_entries",
                (now,),
            ).fetchone()
            avg_age = row[0] or 0.0

        return CacheStats(
            hits=self._hits,
            misses=self._misses,
            entries=entries,
            avg_age_seconds=avg_age,
        )

    def cleanup(self) -> int:
        """Remove expired entries.

        Returns:
            Number of entries removed.
        """
        conn = self._get_conn()
        now = datetime.now(tz=UTC)

        rows = conn.execute("SELECT id, created_at FROM cache_entries").fetchall()
        expired_ids = []
        for row in rows:
            created = datetime.fromisoformat(row["created_at"])
            if (now - created).total_seconds() > self.ttl_seconds:
                expired_ids.append(row["id"])

        if expired_ids:
            placeholders = ",".join("?" * len(expired_ids))
            # nosemgrep: sql-injection - placeholders is "?,?,?" built from safe data
            conn.execute(
                f"DELETE FROM cache_vectors WHERE id IN ({placeholders})",  # noqa: S608
                expired_ids,
            )
            # nosemgrep: sql-injection - placeholders is "?,?,?" built from safe data
            conn.execute(
                f"DELETE FROM cache_entries WHERE id IN ({placeholders})",  # noqa: S608
                expired_ids,
            )
            conn.commit()

        return len(expired_ids)

    def close(self) -> None:
        """Close all tracked database connections across all threads."""
        with self._conn_lock:
            for conn in self._all_conns:
                with contextlib.suppress(Exception):
                    conn.close()
            self._all_conns.clear()
        self._local.conn = None
