"""
cortex/core/retrieval_scoring.py — Pure scoring, fusion, and classification functions.

Extracted from cortex/core/retrieval.py (#947 — SRP split).
All functions here are pure (no I/O, no state) and trivially unit-testable.
"""

from __future__ import annotations

import math
import re
from typing import Any, Dict, List, Optional, Tuple

from cortex.core.retrieval_types import (
    SENSITIVITY_ORDER,
    _SENSITIVITY_ALIASES,
    _FTSResult,
)


# ---------------------------------------------------------------------------
# Query complexity heuristics for dynamic top-k and selective rerank (#495).
# ---------------------------------------------------------------------------

# Keywords that signal a high-complexity query (architecture, decisions, etc.).
# These benefit from more candidates and reranking to cut through noise.
_COMPLEX_QUERY_SIGNALS = re.compile(
    r"\b(decision|architecture|migration|design|tradeoff|trade-off|why did|"
    r"technical|strategy|rationale|reasoning|compare|versus|pros and cons|"
    r"incident|postmortem|root cause|benchmark|evaluation)\b",
    re.IGNORECASE,
)

# Minimum query length (chars) to be considered complex regardless of keywords.
_COMPLEX_QUERY_MIN_LENGTH = 800


def _is_complex_query(query: str) -> bool:
    """Return True if a query is complex enough to warrant reranking + more candidates.

    Heuristic: keyword match OR query length exceeds threshold.  Simple identity
    / preference recall ('what is my name?', 'what timezone?') stays fast.
    """
    if len(query) >= _COMPLEX_QUERY_MIN_LENGTH:
        return True
    return bool(_COMPLEX_QUERY_SIGNALS.search(query))


def _sensitivity_level(value: str) -> int:
    """Return numeric level for a sensitivity string (fail-closed for unknowns)."""
    canonical = _SENSITIVITY_ALIASES.get(value, value)
    try:
        return SENSITIVITY_ORDER.index(canonical)
    except ValueError:
        return len(SENSITIVITY_ORDER)


# ---------------------------------------------------------------------------
# Scoring helpers (pure functions — easy to unit-test)
# ---------------------------------------------------------------------------

def rrf_fusion(
    ranked_lists: List[List[Tuple[str, float]]],
    k: int = 60,
) -> List[Tuple[str, float]]:
    """Reciprocal Rank Fusion — combines multiple ranked lists into one.

    RRF is a simple, robust fusion method that's less sensitive to score
    scale differences between rankers than raw score averaging. It works by
    converting scores to reciprocal ranks: score(d) = Σ 1/(k + rank(d)).

    The k parameter controls how much weight top-ranked items get vs. lower-ranked
    ones. k=60 is the standard value from the original RRF paper (Cormack 2009).

    Args:
        ranked_lists: List of ranked lists, each containing (item_id, score)
                      tuples sorted best-first.
        k: Smoothing constant (default 60). Higher k = more uniform weighting.

    Returns:
        Fused list of (item_id, rrf_score) sorted by score descending.
    """
    scores: Dict[str, float] = {}
    for ranked_list in ranked_lists:
        for rank, (item_id, _) in enumerate(ranked_list):
            scores[item_id] = scores.get(item_id, 0.0) + 1.0 / (k + rank + 1)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)


def cosine_similarity(a: List[float], b: List[float]) -> float:
    """
    Brute-force cosine similarity for the sqlite-vec fallback path.
    Returns value in [-1, 1]; higher = more similar.
    """
    if len(a) != len(b):
        raise ValueError(f"Embedding dimension mismatch: {len(a)} vs {len(b)}")
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = math.sqrt(sum(x * x for x in a))
    norm_b = math.sqrt(sum(x * x for x in b))
    if norm_a == 0.0 or norm_b == 0.0:
        return 0.0
    return dot / (norm_a * norm_b)


def _normalise_bm25(
    results: List[_FTSResult],
) -> List[Tuple[str, float]]:
    """Convert FTS5 bm25() scores to [0,1] similarity scores for RRF input.

    SQLite FTS5's bm25() returns negative values where more negative = better match.
    We negate to make larger = better, then min-max normalise to [0, 1] so the
    scores are compatible with vector similarity scores in RRF fusion.

    Args:
        results: List of FTS results with .rank (negative bm25 scores).

    Returns:
        List of (item_id, normalised_score) tuples, higher = better match.
    """
    if not results:
        return []
    # bm25() values are ≤ 0; negate so larger = better, then min-max normalise
    ranks = [-r.rank for r in results]  # now all ≥ 0
    min_r, max_r = min(ranks), max(ranks)
    span = max_r - min_r or 1.0
    normalised = [(r.item_id, (rank - min_r) / span) for r, rank in zip(results, ranks)]
    return normalised
