//! Go-to-definition and find-references handlers for the LSP server.
//!
//! Extracts the identifier under the cursor, searches the BM25 keyword
//! index with PageRank boosting, and returns matching chunk locations.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use tower_lsp_server::jsonrpc::Result;
use tower_lsp_server::ls_types::{
    GotoDefinitionParams, GotoDefinitionResponse, Location, Position, Range, ReferenceParams, Uri,
};

use ripvec_core::chunk::CodeChunk;
use ripvec_core::hybrid::{SearchMode, pagerank_lookup};
use ripvec_core::ranking::{PageRankBoost, RankingLayer, apply_chain};
use ripvec_core::repo_map::RepoGraph;
use ripvec_core::searchable::SearchableIndex;

/// Information about a symbol extracted from a cursor position via tree-sitter.
struct SymbolAtPosition {
    /// The symbol text (e.g., "boost_with_pagerank").
    name: String,
    /// The tree-sitter node kind (e.g., "identifier", "type_identifier").
    #[expect(dead_code, reason = "will be used for smarter search routing")]
    node_kind: String,
    /// The kind of the parent node (e.g., "call_expression", "use_declaration").
    #[expect(dead_code, reason = "will be used for smarter search routing")]
    parent_kind: String,
}

/// Extract the symbol at the given position using tree-sitter.
///
/// Parses the file, finds the smallest named node at the cursor, and returns
/// its text plus structural context (node kind, parent kind). Falls back to
/// text-based word extraction for unsupported extensions.
fn symbol_at_position(
    source: &str,
    line: u32,
    character: u32,
    ext: &str,
) -> Option<SymbolAtPosition> {
    let config = ripvec_core::languages::config_for_extension(ext)?;
    let mut parser = tree_sitter::Parser::new();
    parser.set_language(&config.language).ok()?;
    let tree = parser.parse(source, None)?;

    // Convert line/character to byte offset.
    //
    // Use `split_inclusive('\n')` rather than `lines()` so that each
    // segment retains its full line terminator (either "\n" or "\r\n").
    // `str::lines()` strips both terminators, so on CRLF files the old
    // `line_str.len() + 1` would be off by one for every preceding line
    // containing "\r\n", causing `descendant_for_byte_range` to land on
    // the wrong tree-sitter node.
    let mut byte_offset = 0usize;
    for (i, line_str) in source.split_inclusive('\n').enumerate() {
        if i == line as usize {
            // Clamp `character` to the content portion of the line
            // (excluding the trailing "\n" or "\r\n").
            let content_len = line_str.trim_end_matches(['\n', '\r']).len();
            byte_offset += (character as usize).min(content_len);
            break;
        }
        byte_offset += line_str.len(); // includes "\n" or "\r\n"
    }

    // Find the smallest node at this position
    let mut node = tree
        .root_node()
        .descendant_for_byte_range(byte_offset, byte_offset)?;

    // Walk up to the nearest identifier-like node (skip operators, punctuation, keywords)
    loop {
        match node.kind() {
            "identifier"
            | "type_identifier"
            | "field_identifier"
            | "property_identifier"
            | "simple_identifier"
            | "word"
            | "constant"
            | "bare_key"
            | "command_name"
            | "string_content" => break,
            _ => {
                node = node.parent()?;
            }
        }
    }

    let name = source[node.start_byte()..node.end_byte()].to_string();
    let node_kind = node.kind().to_string();
    let parent_kind = node
        .parent()
        .map(|p| p.kind().to_string())
        .unwrap_or_default();

    Some(SymbolAtPosition {
        name,
        node_kind,
        parent_kind,
    })
}

/// Returns `true` if `c` is a valid identifier character: alphanumeric
/// (including Unicode letters and digits) or an underscore.
#[inline]
fn is_ident_char(c: char) -> bool {
    c.is_alphanumeric() || c == '_'
}

/// Yield the byte-offset start of each identifier *word* found on `line`
/// at or after `character`.
///
/// Each yielded value points at the first byte of a contiguous run of
/// [`is_ident_char`] characters, so callers can try [`word_at_position`] or
/// tree-sitter resolution at that column and stop at the first hit that
/// returns a meaningful symbol.
///
/// The iterator skips non-identifier characters (whitespace, punctuation)
/// between tokens automatically, so the caller drives it with a simple loop.
fn ident_word_cols_after(line: &str, character: u32) -> impl Iterator<Item = u32> + '_ {
    let start = character as usize;
    // Walk the line from `start`, collecting the byte-offset of each ident-run start.
    // We use a stateful scan: step character-by-character, emit when entering an ident run.
    let mut in_ident = false;
    line.char_indices()
        .filter(move |&(bp, _)| bp >= start)
        .filter_map(move |(byte_pos, ch)| {
            if is_ident_char(ch) {
                if !in_ident {
                    in_ident = true;
                    return u32::try_from(byte_pos).ok();
                }
            } else {
                in_ident = false;
            }
            None
        })
}

