//! Search algorithms and name matching
//!
//! Implements search methods on Store for semantic, hybrid, and index-guided
//! search. See `math.rs` for similarity scoring.

use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap, HashSet};

use sqlx::Row;

use crate::embedder::Embedding;
use crate::index::VectorIndex;
use crate::language::ChunkType;
use crate::math::cosine_similarity;
use crate::nl::normalize_for_fts;
use crate::nl::tokenize_identifier;
use crate::note::path_matches_mention;
use crate::store::helpers::{
    embedding_slice, CandidateRow, ChunkSummary, NoteSummary, SearchFilter, SearchResult,
};
use crate::store::sanitize_fts_query;
use crate::store::{Store, StoreError, UnifiedResult};

/// Result of resolving a target name to a concrete chunk.
///
/// Contains the best-matching chunk and any alternative matches
/// found during resolution (useful for disambiguation UIs).
#[derive(Debug, Clone)]
pub struct ResolvedTarget {
    /// The resolved chunk (best match for the target name)
    pub chunk: ChunkSummary,
    /// Other candidates found during resolution, ordered by match quality
    pub alternatives: Vec<SearchResult>,
}

/// Minimum code slots for a given result limit (60% floor, at least 1).
///
/// Used for note/code slot allocation in unified search.
pub(crate) fn min_code_slot_count(limit: usize) -> usize {
    ((limit * 3) / 5).max(1)
}

// ============ Target Resolution ============

/// Parse a target string into (optional_file_filter, function_name).
///
/// Supports formats:
/// - `"function_name"` -> (None, "function_name")
/// - `"path/to/file.rs:function_name"` -> (Some("path/to/file.rs"), "function_name")
pub fn parse_target(target: &str) -> (Option<&str>, &str) {
    if let Some(pos) = target.rfind(':') {
        let file = &target[..pos];
        let name = &target[pos + 1..];
        if !file.is_empty() && !name.is_empty() {
            return (Some(file), name);
        }
    }
    (None, target.trim_end_matches(':'))
}

/// Resolve a target string to a [`ResolvedTarget`].
///
/// Uses search_by_name with optional file filtering.
/// Returns the best-matching chunk and alternatives, or an error if none found.
pub fn resolve_target(store: &Store, target: &str) -> Result<ResolvedTarget, StoreError> {
    let _span = tracing::info_span!("resolve_target", target).entered();
    let (file_filter, name) = parse_target(target);
    let results = store.search_by_name(name, 20)?;
    if results.is_empty() {
        return Err(StoreError::NotFound(format!(
            "No function found matching '{}'. Check the name and try again.",
            name
        )));
    }

    let idx = if let Some(file) = file_filter {
        let matched = results.iter().position(|r| {
            let path = r.chunk.file.to_string_lossy();
            path.ends_with(file) || path.contains(file)
        });
        match matched {
            Some(i) => i,
            None => {
                let found_in: Vec<_> = results
                    .iter()
                    .take(3)
                    .map(|r| r.chunk.file.to_string_lossy().to_string())
                    .collect();
                return Err(StoreError::NotFound(format!(
                    "No function '{}' found in file matching '{}'. Found in: {}",
                    name,
                    file,
                    found_in.join(", ")
                )));
            }
        }
    } else {
        // Prefer non-test chunks when names are ambiguous
        results
            .iter()
            .position(|r| {
                let path = r.chunk.file.to_string_lossy();
                let name = &r.chunk.name;
                !name.starts_with("test_")
                    && !path.contains("/tests/")
                    && !path.ends_with("_test.rs")
            })
            .unwrap_or(0)
    };
    let chunk = results[idx].chunk.clone();
    Ok(ResolvedTarget {
        chunk,
        alternatives: results,
    })
}

// ============ Name Matching ============

/// Detect whether a query looks like a code identifier vs natural language.
///
/// Name-like: "parseConfig", "handle_error", "CircuitBreaker"
/// NL-like: "function that handles errors", "how does parsing work"
///
/// Used to gate name_boost — boosting by name similarity is harmful for
/// NL queries because it rewards coincidental substring matches over
/// semantic relevance.
fn is_name_like_query(query: &str) -> bool {
    let words: Vec<&str> = query.split_whitespace().collect();
    // Single token or two-token queries are likely identifiers
    if words.len() <= 2 {
        return true;
    }
    // NL indicators: common function words that never appear in identifiers
    const NL_WORDS: &[&str] = &[
        "the",
        "a",
        "an",
        "is",
        "are",
        "was",
        "were",
        "that",
        "which",
        "how",
        "what",
        "where",
        "when",
        "does",
        "do",
        "can",
        "should",
        "would",
        "could",
        "for",
        "with",
        "from",
        "into",
        "this",
        "these",
        "those",
        "function",
        "method",
        "code",
        "implement",
        "find",
        "search",
    ];
    let lower = query.to_lowercase();
    let lower_words: Vec<&str> = lower.split_whitespace().collect();
    for w in &lower_words {
        if NL_WORDS.contains(w) {
            return false;
        }
    }
    // 3+ words with no NL indicators — still likely NL if all lowercase
    // (identifiers are usually camelCase or snake_case)
    if words.len() >= 3 && lower == query && !query.contains('_') {
        return false;
    }
    true
}

/// Pre-tokenized query for efficient name matching in loops
///
/// Create once before iterating over search results, then call `score()` for each name.
/// Avoids re-tokenizing the query for every result.
pub(crate) struct NameMatcher {
    query_lower: String,
    query_words: Vec<String>,
}

impl NameMatcher {
    /// Create a new matcher with pre-tokenized query
    pub fn new(query: &str) -> Self {
        Self {
            query_lower: query.to_lowercase(),
            query_words: tokenize_identifier(query)
                .into_iter()
                .map(|w| w.to_lowercase())
                .collect(),
        }
    }

    // Name match score tiers
    const SCORE_EXACT: f32 = 1.0;
    const SCORE_CONTAINS: f32 = 0.8;
    const SCORE_CONTAINED_BY: f32 = 0.6;
    const SCORE_MAX_OVERLAP: f32 = 0.5;

    /// Compute name match score against pre-tokenized query
    pub fn score(&self, name: &str) -> f32 {
        let name_lower = name.to_lowercase();

        // Exact match
        if name_lower == self.query_lower {
            return Self::SCORE_EXACT;
        }

        // Name contains query as substring
        if name_lower.contains(&self.query_lower) {
            return Self::SCORE_CONTAINS;
        }

        // Query contains name as substring
        if self.query_lower.contains(&name_lower) {
            return Self::SCORE_CONTAINED_BY;
        }

        // Word overlap scoring
        if self.query_words.is_empty() {
            return 0.0;
        }

        // Trade-off: Building name_words Vec per result adds allocation overhead,
        // but pre-indexing names would require storing tokenized names in the DB
        // (increasing schema complexity and storage ~20%). Given name_words are
        // typically 1-5 words and this only runs for top-N results after filtering,
        // the per-result allocation is acceptable.
        let name_words: Vec<String> = tokenize_identifier(name)
            .into_iter()
            .map(|w| w.to_lowercase())
            .collect();

        if name_words.is_empty() {
            return 0.0;
        }

        // Fast path: build HashSet for O(1) exact match lookup
        let name_word_set: HashSet<&str> = name_words.iter().map(String::as_str).collect();

        // O(m*n) substring matching trade-off:
        // - m = query words (typically 1-5), n = name words (typically 1-5)
        // - Worst case: ~25 comparisons per name, but short-circuits on exact match
        // - Alternative (pre-indexing substring tries) would add complexity for minimal gain
        //   since names are short and search results are already capped by limit
        let overlap = self
            .query_words
            .iter()
            .filter(|w| {
                // Fast path: exact word match
                if name_word_set.contains(w.as_str()) {
                    return true;
                }
                // Slow path: substring matching (only if no exact match)
                // Intentionally excludes equal-length substrings: if lengths are equal
                // but strings differ, they're not substrings of each other (would need
                // exact match, handled above). This avoids redundant contains() calls.
                name_words.iter().any(|nw| {
                    // Short-circuit: check length before expensive substring search
                    (nw.len() > w.len() && nw.contains(w.as_str()))
                        || (w.len() > nw.len() && w.contains(nw.as_str()))
                })
            })
            .count() as f32;
        let total = self.query_words.len().max(1) as f32;

        (overlap / total) * Self::SCORE_MAX_OVERLAP
    }
}

/// Extract file path from a chunk ID.
///
/// Standard format: `"path:line_start:hash_prefix"` (3 segments from right)
/// Windowed format: `"path:line_start:hash_prefix:wN"` (4 segments)
///
/// The hash_prefix is always 8 hex chars. Windowed chunk IDs append `:wN` where
/// N is a small integer (0-99). We detect windowed IDs by checking if the last
/// segment starts with 'w' followed by digits.
fn extract_file_from_chunk_id(id: &str) -> &str {
    // Strip last segment
    let Some(last_colon) = id.rfind(':') else {
        return id;
    };
    let last_seg = &id[last_colon + 1..];

    // Determine how many segments to strip from the right:
    // - Standard: 2 (hash_prefix, line_start)
    // - Windowed: 3 (wN, hash_prefix, line_start)
    // Window suffix format: "w0", "w1", ..., "w99"
    let segments_to_strip = if !last_seg.is_empty()
        && last_seg.starts_with('w')
        && last_seg.len() <= 3
        && last_seg[1..].bytes().all(|b| b.is_ascii_digit())
    {
        3
    } else {
        2
    };

    let mut end = id.len();
    for _ in 0..segments_to_strip {
        if let Some(i) = id[..end].rfind(':') {
            end = i;
        } else {
            break;
        }
    }
    &id[..end]
}

/// Compile a glob pattern into a matcher, logging and ignoring invalid patterns.
///
/// Returns `None` if the pattern is `None` or invalid (with a warning logged).
fn compile_glob_filter(pattern: Option<&String>) -> Option<globset::GlobMatcher> {
    pattern.and_then(|p| match globset::Glob::new(p) {
        Ok(g) => Some(g.compile_matcher()),
        Err(e) => {
            tracing::warn!(pattern = %p, error = %e, "Invalid glob pattern, ignoring filter");
            None
        }
    })
}

/// Compute name match score for hybrid search
///
/// For repeated calls with the same query, use `NameMatcher::new(query).score(name)` instead.
#[cfg(test)]
pub(crate) fn name_match_score(query: &str, name: &str) -> f32 {
    NameMatcher::new(query).score(name)
}

