"""Hybrid search engine combining FTS5 BM25 and sqlite-vec cosine similarity via RRF fusion.

This module implements Phase 1 of hybrid search for Engram:
- BM25 full-text search via FTS5
- Vector cosine similarity via sqlite-vec
- Reciprocal Rank Fusion (RRF) to combine results
- Graceful fallback to vector-only if FTS5 table doesn't exist

Architecture:
    1. Fetch candidate results from FTS5 (BM25 scoring)
    2. Fetch candidate results from sqlite-vec (cosine similarity)
    3. Fuse results using RRF with configurable weights
    4. Return top-k ranked by RRF score

References:
    - Cormack et al. (2009): Reciprocal Rank Fusion
    - sqlite-vec documentation
    - FTS5 BM25 documentation
"""

from __future__ import annotations

import sqlite3
from dataclasses import dataclass
from typing import Any


@dataclass
class HybridSearchConfig:
    """Configuration for hybrid search fusion.

    Attributes:
        candidate_k: Number of candidates to fetch from each retriever before fusion.
        rrf_k: RRF constant from Cormack et al. (2009). Default 60 is standard.
        weight_fts: Weight for FTS5 results in RRF fusion (default 1.0).
        weight_vec: Weight for vector results in RRF fusion (default 1.0).
        bm25_weights: Column weights for BM25 scoring (content, category).
    """

    candidate_k: int = 50
    rrf_k: int = 60
    weight_fts: float = 1.0
    weight_vec: float = 1.0
    bm25_weights: tuple[float, float] = (10.0, 1.0)


def _escape_fts5_query(query: str) -> str:
    """Escape FTS5 query to treat as literal tokens instead of operators.

    FTS5 MATCH syntax includes operators like AND, OR, NOT, NEAR, quotes, parens.
    To avoid syntax errors from user input, wrap each word in double quotes for
    literal matching.

    Args:
        query: Raw query string from user.

    Returns:
        Escaped query safe for FTS5 MATCH.

    Examples:
        >>> _escape_fts5_query("hello world")
        '"hello" "world"'
        >>> _escape_fts5_query("Phase 7.1")
        '"Phase" "7.1"'
    """
    if not query.strip():
        return ""

    # Split on whitespace and wrap each token in double quotes
    tokens = query.split()
    return " ".join(f'"{token}"' for token in tokens)


def _fetch_fts_results(
    conn: sqlite3.Connection, query_text: str, config: HybridSearchConfig
) -> list[tuple[int, float]]:
    """Fetch top-k memories from FTS5 BM25 search.

    Args:
        conn: SQLite connection with memory_fts table.
        query_text: Escaped FTS5 query string.
        config: Hybrid search configuration.

    Returns:
        List of (memory_id, bm25_score) tuples, sorted by score descending.
        Returns empty list if FTS5 query fails or no results.
    """
    if not query_text.strip():
        return []

    escaped_query = _escape_fts5_query(query_text)
    if not escaped_query:
        return []

    try:
        # BM25 with column weights: content gets 10x weight vs category
        sql = """
            SELECT rowid, bm25(memory_fts, ?, ?) AS score
            FROM memory_fts
            WHERE memory_fts MATCH ?
            ORDER BY score ASC
            LIMIT ?
        """
        # BM25 returns negative scores (lower is better), so we ORDER BY ASC
        rows = conn.execute(
            sql,
            (config.bm25_weights[0], config.bm25_weights[1], escaped_query, config.candidate_k),
        ).fetchall()

        # Convert to (memory_id, abs(score)) for consistency (higher is better)
        return [(row[0], abs(row[1])) for row in rows]
    except sqlite3.OperationalError:
        # FTS5 query syntax error or table doesn't exist
        return []


def _fetch_vec_results(
    conn: sqlite3.Connection, query_embedding: list[float], config: HybridSearchConfig
) -> list[tuple[int, float]]:
    """Fetch top-k memories from sqlite-vec cosine similarity search.

    Args:
        conn: SQLite connection with memory_vectors table.
        query_embedding: Query embedding vector (384-dim for fastembed).
        config: Hybrid search configuration.

    Returns:
        List of (memory_id, distance) tuples, sorted by distance ascending.
        Returns empty list if no vectors or query fails.
    """
    if not query_embedding:
        return []

    try:
        # Serialize embedding for sqlite-vec
        from claude_engram.storage import _serialize_f32

        embedding_blob = _serialize_f32(query_embedding)

        sql = """
            SELECT id, vec_distance_cosine(embedding, ?) AS distance
            FROM memory_vectors
            ORDER BY distance ASC
            LIMIT ?
        """
        rows = conn.execute(sql, (embedding_blob, config.candidate_k)).fetchall()
        return [(row[0], row[1]) for row in rows]
    except Exception:
        # Table doesn't exist or other error
        return []


