//! 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::cache::reindex::load_cached_index;
use ripvec_core::chunk::CodeChunk;
use ripvec_core::hybrid::{HybridIndex, SearchMode, boost_with_pagerank, pagerank_lookup};
use ripvec_core::repo_map::RepoGraph;

/// A cached index loaded from disk for a non-default project root.
struct CachedRoot {
    root: PathBuf,
    index: HybridIndex,
}

/// Walk up from a file path to find a directory with a cached ripvec index.
///
/// Checks each ancestor directory for a disk cache (built by MCP or CLI).
/// Returns the index and root if found. This enables cross-process index
/// sharing: the MCP builds the cache, the LSP reads it.
fn find_cached_root_for_file(file_path: &Path, model_repo: &str) -> Option<CachedRoot> {
    let mut candidate = file_path.parent()?;
    loop {
        // Check if this directory has a .git (likely project root)
        if (candidate.join(".git").exists() || candidate.join(".ripvec").exists())
            && let Some(index) = load_cached_index(candidate, model_repo)
        {
            return Some(CachedRoot {
                root: candidate.to_path_buf(),
                index,
            });
        }
        candidate = candidate.parent()?;
    }
}

/// 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
    let mut byte_offset = 0;
    for (i, line_str) in source.lines().enumerate() {
        if i == line as usize {
            byte_offset += (character as usize).min(line_str.len());
            break;
        }
        byte_offset += line_str.len() + 1; // +1 for newline
    }

    // 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,
    })
}