/// Multiplicative boost factor for note-matched code chunks.
///
/// A note with sentiment +1 boosts the chunk's score by 15%.
/// A note with sentiment -1 reduces it by 15%.
const NOTE_BOOST_FACTOR: f32 = 0.15;

/// Compute the note-based score boost for a chunk.
///
/// Checks if any note's mentions match the chunk's file path or name.
/// When multiple notes match, takes the strongest absolute sentiment
/// (preserving sign) to avoid averaging away strong signals.
///
/// Returns a multiplier: `1.0 + sentiment * NOTE_BOOST_FACTOR`
///
/// Production code uses [`NoteBoostIndex::boost`] for amortized O(1) lookups.
/// This function is retained for unit tests.
#[cfg(test)]
fn note_boost(file_path: &str, chunk_name: &str, notes: &[NoteSummary]) -> f32 {
    let mut strongest: Option<f32> = None;
    for note in notes {
        for mention in &note.mentions {
            if path_matches_mention(file_path, mention) || chunk_name == mention {
                match strongest {
                    Some(prev) if note.sentiment.abs() > prev.abs() => {
                        strongest = Some(note.sentiment);
                    }
                    None => {
                        strongest = Some(note.sentiment);
                    }
                    _ => {}
                }
                break; // This note already matched, check next note
            }
        }
    }
    match strongest {
        Some(s) => 1.0 + s * NOTE_BOOST_FACTOR,
        None => 1.0,
    }
}

/// Pre-computed note boost lookup for O(1) name matching and reduced path scans.
///
/// Built once from notes before the scoring loop, amortizing the O(notes x mentions)
/// cost across all chunks. Name mentions use exact HashMap lookup (O(1)).
/// Path mentions are stored separately for suffix/prefix matching, but with only
/// the path-type mentions instead of all mentions.
struct NoteBoostIndex<'a> {
    /// Exact name -> strongest sentiment (absolute value wins, preserving sign)
    name_sentiments: HashMap<&'a str, f32>,
    /// (mention_str, sentiment) pairs for path-based mentions
    path_mentions: Vec<(&'a str, f32)>,
}

impl<'a> NoteBoostIndex<'a> {
    /// Build the lookup index from notes. O(notes x mentions), done once.
    fn new(notes: &'a [NoteSummary]) -> Self {
        let mut name_sentiments: HashMap<&'a str, f32> = HashMap::new();
        let mut path_mentions: Vec<(&'a str, f32)> = Vec::new();

        for note in notes {
            for mention in &note.mentions {
                // Heuristic: mentions containing '/' or '.' or '\' are path-like,
                // others are name-like (exact match on chunk name)
                let is_path_like =
                    mention.contains('/') || mention.contains('.') || mention.contains('\\');
                if is_path_like {
                    path_mentions.push((mention.as_str(), note.sentiment));
                } else {
                    let entry = name_sentiments.entry(mention.as_str()).or_insert(0.0);
                    if note.sentiment.abs() > entry.abs() {
                        *entry = note.sentiment;
                    }
                }
            }
        }

        Self {
            name_sentiments,
            path_mentions,
        }
    }

    /// Compute the note-based score boost for a chunk.
    ///
    /// Checks name mentions via HashMap lookup (O(1)), then scans path mentions
    /// for suffix/prefix matches. Takes strongest absolute sentiment across all
    /// matches (preserving sign).
    ///
    /// Returns a multiplier: `1.0 + sentiment * NOTE_BOOST_FACTOR`
    #[inline]
    fn boost(&self, file_path: &str, chunk_name: &str) -> f32 {
        let mut strongest: Option<f32> = None;

        // O(1) name lookup
        if let Some(&sentiment) = self.name_sentiments.get(chunk_name) {
            strongest = Some(sentiment);
        }

        // Path mention scan (only path-like mentions, not all mentions)
        for &(mention, sentiment) in &self.path_mentions {
            if path_matches_mention(file_path, mention) {
                match strongest {
                    Some(prev) if sentiment.abs() > prev.abs() => {
                        strongest = Some(sentiment);
                    }
                    None => {
                        strongest = Some(sentiment);
                    }
                    _ => {}
                }
            }
        }

        match strongest {
            Some(s) => 1.0 + s * NOTE_BOOST_FACTOR,
            None => 1.0,
        }
    }
}

/// Compute search-time importance multiplier for a chunk.
///
/// Demotes test functions and underscore-prefixed private helpers.
/// Applied as a multiplier like `note_boost`, so it composes: `score * note_boost * importance`.
///
/// | Signal                   | Detection                                        | Multiplier |
/// |--------------------------|--------------------------------------------------|------------|
/// | Test function (name)     | name starts with `test_` or `Test`               | 0.70       |
/// | Test file (filename)     | filename contains `_test.` or starts with `test_` | 0.70      |
/// | Underscore-prefixed      | name starts with `_` (not `__`)                  | 0.80       |
///
/// File-based detection uses only the filename, not the full path — being inside a
/// `tests/` directory doesn't demote. This avoids false positives on test fixtures
/// and monorepo layouts.
///
/// Returns 1.0 (no change) when demotion doesn't apply.
const IMPORTANCE_TEST: f32 = 0.70;
const IMPORTANCE_PRIVATE: f32 = 0.80;

fn chunk_importance(name: &str, file_path: &str) -> f32 {
    // Name-based: test function
    if name.starts_with("test_")
        || name.starts_with("Test")
        || name.starts_with("spec_")
        || name.ends_with("_test")
        || name.ends_with("_spec")
    {
        return IMPORTANCE_TEST;
    }
    // File-based: test file (by filename, not full path)
    let filename = file_path.rsplit('/').next().unwrap_or(file_path);
    if filename.contains("_test.")
        || filename.contains(".test.")
        || filename.contains(".spec.")
        || filename.contains("_spec.")
        || filename.starts_with("test_")
    {
        return IMPORTANCE_TEST;
    }
    // Path-based: tests/ directory
    if file_path.contains("/tests/") || file_path.starts_with("tests/") {
        return IMPORTANCE_TEST;
    }
    // Underscore-prefixed private (but not dunder like __init__)
    if name.starts_with('_') && !name.starts_with("__") {
        return IMPORTANCE_PRIVATE;
    }
    1.0
}

/// Boost container chunks (Class, Struct, Interface) when multiple child methods
/// from the same parent appear in search results.
///
/// When a query semantically matches several methods of one class, the class
/// itself is usually the best answer — the methods individually match fragments
/// of the query, but the class embodies the whole concept (e.g., "circuit breaker
/// pattern" → `CircuitBreaker` class, not `recordFailure` method).
///
/// Algorithm: count how many results have `parent_type_name == X`. If a
/// Class/Struct/Interface chunk named `X` also appears in results, boost it.
///
/// Boost magnitude: `1.0 + 0.05 × (child_count - 1)`, capped at 1.15.
/// With 2 children → 1.05×, 3 → 1.10×, 4+ → 1.15×.
///
/// Re-sorts results by score after boosting.
fn apply_parent_boost(results: &mut [SearchResult]) {
    if results.len() < 3 {
        return; // Need at least a container + 2 children
    }

    // Count how many results share each parent_type_name
    let mut parent_counts: HashMap<String, usize> = HashMap::new();
    for r in results.iter() {
        if let Some(ref ptn) = r.chunk.parent_type_name {
            *parent_counts.entry(ptn.clone()).or_insert(0) += 1;
        }
    }

    // Only proceed if any parent_type_name appears 2+ times
    if !parent_counts.values().any(|&c| c >= 2) {
        return;
    }

    let mut boosted = false;
    for r in results.iter_mut() {
        let is_container = matches!(
            r.chunk.chunk_type,
            ChunkType::Class | ChunkType::Struct | ChunkType::Interface
        );
        if !is_container {
            continue;
        }
        if let Some(&count) = parent_counts.get(&r.chunk.name) {
            if count >= 2 {
                let boost = 1.0 + 0.05 * (count as f32 - 1.0).min(3.0);
                tracing::debug!(
                    name = %r.chunk.name,
                    child_count = count,
                    boost = %boost,
                    "parent_boost: boosting container"
                );
                r.score *= boost;
                boosted = true;
            }
        }
    }

    if boosted {
        results.sort_by(|a, b| b.score.total_cmp(&a.score));
    }
}

/// Bounded min-heap for maintaining top-N search results by score.
///
/// Uses a min-heap internally so the smallest score is always at the top,
/// allowing O(log N) eviction when the heap is full. This bounds memory to
/// O(limit) instead of O(total_chunks) for the scoring phase.
struct BoundedScoreHeap {
    heap: BinaryHeap<Reverse<(OrderedFloat, String)>>,
    capacity: usize,
}

/// Wrapper for f32 that implements Ord for use in BinaryHeap.
/// Uses total_cmp for consistent ordering (NaN sorts to the end).
#[derive(Clone, Copy, PartialEq)]
struct OrderedFloat(f32);

impl Eq for OrderedFloat {}

impl PartialOrd for OrderedFloat {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for OrderedFloat {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.0.total_cmp(&other.0)
    }
}

impl BoundedScoreHeap {
    fn new(capacity: usize) -> Self {
        Self {
            heap: BinaryHeap::with_capacity(capacity + 1),
            capacity,
        }
    }

    /// Push a scored result. If at capacity, evicts the lowest score.
    fn push(&mut self, id: String, score: f32) {
        if !score.is_finite() {
            tracing::warn!("BoundedScoreHeap: ignoring non-finite score");
            return;
        }

        // If below capacity, always insert
        if self.heap.len() < self.capacity {
            self.heap.push(Reverse((OrderedFloat(score), id)));
            return;
        }

        // At capacity - only insert if strictly better than current minimum.
        // Using > (not >=) gives first-indexed stability: when scores are equal,
        // earlier items are kept. This prevents last-wins bias where later-indexed
        // chunks systematically replace earlier ones at equal scores.
        if let Some(Reverse((OrderedFloat(min_score), _))) = self.heap.peek() {
            if score > *min_score {
                self.heap.pop();
                self.heap.push(Reverse((OrderedFloat(score), id)));
            }
        }
    }