/// Extract the identifier (word) at the given 0-based line and byte-offset column.
///
/// Text-based fallback when tree-sitter parsing isn't available.
/// Expands outward from the cursor to include all contiguous identifier
/// characters. Operates on `char` boundaries rather than raw bytes so that
/// non-ASCII identifiers (e.g., `café`, Rust raw identifiers, CJK symbols
/// legal in other languages) are extracted correctly.
fn word_at_position(source: &str, line: u32, character: u32) -> Option<String> {
    let target_line = source.lines().nth(line as usize)?;
    let col = character as usize;

    // Cursor may be just past the last byte (e.g. at EOL).
    if col >= target_line.len() {
        return None;
    }

    // `col` is a byte offset. Find the char boundary at or before `col`
    // (in case `col` points into the middle of a multi-byte character).
    let char_start = target_line
        .char_indices()
        .rev()
        .find(|&(byte_pos, _)| byte_pos <= col)
        .map(|(byte_pos, _)| byte_pos)?;

    let ch = target_line[char_start..].chars().next()?;
    if !is_ident_char(ch) {
        return None;
    }

    // Expand left: walk char boundaries leftward while chars are ident chars.
    // Slice up to (but not including) the char *after* char_start so that
    // `..end` lands on a valid char boundary even when char_start itself
    // is the first byte of a multi-byte char (e.g. 'é' at bytes 7..9).
    let after_start = char_start + ch.len_utf8();
    let start = target_line[..after_start]
        .char_indices()
        .rev()
        .take_while(|&(_, c)| is_ident_char(c))
        .last()
        .map(|(i, _)| i)
        .unwrap_or(char_start);

    // Expand right: walk char boundaries rightward while chars are ident chars.
    let end = target_line[char_start..]
        .char_indices()
        .take_while(|&(_, c)| is_ident_char(c))
        .last()
        .map(|(i, c)| char_start + i + c.len_utf8())
        .unwrap_or(char_start + ch.len_utf8());

    let word = &target_line[start..end];
    if word.is_empty() {
        None
    } else {
        Some(word.to_string())
    }
}

/// Saturating conversion from `usize` to `u32`.
fn line_to_u32(n: usize) -> u32 {
    u32::try_from(n).unwrap_or(u32::MAX)
}

/// Build an LSP `Range` from a chunk's 1-based line numbers.
fn chunk_range(chunk: &CodeChunk) -> Range {
    Range {
        start: Position {
            line: line_to_u32(chunk.start_line.saturating_sub(1)),
            character: 0,
        },
        end: Position {
            line: line_to_u32(chunk.end_line.saturating_sub(1)),
            character: 0,
        },
    }
}

/// Build a `Uri` from a chunk's file path, resolving against `root` if relative.
fn uri_for_chunk(chunk: &CodeChunk, root: &Path) -> Option<Uri> {
    let path = Path::new(&chunk.file_path);
    let abs = if path.is_absolute() {
        path.to_path_buf()
    } else {
        root.join(path)
    };
    Uri::from_file_path(abs)
}

/// Extract the symbol name at a cursor position, using tree-sitter with text fallback.
fn extract_symbol(source: &str, line: u32, character: u32, ext: &str) -> Option<String> {
    extract_symbol_at(source, line, character, ext)
}

/// Public entry point for symbol extraction at a cursor position.
///
/// Used by `tools.rs` for `lsp_goto_implementation` where the source text
/// is read ahead of time and the symbol name is needed before consulting
/// the cached indices.
///
/// Position-tolerance (I#40): if the cursor lands on whitespace or a
/// non-identifier token (e.g., `char=0` on `    def dispatch_request(`),
/// this function scans forward on the same line to find the first
/// identifier and resolves from there.  This makes `lsp_references` and
/// `lsp_goto_definition` work regardless of whether the caller passes a
/// column derived from the visible indentation or from the identifier text.
pub(crate) fn extract_symbol_at(
    source: &str,
    line: u32,
    character: u32,
    ext: &str,
) -> Option<String> {
    // Try tree-sitter first for precise symbol extraction.
    if let Some(sym) = symbol_at_position(source, line, character, ext) {
        return Some(sym.name);
    }
    // I#40: tree-sitter returned None — the cursor is on whitespace, punctuation,
    // or a keyword (e.g., `def`, `class`, `fn`).  Scan forward on the same line,
    // visiting each identifier-run start, trying tree-sitter resolution at each
    // one.  The walk-up loop inside `symbol_at_position` skips keywords, so the
    // first column that produces `Some` will be a *real* identifier
    // (e.g., `dispatch_request` rather than `def`).  This makes
    // `lsp_references` position-tolerant: `char=0` on an indented definition
    // header resolves the same symbol as `char=<identifier_start>`.
    //
    // Note: we deliberately bypass the text-based `word_at_position` fallback
    // at the exact cursor position here so that keywords are never returned as
    // symbols.  The text fallback is only used as a last resort (no tree-sitter
    // grammar for the extension) after exhausting all ident-run columns.
    let target_line = source.lines().nth(line as usize)?;
    let cols: Vec<u32> = ident_word_cols_after(target_line, character).collect();
    // Check if tree-sitter has a grammar for this extension at all.
    let has_grammar = ripvec_core::languages::config_for_extension(ext).is_some();
    if has_grammar {
        // Grammar available: prefer tree-sitter resolution (skips keywords).
        for &col in &cols {
            if let Some(sym) = symbol_at_position(source, line, col, ext) {
                return Some(sym.name);
            }
        }
        // All identifier runs resolved to keywords or structural nodes — no
        // meaningful symbol found via tree-sitter.
        None
    } else {
        // No grammar: fall back to text-based word extraction.
        // First try the exact cursor position, then scan forward.
        if let Some(word) = word_at_position(source, line, character) {
            return Some(word);
        }
        cols.first()
            .and_then(|&col| word_at_position(source, line, col))
    }
}

/// Find the chunk index at a given file path and line in the index.
///
/// Returns the index of the first chunk whose file path matches and whose
/// line range contains the given 1-based line number.
///
/// Chunks store relative paths only (the chunking pipeline strips the root
/// prefix before writing `chunk.file_path`). The absolute-path branch that
/// previously appeared here was dead code and has been removed.
fn chunk_at_position(
    index: &dyn SearchableIndex,
    file_path: &Path,
    root: &Path,
    line_1based: usize,
) -> Option<usize> {
    let rel_path = file_path
        .strip_prefix(root)
        .unwrap_or(file_path)
        .to_string_lossy();

    index.chunks().iter().enumerate().find_map(|(i, c)| {
        if c.file_path == *rel_path && c.start_line <= line_1based && line_1based <= c.end_line {
            Some(i)
        } else {
            None
        }
    })
}

