"""Hybrid search combining semantic (vector) and keyword (FTS5/BM25) matching.

This module implements industry-standard hybrid search that:
1. Performs vector search via Qdrant for semantic similarity
2. Performs BM25 keyword matching via SQLite FTS5
3. Normalizes and fuses scores with configurable alpha weight

Based on research from Stack Overflow, Elastic, Zep, and InfinityFlow.
"""

from dataclasses import dataclass, field
from typing import Any

import structlog

log = structlog.get_logger(__name__)


@dataclass
class HybridSearchConfig:
    """Configuration for hybrid search.
    
    Research-backed defaults:
    - alpha=0.4 gives good balance (keyword 40%, semantic 60%)
    - Higher alpha = more weight to keyword/BM25 matches
    """
    
    # Alpha: weight for keyword (BM25) scores
    # Final = alpha * keyword + (1-alpha) * semantic
    alpha: float = 0.4
    
    # Minimum score threshold for results
    min_score: float = 0.1
    
    # Whether to include results that only appear in one search
    include_partial: bool = True


@dataclass
class SearchResult:
    """A single search result with hybrid scoring."""
    
    id: str
    content: str
    # Individual scores (normalized 0-1)
    semantic_score: float = 0.0
    keyword_score: float = 0.0
    # Combined/final score
    combined_score: float = 0.0
    # Source: 'both', 'semantic', 'keyword'
    source: str = "both"
    # Optional payload from vector store
    payload: dict[str, Any] = field(default_factory=dict)


def min_max_normalize(scores: list[float]) -> list[float]:
    """
    Normalize scores to 0-1 range using min-max scaling.
    
    This is the standard approach recommended by Elastic and other
    hybrid search implementations.
    """
    if not scores:
        return []
    
    min_score = min(scores)
    max_score = max(scores)
    score_range = max_score - min_score
    
    if score_range == 0:
        # All scores are the same
        return [0.5] * len(scores)
    
    return [(s - min_score) / score_range for s in scores]


class HybridSearcher:
    """
    Hybrid searcher combining semantic (vector) and keyword (FTS5) search.
    
    Uses SQLite FTS5 for BM25 keyword matching (research recommendation:
    "Use SQLite FTS5 since we already have SQLite. Zero new dependencies.")
    
    Score fusion formula: final = alpha * keyword + (1-alpha) * semantic
    """
    
    def __init__(self, config: HybridSearchConfig | None = None):
        self.config = config or HybridSearchConfig()
    
    def fuse_results(
        self,
        semantic_results: list[tuple[str, float, dict[str, Any]]],
        keyword_results: list[tuple[str, float]],
        limit: int = 10,
    ) -> list[SearchResult]:
        """
        Fuse semantic and keyword search results.
        
        Args:
            semantic_results: From Qdrant - list of (id, score, payload)
            keyword_results: From FTS5 - list of (id, bm25_score)
            limit: Max results to return
            
        Returns:
            List of SearchResult with combined scores
        """
        # Build maps for quick lookup
        semantic_map: dict[str, tuple[float, dict[str, Any]]] = {
            str(r[0]): (r[1], r[2]) for r in semantic_results
        }
        keyword_map: dict[str, float] = {
            r[0]: r[1] for r in keyword_results
        }
        
        # Get all unique IDs
        all_ids = set(semantic_map.keys())
        if self.config.include_partial:
            all_ids |= set(keyword_map.keys())
        else:
            # Only include IDs that appear in both
            all_ids &= set(keyword_map.keys())
        
        if not all_ids:
            return []
        
        # Extract scores for normalization
        semantic_scores = [semantic_map.get(id, (0.0, {}))[0] for id in all_ids]
        keyword_scores = [keyword_map.get(id, 0.0) for id in all_ids]
        
        # Normalize to 0-1 range
        norm_semantic = min_max_normalize(semantic_scores)
        norm_keyword = min_max_normalize(keyword_scores)
        
        results: list[SearchResult] = []
        
        for i, doc_id in enumerate(all_ids):
            sem_data = semantic_map.get(doc_id)
            kw_score_raw = keyword_map.get(doc_id, 0.0)
            
            sem_score = norm_semantic[i] if sem_data else 0.0
            kw_score = norm_keyword[i] if kw_score_raw > 0 else 0.0
            
            payload = sem_data[1] if sem_data else {}
            content = payload.get("content", "")
            
            # Determine source
            if doc_id in semantic_map and doc_id in keyword_map:
                source = "both"
            elif doc_id in semantic_map:
                source = "semantic"
            else:
                source = "keyword"
            
            # Hybrid fusion: alpha * keyword + (1-alpha) * semantic
            combined = (
                self.config.alpha * kw_score +
                (1 - self.config.alpha) * sem_score
            )
            
            # Apply minimum threshold
            if combined < self.config.min_score and source != "both":
                continue
            
            results.append(SearchResult(
                id=doc_id,
                content=content,
                semantic_score=sem_score,
                keyword_score=kw_score,
                combined_score=combined,
                source=source,
                payload=payload,
            ))
        
        # Sort by combined score
        results.sort(key=lambda r: r.combined_score, reverse=True)
        
        log.debug(
            "hybrid_fusion_complete",
            semantic_count=len(semantic_results),
            keyword_count=len(keyword_results),
            fused_count=len(results[:limit]),
        )
        
        return results[:limit]
    
    async def search(
        self,
        semantic_results: list[tuple[str, float, dict[str, Any]]],
        keyword_results: list[tuple[str, float]],
        limit: int = 10,
    ) -> list[SearchResult]:
        """
        Perform hybrid search by fusing semantic and keyword results.
        
        This is the main entry point. Call Qdrant and FTS5 externally,
        then pass results here for fusion.
        
        Args:
            semantic_results: From Qdrant.search() - (id, score, payload)
            keyword_results: From Database.search_fts() - (id, bm25_score)
            limit: Maximum results to return
            
        Returns:
            List of SearchResult with fused scores
        """
        return self.fuse_results(semantic_results, keyword_results, limit)


# Convenience function for simple cases
def fuse_scores(
    semantic_score: float,
    keyword_score: float,
    alpha: float = 0.4,
) -> float:
    """
    Fuse a single pair of scores.
    
    Args:
        semantic_score: Normalized semantic similarity (0-1)
        keyword_score: Normalized BM25 score (0-1)
        alpha: Keyword weight (default: 0.4)
        
    Returns:
        Combined score (0-1)
    """
    return alpha * keyword_score + (1 - alpha) * semantic_score