    /// Drain into a sorted Vec (highest score first).
    fn into_sorted_vec(self) -> Vec<(String, f32)> {
        let mut results: Vec<_> = self
            .heap
            .into_iter()
            .map(|Reverse((OrderedFloat(score), id))| (id, score))
            .collect();
        results.sort_by(|a, b| b.1.total_cmp(&a.1));
        results
    }
}

/// Result of assembling SQL WHERE conditions from a [`SearchFilter`].
///
/// Separates filter analysis (testable without a database) from SQL execution.
/// The caller combines these pieces with cursor-specific clauses (rowid, LIMIT).
struct FilterSql {
    /// SQL WHERE conditions (e.g., `"language IN (?1,?2)"`)
    conditions: Vec<String>,
    /// Bind values corresponding to the placeholders in `conditions`, in order
    bind_values: Vec<String>,
    /// Column list for SELECT (includes `name` when hybrid scoring or demotion is needed)
    columns: &'static str,
    /// Whether hybrid name+embedding scoring is active
    use_hybrid: bool,
    /// Whether RRF fusion with FTS keyword search is active
    use_rrf: bool,
}

/// Build SQL filter components from a [`SearchFilter`].
///
/// Pure function — no database access. Returns conditions, bind values, and
/// the column list needed for the scoring loop. Bind parameter indices are
/// 1-based and contiguous.
fn build_filter_sql(filter: &SearchFilter) -> FilterSql {
    let mut conditions = Vec::new();
    let mut bind_values: Vec<String> = Vec::new();

    if let Some(ref langs) = filter.languages {
        let placeholders: Vec<_> = (0..langs.len())
            .map(|i| format!("?{}", bind_values.len() + i + 1))
            .collect();
        conditions.push(format!("language IN ({})", placeholders.join(",")));
        for lang in langs {
            bind_values.push(lang.to_string());
        }
    }

    if let Some(ref types) = filter.chunk_types {
        let placeholders: Vec<_> = (0..types.len())
            .map(|i| format!("?{}", bind_values.len() + i + 1))
            .collect();
        conditions.push(format!("chunk_type IN ({})", placeholders.join(",")));
        for ct in types {
            bind_values.push(ct.to_string());
        }
    }

    let use_hybrid = filter.name_boost > 0.0
        && !filter.query_text.is_empty()
        && is_name_like_query(&filter.query_text);
    let use_rrf = filter.enable_rrf && !filter.query_text.is_empty();

    // Select columns: always id + embedding, optionally name for hybrid scoring
    // or demotion (test function detection needs the name)
    let need_name = use_hybrid || filter.enable_demotion;
    let columns = if need_name {
        "rowid, id, embedding, name"
    } else {
        "rowid, id, embedding"
    };

    FilterSql {
        conditions,
        bind_values,
        columns,
        use_hybrid,
        use_rrf,
    }
}

/// Score a single candidate chunk against the query.
///
/// Pure function — no database access. Combines embedding similarity, optional
/// name boosting, glob filtering, note boosting, and test-function demotion.
///
/// Returns `None` if the candidate is filtered out (glob mismatch or below threshold).
#[allow(clippy::too_many_arguments)]
fn score_candidate(
    embedding: &[f32],
    query: &[f32],
    name: Option<&str>,
    file_part: &str,
    filter: &SearchFilter,
    name_matcher: Option<&NameMatcher>,
    glob_matcher: Option<&globset::GlobMatcher>,
    note_index: &NoteBoostIndex<'_>,
    threshold: f32,
) -> Option<f32> {
    let embedding_score = cosine_similarity(query, embedding)?;

    let base_score = if let Some(matcher) = name_matcher {
        let n = name.unwrap_or("");
        let name_score = matcher.score(n);
        (1.0 - filter.name_boost) * embedding_score + filter.name_boost * name_score
    } else {
        embedding_score
    };

    if let Some(matcher) = glob_matcher {
        if !matcher.is_match(file_part) {
            return None;
        }
    }

    // Apply note-based boost: notes mentioning this chunk's file or name
    // adjust its score by up to ±15%
    let chunk_name = name.unwrap_or("");
    let mut score = base_score * note_index.boost(file_part, chunk_name);

    // Apply demotion for test functions and underscore-prefixed names
    if filter.enable_demotion {
        score *= chunk_importance(chunk_name, file_part);
    }

    if score >= threshold {
        Some(score)
    } else {
        None
    }
}

impl Store {
    /// Raw embedding-only cosine similarity search (no RRF, no keyword matching).
    ///
    /// **You almost certainly want `search_filtered()` instead.** This method skips
    /// hybrid RRF ranking, name boosting, and all filters. It exists for tests and
    /// internal building blocks only. Two production bugs came from calling this
    /// directly (PR #305).
    pub fn search_embedding_only(
        &self,
        query: &Embedding,
        limit: usize,
        threshold: f32,
    ) -> Result<Vec<SearchResult>, StoreError> {
        self.search_filtered(query, &SearchFilter::default(), limit, threshold)
    }

    /// Search with filters
    pub fn search_filtered(
        &self,
        query: &Embedding,
        filter: &SearchFilter,
        limit: usize,
        threshold: f32,
    ) -> Result<Vec<SearchResult>, StoreError> {
        let _span = tracing::info_span!("search_filtered", limit = limit, rrf = filter.enable_rrf)
            .entered();

        // Load notes once for note-boosted ranking (cheap — no embeddings)
        let notes = match self.cached_notes_summaries() {
            Ok(n) => n,
            Err(e) => {
                tracing::warn!(error = %e, "Failed to load notes for search boosting");
                Vec::new()
            }
        };

        self.rt.block_on(async {
            let fsql = build_filter_sql(filter);
            let semantic_limit = if fsql.use_rrf { limit * 3 } else { limit };
            let need_name = fsql.use_hybrid || filter.enable_demotion;

            // Compile glob pattern once outside the loop (not per-chunk).
            // Note: Invalid patterns are logged and silently ignored (returns all results).
            // Callers should validate patterns upfront via SearchFilter::validate() if they
            // want to reject invalid patterns. This lenient behavior is intentional to allow
            // partial searches when users provide malformed patterns interactively.
            let glob_matcher = compile_glob_filter(filter.path_pattern.as_ref());

            // Pre-tokenize query for name matching (avoids re-tokenizing per result)
            let name_matcher = if fsql.use_hybrid {
                Some(NameMatcher::new(&filter.query_text))
            } else {
                None
            };

            // Pre-compute note boost lookup for O(1) name matching in scoring loop
            let note_index = NoteBoostIndex::new(&notes);

            // Use bounded heap to maintain only top-N results during iteration.
            // This bounds memory to O(semantic_limit) instead of O(total_chunks).
            let mut score_heap = BoundedScoreHeap::new(semantic_limit);

            // Cursor-based batching: load embeddings in batches of 5000 instead of
            // all at once. This bounds memory to O(batch_size) instead of O(total_chunks).
            // Uses the same cursor pattern as EmbeddingBatchIterator in store/chunks.rs.
            const BRUTE_FORCE_BATCH_SIZE: i64 = 5000;
            let mut last_rowid: i64 = 0;

            // Hoist SQL template out of cursor loop — only last_rowid changes per iteration
            let rowid_condition = format!("rowid > ?{}", fsql.bind_values.len() + 1);
            let limit_param = format!("?{}", fsql.bind_values.len() + 2);
            let batch_where = if fsql.conditions.is_empty() {
                format!(
                    " WHERE {} ORDER BY rowid ASC LIMIT {}",
                    rowid_condition, limit_param
                )
            } else {
                format!(
                    " WHERE {} AND {} ORDER BY rowid ASC LIMIT {}",
                    fsql.conditions.join(" AND "),
                    rowid_condition,
                    limit_param
                )
            };
            let sql = format!("SELECT {} FROM chunks{}", fsql.columns, batch_where);

            loop {
                let batch: Vec<_> = {
                    let mut q = sqlx::query(&sql);
                    for val in &fsql.bind_values {
                        q = q.bind(val);
                    }
                    q = q.bind(last_rowid);
                    q = q.bind(BRUTE_FORCE_BATCH_SIZE);
                    q.fetch_all(&self.pool).await?
                };

                if batch.is_empty() {
                    break;
                }
                last_rowid = batch
                    .last()
                    .expect("batch non-empty checked above")
                    .get::<i64, _>("rowid");

                for row in &batch {
                    let id: String = row.get("id");
                    let embedding_bytes: Vec<u8> = row.get("embedding");
                    let name: Option<String> = if need_name { row.get("name") } else { None };

                    let Some(embedding) = embedding_slice(&embedding_bytes) else {
                        continue;
                    };
                    let file_part = extract_file_from_chunk_id(&id);

                    if let Some(score) = score_candidate(
                        embedding,
                        query.as_slice(),
                        name.as_deref(),
                        file_part,
                        filter,
                        name_matcher.as_ref(),
                        glob_matcher.as_ref(),
                        &note_index,
                        threshold,
                    ) {
                        score_heap.push(id, score);
                    }
                }
            }

            let mut scored = score_heap.into_sorted_vec();

            // Normalize + sanitize query text for FTS5 MATCH (defense-in-depth)
            let normalized_query = if fsql.use_rrf {
                let normalized = normalize_for_fts(&filter.query_text);
                Some(sanitize_fts_query(&normalized))
            } else {
                None
            };

            let final_scored: Vec<(String, f32)> = if fsql.use_rrf {
                let fts_ids = if let Some(nq) = normalized_query.as_ref() {
                    if nq.is_empty() {
                        vec![]
                    } else {
                        let fts_rows: Vec<(String,)> = sqlx::query_as(
                            "SELECT id FROM chunks_fts WHERE chunks_fts MATCH ?1 ORDER BY bm25(chunks_fts) LIMIT ?2",
                        )
                        .bind(nq)
                        .bind(semantic_limit as i64)
                        .fetch_all(&self.pool)
                        .await?;
                        fts_rows.into_iter().map(|(id,)| id).collect()
                    }
                } else {
                    vec![]
                };
                let semantic_ids: Vec<&str> = scored.iter().map(|(id, _)| id.as_str()).collect();
                // Request extra candidates from RRF to compensate for parent dedup
                // filtering below — dedup can drop results, leaving fewer than `limit`.
                Self::rrf_fuse(&semantic_ids, &fts_ids, limit * 2)
            } else {
                scored.truncate(limit);
                scored
            };

            if final_scored.is_empty() {
                return Ok(vec![]);
            }

            // Phase 2: Fetch full content only for top-N results
            let ids: Vec<&str> = final_scored.iter().map(|(id, _)| id.as_str()).collect();
            let rows_map = self.fetch_chunks_by_ids_async(&ids).await?;

            let mut seen_parents: HashSet<String> = HashSet::new();
            let mut results: Vec<SearchResult> = final_scored
                .into_iter()
                .filter_map(|(id, score)| {
                    rows_map.get(&id).and_then(|row| {
                        let dedup_key = row.parent_id.clone().unwrap_or_else(|| row.id.clone());
                        if seen_parents.insert(dedup_key) {
                            Some(SearchResult {
                                chunk: ChunkSummary::from(row.clone()),
                                score,
                            })
                        } else {
                            None
                        }
                    })
                })
                .collect();

            // Boost container chunks when multiple child methods appear in results
            apply_parent_boost(&mut results);

            // Truncate back to requested limit after parent dedup
            results.truncate(limit);

            tracing::debug!(count = results.len(), "search_filtered complete");
            Ok(results)
        })
    }