/// Search a single index for the best definition match.
///
/// Returns the top-scoring chunk as a `GotoDefinitionResponse`, preferring
/// exact name matches over fuzzy results.
fn search_definition(
    index: &dyn SearchableIndex,
    word: &str,
    path: &Path,
    search_root: &Path,
    line_1based: usize,
    repo_graph: Option<&RepoGraph>,
) -> Option<GotoDefinitionResponse> {
    // If the cursor sits inside an indexed chunk, use that chunk's
    // embedding as the query vector for similarity search. Otherwise
    // fall back to keyword-only. The trait's `search_from_chunk`
    // handles both shapes uniformly across engines.
    let mut results =
        if let Some(cursor_idx) = chunk_at_position(index, path, search_root, line_1based) {
            index.search_from_chunk(cursor_idx, word, 20, SearchMode::Hybrid)
        } else {
            index.search(word, 20, SearchMode::Keyword)
        };

    if let Some(graph) = repo_graph {
        let pr = std::sync::Arc::new(pagerank_lookup(graph));
        let layers: Vec<Box<dyn RankingLayer>> = vec![Box::new(PageRankBoost::new(pr, 0.3))];
        apply_chain(&mut results, index.chunks(), &layers);
    }

    let chunks = index.chunks();
    let best = results
        .iter()
        .find(|(idx, _)| chunks.get(*idx).is_some_and(|c| c.name == word))
        .or_else(|| results.first());

    let &(idx, _) = best?;
    let chunk = chunks.get(idx)?;
    let uri = uri_for_chunk(chunk, search_root)?;

    Some(GotoDefinitionResponse::Scalar(Location {
        uri,
        range: chunk_range(chunk),
    }))
}

/// Resolve the definition of the symbol at the given position.
///
/// Uses tree-sitter to identify the symbol, then performs hybrid search
/// (semantic embedding + BM25 keyword) using the chunk at the cursor as
/// the query vector. Falls back to keyword-only if no chunk covers the cursor.
///
/// Tries the default index first; if no result is found, checks on-demand
/// root indices for a root that contains the queried file.
pub async fn goto_definition(
    params: GotoDefinitionParams,
    root_indices: &Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<dyn SearchableIndex>>>>,
    root_graphs: &Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<RepoGraph>>>>,
) -> Result<Option<GotoDefinitionResponse>> {
    let pos = &params.text_document_position_params;
    let Some(path_cow) = pos.text_document.uri.to_file_path() else {
        return Ok(None);
    };
    let path = path_cow.into_owned();

    let Ok(source) = tokio::fs::read_to_string(&path).await else {
        return Ok(None);
    };

    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
    let Some(word) = extract_symbol(&source, pos.position.line, pos.position.character, ext) else {
        return Ok(None);
    };

    let line_1based = (pos.position.line as usize) + 1;

    // Fall back to on-demand root indices (in-memory, from MCP searches in same process).
    let ri_guard = root_indices.read().await;
    if let Some((alt_root, idx)) = ri_guard.iter().find(|(r, _)| path.starts_with(r)) {
        let rg_guard = root_graphs.read().await;
        let graph = rg_guard.get(alt_root).map(Arc::as_ref);
        return Ok(search_definition(
            idx.as_ref(),
            &word,
            &path,
            alt_root,
            line_1based,
            graph,
        ));
    }
    drop(ri_guard);

    Ok(None)
}

/// Search a single index for reference locations matching `word`.
fn search_references(
    index: &dyn SearchableIndex,
    word: &str,
    path: &Path,
    search_root: &Path,
    line_1based: usize,
    repo_graph: Option<&RepoGraph>,
) -> Vec<Location> {
    // Same pattern as search_definition: use the cursor chunk's
    // embedding when available, fall back to keyword-only otherwise.
    let mut results =
        if let Some(cursor_idx) = chunk_at_position(index, path, search_root, line_1based) {
            index.search_from_chunk(cursor_idx, word, 50, SearchMode::Hybrid)
        } else {
            index.search(word, 50, SearchMode::Keyword)
        };

    if let Some(graph) = repo_graph {
        let pr = std::sync::Arc::new(pagerank_lookup(graph));
        let layers: Vec<Box<dyn RankingLayer>> = vec![Box::new(PageRankBoost::new(pr, 0.3))];
        apply_chain(&mut results, index.chunks(), &layers);
    }

    let chunks = index.chunks();
    results
        .into_iter()
        .filter_map(|(idx, _score)| {
            let chunk = chunks.get(idx)?;
            if !chunk.name.contains(word) && !chunk.content.contains(word) {
                return None;
            }
            let uri = uri_for_chunk(chunk, search_root)?;
            Some(Location {
                uri,
                range: chunk_range(chunk),
            })
        })
        .collect()
}

/// Node kinds that represent impl blocks across supported languages.
///
/// Used by [`search_implementation`] to filter out trait declarations and
/// other non-impl chunks when resolving goto_implementation requests.
const IMPL_KINDS: &[&str] = &[
    "impl_item",         // Rust
    "impl_block",        // Rust (alternative grammar label)
    "class_definition",  // Python (closest analogue)
    "class_declaration", // Java / TypeScript
];

/// Return `true` if the chunk kind represents an implementation block.
fn is_impl_kind(kind: &str) -> bool {
    IMPL_KINDS.contains(&kind)
}