def _fuse_rrf(
    fts_results: list[tuple[int, float]],
    vec_results: list[tuple[int, float]],
    config: HybridSearchConfig,
) -> list[tuple[int, float, int, int, float, float]]:
    """Fuse FTS5 and vector results using Reciprocal Rank Fusion.

    RRF formula: score = sum(weight * 1 / (k + rank))
    where rank is 1-indexed (1 = best match).

    For memories not present in a retriever, use candidate_k + 1 as fallback rank
    to avoid infinite scores while still penalizing single-retriever matches.

    Args:
        fts_results: List of (memory_id, bm25_score) from FTS5.
        vec_results: List of (memory_id, distance) from sqlite-vec.
        config: Hybrid search configuration.

    Returns:
        List of (memory_id, rrf_score, fts_rank, vec_rank, bm25_score, vec_distance)
        sorted by rrf_score descending.
    """
    # Build rank maps (1-indexed)
    fts_rank_map = {mem_id: rank + 1 for rank, (mem_id, _) in enumerate(fts_results)}
    vec_rank_map = {mem_id: rank + 1 for rank, (mem_id, _) in enumerate(vec_results)}

    # Build score maps
    fts_score_map = {mem_id: score for mem_id, score in fts_results}
    vec_score_map = {mem_id: score for mem_id, score in vec_results}

    # Collect all unique memory IDs
    all_ids = set(fts_rank_map.keys()) | set(vec_rank_map.keys())

    # Cap for unranked results (present in only one retriever)
    unranked_cap = config.candidate_k + 1

    results = []
    for mem_id in all_ids:
        fts_rank = fts_rank_map.get(mem_id, unranked_cap)
        vec_rank = vec_rank_map.get(mem_id, unranked_cap)

        # RRF score
        rrf_score = (config.weight_fts / (config.rrf_k + fts_rank)) + (
            config.weight_vec / (config.rrf_k + vec_rank)
        )

        # Original scores (for debugging/transparency)
        bm25_score = fts_score_map.get(mem_id, 0.0)
        vec_distance = vec_score_map.get(mem_id, 1.0)

        results.append((mem_id, rrf_score, fts_rank, vec_rank, bm25_score, vec_distance))

    # Sort by RRF score descending
    results.sort(key=lambda x: x[1], reverse=True)

    return results


def has_fts5_table(conn: sqlite3.Connection) -> bool:
    """Check if memory_fts FTS5 table exists in the database.

    Args:
        conn: SQLite connection.

    Returns:
        True if memory_fts table exists, False otherwise.
    """
    row = conn.execute(
        "SELECT name FROM sqlite_master WHERE type='table' AND name='memory_fts'"
    ).fetchone()
    return row is not None


def hybrid_search(
    conn: sqlite3.Connection,
    query_text: str,
    query_embedding: list[float],
    limit: int,
    config: HybridSearchConfig,
    where_str: str = "",
    where_params: tuple[Any, ...] = (),
) -> list[dict[str, Any]]:
    """Execute hybrid search combining FTS5 BM25 and vector cosine similarity.

    Orchestrates:
    1. Fetch FTS5 candidates (BM25 scoring)
    2. Fetch vector candidates (cosine similarity)
    3. Fuse results using RRF
    4. Fetch full memory data for top-k results
    5. Apply additional filters via where_str (proj_tag, TTL, etc.)

    Args:
        conn: SQLite connection with memories, memory_fts, and memory_vectors tables.
        query_text: Raw query string for FTS5.
        query_embedding: Query embedding vector for sqlite-vec.
        limit: Maximum number of results to return.
        config: Hybrid search configuration.
        where_str: Additional WHERE clause filters (e.g., "AND proj_tag = ?").
        where_params: Parameters for where_str placeholders.

    Returns:
        List of memory dicts with fields:
            - id, content, category, metadata, created_at, updated_at
            - rrf_score: Reciprocal Rank Fusion score
            - fts_rank: Rank in FTS5 results (1-indexed, or candidate_k+1 if not present)
            - vec_rank: Rank in vector results (1-indexed, or candidate_k+1 if not present)
            - bm25_score: BM25 score from FTS5
            - vec_distance: Cosine distance from sqlite-vec

    Raises:
        ValueError: If both query_text and query_embedding are empty.
    """
    if not query_text.strip() and not query_embedding:
        raise ValueError("At least one of query_text or query_embedding must be provided")

    # Fetch candidates from both retrievers
    fts_results = _fetch_fts_results(conn, query_text, config)
    vec_results = _fetch_vec_results(conn, query_embedding, config)

    # Fuse results
    fused = _fuse_rrf(fts_results, vec_results, config)

    # Take top-k after fusion
    top_k = fused[:limit]

    if not top_k:
        return []

    # Fetch full memory data
    memory_ids = [mem_id for mem_id, *_ in top_k]
    placeholders = ",".join("?" * len(memory_ids))

    base_sql = f"""
        SELECT id, content, category, metadata, created_at, updated_at
        FROM memories
        WHERE id IN ({placeholders})
    """

    # Add additional filters
    if where_str:
        base_sql += f" {where_str}"
        params = tuple(memory_ids) + where_params
    else:
        params = tuple(memory_ids)

    rows = conn.execute(base_sql, params).fetchall()

    # Build result dicts with RRF metadata
    rrf_metadata = {
        mem_id: (rrf, fts_r, vec_r, bm25, vec_d) for mem_id, rrf, fts_r, vec_r, bm25, vec_d in top_k
    }

    results = []
    for row in rows:
        mem_id = row[0]
        rrf_score, fts_rank, vec_rank, bm25_score, vec_distance = rrf_metadata[mem_id]

        results.append(
            {
                "id": mem_id,
                "content": row[1],
                "category": row[2],
                "metadata": row[3],
                "created_at": row[4],
                "updated_at": row[5],
                "rrf_score": rrf_score,
                "fts_rank": fts_rank,
                "vec_rank": vec_rank,
                "bm25_score": bm25_score,
                "vec_distance": vec_distance,
            }
        )

    # Re-sort by RRF score (in case WHERE clause filtered some results)
    results.sort(key=lambda x: x["rrf_score"], reverse=True)

    return results