    /// Search with optional vector index for O(log n) candidate retrieval
    pub fn search_filtered_with_index(
        &self,
        query: &Embedding,
        filter: &SearchFilter,
        limit: usize,
        threshold: f32,
        index: Option<&dyn VectorIndex>,
    ) -> Result<Vec<SearchResult>, StoreError> {
        if let Some(idx) = index {
            let _span = tracing::info_span!("search_index_guided", limit = limit).entered();

            let candidate_count = (limit * 5).max(100);
            let index_results = idx.search(query, candidate_count);

            if index_results.is_empty() {
                tracing::info!("Index returned no candidates, falling back to brute-force search (performance may degrade)");
                return self.search_filtered(query, filter, limit, threshold);
            }

            tracing::debug!("Index returned {} candidates", index_results.len());

            let candidate_ids: Vec<&str> = index_results.iter().map(|r| r.id.as_str()).collect();
            return self.search_by_candidate_ids(&candidate_ids, query, filter, limit, threshold);
        }

        self.search_filtered(query, filter, limit, threshold)
    }

    /// Search within a set of candidate IDs (for HNSW-guided filtered search)
    pub fn search_by_candidate_ids(
        &self,
        candidate_ids: &[&str],
        query: &Embedding,
        filter: &SearchFilter,
        limit: usize,
        threshold: f32,
    ) -> Result<Vec<SearchResult>, StoreError> {
        let _span = tracing::info_span!(
            "search_by_candidates",
            candidates = candidate_ids.len(),
            limit
        )
        .entered();

        if candidate_ids.is_empty() {
            return Ok(vec![]);
        }

        let use_hybrid = filter.name_boost > 0.0
            && !filter.query_text.is_empty()
            && is_name_like_query(&filter.query_text);
        let use_rrf = filter.enable_rrf && !filter.query_text.is_empty();

        // Load notes once for note-boosted ranking
        let notes = match self.cached_notes_summaries() {
            Ok(n) => n,
            Err(e) => {
                tracing::warn!(error = %e, "Failed to load notes for search boosting");
                Vec::new()
            }
        };

        self.rt.block_on(async {
            // Phase 1: Lightweight candidate fetch — only scoring fields + embedding.
            // Excludes heavy content/doc/signature columns (PF-5).
            let candidates = self
                .fetch_candidates_by_ids_async(candidate_ids)
                .await?;

            // Compile glob pattern once outside the loop (not per-chunk).
            let glob_matcher = compile_glob_filter(filter.path_pattern.as_ref());

            // Pre-tokenize query for name matching (avoids re-tokenizing per result)
            let name_matcher = if use_hybrid {
                Some(NameMatcher::new(&filter.query_text))
            } else {
                None
            };

            // Pre-compute note boost lookup for O(1) name matching in scoring loop
            let note_index = NoteBoostIndex::new(&notes);

            // Pre-build filter sets once — avoids per-candidate string parsing (PF-1)
            let lang_set: Option<HashSet<String>> = filter.languages.as_ref().map(|langs| {
                langs.iter().map(|l| l.to_string().to_lowercase()).collect()
            });
            let type_set: Option<HashSet<String>> = filter.chunk_types.as_ref().map(|types| {
                types.iter().map(|t| t.to_string().to_lowercase()).collect()
            });

            let mut scored: Vec<(CandidateRow, f32)> = candidates
                .into_iter()
                .filter_map(|(candidate, embedding_bytes)| {
                    if let Some(ref langs) = lang_set {
                        if !langs.contains(&candidate.language.to_lowercase()) {
                            return None;
                        }
                    }

                    if let Some(ref types) = type_set {
                        if !types.contains(&candidate.chunk_type.to_lowercase()) {
                            return None;
                        }
                    }

                    let embedding = embedding_slice(&embedding_bytes)?;

                    let score = score_candidate(
                        embedding,
                        query.as_slice(),
                        Some(&candidate.name),
                        &candidate.origin,
                        filter,
                        name_matcher.as_ref(),
                        glob_matcher.as_ref(),
                        &note_index,
                        threshold,
                    )?;

                    Some((candidate, score))
                })
                .collect();

            scored.sort_by(|a, b| b.1.total_cmp(&a.1));

            // Apply RRF fusion with FTS keyword search (same pattern as search_filtered)
            let final_scored: Vec<(String, f32)> = if use_rrf {
                let normalized = normalize_for_fts(&filter.query_text);
                let sanitized = sanitize_fts_query(&normalized);
                let fts_ids = if sanitized.is_empty() {
                    vec![]
                } else {
                    let fts_rows: Vec<(String,)> = sqlx::query_as(
                        "SELECT id FROM chunks_fts WHERE chunks_fts MATCH ?1 ORDER BY bm25(chunks_fts) LIMIT ?2",
                    )
                    .bind(&sanitized)
                    .bind((limit * 3) as i64)
                    .fetch_all(&self.pool)
                    .await?;
                    fts_rows.into_iter().map(|(id,)| id).collect()
                };
                let semantic_ids: Vec<&str> =
                    scored.iter().map(|(c, _)| c.id.as_str()).collect();
                // Request extra candidates from RRF to compensate for parent dedup
                Self::rrf_fuse(&semantic_ids, &fts_ids, limit * 2)
            } else {
                scored
                    .iter()
                    .take(limit)
                    .map(|(c, score)| (c.id.clone(), *score))
                    .collect()
            };

            if final_scored.is_empty() {
                return Ok(vec![]);
            }

            // Phase 2: Fetch full content only for survivors (~limit*2 rows
            // instead of all candidates). This is the PF-5 payoff — heavy
            // content/doc/signature columns skipped for the 500+ scoring
            // candidates, loaded only for the ~20 winners.
            let fetch_ids: Vec<&str> =
                final_scored.iter().map(|(id, _)| id.as_str()).collect();
            let full_rows = self.fetch_chunks_by_ids_async(&fetch_ids).await?;

            let mut seen_parents: HashSet<String> = HashSet::new();
            let mut results: Vec<SearchResult> = final_scored
                .into_iter()
                .filter_map(|(id, score)| {
                    let row = full_rows.get(&id)?;
                    let dedup_key =
                        row.parent_id.clone().unwrap_or_else(|| row.id.clone());
                    if seen_parents.insert(dedup_key) {
                        Some(SearchResult {
                            chunk: ChunkSummary::from(row.clone()),
                            score,
                        })
                    } else {
                        None
                    }
                })
                .collect();

            // Boost container chunks when multiple child methods appear in results
            apply_parent_boost(&mut results);

            // Truncate back to requested limit after parent dedup
            results.truncate(limit);

            Ok(results)
        })
    }