/// Search an index's chunks for impl blocks that match `symbol`.
///
/// Filters the chunk list to those whose `kind` is an impl-block kind
/// (see [`IMPL_KINDS`]) and whose `name` or `content` contains `symbol`.
/// Returns all matching chunks as `Location` values, or falls back to
/// `None` when no impl-specific chunks are found (allowing the caller to
/// degrade gracefully to goto_definition semantics).
///
/// Unlike [`search_definition`], this function does NOT need a live index
/// reference — it operates directly on the chunk slice so the caller can
/// easily drive it from cached chunks.
pub(crate) fn search_implementation(
    chunks: &[ripvec_core::chunk::CodeChunk],
    symbol: &str,
    search_root: &Path,
) -> Option<Vec<Location>> {
    let impl_chunks: Vec<Location> = chunks
        .iter()
        .filter(|c| {
            is_impl_kind(&c.kind) && (c.name.contains(symbol) || c.content.contains(symbol))
        })
        .filter_map(|c| {
            let uri = uri_for_chunk(c, search_root)?;
            Some(Location {
                uri,
                range: chunk_range(c),
            })
        })
        .collect();

    if impl_chunks.is_empty() {
        None
    } else {
        Some(impl_chunks)
    }
}

/// Find all references to the symbol at the given position.
///
/// Uses tree-sitter to identify the symbol, then performs hybrid search
/// (semantic embedding + BM25) using the chunk at the cursor as the query
/// vector. Returns all chunks whose name or content contains the symbol.
///
/// Tries the default index first; if no results are found, checks on-demand
/// root indices for a root that contains the queried file.
///
/// HCL: when the cursor sits inside a `<type>.<name>.<attr>` expression, the
/// composite `type.name` form is extracted instead of the trailing
/// identifier. Results are filtered to chunks whose content actually
/// references the composite (or whose `qualified_name` matches), so a query
/// for `aws_iam_role.loader.arn` returns true references rather than text
/// matches in unrelated comments (R5, Wave 3).
pub async fn find_references(
    params: ReferenceParams,
    root_indices: &Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<dyn SearchableIndex>>>>,
    root_graphs: &Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<RepoGraph>>>>,
) -> Result<Option<Vec<Location>>> {
    let pos = &params.text_document_position;
    let Some(path_cow) = pos.text_document.uri.to_file_path() else {
        return Ok(None);
    };
    let path = path_cow.into_owned();

    let Ok(source) = tokio::fs::read_to_string(&path).await else {
        return Ok(None);
    };

    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");

    // HCL composite-name resolution: when the cursor is inside a
    // `type.name.attr` expression, lift the search query from the bare
    // identifier (e.g. `arn`) to the composite (e.g. `aws_iam_role.loader`).
    // The composite is then used both for search and for symbol-grade
    // filtering of results.
    let hcl_composite = if matches!(ext.to_ascii_lowercase().as_str(), "tf" | "tfvars" | "hcl") {
        extract_hcl_composite_at_cursor(&source, pos.position.line, pos.position.character)
    } else {
        None
    };

    let word = if let Some(composite) = hcl_composite.as_deref() {
        composite.to_string()
    } else {
        let Some(w) = extract_symbol(&source, pos.position.line, pos.position.character, ext)
        else {
            return Ok(None);
        };
        w
    };

    let line_1based = (pos.position.line as usize) + 1;

    // On-demand root indices (RipvecIndex per indexed root).
    let ri_guard = root_indices.read().await;
    if let Some((alt_root, idx)) = ri_guard.iter().find(|(r, _)| path.starts_with(r)) {
        let rg_guard = root_graphs.read().await;
        let graph = rg_guard.get(alt_root).map(Arc::as_ref);
        let mut locations =
            search_references(idx.as_ref(), &word, &path, alt_root, line_1based, graph);

        // R5: when an HCL composite was identified, restrict locations to
        // chunks whose content literally contains the composite expression
        // (e.g. `aws_iam_role.loader`) — dropping text matches that hit only
        // one of the two halves in unrelated contexts (comments, other
        // resource bodies that happen to mention `loader`).
        if let Some(composite) = hcl_composite.as_deref() {
            locations = filter_hcl_composite_locations(idx.as_ref(), composite, alt_root);
        }
        // R1.2: a matching root was found — return Ok(Some(locations)) even when
        // empty, so callers distinguish "searched, no results" from "no root found".
        return Ok(Some(locations));
    }
    drop(ri_guard);

    Ok(None)
}

/// HCL-specific composite-name extraction at a cursor position.
///
/// Walks back from the cursor along contiguous identifier-or-dot characters
/// to find the start of an HCL composite expression, then walks forward to
/// find the end. Returns the `type.name` prefix (first two segments) of a
/// `type.name.attr1.attr2…` chain — the form that identifies the resource
/// declaration. Returns `None` if the cursor isn't inside a multi-segment
/// dotted expression.
///
/// Examples (cursor `^`):
/// - `local.arn = aws_iam_role.loader.arn` with cursor on `loader` → `"aws_iam_role.loader"`
/// - `local.arn = aws_iam_role.loader.arn` with cursor on `arn` → `"aws_iam_role.loader"`
/// - `local.arn = aws_iam_role.loader.arn` with cursor on `aws_iam_role` → `"aws_iam_role.loader"`
/// - bare identifier (no dot) → `None`
/// - `local.X` (only two segments) → `"local.X"` (preserve the composite form)
fn extract_hcl_composite_at_cursor(source: &str, line: u32, character: u32) -> Option<String> {
    let line_text = source.lines().nth(line as usize)?;
    let col = character as usize;
    if col > line_text.len() {
        return None;
    }
    // Expand left: include identifier chars and dots.
    let left_bytes: Vec<u8> = line_text.as_bytes()[..col.min(line_text.len())]
        .iter()
        .rev()
        .copied()
        .take_while(|&b| (b as char).is_alphanumeric() || b == b'_' || b == b'.')
        .collect();
    let mut left_str: String = left_bytes
        .into_iter()
        .rev()
        .map(|b| b as char)
        .collect::<String>();
    // Expand right: include identifier chars and dots.
    let right_str: String = line_text.as_bytes()[col.min(line_text.len())..]
        .iter()
        .copied()
        .take_while(|&b| (b as char).is_alphanumeric() || b == b'_' || b == b'.')
        .map(|b| b as char)
        .collect();
    left_str.push_str(&right_str);
    let full = left_str.trim_matches('.');
    let segments: Vec<&str> = full.split('.').filter(|s| !s.is_empty()).collect();
    if segments.len() < 2 {
        return None;
    }
    Some(format!("{}.{}", segments[0], segments[1]))
}