/// Extract the identifier (word) at the given 0-based line and character.
///
/// Text-based fallback when tree-sitter parsing isn't available.
/// Expands outward from the cursor to include all contiguous
/// identifier characters (`[a-zA-Z0-9_]`).
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 char (e.g. at EOL).
    if col >= target_line.len() {
        return None;
    }

    let bytes = target_line.as_bytes();
    if !bytes[col].is_ascii_alphanumeric() && bytes[col] != b'_' {
        return None;
    }

    // Expand left.
    let start = (0..=col)
        .rev()
        .take_while(|&i| bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_')
        .last()?;

    // Expand right.
    let end = (col..bytes.len())
        .take_while(|&i| bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_')
        .last()
        .map_or(col, |i| i + 1);

    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> {
    // Try tree-sitter first for precise symbol extraction
    if let Some(sym) = symbol_at_position(source, line, character, ext) {
        return Some(sym.name);
    }
    // Fall back to text-based word extraction
    word_at_position(source, line, character)
}

/// 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.
fn chunk_at_position(
    hybrid: &HybridIndex,
    file_path: &Path,
    root: &Path,
    line_1based: usize,
) -> Option<usize> {
    let abs_str = file_path.to_string_lossy();
    let rel_path = file_path
        .strip_prefix(root)
        .unwrap_or(file_path)
        .to_string_lossy();

    hybrid.chunks().iter().enumerate().find_map(|(i, c)| {
        let matches = c.file_path == *abs_str || c.file_path == *rel_path;
        if matches && 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(
    hybrid: &HybridIndex,
    word: &str,
    path: &Path,
    search_root: &Path,
    line_1based: usize,
    repo_graph: Option<&RepoGraph>,
) -> Option<GotoDefinitionResponse> {
    let query_embedding = chunk_at_position(hybrid, path, search_root, line_1based)
        .and_then(|idx| hybrid.semantic.embedding(idx))
        .unwrap_or_default();

    let mode = if query_embedding.is_empty() {
        SearchMode::Keyword
    } else {
        SearchMode::Hybrid
    };
    let mut results = hybrid.search(&query_embedding, word, 20, 0.0, mode);

    if let Some(graph) = repo_graph {
        let pr = pagerank_lookup(graph);
        boost_with_pagerank(&mut results, hybrid.chunks(), &pr, 0.3);
    }

    let chunks = hybrid.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,
    index: &Arc<tokio::sync::RwLock<Option<HybridIndex>>>,
    root_indices: &Arc<tokio::sync::RwLock<HashMap<PathBuf, HybridIndex>>>,
    repo_graph: &Arc<std::sync::RwLock<Option<RepoGraph>>>,
    root_graphs: &Arc<std::sync::RwLock<HashMap<PathBuf, RepoGraph>>>,
    root: &Path,
) -> 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;

    // Try the default index first.
    let result = {
        let guard = index.read().await;
        if let Some(hybrid) = guard.as_ref() {
            let graph_ref = repo_graph.read().ok();
            let graph = graph_ref.as_ref().and_then(|g| g.as_ref());
            search_definition(hybrid, &word, &path, root, line_1based, graph)
        } else {
            None
        }
    };

    if result.is_some() {
        return Ok(result);
    }

    // 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, hybrid)) = ri_guard.iter().find(|(r, _)| path.starts_with(r)) {
        let rg_guard = root_graphs.read().ok();
        let graph = rg_guard.as_ref().and_then(|g| g.get(alt_root));
        return Ok(search_definition(
            hybrid,
            &word,
            &path,
            alt_root,
            line_1based,
            graph,
        ));
    }
    drop(ri_guard);

    // Fall back to disk cache (cross-process: index built by MCP in another process).
    // Walk up from the file path to find a cached index root.
    let model_repo = "nomic-ai/modernbert-embed-base";
    if let Some(cached) = find_cached_root_for_file(&path, model_repo) {
        let result =
            search_definition(&cached.index, &word, &path, &cached.root, line_1based, None);
        if result.is_some() {
            return Ok(result);
        }
    }

    Ok(None)
}

/// Search a single index for reference locations matching `word`.
fn search_references(
    hybrid: &HybridIndex,
    word: &str,
    path: &Path,
    search_root: &Path,
    line_1based: usize,
    repo_graph: Option<&RepoGraph>,
) -> Vec<Location> {
    let query_embedding = chunk_at_position(hybrid, path, search_root, line_1based)
        .and_then(|idx| hybrid.semantic.embedding(idx))
        .unwrap_or_default();

    let mode = if query_embedding.is_empty() {
        SearchMode::Keyword
    } else {
        SearchMode::Hybrid
    };
    let mut results = hybrid.search(&query_embedding, word, 50, 0.0, mode);

    if let Some(graph) = repo_graph {
        let pr = pagerank_lookup(graph);
        boost_with_pagerank(&mut results, hybrid.chunks(), &pr, 0.3);
    }

    let chunks = hybrid.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()
}

/// 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.
pub async fn find_references(
    params: ReferenceParams,
    index: &Arc<tokio::sync::RwLock<Option<HybridIndex>>>,
    root_indices: &Arc<tokio::sync::RwLock<HashMap<PathBuf, HybridIndex>>>,
    repo_graph: &Arc<std::sync::RwLock<Option<RepoGraph>>>,
    root_graphs: &Arc<std::sync::RwLock<HashMap<PathBuf, RepoGraph>>>,
    root: &Path,
) -> 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("");
    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;

    // Try the default index first.
    let locations = {
        let guard = index.read().await;
        if let Some(hybrid) = guard.as_ref() {
            let graph_ref = repo_graph.read().ok();
            let graph = graph_ref.as_ref().and_then(|g| g.as_ref());
            search_references(hybrid, &word, &path, root, line_1based, graph)
        } else {
            Vec::new()
        }
    };

    if !locations.is_empty() {
        return Ok(Some(locations));
    }

    // Fall back to on-demand root indices.
    let ri_guard = root_indices.read().await;
    if let Some((alt_root, hybrid)) = ri_guard.iter().find(|(r, _)| path.starts_with(r)) {
        let rg_guard = root_graphs.read().ok();
        let graph = rg_guard.as_ref().and_then(|g| g.get(alt_root));
        let locations = search_references(hybrid, &word, &path, alt_root, line_1based, graph);
        if !locations.is_empty() {
            return Ok(Some(locations));
        }
    }
    drop(ri_guard);

    // Fall back to disk cache (cross-process).
    let model_repo = "nomic-ai/modernbert-embed-base";
    if let Some(cached) = find_cached_root_for_file(&path, model_repo) {
        let locations =
            search_references(&cached.index, &word, &path, &cached.root, line_1based, None);
        if !locations.is_empty() {
            return Ok(Some(locations));
        }
    }

    Ok(None)
}

#[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);
    }
}