    /// Unified search with optional vector index
    ///
    /// When an HNSW index is provided, uses O(log n) search for both chunks and notes.
    /// Note IDs in HNSW are prefixed with `note:` to distinguish from chunk IDs.
    pub fn search_unified_with_index(
        &self,
        query: &Embedding,
        filter: &SearchFilter,
        limit: usize,
        threshold: f32,
        index: Option<&dyn VectorIndex>,
    ) -> Result<Vec<crate::store::UnifiedResult>, StoreError> {
        if limit == 0 {
            return Ok(vec![]);
        }

        let _span = tracing::info_span!("search_unified", limit, threshold = %threshold).entered();

        // note_only: return only notes, skip code search entirely
        if filter.note_only {
            let note_results = self.search_notes(query, limit, threshold)?;
            return Ok(note_results.into_iter().map(UnifiedResult::Note).collect());
        }

        // Skip note search entirely when note_weight is effectively zero
        let skip_notes = filter.note_weight <= 0.0;

        // Notes always use brute-force search from SQLite (capped at 1000).
        // This ensures notes are immediately searchable without
        // waiting for an HNSW rebuild. HNSW is only used for chunks (10k-100k+).
        let note_results = if skip_notes {
            vec![]
        } else {
            self.search_notes(query, limit, threshold)?
        };

        let code_results = if let Some(idx) = index {
            // Query HNSW for chunk candidates only
            let candidate_count = (limit * 5).max(100);
            let index_results = idx.search(query, candidate_count);

            if index_results.is_empty() {
                tracing::info!("Index returned no candidates, falling back to brute-force search (performance may degrade)");
                self.search_filtered(query, filter, limit, threshold)?
            } else {
                // Filter to chunk IDs only (skip any legacy note: prefixed entries)
                let chunk_ids: Vec<&str> = index_results
                    .iter()
                    .filter_map(|r| {
                        if r.id.starts_with("note:") {
                            None
                        } else {
                            Some(r.id.as_str())
                        }
                    })
                    .collect();

                tracing::debug!("Index returned {} chunk candidates", chunk_ids.len());

                self.search_by_candidate_ids(&chunk_ids, query, filter, limit, threshold)?
            }
        } else {
            self.search_filtered(query, filter, limit, threshold)?
        };

        // Slot allocation: reserve minimum 60% for code results, up to 40% for notes.
        // This prevents notes from dominating while still surfacing relevant observations.
        // When code results are sparse, cap notes to the proportional amount (40%)
        // rather than letting them fill all remaining slots.
        let min_code_slots = min_code_slot_count(limit);
        let code_count = code_results.len().min(limit);
        let note_slots = if code_count >= min_code_slots {
            limit.saturating_sub(code_count)
        } else {
            // Code is sparse — still cap notes to proportional amount
            limit.saturating_sub(min_code_slots)
        };

        let mut unified: Vec<crate::store::UnifiedResult> = code_results
            .into_iter()
            .take(limit)
            .map(crate::store::UnifiedResult::Code)
            .collect();

        // Apply note_weight to attenuate note scores before merging
        let notes_to_add: Vec<crate::store::UnifiedResult> = note_results
            .into_iter()
            .take(note_slots)
            .map(|mut r| {
                r.score *= filter.note_weight;
                crate::store::UnifiedResult::Note(r)
            })
            .collect();
        unified.extend(notes_to_add);

        unified.sort_by(|a, b| b.score().total_cmp(&a.score()));
        unified.truncate(limit);

        Ok(unified)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // cosine_similarity tests are in src/math.rs

    // ===== name_match_score tests =====

    #[test]
    fn test_name_match_exact() {
        assert_eq!(name_match_score("parse", "parse"), 1.0);
    }

    #[test]
    fn test_name_match_contains() {
        assert_eq!(name_match_score("parse", "parseConfig"), 0.8);
    }

    #[test]
    fn test_name_match_contained() {
        assert_eq!(name_match_score("parseConfigFile", "parse"), 0.6);
    }

    #[test]
    fn test_name_match_partial_overlap() {
        let score = name_match_score("parseConfig", "configParser");
        assert!(score > 0.0 && score <= 0.5);
    }

    #[test]
    fn test_name_match_no_match() {
        assert_eq!(name_match_score("foo", "bar"), 0.0);
    }

    // ===== note_boost tests =====

    fn make_note(sentiment: f32, mentions: &[&str]) -> NoteSummary {
        NoteSummary {
            id: "note:test".to_string(),
            text: "test note".to_string(),
            sentiment,
            mentions: mentions.iter().map(|s| s.to_string()).collect(),
        }
    }

    #[test]
    fn test_note_boost_no_notes() {
        let boost = note_boost("src/lib.rs", "my_fn", &[]);
        assert_eq!(boost, 1.0);
    }

    #[test]
    fn test_note_boost_no_match() {
        let notes = vec![make_note(-0.5, &["other.rs"])];
        let boost = note_boost("src/lib.rs", "my_fn", &notes);
        assert_eq!(boost, 1.0);
    }

    #[test]
    fn test_note_boost_file_match_negative() {
        let notes = vec![make_note(-1.0, &["lib.rs"])];
        let boost = note_boost("src/lib.rs", "my_fn", &notes);
        assert!(
            (boost - 0.85).abs() < 0.001,
            "Expected ~0.85, got {}",
            boost
        );
    }

    #[test]
    fn test_note_boost_file_match_positive() {
        let notes = vec![make_note(1.0, &["lib.rs"])];
        let boost = note_boost("src/lib.rs", "my_fn", &notes);
        assert!(
            (boost - 1.15).abs() < 0.001,
            "Expected ~1.15, got {}",
            boost
        );
    }

    #[test]
    fn test_note_boost_name_match() {
        let notes = vec![make_note(0.5, &["my_fn"])];
        let boost = note_boost("src/lib.rs", "my_fn", &notes);
        assert!(
            (boost - 1.075).abs() < 0.001,
            "Expected ~1.075, got {}",
            boost
        );
    }

    #[test]
    fn test_note_boost_strongest_wins() {
        // Two notes: weak positive and strong negative. Strong negative should win.
        let notes = vec![make_note(0.5, &["lib.rs"]), make_note(-1.0, &["lib.rs"])];
        let boost = note_boost("src/lib.rs", "my_fn", &notes);
        assert!(
            (boost - 0.85).abs() < 0.001,
            "Expected ~0.85, got {}",
            boost
        );
    }

    #[test]
    fn test_note_boost_strongest_absolute_preserves_sign() {
        // Two notes: strong positive and weak negative. Strong positive should win.
        let notes = vec![make_note(1.0, &["lib.rs"]), make_note(-0.5, &["lib.rs"])];
        let boost = note_boost("src/lib.rs", "my_fn", &notes);
        assert!(
            (boost - 1.15).abs() < 0.001,
            "Expected ~1.15, got {}",
            boost
        );
    }

    // ===== min_code_slots tests =====

    #[test]
    fn test_min_code_slots_limit_1() {
        // With limit=1, (1*3)/5 = 0 which starved code results.
        // After fix: .max(1) ensures at least 1 code slot.
        assert_eq!(min_code_slot_count(1), 1);
    }

    #[test]
    fn test_min_code_slots_limit_5() {
        assert_eq!(min_code_slot_count(5), 3);
    }

    // ===== compile_glob_filter tests =====

    #[test]
    fn test_compile_glob_filter_none() {
        assert!(compile_glob_filter(None).is_none());
    }

    #[test]
    fn test_compile_glob_filter_valid() {
        let pattern = "src/**/*.rs".to_string();
        let matcher = compile_glob_filter(Some(&pattern));
        assert!(matcher.is_some());
        let m = matcher.unwrap();
        assert!(m.is_match("src/cli/mod.rs"));
        assert!(!m.is_match("tests/foo.py"));
    }

    #[test]
    fn test_compile_glob_filter_invalid() {
        let pattern = "[invalid".to_string();
        assert!(compile_glob_filter(Some(&pattern)).is_none());
    }

    // ===== extract_file_from_chunk_id tests =====

    #[test]
    fn test_extract_file_standard_chunk_id() {
        // Standard: "path:line_start:hash_prefix"
        assert_eq!(
            extract_file_from_chunk_id("src/foo.rs:10:abc12345"),
            "src/foo.rs"
        );
    }

    #[test]
    fn test_extract_file_windowed_chunk_id() {
        // Windowed: "path:line_start:hash_prefix:wN"
        assert_eq!(
            extract_file_from_chunk_id("src/foo.rs:10:abc12345:w0"),
            "src/foo.rs"
        );
        assert_eq!(
            extract_file_from_chunk_id("src/foo.rs:10:abc12345:w3"),
            "src/foo.rs"
        );
    }

    #[test]
    fn test_extract_file_nested_path() {
        assert_eq!(
            extract_file_from_chunk_id("src/cli/commands/mod.rs:42:deadbeef"),
            "src/cli/commands/mod.rs"
        );
        assert_eq!(
            extract_file_from_chunk_id("src/cli/commands/mod.rs:42:deadbeef:w1"),
            "src/cli/commands/mod.rs"
        );
    }

    #[test]
    fn test_extract_file_windowed_chunk_id_w_prefix() {
        // Windowed IDs use "wN" format (not bare digits)
        assert_eq!(
            extract_file_from_chunk_id("src/foo.rs:10:abc12345:w0"),
            "src/foo.rs"
        );
        assert_eq!(
            extract_file_from_chunk_id("src/foo.rs:10:abc12345:w12"),
            "src/foo.rs"
        );
    }

    #[test]
    fn test_extract_file_hash_not_confused_with_window() {
        // 8-char hex hash should NOT be mistaken for a window index
        assert_eq!(
            extract_file_from_chunk_id("src/foo.rs:10:deadbeef"),
            "src/foo.rs"
        );
    }

    #[test]
    fn test_extract_file_no_colons() {
        assert_eq!(extract_file_from_chunk_id("justanid"), "justanid");
    }

    // ===== BoundedScoreHeap tests =====

    #[test]
    fn test_bounded_heap_equal_scores() {
        let mut heap = BoundedScoreHeap::new(2);
        heap.push("a".to_string(), 0.5);
        heap.push("b".to_string(), 0.5);
        heap.push("c".to_string(), 0.5);
        let results = heap.into_sorted_vec();
        assert_eq!(results.len(), 2);
        // First-indexed stability: equal scores don't replace existing entries,
        // so "a" and "b" are kept, "c" is rejected.
        assert!(results.iter().any(|(id, _)| id == "a"));
        assert!(results.iter().any(|(id, _)| id == "b"));
    }

    // ===== BoundedScoreHeap additional tests =====

    #[test]
    fn test_bounded_heap_evicts_lowest() {
        let mut heap = BoundedScoreHeap::new(2);
        heap.push("low".to_string(), 0.1);
        heap.push("mid".to_string(), 0.5);
        heap.push("high".to_string(), 0.9);
        let results = heap.into_sorted_vec();
        assert_eq!(results.len(), 2);
        assert_eq!(results[0].0, "high");
        assert_eq!(results[1].0, "mid");
    }

    #[test]
    fn test_bounded_heap_ignores_non_finite() {
        let mut heap = BoundedScoreHeap::new(5);
        heap.push("nan".to_string(), f32::NAN);
        heap.push("inf".to_string(), f32::INFINITY);
        heap.push("neginf".to_string(), f32::NEG_INFINITY);
        heap.push("ok".to_string(), 0.5);
        let results = heap.into_sorted_vec();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].0, "ok");
    }

    #[test]
    fn test_bounded_heap_empty() {
        let heap = BoundedScoreHeap::new(5);
        let results = heap.into_sorted_vec();
        assert!(results.is_empty());
    }

    // ===== parent_boost tests =====

    fn make_result(
        name: &str,
        chunk_type: ChunkType,
        parent_type_name: Option<&str>,
        score: f32,
    ) -> SearchResult {
        SearchResult {
            chunk: ChunkSummary {
                id: name.to_string(),
                file: std::path::PathBuf::from("test.ts"),
                language: crate::parser::Language::TypeScript,
                chunk_type,
                name: name.to_string(),
                signature: String::new(),
                content: String::new(),
                doc: None,
                line_start: 1,
                line_end: 10,
                parent_id: None,
                parent_type_name: parent_type_name.map(|s| s.to_string()),
            },
            score,
        }
    }

    #[test]
    fn test_parent_boost_circuit_breaker() {
        // CircuitBreaker class at rank 4, its methods rank 1-3
        let mut results = vec![
            make_result(
                "recordFailure",
                ChunkType::Method,
                Some("CircuitBreaker"),
                0.88,
            ),
            make_result(
                "retryWithBackoff",
                ChunkType::Method,
                Some("CircuitBreaker"),
                0.86,
            ),
            make_result(
                "shouldAllow",
                ChunkType::Method,
                Some("CircuitBreaker"),
                0.85,
            ),
            make_result("CircuitBreaker", ChunkType::Class, None, 0.82),
        ];
        apply_parent_boost(&mut results);
        // 3 children → boost = 1.10, 0.82 * 1.10 = 0.902 > 0.88
        assert_eq!(results[0].chunk.name, "CircuitBreaker");
        assert!(results[0].score > 0.90);
    }

    #[test]
    fn test_parent_boost_no_effect_on_standalone_functions() {
        // Sort variants — standalone functions, no parent_type_name
        let mut results = vec![
            make_result("_insertionSortSmall", ChunkType::Function, None, 0.88),
            make_result("insertionSort", ChunkType::Function, None, 0.85),
            make_result("mergeSort", ChunkType::Function, None, 0.80),
        ];
        let scores_before: Vec<f32> = results.iter().map(|r| r.score).collect();
        apply_parent_boost(&mut results);
        let scores_after: Vec<f32> = results.iter().map(|r| r.score).collect();
        assert_eq!(scores_before, scores_after);
    }