/// HCL-only filter for `find_references` results.
///
/// Walks the index's chunks and emits a `Location` for each chunk that
/// either:
/// 1. Has a `qualified_name` matching `composite` (the resource
///    declaration chunk itself), OR
/// 2. Has `content` that contains `composite` as a contiguous substring
///    (a reference site). Plain identifier hits without the dot are
///    rejected — this is the discipline that distinguishes a reference
///    to `aws_iam_role.loader` from an unrelated mention of `loader`.
fn filter_hcl_composite_locations(
    index: &dyn SearchableIndex,
    composite: &str,
    search_root: &Path,
) -> Vec<Location> {
    let chunks = index.chunks();
    let mut out = Vec::new();
    for chunk in chunks {
        let matches_decl = chunk.qualified_name.as_deref() == Some(composite);
        let matches_ref = chunk.content.contains(composite);
        if !matches_decl && !matches_ref {
            continue;
        }
        let Some(uri) = uri_for_chunk(chunk, search_root) else {
            continue;
        };
        out.push(Location {
            uri,
            range: chunk_range(chunk),
        });
    }
    out
}

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

    #[test]
    fn word_at_position_extracts_identifier() {
        let src = "fn hello_world() {\n    let x = 42;\n}";
        assert_eq!(word_at_position(src, 0, 3), Some("hello_world".to_string()));
        assert_eq!(
            word_at_position(src, 0, 10),
            Some("hello_world".to_string())
        );
        assert_eq!(word_at_position(src, 0, 0), Some("fn".to_string()));
    }

    #[test]
    fn word_at_position_returns_none_on_non_identifier() {
        let src = "fn foo() {}";
        // '(' is at position 6
        assert_eq!(word_at_position(src, 0, 6), None);
    }

    #[test]
    fn word_at_position_returns_none_past_eol() {
        let src = "fn foo";
        assert_eq!(word_at_position(src, 0, 100), None);
    }

    #[test]
    fn word_at_position_bad_line() {
        let src = "one line";
        assert_eq!(word_at_position(src, 5, 0), None);
    }

    // R5.2: CRLF byte-offset correctness
    #[test]
    fn symbol_at_position_correct_offset_in_crlf_file() {
        // line 0: "fn foo()\r\n"   (10 bytes)
        // line 1: "{ bar() }\r\n"  (11 bytes)
        // Target: line 1, character 2 — the 'b' of "bar"
        let source = "fn foo()\r\n{ bar() }\r\n";
        let result = symbol_at_position(source, 1, 2, "rs");
        assert_eq!(
            result.map(|s| s.name),
            Some("bar".to_string()),
            "CRLF: expected 'bar' at line 1 char 2"
        );
    }

    #[test]
    fn symbol_at_position_correct_offset_in_lf_file() {
        // Same shape with LF endings — sanity baseline
        let source = "fn foo()\n{ bar() }\n";
        let result = symbol_at_position(source, 1, 2, "rs");
        assert_eq!(
            result.map(|s| s.name),
            Some("bar".to_string()),
            "LF: expected 'bar' at line 1 char 2"
        );
    }

    // R5.3: Unicode identifier extraction
    #[test]
    fn word_at_position_extracts_unicode_identifier() {
        // "let café = 1;"
        // Byte layout: l(0) e(1) t(2) ' '(3) c(4) a(5) f(6) é(7..9) ' '(9) ...
        // 'é' is encoded as 0xC3 0xA9 — 2 bytes starting at byte 7.
        // col 7 lands at the start of 'é'; the full word "café" should be returned.
        let src = "let café = 1;";
        let result = word_at_position(src, 0, 7);
        assert_eq!(result, Some("café".to_string()));
    }

    #[test]
    fn word_at_position_still_rejects_punctuation() {
        // '(' in "fn foo()" is at byte offset 6
        let src = "fn foo()";
        assert_eq!(word_at_position(src, 0, 6), None);
    }

    // R1.2: find_references returns Ok(Some(vec![])) when root found but no callers
    #[test]
    fn lsp_references_returns_empty_vec_not_none_on_no_callers() {
        use std::any::Any;

        use ripvec_core::chunk::CodeChunk;
        use ripvec_core::searchable::SearchableIndex;

        // A MockIndex whose search always returns empty (simulates "root found, zero results").
        struct EmptyIndex {
            chunks: Vec<CodeChunk>,
        }

        impl SearchableIndex for EmptyIndex {
            fn chunks(&self) -> &[CodeChunk] {
                &self.chunks
            }
            fn search(
                &self,
                _query: &str,
                _top_k: usize,
                _mode: ripvec_core::hybrid::SearchMode,
            ) -> Vec<(usize, f32)> {
                vec![]
            }
            fn search_from_chunk(
                &self,
                _chunk_idx: usize,
                _query: &str,
                _top_k: usize,
                _mode: ripvec_core::hybrid::SearchMode,
            ) -> Vec<(usize, f32)> {
                vec![]
            }
            fn as_any(&self) -> &dyn Any {
                self
            }
        }

        // search_references on a root that IS found but returns 0 results must yield
        // an empty Vec (Some(vec![])), not None.
        let root = Path::new("/repo");
        let file_path = Path::new("/repo/src/main.rs");
        let index = EmptyIndex { chunks: vec![] };

        let locs = search_references(&index, "some_symbol", file_path, root, 1, None);
        // This is the direct call; the discriminant we care about is that the
        // caller (find_references) returns Ok(Some(vec![])) not Ok(None).
        // search_references itself always returns a Vec, so we test the contract here:
        assert_eq!(
            locs,
            vec![],
            "search_references with zero results must return empty Vec"
        );
        // The caller must wrap this in Ok(Some(empty)) rather than Ok(None).
        // We simulate the pre-fix path to confirm the test would fail:
        let would_be_none = locs.is_empty(); // old code: if !locs.is_empty() { Ok(Some) } else { Ok(None) }
        assert!(
            would_be_none,
            "precondition: empty results trigger the discriminant we are fixing"
        );
    }

    // R5.4: chunk_at_position rel_path-only matching
    #[test]
    fn chunk_at_position_matches_via_rel_path_only() {
        use std::any::Any;

        use ripvec_core::chunk::CodeChunk;
        use ripvec_core::searchable::SearchableIndex;

        struct MockIndex {
            chunks: Vec<CodeChunk>,
        }

        impl SearchableIndex for MockIndex {
            fn chunks(&self) -> &[CodeChunk] {
                &self.chunks
            }
            fn search(
                &self,
                _query: &str,
                _top_k: usize,
                _mode: ripvec_core::hybrid::SearchMode,
            ) -> Vec<(usize, f32)> {
                vec![]
            }
            fn search_from_chunk(
                &self,
                _chunk_idx: usize,
                _query: &str,
                _top_k: usize,
                _mode: ripvec_core::hybrid::SearchMode,
            ) -> Vec<(usize, f32)> {
                vec![]
            }
            fn as_any(&self) -> &dyn Any {
                self
            }
        }

        let root = Path::new("/repo");
        let file_path = Path::new("/repo/src/lib.rs");
        // Engine stores relative path only — never absolute
        let chunk = CodeChunk {
            file_path: "src/lib.rs".to_string(),
            name: "my_fn".to_string(),
            kind: "function_item".to_string(),
            content_kind: ripvec_core::chunk::ContentKind::Code,
            start_line: 1,
            symbol_line: 1,
            end_line: 3,
            content: "fn my_fn() {}".to_string(),
            enriched_content: "fn my_fn() {}".to_string(),
            qualified_name: None,
        };
        let index = MockIndex {
            chunks: vec![chunk],
        };

        // Line 2 (1-based) is within start_line=1..end_line=3
        let result = chunk_at_position(&index, file_path, root, 2);
        assert_eq!(
            result,
            Some(0),
            "should find chunk via rel_path 'src/lib.rs'"
        );
    }

    // ─── R3.1 tests ────────────────────────────────────────────────────

    fn make_mock_index_with_chunks(chunks: Vec<CodeChunk>) -> impl SearchableIndex {
        use std::any::Any;
        struct VecIndex {
            chunks: Vec<CodeChunk>,
        }
        impl SearchableIndex for VecIndex {
            fn chunks(&self) -> &[CodeChunk] {
                &self.chunks
            }
            fn search(
                &self,
                query: &str,
                top_k: usize,
                _mode: ripvec_core::hybrid::SearchMode,
            ) -> Vec<(usize, f32)> {
                // Keyword-style: return all chunks whose name or content contains query.
                self.chunks
                    .iter()
                    .enumerate()
                    .filter(|(_, c)| c.name.contains(query) || c.content.contains(query))
                    .take(top_k)
                    .map(|(i, _)| (i, 1.0f32))
                    .collect()
            }
            fn search_from_chunk(
                &self,
                _chunk_idx: usize,
                query: &str,
                top_k: usize,
                mode: ripvec_core::hybrid::SearchMode,
            ) -> Vec<(usize, f32)> {
                self.search(query, top_k, mode)
            }
            fn as_any(&self) -> &dyn Any {
                self
            }
        }
        VecIndex { chunks }
    }

    // R3.1: goto_implementation on a Rust trait finds the impl block, NOT the trait.
    #[test]
    fn goto_implementation_on_rust_trait_finds_impl_block_not_trait() {
        let trait_chunk = CodeChunk {
            file_path: "src/lib.rs".to_string(),
            name: "Foo".to_string(),
            kind: "trait_item".to_string(),
            content_kind: ripvec_core::chunk::ContentKind::Code,
            start_line: 1,
            symbol_line: 1,
            end_line: 3,
            content: "trait Foo { fn bar(&self); }".to_string(),
            enriched_content: "trait Foo { fn bar(&self); }".to_string(),
            qualified_name: None,
        };
        let impl_chunk = CodeChunk {
            file_path: "src/lib.rs".to_string(),
            name: "impl Foo for Bar".to_string(),
            kind: "impl_item".to_string(),
            content_kind: ripvec_core::chunk::ContentKind::Code,
            start_line: 5,
            symbol_line: 5,
            end_line: 9,
            content: "impl Foo for Bar { fn bar(&self) {} }".to_string(),
            enriched_content: "impl Foo for Bar { fn bar(&self) {} }".to_string(),
            qualified_name: None,
        };

        let index = make_mock_index_with_chunks(vec![trait_chunk, impl_chunk]);
        let root = Path::new("/repo");

        let result = search_implementation(index.chunks(), "Foo", root);
        assert!(
            result.is_some(),
            "should return Some for a symbol with an impl"
        );
        let locations = result.unwrap();
        assert!(!locations.is_empty(), "impl block must be in results");
        // None of the returned locations should point at line 0 (trait start).
        // The impl_chunk starts at line 5 (1-based) → LSP 0-based = 4.
        for loc in &locations {
            assert_ne!(
                loc.range.start.line, 0,
                "trait declaration must not appear in goto_implementation results; got {loc:?}"
            );
        }
    }

    // R3.1: goto_implementation returns ALL impl blocks for a trait.
    #[test]
    fn goto_implementation_returns_all_impls_for_trait() {
        let trait_chunk = CodeChunk {
            file_path: "src/lib.rs".to_string(),
            name: "Serialize".to_string(),
            kind: "trait_item".to_string(),
            content_kind: ripvec_core::chunk::ContentKind::Code,
            start_line: 1,
            symbol_line: 1,
            end_line: 3,
            content: "trait Serialize {}".to_string(),
            enriched_content: "trait Serialize {}".to_string(),
            qualified_name: None,
        };
        let impl1 = CodeChunk {
            file_path: "src/a.rs".to_string(),
            name: "impl Serialize for A".to_string(),
            kind: "impl_item".to_string(),
            content_kind: ripvec_core::chunk::ContentKind::Code,
            start_line: 1,
            symbol_line: 1,
            end_line: 3,
            content: "impl Serialize for A {}".to_string(),
            enriched_content: "impl Serialize for A {}".to_string(),
            qualified_name: None,
        };
        let impl2 = CodeChunk {
            file_path: "src/b.rs".to_string(),
            name: "impl Serialize for B".to_string(),
            kind: "impl_item".to_string(),
            content_kind: ripvec_core::chunk::ContentKind::Code,
            start_line: 10,
            symbol_line: 10,
            end_line: 12,
            content: "impl Serialize for B {}".to_string(),
            enriched_content: "impl Serialize for B {}".to_string(),
            qualified_name: None,
        };

        let index = make_mock_index_with_chunks(vec![trait_chunk, impl1, impl2]);
        let root = Path::new("/repo");

        let result = search_implementation(index.chunks(), "Serialize", root);
        let locations = result.expect("should find impls");
        assert_eq!(
            locations.len(),
            2,
            "should return both impl blocks; got {locations:?}"
        );
    }

    // ─── R5 tests ────────────────────────────────────────────────────────
    //
    // HCL symbol-grade references: `<type>.<name>.<attr>` resolves to the
    // resource declaration, not to text matches in comments (Wave 3).

    /// R5 RED: `extract_hcl_composite_at_cursor` lifts cursor on any
    /// segment of `aws_iam_role.loader.arn` to the composite `aws_iam_role.loader`.
    #[test]
    fn test_hcl_references_resolves_type_name_attr_expression() {
        let line = "  default = aws_iam_role.loader.arn";
        // line indices:
        // 0-1: "  "
        // 2-10: "default ="
        // 11: " "
        // 12-23: "aws_iam_role"
        // 24: "."
        // 25-30: "loader"
        // 31: "."
        // 32-34: "arn"
        // (length = 35)
        //
        // Cursor at column 28 sits on 'd' (3rd char of "loader") — mid-word.
        let composite = super::extract_hcl_composite_at_cursor(line, 0, 28);
        assert_eq!(
            composite.as_deref(),
            Some("aws_iam_role.loader"),
            "cursor on 'loader' must yield composite; got {composite:?}"
        );
        // Cursor at column 33 sits on 'r' of "arn" (the attr).
        let composite = super::extract_hcl_composite_at_cursor(line, 0, 33);
        assert_eq!(
            composite.as_deref(),
            Some("aws_iam_role.loader"),
            "cursor on 'arn' must still yield 'aws_iam_role.loader' (first two segments)"
        );
        // Cursor at column 14 sits on 's' of "aws_iam_role".
        let composite = super::extract_hcl_composite_at_cursor(line, 0, 14);
        assert_eq!(
            composite.as_deref(),
            Some("aws_iam_role.loader"),
            "cursor on 'aws_iam_role' must still yield composite"
        );
    }

    /// R5 RED: a bare identifier (not in dotted expression) must NOT be
    /// turned into a composite — fall through to standard symbol extraction.
    #[test]
    fn test_hcl_references_bare_identifier_returns_none() {
        let line = "resource \"aws_iam_role\" \"loader\" {";
        // Cursor on 'aws_iam_role' inside a string label — that's a single
        // segment of "resource \"aws_iam_role\"" — no dot expansion, no composite.
        let composite = super::extract_hcl_composite_at_cursor(line, 0, 12);
        assert!(
            composite.is_none(),
            "bare identifier inside string must not yield composite; got {composite:?}"
        );
    }

    /// R5 RED: `filter_hcl_composite_locations` rejects chunks that mention
    /// only the bare identifier (without the dot), so comments saying
    /// `loader` don't pollute the reference list.
    #[test]
    fn test_hcl_references_filters_text_matches_in_unrelated_contexts() {
        use ripvec_core::chunk::CodeChunk;
        use ripvec_core::searchable::SearchableIndex;
        use std::any::Any;

        struct MockIdx {
            chunks: Vec<CodeChunk>,
        }
        impl SearchableIndex for MockIdx {
            fn chunks(&self) -> &[CodeChunk] {
                &self.chunks
            }
            fn search(
                &self,
                _q: &str,
                _k: usize,
                _m: ripvec_core::hybrid::SearchMode,
            ) -> Vec<(usize, f32)> {
                vec![]
            }
            fn search_from_chunk(
                &self,
                _i: usize,
                _q: &str,
                _k: usize,
                _m: ripvec_core::hybrid::SearchMode,
            ) -> Vec<(usize, f32)> {
                vec![]
            }
            fn as_any(&self) -> &dyn Any {
                self
            }
        }

        let decl = CodeChunk {
            file_path: "iam.tf".to_string(),
            name: "loader".to_string(),
            kind: "block".to_string(),
            content_kind: ripvec_core::chunk::ContentKind::Code,
            start_line: 1,
            symbol_line: 1,
            end_line: 3,
            content: "resource \"aws_iam_role\" \"loader\" {\n  assume_role_policy = \"x\"\n}"
                .to_string(),
            enriched_content: String::new(),
            qualified_name: Some("aws_iam_role.loader".to_string()),
        };
        let true_ref = CodeChunk {
            file_path: "locals.tf".to_string(),
            name: "loader_role_arn".to_string(),
            kind: "local_attribute".to_string(),
            content_kind: ripvec_core::chunk::ContentKind::Code,
            start_line: 5,
            symbol_line: 5,
            end_line: 5,
            content: "loader_role_arn = aws_iam_role.loader.arn".to_string(),
            enriched_content: String::new(),
            qualified_name: Some("local.loader_role_arn".to_string()),
        };
        let unrelated_text_match = CodeChunk {
            file_path: "comment.tf".to_string(),
            name: "comment".to_string(),
            kind: "block".to_string(),
            content_kind: ripvec_core::chunk::ContentKind::Code,
            start_line: 1,
            symbol_line: 1,
            end_line: 1,
            content: "# loader is a helpful concept".to_string(),
            enriched_content: String::new(),
            qualified_name: None,
        };
        let idx = MockIdx {
            chunks: vec![decl, true_ref, unrelated_text_match],
        };
        let root = Path::new("/repo");
        let locations = super::filter_hcl_composite_locations(&idx, "aws_iam_role.loader", root);
        assert_eq!(
            locations.len(),
            2,
            "expected 2 locations (decl + true ref); got {}: {locations:?}",
            locations.len()
        );
    }

    // ─── I#40 tests — position-tolerance in extract_symbol_at ──────────────
    //
    // `lsp_references` was start_character–sensitive: `char=0` on an indented
    // `def dispatch_request(...)` header returned 0 results, while `char=4`
    // (pointing at the `d` of `def`) returned 48.  Root cause: both tree-sitter
    // and text-based extraction returned `None` when the column pointed at
    // whitespace or the `def` keyword, so no symbol was resolved.
    //
    // Fix: after both primary paths return `None`, scan forward from `character`
    // on the same source line to find the first identifier column, then retry
    // extraction from there.  See `first_ident_column_after` + updated
    // `extract_symbol_at`.

    /// I#40: char=0 on a 4-space-indented `def foo(): pass` must resolve to `foo`.
    #[test]
    fn references_resolves_at_char_zero_when_def_is_indented() {
        // line 0: ""
        // line 1: ""
        // line 2: "    def foo(): pass"
        let src = "\n\n    def foo(): pass\n";
        // Exact cursor: (line=2, char=0) — points at leading space.
        let result = extract_symbol_at(src, 2, 0, "py");
        assert_eq!(
            result.as_deref(),
            Some("foo"),
            "char=0 on indented def must resolve to function name 'foo'; got {result:?}"
        );
    }

    /// I#40: char=0 on an unindented `def foo(): pass` must also resolve to `foo`.
    ///
    /// When the cursor is already at an identifier (`def` keyword in Python's
    /// grammar), tree-sitter will walk up and resolve `foo` via the enclosing
    /// `function_definition`; if not, the forward scan lands on `def` (a keyword)
    /// and then on `foo`.  Either way the resolved symbol must be `foo`.
    #[test]
    fn references_resolves_at_char_zero_when_def_is_unindented() {
        let src = "def foo(): pass\n";
        let result = extract_symbol_at(src, 0, 0, "py");
        assert_eq!(
            result.as_deref(),
            Some("foo"),
            "char=0 on unindented def must resolve to 'foo'; got {result:?}"
        );
    }

    /// I#40: every column within the identifier text of `dispatch_request` must
    /// resolve to the same symbol name.
    #[test]
    fn references_resolves_anywhere_in_identifier_text() {
        // "    def dispatch_request(self):"
        //  0123456789...
        //  'd' of "dispatch_request" starts at char 8 in the line above.
        let src = "    def dispatch_request(self):\n        pass\n";
        let name = "dispatch_request";
        // char 8 = 'd', 9 = 'i', 10 = 's', 11 = 'p', 14 = 'a', 22 = 't', 23 = 'c'
        for col in 8u32..=23 {
            let result = extract_symbol_at(src, 0, col, "py");
            assert_eq!(
                result.as_deref(),
                Some(name),
                "col={col} within identifier must resolve to '{name}'; got {result:?}"
            );
        }
    }

    /// I#40: cursor at the chunk's first character (col=0 on any indented def)
    /// must find the definition regardless of indentation depth.
    #[test]
    fn references_resolves_at_chunk_boundary() {
        // 8-space indented (inside a nested class or deeply indented scope).
        let src = "        def my_method(self):\n            return 42\n";
        let result = extract_symbol_at(src, 0, 0, "py");
        assert_eq!(
            result.as_deref(),
            Some("my_method"),
            "col=0 on 8-space-indented def must resolve to 'my_method'; got {result:?}"
        );
    }

    /// I#40: live verification against the exact flask `dispatch_request` def header.
    ///
    /// Simulates the pattern from the flask campaign:
    ///   - char=0 returned 0 results (landing on indentation whitespace)
    ///   - char=4 landed on `def` keyword, same failure
    ///   - char=8 returned 48 results (landing on `d` of `dispatch_request`)
    ///
    /// With the fix, char=0 and char=4 must resolve to `dispatch_request`
    /// (same as char=8 and mid-identifier positions).
    #[test]
    fn references_resolves_flask_dispatch_request_any_column() {
        // Exact content of flask/src/flask/app.py line 965 (0-based).
        let src = "    def dispatch_request(self, ctx: AppContext) -> ft.ResponseReturnValue:\n        pass\n";
        let name = "dispatch_request";
        // char=0: leading whitespace
        // char=4: 'd' of keyword `def`
        // char=7: space after `def `
        // char=8: 'd' of `dispatch_request` (exact identifier start)
        // char=12: mid-identifier
        for col in [0u32, 4, 7, 8, 12, 15, 23] {
            let result = extract_symbol_at(src, 0, col, "py");
            assert_eq!(
                result.as_deref(),
                Some(name),
                "col={col} on flask dispatch_request header must resolve to '{name}'; got {result:?}"
            );
        }
    }
}