    #[test]
    fn test_parent_boost_needs_minimum_two_children() {
        // Only 1 method from the class — no boost
        let mut results = vec![
            make_result(
                "recordFailure",
                ChunkType::Method,
                Some("CircuitBreaker"),
                0.88,
            ),
            make_result("CircuitBreaker", ChunkType::Class, None, 0.82),
            make_result("unrelatedFn", ChunkType::Function, None, 0.80),
        ];
        apply_parent_boost(&mut results);
        // CircuitBreaker should stay at rank 2
        assert_eq!(results[0].chunk.name, "recordFailure");
        assert_eq!(results[1].chunk.name, "CircuitBreaker");
    }

    #[test]
    fn test_parent_boost_caps_at_1_15() {
        // 5 children → should cap at 1.15, not 1.20
        let mut results = vec![
            make_result("m1", ChunkType::Method, Some("BigClass"), 0.88),
            make_result("m2", ChunkType::Method, Some("BigClass"), 0.87),
            make_result("m3", ChunkType::Method, Some("BigClass"), 0.86),
            make_result("m4", ChunkType::Method, Some("BigClass"), 0.85),
            make_result("m5", ChunkType::Method, Some("BigClass"), 0.84),
            make_result("BigClass", ChunkType::Class, None, 0.78),
        ];
        apply_parent_boost(&mut results);
        // max boost = 1.15, 0.78 * 1.15 = 0.897
        let class_score = results
            .iter()
            .find(|r| r.chunk.name == "BigClass")
            .unwrap()
            .score;
        assert!(
            (class_score - 0.897).abs() < 0.001,
            "Expected ~0.897, got {class_score}"
        );
    }

    #[test]
    fn test_parent_boost_too_few_results() {
        // Only 2 results — function returns early
        let mut results = vec![
            make_result("foo", ChunkType::Method, Some("Bar"), 0.88),
            make_result("Bar", ChunkType::Class, None, 0.82),
        ];
        let score_before = results[1].score;
        apply_parent_boost(&mut results);
        assert_eq!(results[1].score, score_before);
    }

    // ===== search_filtered integration tests (TC4) =====

    mod search_filtered_tests {
        use crate::embedder::Embedding;
        use crate::parser::{ChunkType, Language};
        use crate::store::helpers::{ModelInfo, SearchFilter};
        use crate::store::Store;
        use std::path::PathBuf;

        fn setup_store() -> (Store, tempfile::TempDir) {
            let dir = tempfile::TempDir::new().unwrap();
            let db_path = dir.path().join("index.db");
            let store = Store::open(&db_path).unwrap();
            store.init(&ModelInfo::default()).unwrap();
            (store, dir)
        }

        fn mock_embedding(seed: f32) -> Embedding {
            let mut v = vec![seed; 768];
            let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
            if norm > 0.0 {
                for x in &mut v {
                    *x /= norm;
                }
            }
            v.push(0.0);
            Embedding::new(v)
        }

        fn make_chunk(
            name: &str,
            file: &str,
            lang: Language,
            chunk_type: ChunkType,
        ) -> crate::parser::Chunk {
            let content = format!("fn {}() {{ /* body */ }}", name);
            let hash = blake3::hash(content.as_bytes()).to_hex().to_string();
            crate::parser::Chunk {
                id: format!("{}:1:{}", file, &hash[..8]),
                file: PathBuf::from(file),
                language: lang,
                chunk_type,
                name: name.to_string(),
                signature: format!("fn {}()", name),
                content,
                doc: None,
                line_start: 1,
                line_end: 5,
                content_hash: hash,
                parent_id: None,
                window_idx: None,
                parent_type_name: None,
            }
        }

        #[test]
        fn test_search_filtered_language_filter() {
            let (store, _dir) = setup_store();

            let rust_chunk =
                make_chunk("rust_fn", "src/lib.rs", Language::Rust, ChunkType::Function);
            let py_chunk = make_chunk(
                "py_fn",
                "src/main.py",
                Language::Python,
                ChunkType::Function,
            );
            let emb = mock_embedding(1.0);

            store
                .upsert_chunks_batch(
                    &[(rust_chunk, emb.clone()), (py_chunk, emb.clone())],
                    Some(12345),
                )
                .unwrap();

            let filter = SearchFilter {
                languages: Some(vec![Language::Rust]),
                ..Default::default()
            };
            let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
            assert_eq!(results.len(), 1);
            assert_eq!(results[0].chunk.language, Language::Rust);
        }

        #[test]
        fn test_search_filtered_chunk_type_filter() {
            let (store, _dir) = setup_store();

            let fn_chunk = make_chunk("my_fn", "src/a.rs", Language::Rust, ChunkType::Function);
            let struct_chunk =
                make_chunk("MyStruct", "src/b.rs", Language::Rust, ChunkType::Struct);
            let emb = mock_embedding(1.0);

            store
                .upsert_chunks_batch(
                    &[(fn_chunk, emb.clone()), (struct_chunk, emb.clone())],
                    Some(12345),
                )
                .unwrap();

            let filter = SearchFilter {
                chunk_types: Some(vec![ChunkType::Struct]),
                ..Default::default()
            };
            let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
            assert_eq!(results.len(), 1);
            assert_eq!(results[0].chunk.chunk_type, ChunkType::Struct);
        }

        #[test]
        fn test_search_filtered_path_pattern() {
            let (store, _dir) = setup_store();

            let src_chunk = make_chunk("src_fn", "src/lib.rs", Language::Rust, ChunkType::Function);
            let test_chunk = make_chunk(
                "test_fn",
                "tests/test.rs",
                Language::Rust,
                ChunkType::Function,
            );
            let emb = mock_embedding(1.0);

            store
                .upsert_chunks_batch(
                    &[(src_chunk, emb.clone()), (test_chunk, emb.clone())],
                    Some(12345),
                )
                .unwrap();

            let filter = SearchFilter {
                path_pattern: Some("src/**".to_string()),
                ..Default::default()
            };
            let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
            assert_eq!(results.len(), 1);
            assert_eq!(results[0].chunk.name, "src_fn");
        }

        #[test]
        fn test_search_filtered_combined_filters() {
            let (store, _dir) = setup_store();

            let rust_src = make_chunk("rs_src", "src/a.rs", Language::Rust, ChunkType::Function);
            let py_src = make_chunk("py_src", "src/b.py", Language::Python, ChunkType::Function);
            let rust_test =
                make_chunk("rs_test", "tests/t.rs", Language::Rust, ChunkType::Function);
            let emb = mock_embedding(1.0);

            store
                .upsert_chunks_batch(
                    &[
                        (rust_src, emb.clone()),
                        (py_src, emb.clone()),
                        (rust_test, emb.clone()),
                    ],
                    Some(12345),
                )
                .unwrap();

            let filter = SearchFilter {
                languages: Some(vec![Language::Rust]),
                path_pattern: Some("src/**".to_string()),
                ..Default::default()
            };
            let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
            assert_eq!(results.len(), 1);
            assert_eq!(results[0].chunk.name, "rs_src");
        }

        #[test]
        fn test_search_filtered_rrf_hybrid() {
            let (store, _dir) = setup_store();

            let chunk = make_chunk(
                "handleError",
                "src/err.rs",
                Language::Rust,
                ChunkType::Function,
            );
            let emb = mock_embedding(1.0);
            store
                .upsert_chunks_batch(&[(chunk, emb.clone())], Some(12345))
                .unwrap();

            let filter = SearchFilter {
                enable_rrf: true,
                query_text: "error handling".to_string(),
                ..Default::default()
            };
            let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
            assert!(!results.is_empty(), "RRF hybrid should return results");
        }

        #[test]
        fn test_search_filtered_name_boost() {
            let (store, _dir) = setup_store();

            let c1 = make_chunk(
                "parseConfig",
                "src/a.rs",
                Language::Rust,
                ChunkType::Function,
            );
            let c2 = make_chunk("renderUI", "src/b.rs", Language::Rust, ChunkType::Function);
            let emb = mock_embedding(1.0);

            store
                .upsert_chunks_batch(&[(c1, emb.clone()), (c2, emb.clone())], Some(12345))
                .unwrap();

            // With name_boost, parseConfig should rank higher for query "parse"
            let filter = SearchFilter {
                name_boost: 0.3,
                query_text: "parseConfig".to_string(),
                ..Default::default()
            };
            let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
            assert!(!results.is_empty());
            // The chunk whose name matches query text should rank first
            assert_eq!(results[0].chunk.name, "parseConfig");
        }

        #[test]
        fn test_search_filtered_empty_store() {
            let (store, _dir) = setup_store();
            let emb = mock_embedding(1.0);
            let filter = SearchFilter::default();
            let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
            assert!(results.is_empty());
        }

        /// TC-7: Verify HNSW-guided path produces RRF results when enable_rrf is true.
        ///
        /// The search_by_candidate_ids path must apply the same RRF fusion as
        /// search_filtered, combining cosine-scored candidates with FTS keyword hits.
        #[test]
        fn test_search_by_candidate_ids_rrf() {
            let (store, _dir) = setup_store();

            // Insert chunks with content that FTS can match by keyword
            let mut c_error = make_chunk(
                "handleError",
                "src/err.rs",
                Language::Rust,
                ChunkType::Function,
            );
            c_error.content =
                "fn handleError() { log_error(\"error handling failed\"); }".to_string();
            let mut c_parse = make_chunk(
                "parseConfig",
                "src/cfg.rs",
                Language::Rust,
                ChunkType::Function,
            );
            c_parse.content = "fn parseConfig() { read_toml(\"config.toml\"); }".to_string();
            let emb1 = mock_embedding(1.0);
            let emb2 = mock_embedding(0.9);

            store
                .upsert_chunks_batch(
                    &[(c_error.clone(), emb1.clone()), (c_parse.clone(), emb2)],
                    Some(12345),
                )
                .unwrap();

            // Search by candidate IDs with RRF enabled — FTS should boost "handleError"
            // for the query text "error handling"
            let candidate_ids: Vec<&str> = vec![&c_error.id, &c_parse.id];
            let filter = SearchFilter {
                enable_rrf: true,
                query_text: "error handling".to_string(),
                ..Default::default()
            };

            let results = store
                .search_by_candidate_ids(&candidate_ids, &emb1, &filter, 10, 0.0)
                .unwrap();

            assert!(
                !results.is_empty(),
                "RRF in candidate path should return results"
            );
            // "handleError" should rank first because it matches both semantically
            // and via FTS keyword "error"
            assert_eq!(
                results[0].chunk.name, "handleError",
                "FTS+RRF should boost the keyword-matching chunk"
            );

            // Compare with non-RRF path to verify RRF actually changes behavior
            let filter_no_rrf = SearchFilter {
                enable_rrf: false,
                query_text: "error handling".to_string(),
                ..Default::default()
            };
            let results_no_rrf = store
                .search_by_candidate_ids(&candidate_ids, &emb1, &filter_no_rrf, 10, 0.0)
                .unwrap();
            assert!(
                !results_no_rrf.is_empty(),
                "Non-RRF candidate path should also return results"
            );
        }

        #[test]
        fn test_search_filtered_respects_threshold() {
            let (store, _dir) = setup_store();

            let c1 = make_chunk("fn_a", "src/a.rs", Language::Rust, ChunkType::Function);
            let emb_opposite = mock_embedding(-1.0);
            store
                .upsert_chunks_batch(&[(c1, emb_opposite)], Some(12345))
                .unwrap();

            let query = mock_embedding(1.0);
            let filter = SearchFilter::default();
            let results = store.search_filtered(&query, &filter, 10, 0.99).unwrap();
            assert!(
                results.is_empty(),
                "Opposite embedding should not meet 0.99 threshold"
            );
        }

        #[test]
        fn test_search_filtered_respects_limit() {
            let (store, _dir) = setup_store();

            for i in 0..10 {
                let c = make_chunk(
                    &format!("fn_{}", i),
                    &format!("src/{}.rs", i),
                    Language::Rust,
                    ChunkType::Function,
                );
                let emb = mock_embedding(1.0 + i as f32 * 0.001);
                store.upsert_chunks_batch(&[(c, emb)], Some(12345)).unwrap();
            }

            let query = mock_embedding(1.0);
            let filter = SearchFilter::default();
            let results = store.search_filtered(&query, &filter, 3, 0.0).unwrap();
            assert_eq!(results.len(), 3);
        }
    }

    // ===== chunk_importance tests =====

    #[test]
    fn test_chunk_importance_normal() {
        assert_eq!(chunk_importance("parse_config", "src/lib.rs"), 1.0);
    }

    #[test]
    fn test_chunk_importance_test_prefix() {
        assert_eq!(chunk_importance("test_parse_config", "src/lib.rs"), 0.70);
    }

    #[test]
    fn test_chunk_importance_test_upper() {
        // Go convention: TestFoo
        assert_eq!(
            chunk_importance("TestParseConfig", "src/lib.go"),
            IMPORTANCE_TEST
        );
    }

    #[test]
    fn test_chunk_importance_underscore() {
        assert_eq!(
            chunk_importance("_helper", "src/lib.rs"),
            IMPORTANCE_PRIVATE
        );
    }

    #[test]
    fn test_chunk_importance_dunder_not_demoted() {
        // Python dunders like __init__ should NOT be demoted
        assert_eq!(chunk_importance("__init__", "src/lib.py"), 1.0);
    }

    #[test]
    fn test_chunk_importance_test_file() {
        // File named foo_test.rs → demotion via filename
        assert_eq!(
            chunk_importance("helper_fn", "src/foo_test.rs"),
            IMPORTANCE_TEST
        );
    }

    #[test]
    fn test_chunk_importance_test_dir_demoted() {
        // Files in tests/ directory are test infrastructure → demoted
        assert_eq!(
            chunk_importance("real_fn", "tests/fixtures/eval.rs"),
            IMPORTANCE_TEST
        );
    }

    #[test]
    fn test_chunk_importance_test_name_beats_path() {
        // test_ name triggers demotion even in normal directory
        assert_eq!(chunk_importance("test_foo", "src/lib.rs"), IMPORTANCE_TEST);
    }

    // ===== is_name_like_query tests =====

    #[test]
    fn test_name_like_single_token() {
        assert!(is_name_like_query("parseConfig"));
        assert!(is_name_like_query("CircuitBreaker"));
        assert!(is_name_like_query("handle_error"));
    }

    #[test]
    fn test_name_like_two_tokens() {
        assert!(is_name_like_query("parse config"));
        assert!(is_name_like_query("error handler"));
    }

    #[test]
    fn test_nl_query_with_indicators() {
        assert!(!is_name_like_query("function that handles errors"));
        assert!(!is_name_like_query("how does parsing work"));
        assert!(!is_name_like_query("find error handling code"));
        assert!(!is_name_like_query("code that implements retry logic"));
    }

    #[test]
    fn test_nl_query_all_lowercase_3_plus_words() {
        assert!(!is_name_like_query("error handling retry"));
    }

    #[test]
    fn test_name_like_snake_case_multi() {
        // snake_case with 3+ words is still name-like
        assert!(is_name_like_query("handle_error_retry"));
    }

    // ===== build_filter_sql tests =====

    #[test]
    fn test_build_filter_sql_default() {
        let filter = SearchFilter::default();
        let fsql = build_filter_sql(&filter);
        assert!(fsql.conditions.is_empty());
        assert!(fsql.bind_values.is_empty());
        // Default has enable_demotion=true, which requires name column
        assert_eq!(fsql.columns, "rowid, id, embedding, name");
        assert!(!fsql.use_hybrid);
        assert!(!fsql.use_rrf);
    }

    #[test]
    fn test_build_filter_sql_no_name_column() {
        // Explicitly disable demotion + no hybrid → no name column needed
        let filter = SearchFilter {
            enable_demotion: false,
            ..Default::default()
        };
        let fsql = build_filter_sql(&filter);
        assert_eq!(fsql.columns, "rowid, id, embedding");
    }

    #[test]
    fn test_build_filter_sql_language_filter() {
        use crate::parser::Language;
        let filter = SearchFilter {
            languages: Some(vec![Language::Rust, Language::Python]),
            ..Default::default()
        };
        let fsql = build_filter_sql(&filter);
        assert_eq!(fsql.conditions.len(), 1);
        assert!(fsql.conditions[0].starts_with("language IN"));
        assert_eq!(fsql.bind_values.len(), 2);
        assert_eq!(fsql.bind_values[0], "rust");
        assert_eq!(fsql.bind_values[1], "python");
    }

    #[test]
    fn test_build_filter_sql_chunk_type_filter() {
        use crate::parser::ChunkType;
        let filter = SearchFilter {
            chunk_types: Some(vec![ChunkType::Function]),
            ..Default::default()
        };
        let fsql = build_filter_sql(&filter);
        assert_eq!(fsql.conditions.len(), 1);
        assert!(fsql.conditions[0].starts_with("chunk_type IN"));
        assert_eq!(fsql.bind_values.len(), 1);
    }

    #[test]
    fn test_build_filter_sql_combined_filters() {
        use crate::parser::{ChunkType, Language};
        let filter = SearchFilter {
            languages: Some(vec![Language::Rust]),
            chunk_types: Some(vec![ChunkType::Function, ChunkType::Method]),
            ..Default::default()
        };
        let fsql = build_filter_sql(&filter);
        assert_eq!(fsql.conditions.len(), 2);
        // 1 language + 2 chunk types = 3 bind values
        assert_eq!(fsql.bind_values.len(), 3);
        // Verify contiguous bind param indices: language gets ?1, chunk_types get ?2,?3
        assert!(fsql.conditions[0].contains("?1"));
        assert!(fsql.conditions[1].contains("?2"));
        assert!(fsql.conditions[1].contains("?3"));
    }

    #[test]
    fn test_build_filter_sql_hybrid_flags() {
        let filter = SearchFilter {
            name_boost: 0.3,
            query_text: "parse".to_string(),
            enable_rrf: true,
            ..Default::default()
        };
        let fsql = build_filter_sql(&filter);
        assert!(fsql.use_hybrid);
        assert!(fsql.use_rrf);
        // name needed for hybrid scoring
        assert!(fsql.columns.contains("name"));
    }

    #[test]
    fn test_build_filter_sql_demotion_includes_name() {
        let filter = SearchFilter {
            enable_demotion: true,
            ..Default::default()
        };
        let fsql = build_filter_sql(&filter);
        assert!(fsql.columns.contains("name"));
    }

    #[test]
    fn test_build_filter_sql_rrf_needs_query_text() {
        // RRF enabled but empty query text → use_rrf should be false
        let filter = SearchFilter {
            enable_rrf: true,
            query_text: String::new(),
            ..Default::default()
        };
        let fsql = build_filter_sql(&filter);
        assert!(!fsql.use_rrf);
    }

    // ===== score_candidate tests =====

    /// Build a normalized 769-dim test vector (768 base + 1 sentiment) for score_candidate tests.
    fn test_embedding(seed: f32) -> Vec<f32> {
        let mut v = vec![seed; 768];
        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
        if norm > 0.0 {
            for x in &mut v {
                *x /= norm;
            }
        }
        v.push(0.0); // sentiment dimension
        v
    }

    #[test]
    fn test_score_candidate_basic() {
        let emb = test_embedding(1.0);
        let query = test_embedding(1.0);
        let filter = SearchFilter::default();
        let note_index = NoteBoostIndex::new(&[]);

        let score = score_candidate(
            &emb,
            &query,
            None,
            "src/lib.rs",
            &filter,
            None,
            None,
            &note_index,
            0.0,
        );
        assert!(score.is_some());
        assert!(
            score.unwrap() > 0.9,
            "Self-similarity should be ~1.0, got {}",
            score.unwrap()
        );
    }

    #[test]
    fn test_score_candidate_below_threshold() {
        // Near-orthogonal vectors → low cosine similarity
        let emb = test_embedding(1.0);
        let query = test_embedding(-1.0);
        let filter = SearchFilter::default();
        let note_index = NoteBoostIndex::new(&[]);

        let score = score_candidate(
            &emb,
            &query,
            None,
            "src/lib.rs",
            &filter,
            None,
            None,
            &note_index,
            0.5,
        );
        assert!(
            score.is_none(),
            "Opposite vectors should be below 0.5 threshold"
        );
    }

    #[test]
    fn test_score_candidate_glob_filters() {
        let emb = test_embedding(1.0);
        let query = test_embedding(1.0);
        let filter = SearchFilter::default();
        let note_index = NoteBoostIndex::new(&[]);
        let glob = globset::Glob::new("src/**/*.rs").unwrap().compile_matcher();

        // Matching path
        let score = score_candidate(
            &emb,
            &query,
            None,
            "src/lib.rs",
            &filter,
            None,
            Some(&glob),
            &note_index,
            0.0,
        );
        assert!(score.is_some());

        // Non-matching path
        let score = score_candidate(
            &emb,
            &query,
            None,
            "tests/foo.py",
            &filter,
            None,
            Some(&glob),
            &note_index,
            0.0,
        );
        assert!(score.is_none());
    }

    #[test]
    fn test_score_candidate_name_boost() {
        let emb = test_embedding(1.0);
        let query = test_embedding(1.0);
        let filter_no_boost = SearchFilter::default();
        let filter_with_boost = SearchFilter {
            name_boost: 0.3,
            query_text: "parseConfig".to_string(),
            ..Default::default()
        };
        let note_index = NoteBoostIndex::new(&[]);
        let matcher = NameMatcher::new("parseConfig");

        let score_no = score_candidate(
            &emb,
            &query,
            Some("parseConfig"),
            "src/a.rs",
            &filter_no_boost,
            None,
            None,
            &note_index,
            0.0,
        )
        .unwrap();
        let score_yes = score_candidate(
            &emb,
            &query,
            Some("parseConfig"),
            "src/a.rs",
            &filter_with_boost,
            Some(&matcher),
            None,
            &note_index,
            0.0,
        )
        .unwrap();

        assert!(score_yes > 0.0);
        assert!(score_no > 0.0);
    }

    #[test]
    fn test_score_candidate_demotion() {
        let emb = test_embedding(1.0);
        let query = test_embedding(1.0);
        let note_index = NoteBoostIndex::new(&[]);

        let filter_no_demote = SearchFilter {
            enable_demotion: false,
            ..Default::default()
        };
        let filter_demote = SearchFilter {
            enable_demotion: true,
            ..Default::default()
        };

        let score_normal = score_candidate(
            &emb,
            &query,
            Some("real_fn"),
            "src/lib.rs",
            &filter_demote,
            None,
            None,
            &note_index,
            0.0,
        )
        .unwrap();
        let score_test = score_candidate(
            &emb,
            &query,
            Some("test_foo"),
            "src/lib.rs",
            &filter_demote,
            None,
            None,
            &note_index,
            0.0,
        )
        .unwrap();
        let score_no_demote = score_candidate(
            &emb,
            &query,
            Some("test_foo"),
            "src/lib.rs",
            &filter_no_demote,
            None,
            None,
            &note_index,
            0.0,
        )
        .unwrap();

        // With demotion, test_ function should score lower than normal
        assert!(score_test < score_normal, "test_ should be demoted");
        // Without demotion flag, test_ function scores the same as normal
        assert!(
            (score_no_demote - score_normal).abs() < 0.001,
            "No demotion without flag"
        );
    }

    #[test]
    fn test_score_candidate_note_boost() {
        let emb = test_embedding(1.0);
        let query = test_embedding(1.0);
        let filter = SearchFilter::default();

        let notes = vec![make_note(1.0, &["lib.rs"])];
        let note_index_boosted = NoteBoostIndex::new(&notes);
        let note_index_empty = NoteBoostIndex::new(&[]);

        let score_boosted = score_candidate(
            &emb,
            &query,
            Some("my_fn"),
            "src/lib.rs",
            &filter,
            None,
            None,
            &note_index_boosted,
            0.0,
        )
        .unwrap();
        let score_plain = score_candidate(
            &emb,
            &query,
            Some("my_fn"),
            "src/lib.rs",
            &filter,
            None,
            None,
            &note_index_empty,
            0.0,
        )
        .unwrap();

        assert!(
            score_boosted > score_plain,
            "Positive note should boost score"
        );
    }

    // ===== NoteBoostIndex tests (TC-2) =====

    #[test]
    fn test_note_boost_index_empty_notes() {
        let notes: Vec<NoteSummary> = vec![];
        let index = NoteBoostIndex::new(&notes);
        assert_eq!(index.boost("src/lib.rs", "my_fn"), 1.0);
    }

    #[test]
    fn test_note_boost_index_name_mention_positive() {
        let notes = vec![NoteSummary {
            id: "1".into(),
            text: "good pattern".into(),
            sentiment: 0.5,
            mentions: vec!["my_fn".into()],
        }];
        let index = NoteBoostIndex::new(&notes);
        let boost = index.boost("src/lib.rs", "my_fn");
        assert!(
            boost > 1.0,
            "Positive sentiment should boost > 1.0, got {boost}"
        );
        assert!((boost - (1.0 + 0.5 * NOTE_BOOST_FACTOR)).abs() < 1e-6);
    }

    #[test]
    fn test_note_boost_index_name_mention_negative() {
        let notes = vec![NoteSummary {
            id: "1".into(),
            text: "buggy code".into(),
            sentiment: -1.0,
            mentions: vec!["broken_fn".into()],
        }];
        let index = NoteBoostIndex::new(&notes);
        let boost = index.boost("src/lib.rs", "broken_fn");
        assert!(
            boost < 1.0,
            "Negative sentiment should reduce score, got {boost}"
        );
        assert!((boost - (1.0 - 1.0 * NOTE_BOOST_FACTOR)).abs() < 1e-6);
    }

    #[test]
    fn test_note_boost_index_path_mention() {
        let notes = vec![NoteSummary {
            id: "1".into(),
            text: "important file".into(),
            sentiment: 0.5,
            mentions: vec!["src/search.rs".into()],
        }];
        let index = NoteBoostIndex::new(&notes);

        // Path mention should match file containing the path
        let boost = index.boost("src/search.rs", "unrelated_fn");
        assert!(
            boost > 1.0,
            "Path mention should boost matching file, got {boost}"
        );

        // Non-matching path should not be boosted
        let no_boost = index.boost("src/lib.rs", "unrelated_fn");
        assert_eq!(no_boost, 1.0, "Non-matching path should not be boosted");
    }

    #[test]
    fn test_note_boost_index_strongest_absolute_wins() {
        let notes = vec![
            NoteSummary {
                id: "1".into(),
                text: "mildly good".into(),
                sentiment: 0.5,
                mentions: vec!["my_fn".into()],
            },
            NoteSummary {
                id: "2".into(),
                text: "very bad".into(),
                sentiment: -1.0,
                mentions: vec!["my_fn".into()],
            },
        ];
        let index = NoteBoostIndex::new(&notes);
        let boost = index.boost("src/lib.rs", "my_fn");
        // -1.0 has stronger absolute value than 0.5, so it should win
        assert!(
            boost < 1.0,
            "Stronger negative should win over weaker positive, got {boost}"
        );
        assert!((boost - (1.0 - 1.0 * NOTE_BOOST_FACTOR)).abs() < 1e-6);
    }

    #[test]
    fn test_note_boost_index_name_vs_path_classification() {
        // "search.rs" contains '.' so it's path-like
        // "my_fn" has no separators so it's name-like
        let notes = vec![NoteSummary {
            id: "1".into(),
            text: "note".into(),
            sentiment: 0.5,
            mentions: vec!["my_fn".into(), "search.rs".into()],
        }];
        let index = NoteBoostIndex::new(&notes);

        // Name-like mention should only match chunk name, not file path
        assert!(index.name_sentiments.contains_key("my_fn"));
        assert!(!index.name_sentiments.contains_key("search.rs"));
        assert_eq!(index.path_mentions.len(), 1);
    }

    #[test]
    fn test_note_boost_index_no_match() {
        let notes = vec![NoteSummary {
            id: "1".into(),
            text: "specific note".into(),
            sentiment: 1.0,
            mentions: vec!["other_fn".into()],
        }];
        let index = NoteBoostIndex::new(&notes);
        assert_eq!(index.boost("src/lib.rs", "my_fn"), 1.0);
    }

    // ===== language/chunk_type filter set tests (TC-3) =====

    #[test]
    fn test_lang_filter_set_membership() {
        use crate::language::Language;
        let langs = vec![Language::Rust, Language::Python];
        let lang_set: HashSet<String> =
            langs.iter().map(|l| l.to_string().to_lowercase()).collect();
        assert!(lang_set.contains("rust"));
        assert!(lang_set.contains("python"));
        assert!(!lang_set.contains("typescript"));
        assert!(!lang_set.contains("go"));
    }

    #[test]
    fn test_chunk_type_filter_set_membership() {
        use crate::language::ChunkType;
        let types = vec![ChunkType::Function, ChunkType::Method];
        let type_set: HashSet<String> =
            types.iter().map(|t| t.to_string().to_lowercase()).collect();
        assert!(type_set.contains("function"));
        assert!(type_set.contains("method"));
        assert!(!type_set.contains("struct"));
        assert!(!type_set.contains("class"));
    }

    #[test]
    fn test_lang_filter_case_insensitive() {
        use crate::language::Language;
        let langs = vec![Language::Rust];
        let lang_set: HashSet<String> =
            langs.iter().map(|l| l.to_string().to_lowercase()).collect();
        // CandidateRow.language stored as lowercase — filter matching must be case-insensitive
        assert!(lang_set.contains(&"rust".to_lowercase()));
        assert!(lang_set.contains(&"Rust".to_lowercase()));
        assert!(!lang_set.contains(&"Python".to_lowercase()));
    }

    #[test]
    fn test_lang_filter_none_passes_all() {
        // When filter.languages is None, lang_set is None and all candidates pass
        let lang_set: Option<HashSet<String>> = None;
        let candidate_lang = "rust";
        let passes = lang_set
            .as_ref()
            .map_or(true, |s| s.contains(&candidate_lang.to_lowercase()));
        assert!(passes);
    }

    #[test]
    fn test_type_filter_none_passes_all() {
        // When filter.chunk_types is None, type_set is None and all candidates pass
        let type_set: Option<HashSet<String>> = None;
        let candidate_type = "struct";
        let passes = type_set
            .as_ref()
            .map_or(true, |s| s.contains(&candidate_type.to_lowercase()));
        assert!(passes);
    }

    #[test]
    fn test_lang_filter_empty_rejects_all() {
        // Empty language list means nothing passes
        let lang_set: Option<HashSet<String>> = Some(HashSet::new());
        let passes = lang_set
            .as_ref()
            .map_or(true, |s| s.contains(&"rust".to_lowercase()));
        assert!(!passes);
    }
}
