//! Call hierarchy provider for the LSP server.
//!
//! Backed by the definition-level call graph in [`RepoGraph`]. The `prepare`
//! handler finds the definition at the cursor position; `incoming` and
//! `outgoing` traverse `def_callers` / `def_callees` respectively.

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

use serde_json::json;
use tower_lsp_server::jsonrpc::Result;
use tower_lsp_server::ls_types::{
    CallHierarchyIncomingCall, CallHierarchyIncomingCallsParams, CallHierarchyItem,
    CallHierarchyOutgoingCall, CallHierarchyOutgoingCallsParams, CallHierarchyPrepareParams,
    Position, Range, Uri,
};

use ripvec_core::repo_map::{Definition, RepoGraph};

use super::symbols::symbol_kind_for;

/// Saturating conversion from `u32` to LSP 0-based line number.
///
/// Definition line numbers are 1-based; LSP positions are 0-based.
fn def_line_to_lsp(one_based: u32) -> u32 {
    one_based.saturating_sub(1)
}

/// Build an LSP `Range` from a [`Definition`]'s 1-based line numbers.
fn def_range(def: &Definition) -> Range {
    Range {
        start: Position {
            line: def_line_to_lsp(def.start_line),
            character: 0,
        },
        end: Position {
            line: def_line_to_lsp(def.end_line),
            character: 0,
        },
    }
}

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

/// Return `true` if the definition kind is callable and valid for call hierarchy.
///
/// Non-callable kinds (`variable_declarator`, `const_item`, `type_item`, etc.)
/// are excluded so they don't produce hierarchy items whose incoming/outgoing
/// sets are always empty.
fn is_callable_kind(kind: &str) -> bool {
    matches!(
        kind,
        // ── Functions & methods ──────────────────────────────────────
        "function_item"
            | "function_definition"
            | "function_declaration"
            | "method_definition"
            | "method_declaration"
            | "method"
            | "constructor_declaration"
            // ── Impl blocks, structs, enums ──────────────────────────
            | "impl_item"
            | "struct_item"
            | "struct_specifier"
            | "struct_declaration"
            | "enum_item"
            | "enum_declaration"
            | "enum_specifier"
            // ── Traits & interfaces ──────────────────────────────────
            | "trait_item"
            | "trait_definition"
            | "interface_declaration"
            | "protocol_declaration"
            // ── Classes & objects ────────────────────────────────────
            | "class_definition"
            | "class_declaration"
            | "class_specifier"
            | "class"
            | "object_declaration"
            | "object_definition"
    )
}

/// Build a [`CallHierarchyItem`] from a graph definition.
///
/// Stores `file_idx` and `def_idx` in the `data` field so that
/// `incoming` / `outgoing` can recover them without re-searching.
fn def_to_item(
    graph: &RepoGraph,
    file_idx: usize,
    def_idx: usize,
    root: &Path,
) -> Option<CallHierarchyItem> {
    let file = graph.files.get(file_idx)?;
    let def = file.defs.get(def_idx)?;
    let uri = uri_for_file(&file.path, root)?;

    let range = def_range(def);
    let selection_range = Range {
        start: range.start,
        end: Position {
            line: range.start.line,
            character: 0,
        },
    };

    Some(CallHierarchyItem {
        name: def.name.clone(),
        kind: symbol_kind_for(&def.kind),
        tags: None,
        detail: def.signature.clone(),
        uri,
        range,
        selection_range,
        data: Some(json!({
            "file_idx": file_idx,
            "def_idx": def_idx,
            "root": root.to_string_lossy(),
        })),
    })
}

/// Find all callable definitions whose line range contains the cursor in a
/// single graph, returning them as a list of [`CallHierarchyItem`].
///
/// LSP spec §3.16.3 allows `prepare` to return multiple items when multiple
/// definitions span the cursor position (e.g., a method inside an impl block).
/// Non-callable kinds are filtered out via [`is_callable_kind`].
///
/// **Fallback (N1)**: When no callable def's `[start_line, end_line]` range
/// contains the cursor (e.g., the cursor is inside a nested function body that
/// the graph-builder did not index as a separate def, or a def whose `end_line`
/// only covers the header), we fall back to the callable def with the largest
/// `start_line <= cursor_line`. This ensures call-hierarchy navigation is still
/// useful even when the exact target symbol is absent from the def index.
fn prepare_in_graph(
    graph: &RepoGraph,
    req_path: &Path,
    search_root: &Path,
    cursor_line: u32,
) -> Vec<CallHierarchyItem> {
    let rel_path = make_relative(req_path, search_root);
    let Some(file_idx) = graph
        .files
        .iter()
        .position(|f| Path::new(&f.path) == rel_path)
    else {
        return Vec::new();
    };

    let file = &graph.files[file_idx];
    let mut items = Vec::new();
    for (di, def) in file.defs.iter().enumerate() {
        let start = def_line_to_lsp(def.start_line);
        let end = def_line_to_lsp(def.end_line);
        if cursor_line >= start
            && cursor_line <= end
            && is_callable_kind(&def.kind)
            && let Some(item) = def_to_item(graph, file_idx, di, search_root)
        {
            items.push(item);
        }
    }

    if !items.is_empty() {
        return items;
    }

    // Fallback (N1): no def's range covers cursor_line. Pick the callable def
    // with the largest start_line that is still <= cursor_line. This is the
    // "enclosing chunk header" — useful when the target symbol (e.g., a nested
    // Go function) was not indexed as a separate def.
    let best = file
        .defs
        .iter()
        .enumerate()
        .filter(|(_, def)| {
            is_callable_kind(&def.kind) && def_line_to_lsp(def.start_line) <= cursor_line
        })
        .max_by_key(|(_, def)| def_line_to_lsp(def.start_line));

    if let Some((di, _)) = best
        && let Some(item) = def_to_item(graph, file_idx, di, search_root)
    {
        return vec![item];
    }

    Vec::new()
}

/// Find the definition at the cursor position and return it as a
/// [`CallHierarchyItem`].
///
/// Searches definitions in the file matching the cursor's URI for one
/// whose line range contains the cursor line. Returns all callable
/// definitions spanning the cursor (LSP spec allows multiple). Tries the
/// default repo graph first, then on-demand root graphs.
pub async fn prepare(
    params: CallHierarchyPrepareParams,
    repo_graph: &Arc<tokio::sync::RwLock<Option<RepoGraph>>>,
    root_graphs: &Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<RepoGraph>>>>,
    root: &Path,
) -> Result<Option<Vec<CallHierarchyItem>>> {
    let pos = &params.text_document_position_params;
    let Some(path_cow) = pos.text_document.uri.to_file_path() else {
        return Ok(None);
    };
    let req_path = path_cow.into_owned();
    let cursor_line = pos.position.line; // 0-based

    // Try the default repo graph first.
    {
        let graph_guard = repo_graph.read().await;
        if let Some(graph) = graph_guard.as_ref() {
            let items = prepare_in_graph(graph, &req_path, root, cursor_line);
            if !items.is_empty() {
                return Ok(Some(items));
            }
        }
    }

    // Fall back to on-demand root graphs.
    {
        let rg_guard = root_graphs.read().await;
        for (alt_root, graph) in rg_guard.iter() {
            if req_path.starts_with(alt_root) {
                let items = prepare_in_graph(graph, &req_path, alt_root, cursor_line);
                if !items.is_empty() {
                    return Ok(Some(items));
                }
            }
        }
    }

    // Fall back: build graph on demand from disk for the file's project root.
    let mut candidate = req_path.parent();
    while let Some(dir) = candidate {
        if dir.join(".git").exists() || dir.join(".ripvec").exists() {
            if let Ok(graph) = ripvec_core::repo_map::build_graph(dir) {
                let items = prepare_in_graph(&graph, &req_path, dir, cursor_line);
                if !items.is_empty() {
                    return Ok(Some(items));
                }
            }
            break;
        }
        candidate = dir.parent();
    }

    Ok(None)
}

/// Collect incoming calls from a single graph.
///
/// Returns `None` when the `(file_idx, def_idx)` pair has no flat index in
/// this graph (i.e., the definition is not in this corpus). Returns
/// `Some(vec![])` when the definition IS in the graph but has zero callers —
/// this is the R1.2 distinction that lets callers emit `Ok(Some(vec![]))`.
fn incoming_from_graph(
    graph: &RepoGraph,
    file_idx: usize,
    def_idx: usize,
    search_root: &Path,
) -> Option<Vec<CallHierarchyIncomingCall>> {
    let flat = flat_index(graph, file_idx, def_idx)?;
    // R1.2: `?` returns None (not found) when flat index is out of def_callers range.
    // When the entry exists but is empty, we get Some(&[]) → callers is `&[]` →
    // the loop below runs zero iterations → Some(vec![]) is returned at the end.
    let callers = graph.def_callers.get(flat)?;

    let mut results = Vec::with_capacity(callers.len());
    for &(caller_file, caller_def) in callers {
        let caller_file = caller_file as usize;
        let caller_def = caller_def as usize;

        let Some(item) = def_to_item(graph, caller_file, caller_def, search_root) else {
            continue;
        };

        let from_ranges = call_site_ranges_with_source(
            graph,
            caller_file,
            caller_def,
            file_idx,
            def_idx,
            search_root,
        );
        results.push(CallHierarchyIncomingCall {
            from: item,
            from_ranges,
        });
    }
    // Return Some(results) even when empty: caller can distinguish
    // "def found, zero callers" (Some([])) from "def not in this graph" (None).
    Some(results)
}

/// Return callers of the given definition.
///
/// Reads `file_idx` / `def_idx` from the item's `data` field, computes
/// the flat index, and maps each entry in `graph.def_callers[flat]` to a
/// [`CallHierarchyIncomingCall`].
///
/// Tries the default repo graph first; falls back to on-demand root graphs.
/// The on-disk fallback validates that the item's embedded root matches the
/// session root before building an on-demand graph, preventing cross-session
/// index corruption.
pub async fn incoming(
    params: CallHierarchyIncomingCallsParams,
    repo_graph: &Arc<tokio::sync::RwLock<Option<RepoGraph>>>,
    root_graphs: &Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<RepoGraph>>>>,
    root: &Path,
) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
    let Some((file_idx, def_idx)) = extract_ids(&params.item) else {
        return Ok(None);
    };

    // Try the default repo graph first.
    {
        let graph_guard = repo_graph.read().await;
        if let Some(graph) = graph_guard.as_ref() {
            // Some(results) = def was found in this graph (even if 0 callers).
            // None = def is not in this graph — try next source.
            if let Some(results) = incoming_from_graph(graph, file_idx, def_idx, root) {
                return Ok(Some(results));
            }
        }
    }

    // H2 (I#15): scope the in-memory root_graphs loop to the item's embedded root.
    // Without this, iterating all graphs lets a root_b graph resolve indices from
    // a root_a item, returning callers from the wrong project (cross-root leak).
    //
    // Strategy: extract the item's embedded root and canonicalize it. In the
    // loop, only try a graph whose key canonicalizes to the same path.
    // When the item has no embedded root (data field absent), fall back to
    // the session root as the scope guard.
    let item_root_canon: Option<PathBuf> =
        extract_root(&params.item).map(|r| canonicalize_or_self(&r));
    let session_root_canon = canonicalize_or_self(root);

    // Guard: if the item's embedded root differs from the session root, reject
    // immediately without even trying the in-memory graphs. This is the first
    // line of defense for the I#15 cross-root leak.
    if let Some(ref irc) = item_root_canon
        && irc != &session_root_canon
    {
        return Ok(None);
    }

    // Fall back to on-demand root graphs.
    // Only try graphs whose key matches the item's root (or session root when
    // item has no embedded root).
    {
        let effective_root = item_root_canon.as_deref().unwrap_or(&session_root_canon);
        let rg_guard = root_graphs.read().await;
        for (alt_root, graph) in rg_guard.iter() {
            let alt_canon = canonicalize_or_self(alt_root);
            if alt_canon != *effective_root {
                continue; // H2: skip graphs from other roots.
            }
            if let Some(results) = incoming_from_graph(graph, file_idx, def_idx, &alt_canon) {
                return Ok(Some(results));
            }
        }
    }

    // Fall back: rebuild graph from disk using root stored in data.
    // The item-root vs session-root equality guard above already fired; if we
    // reach here the roots are equal, so building on demand is safe.
    if let Some(graph_root) = extract_root(&params.item)
        && let Ok(graph) = ripvec_core::repo_map::build_graph(&graph_root)
        && let Some(results) = incoming_from_graph(&graph, file_idx, def_idx, &graph_root)
    {
        return Ok(Some(results));
    }

    Ok(None)
}

/// Collect outgoing calls from a single graph.
///
/// Returns `None` when the `(file_idx, def_idx)` pair has no flat index in
/// this graph (i.e., the definition is not in this corpus). Returns
/// `Some(vec![])` when the definition IS in the graph but has zero callees.
fn outgoing_from_graph(
    graph: &RepoGraph,
    file_idx: usize,
    def_idx: usize,
    search_root: &Path,
) -> Option<Vec<CallHierarchyOutgoingCall>> {
    let flat = flat_index(graph, file_idx, def_idx)?;
    // R1.2: same semantics as incoming_from_graph — `?` propagates None when out of range;
    // Some(&[]) (empty entry) produces Some(vec![]) at the end.
    let callees = graph.def_callees.get(flat)?;

    let mut results = Vec::with_capacity(callees.len());
    for &(callee_file, callee_def) in callees {
        let callee_file = callee_file as usize;
        let callee_def = callee_def as usize;

        let Some(item) = def_to_item(graph, callee_file, callee_def, search_root) else {
            continue;
        };

        let from_ranges = call_site_ranges_with_source(
            graph,
            file_idx,
            def_idx,
            callee_file,
            callee_def,
            search_root,
        );
        results.push(CallHierarchyOutgoingCall {
            to: item,
            from_ranges,
        });
    }
    // Return Some(results) even when empty — same R1.2 semantics as incoming_from_graph.
    Some(results)
}

/// Return callees of the given definition.
///
/// Same as `incoming` but traverses `graph.def_callees[flat]` and returns
/// [`CallHierarchyOutgoingCall`] entries.
///
/// Tries the default repo graph first; falls back to on-demand root graphs.
/// The on-disk fallback validates that the item's embedded root matches the
/// session root before building an on-demand graph, preventing cross-session
/// index corruption.
pub async fn outgoing(
    params: CallHierarchyOutgoingCallsParams,
    repo_graph: &Arc<tokio::sync::RwLock<Option<RepoGraph>>>,
    root_graphs: &Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<RepoGraph>>>>,
    root: &Path,
) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
    let Some((file_idx, def_idx)) = extract_ids(&params.item) else {
        return Ok(None);
    };

    // Try the default repo graph first.
    {
        let graph_guard = repo_graph.read().await;
        if let Some(graph) = graph_guard.as_ref()
            && let Some(results) = outgoing_from_graph(graph, file_idx, def_idx, root)
        {
            return Ok(Some(results));
        }
    }

    // H2 (I#15): scope the in-memory root_graphs loop to the item's embedded root.
    // Same fix as `incoming` — see that function for the full rationale.
    let item_root_canon: Option<PathBuf> =
        extract_root(&params.item).map(|r| canonicalize_or_self(&r));
    let session_root_canon = canonicalize_or_self(root);

    // Guard: item root must match session root or be absent.
    if let Some(ref irc) = item_root_canon
        && irc != &session_root_canon
    {
        return Ok(None);
    }

    // Fall back to on-demand root graphs — only try the matching root.
    {
        let effective_root = item_root_canon.as_deref().unwrap_or(&session_root_canon);
        let rg_guard = root_graphs.read().await;
        for (alt_root, graph) in rg_guard.iter() {
            let alt_canon = canonicalize_or_self(alt_root);
            if alt_canon != *effective_root {
                continue; // H2: skip graphs from other roots.
            }
            if let Some(results) = outgoing_from_graph(graph, file_idx, def_idx, &alt_canon) {
                return Ok(Some(results));
            }
        }
    }

    // Fall back: rebuild graph from disk. Roots are equal (guard above passed).
    if let Some(graph_root) = extract_root(&params.item)
        && let Ok(graph) = ripvec_core::repo_map::build_graph(&graph_root)
        && let Some(results) = outgoing_from_graph(&graph, file_idx, def_idx, &graph_root)
    {
        return Ok(Some(results));
    }

    Ok(None)
}

// ── Helpers ────────────────────────────────────────────────────────────

/// Extract `(file_idx, def_idx)` from a `CallHierarchyItem`'s JSON `data`.
fn extract_ids(item: &CallHierarchyItem) -> Option<(usize, usize)> {
    let data = item.data.as_ref()?;
    let file_idx = usize::try_from(data.get("file_idx")?.as_u64()?).ok()?;
    let def_idx = usize::try_from(data.get("def_idx")?.as_u64()?).ok()?;
    Some((file_idx, def_idx))
}

/// Extract the project root from the item's `data` field.
fn extract_root(item: &CallHierarchyItem) -> Option<PathBuf> {
    let data = item.data.as_ref()?;
    let root_str = data.get("root")?.as_str()?;
    Some(PathBuf::from(root_str))
}

/// Canonicalize a path, falling back to the original if canonicalization fails
/// (e.g., path does not exist on disk — common in tests).
fn canonicalize_or_self(path: &Path) -> PathBuf {
    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}

/// Compute the flat index from `(file_idx, def_idx)`.
fn flat_index(graph: &RepoGraph, file_idx: usize, def_idx: usize) -> Option<usize> {
    let offset = *graph.def_offsets.get(file_idx)?;
    let flat = offset + def_idx;
    // Bounds-check against the flattened arrays.
    if flat < graph.def_callers.len() {
        Some(flat)
    } else {
        None
    }
}

/// Make `abs_path` relative to `root`, falling back to the full path.
fn make_relative<'a>(abs_path: &'a Path, root: &Path) -> &'a Path {
    abs_path.strip_prefix(root).unwrap_or(abs_path)
}

/// Build a line-offset table from source text.
///
/// Returns a `Vec` where `line_starts[i]` is the byte offset of the first
/// character on line `i` (0-based). Used by [`byte_offset_to_line`].
fn build_line_starts(source: &str) -> Vec<usize> {
    std::iter::once(0)
        .chain(source.match_indices('\n').map(|(i, _)| i + 1))
        .collect()
}

/// Convert a byte offset to a 0-based line number using a pre-built line table.
fn byte_offset_to_line(line_starts: &[usize], byte_offset: usize) -> u32 {
    let line = line_starts
        .partition_point(|&start| start <= byte_offset)
        .saturating_sub(1);
    u32::try_from(line).unwrap_or(u32::MAX)
}

/// Find call-site ranges within `caller` that target `(target_file, target_def)`.
///
/// Reads the caller's source file to build an accurate byte-offset → line
/// table, converting each matching call site's byte offset to a real 0-based
/// line number. Falls back to the old 40-bytes/line heuristic (with a
/// `tracing::debug` log) only if the source file cannot be read.
///
/// If no resolved call sites match, returns a single fallback range covering
/// the caller's first line (the LSP spec requires at least one `from_range`).
///
/// The `root` parameter is used to resolve relative file paths from the graph.
fn call_site_ranges_with_source(
    graph: &RepoGraph,
    caller_file: usize,
    caller_def: usize,
    target_file: usize,
    target_def: usize,
    root: &Path,
) -> Vec<Range> {
    // DefId components are known to fit: file_idx < files.len() (u32 range),
    // def_idx < defs.len() per file (u16 range).
    #[expect(
        clippy::cast_possible_truncation,
        reason = "file_idx and def_idx originate from graph indices within DefId bounds"
    )]
    let target_id = (target_file as u32, target_def as u16);

    let Some(file) = graph.files.get(caller_file) else {
        return vec![fallback_range(0)];
    };
    let Some(def) = file.defs.get(caller_def) else {
        return vec![fallback_range(0)];
    };

    // Try to read the source file for an accurate line table.
    let abs_path = {
        let p = Path::new(&file.path);
        if p.is_absolute() {
            p.to_path_buf()
        } else {
            root.join(p)
        }
    };

    let line_starts_opt: Option<Vec<usize>> = std::fs::read_to_string(&abs_path)
        .ok()
        .map(|s| build_line_starts(&s));

    let ranges: Vec<Range> = def
        .calls
        .iter()
        .filter(|c| c.resolved == Some(target_id))
        .map(|c| {
            let line = if let Some(ref ls) = line_starts_opt {
                // Accurate: binary-search the pre-built line table.
                byte_offset_to_line(ls, c.byte_offset as usize)
            } else {
                // Fallback heuristic: ~40 bytes/line, clamped to definition range.
                tracing::debug!(
                    path = %abs_path.display(),
                    "call_site_ranges: could not read source, using byte-offset heuristic"
                );
                let def_start_line = def_line_to_lsp(def.start_line);
                let offset_bytes = c.byte_offset.saturating_sub(def.start_byte);
                (def_start_line + offset_bytes / 40).min(def_line_to_lsp(def.end_line))
            };
            Range {
                start: Position { line, character: 0 },
                end: Position { line, character: 0 },
            }
        })
        .collect();

    if ranges.is_empty() {
        // Fallback: point at the caller's first line.
        vec![fallback_range(def_line_to_lsp(def.start_line))]
    } else {
        ranges
    }
}

/// A zero-width range at the given line (used when precise call site info
/// is unavailable).
fn fallback_range(line: u32) -> Range {
    Range {
        start: Position { line, character: 0 },
        end: Position { line, character: 0 },
    }
}

// ── Tests ──────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::*;
    use ripvec_core::repo_map::{CallRef, FileNode};
    use tower_lsp_server::ls_types::{PartialResultParams, WorkDoneProgressParams};

    /// Build a minimal `RepoGraph` for testing.
    fn test_graph() -> RepoGraph {
        // Two files, each with one definition.
        // File 0, def 0: "main" calls "helper"
        // File 1, def 0: "helper" (called by main)
        let files = vec![
            FileNode {
                path: "src/main.rs".to_string(),
                defs: vec![Definition {
                    name: "main".to_string(),
                    kind: "function_item".to_string(),
                    start_line: 1,
                    end_line: 10,
                    scope: String::new(),
                    signature: Some("fn main()".to_string()),
                    start_byte: 0,
                    end_byte: 200,
                    calls: vec![CallRef {
                        name: "helper".to_string(),
                        byte_offset: 50,
                        resolved: Some((1, 0)),
                        ..Default::default()
                    }],
                }],
                imports: vec![],
            },
            FileNode {
                path: "src/lib.rs".to_string(),
                defs: vec![Definition {
                    name: "helper".to_string(),
                    kind: "function_item".to_string(),
                    start_line: 5,
                    end_line: 15,
                    scope: String::new(),
                    signature: Some("fn helper() -> i32".to_string()),
                    start_byte: 0,
                    end_byte: 300,
                    calls: vec![],
                }],
                imports: vec![],
            },
        ];

        // def_offsets: file 0 starts at flat 0, file 1 starts at flat 1
        let def_offsets = vec![0, 1];

        // def_callers[flat=0] = [] (nobody calls main)
        // def_callers[flat=1] = [(0,0)] (main calls helper)
        let def_callers = vec![vec![], vec![(0u32, 0u16)]];

        // def_callees[flat=0] = [(1,0)] (main calls helper)
        // def_callees[flat=1] = [] (helper calls nobody)
        let def_callees = vec![vec![(1u32, 0u16)], vec![]];

        RepoGraph {
            files,
            edges: vec![],
            base_ranks: vec![0.5, 0.5],
            callers: vec![vec![], vec![0]],
            callees: vec![vec![1], vec![]],
            def_edges: vec![((0, 0), (1, 0), 1)],
            def_ranks: vec![0.5, 0.5],
            def_callers,
            def_callees,
            def_offsets,
            alpha: 0.3,
        }
    }

    /// Build a graph with two defs whose line ranges overlap:
    /// - impl block: lines 5-15 (1-based)
    /// - method:     lines 8-10 (1-based)
    ///
    /// Both are callable kinds; both span cursor at 0-based line 8 (1-based: 9).
    fn overlapping_graph() -> RepoGraph {
        let files = vec![FileNode {
            path: "src/foo.rs".to_string(),
            defs: vec![
                Definition {
                    name: "Foo".to_string(),
                    kind: "impl_item".to_string(),
                    start_line: 5,
                    end_line: 15,
                    scope: String::new(),
                    signature: None,
                    start_byte: 0,
                    end_byte: 400,
                    calls: vec![],
                },
                Definition {
                    name: "bar".to_string(),
                    kind: "function_item".to_string(),
                    start_line: 8,
                    end_line: 10,
                    scope: "impl_item Foo".to_string(),
                    signature: Some("fn bar(&self)".to_string()),
                    start_byte: 100,
                    end_byte: 200,
                    calls: vec![],
                },
            ],
            imports: vec![],
        }];

        let def_offsets = vec![0];
        let def_callers = vec![vec![], vec![]];
        let def_callees = vec![vec![], vec![]];

        RepoGraph {
            files,
            edges: vec![],
            base_ranks: vec![0.5],
            callers: vec![vec![]],
            callees: vec![vec![]],
            def_edges: vec![],
            def_ranks: vec![0.5, 0.5],
            def_callers,
            def_callees,
            def_offsets,
            alpha: 0.3,
        }
    }

    /// Build a graph with a single definition of non-callable kind.
    fn non_callable_graph(kind: &str) -> RepoGraph {
        let files = vec![FileNode {
            path: "src/consts.rs".to_string(),
            defs: vec![Definition {
                name: "MY_VAR".to_string(),
                kind: kind.to_string(),
                start_line: 1,
                end_line: 1,
                scope: String::new(),
                signature: None,
                start_byte: 0,
                end_byte: 30,
                calls: vec![],
            }],
            imports: vec![],
        }];

        let def_offsets = vec![0];
        let def_callers = vec![vec![]];
        let def_callees = vec![vec![]];

        RepoGraph {
            files,
            edges: vec![],
            base_ranks: vec![0.5],
            callers: vec![vec![]],
            callees: vec![vec![]],
            def_edges: vec![],
            def_ranks: vec![0.5],
            def_callers,
            def_callees,
            def_offsets,
            alpha: 0.3,
        }
    }

    #[test]
    fn extract_ids_round_trip() {
        let graph = test_graph();
        let root = PathBuf::from("/tmp/project");
        let item = def_to_item(&graph, 0, 0, &root).unwrap();

        let (fi, di) = extract_ids(&item).unwrap();
        assert_eq!(fi, 0);
        assert_eq!(di, 0);
    }

    #[test]
    fn flat_index_in_bounds() {
        let graph = test_graph();
        assert_eq!(flat_index(&graph, 0, 0), Some(0));
        assert_eq!(flat_index(&graph, 1, 0), Some(1));
        // Out of bounds.
        assert_eq!(flat_index(&graph, 2, 0), None);
    }

    #[test]
    fn call_site_ranges_finds_resolved_call() {
        let graph = test_graph();
        // Use a non-existent root so source-read fails gracefully → heuristic fallback.
        let root = PathBuf::from("/nonexistent_root_for_test");
        let ranges = call_site_ranges_with_source(&graph, 0, 0, 1, 0, &root);
        assert_eq!(ranges.len(), 1);
        // Should point somewhere within main's range (lines 0..9 in 0-based).
        assert!(ranges[0].start.line <= 9);
    }

    #[test]
    fn call_site_ranges_fallback_when_no_match() {
        let graph = test_graph();
        let root = PathBuf::from("/nonexistent_root_for_test");
        // helper has no calls, so looking for any call site should produce fallback.
        let ranges = call_site_ranges_with_source(&graph, 1, 0, 0, 0, &root);
        assert_eq!(ranges.len(), 1);
        // Fallback should be helper's start line (0-based: 4).
        assert_eq!(ranges[0].start.line, 4);
    }

    #[test]
    fn def_to_item_populates_fields() {
        let graph = test_graph();
        let root = PathBuf::from("/tmp/project");
        let item = def_to_item(&graph, 1, 0, &root).unwrap();
        assert_eq!(item.name, "helper");
        assert_eq!(item.detail.as_deref(), Some("fn helper() -> i32"));
        assert!(item.data.is_some());
    }

    // ── R2.1 tests ──────────────────────────────────────────────────────

    /// When two callable defs overlap at the cursor (impl block lines 5-15
    /// and method lines 8-10, 1-based), cursor on 0-based line 8 (1-based: 9),
    /// prepare must return both items.
    #[test]
    fn prepare_returns_overloads_at_same_line() {
        let graph = overlapping_graph();
        let root = PathBuf::from("/tmp/project");
        // 0-based line 8 is inside both impl_item (lines 4..14 0-based) and
        // function_item (lines 7..9 0-based).
        let cursor_line = 8u32;
        let req_path = root.join("src/foo.rs");

        let items = prepare_in_graph(&graph, &req_path, &root, cursor_line);
        assert_eq!(
            items.len(),
            2,
            "expected both impl_item and function_item to be returned, got: {:?}",
            items.iter().map(|i| &i.name).collect::<Vec<_>>()
        );

        let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
        assert!(names.contains(&"Foo"), "impl_item 'Foo' should be included");
        assert!(
            names.contains(&"bar"),
            "function_item 'bar' should be included"
        );
    }

    /// A def with kind="variable_declarator" must be filtered out;
    /// prepare_in_graph returns an empty Vec even when the cursor is inside
    /// that def's line range.
    #[test]
    fn prepare_rejects_variable_kind() {
        let graph = non_callable_graph("variable_declarator");
        let root = PathBuf::from("/tmp/project");
        let req_path = root.join("src/consts.rs");
        // cursor on line 0 (def occupies 1-based line 1 → 0-based: 0)
        let items = prepare_in_graph(&graph, &req_path, &root, 0);
        assert!(
            items.is_empty(),
            "variable_declarator should be rejected, got: {:?}",
            items.iter().map(|i| &i.name).collect::<Vec<_>>()
        );
    }

    /// A def with kind="type_item" (type alias) must be filtered out.
    #[test]
    fn prepare_rejects_type_alias_kind() {
        let graph = non_callable_graph("type_item");
        let root = PathBuf::from("/tmp/project");
        let req_path = root.join("src/consts.rs");
        let items = prepare_in_graph(&graph, &req_path, &root, 0);
        assert!(
            items.is_empty(),
            "type_item should be rejected, got: {:?}",
            items.iter().map(|i| &i.name).collect::<Vec<_>>()
        );
    }

    // ── R2.2 tests ──────────────────────────────────────────────────────

    /// `incoming` with an item whose embedded root (/tmp/A) differs from the
    /// session root (/tmp/B) must return Ok(None) — not silently build and
    /// query the wrong graph.
    #[tokio::test]
    async fn incoming_rejects_item_from_different_root() {
        let graph = test_graph();
        let root_a = PathBuf::from("/tmp/ripvec_test_root_A");
        let root_b = PathBuf::from("/tmp/ripvec_test_root_B");

        // Build an item stamped with root_a.
        let item = def_to_item(&graph, 1, 0, &root_a).unwrap();

        let repo_graph: Arc<tokio::sync::RwLock<Option<RepoGraph>>> =
            Arc::new(tokio::sync::RwLock::new(None));
        let root_graphs: Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<RepoGraph>>>> =
            Arc::new(tokio::sync::RwLock::new(HashMap::new()));

        let params = CallHierarchyIncomingCallsParams {
            item,
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
        };
        // Session root is root_b — should reject the item from root_a.
        let result = incoming(params, &repo_graph, &root_graphs, &root_b).await;
        assert!(result.is_ok());
        // Should be None (rejected) rather than attempting to build root_a's graph.
        assert!(
            result.unwrap().is_none(),
            "incoming should return None when item root differs from session root"
        );
    }

    // R1.2: incoming returns Ok(Some(vec![])) when graph found but def has zero callers.
    #[tokio::test]
    async fn incoming_calls_returns_empty_vec_on_zero_callers() {
        // Build a graph where def (file 0, def 0 = "main") has empty def_callers.
        let graph = test_graph(); // def_callers[flat=0] = [] (nobody calls main)
        let root = PathBuf::from("/tmp/ripvec_test_incoming_empty");

        // Inject the graph as the default repo graph.
        let repo_graph: Arc<tokio::sync::RwLock<Option<RepoGraph>>> =
            Arc::new(tokio::sync::RwLock::new(Some(graph.clone())));
        let root_graphs: Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<RepoGraph>>>> =
            Arc::new(tokio::sync::RwLock::new(HashMap::new()));

        // Build an item for "main" (file 0, def 0).
        let item = def_to_item(&graph, 0, 0, &root).unwrap();
        let params = CallHierarchyIncomingCallsParams {
            item,
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
        };

        let result = incoming(params, &repo_graph, &root_graphs, &root).await;
        assert!(result.is_ok(), "incoming must not error");
        // The graph was found and the flat index is valid — def_callers[0] is empty.
        // Must return Ok(Some(vec![])) not Ok(None).
        match result.unwrap() {
            Some(calls) => assert!(
                calls.is_empty(),
                "must return empty list not None when graph found but no callers"
            ),
            None => panic!(
                "incoming returned Ok(None) for a def that IS in the graph (zero callers); \
                 expected Ok(Some(vec![]))"
            ),
        }
    }

    // R1.2: outgoing returns Ok(Some(vec![])) when graph found but def has zero callees.
    #[tokio::test]
    async fn outgoing_calls_returns_empty_vec_on_zero_callees() {
        // def_callees[flat=1] = [] (helper calls nobody)
        let graph = test_graph();
        let root = PathBuf::from("/tmp/ripvec_test_outgoing_empty");

        let repo_graph: Arc<tokio::sync::RwLock<Option<RepoGraph>>> =
            Arc::new(tokio::sync::RwLock::new(Some(graph.clone())));
        let root_graphs: Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<RepoGraph>>>> =
            Arc::new(tokio::sync::RwLock::new(HashMap::new()));

        // Build an item for "helper" (file 1, def 0) — has no callees.
        let item = def_to_item(&graph, 1, 0, &root).unwrap();
        let params = CallHierarchyOutgoingCallsParams {
            item,
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
        };

        let result = outgoing(params, &repo_graph, &root_graphs, &root).await;
        assert!(result.is_ok(), "outgoing must not error");
        match result.unwrap() {
            Some(calls) => assert!(
                calls.is_empty(),
                "must return empty list not None when graph found but no callees"
            ),
            None => panic!(
                "outgoing returned Ok(None) for a def that IS in the graph (zero callees); \
                 expected Ok(Some(vec![]))"
            ),
        }
    }

    // R2.4: call_site_ranges matches real source line numbers.
    #[test]
    fn call_site_ranges_match_real_source_lines() {
        use std::io::Write;
        use tempfile::NamedTempFile;

        // Build known content: 10 lines, call "helper" on line 5 (1-based).
        // Lines are each exactly 20 bytes including newline.
        let source = "fn main() {        \n\
                      let a = 1;         \n\
                      let b = 2;         \n\
                      let c = 3;         \n\
                      helper();          \n\
                      let d = 4;         \n\
                      let e = 5;         \n\
                      let f = 6;         \n\
                      let g = 7;         \n\
                      }                  \n";

        let mut tmpfile = NamedTempFile::new().unwrap();
        tmpfile.write_all(source.as_bytes()).unwrap();

        // Compute byte offset of "helper" on line 5 (0-based line 4).
        // Lines 0-3 are each 20 bytes => line 4 starts at byte 80.
        let line4_start: usize = source.split_inclusive('\n').take(4).map(str::len).sum();
        let call_byte = line4_start + source.lines().nth(4).unwrap().find("helper").unwrap();

        // Build a graph where main (start_byte=0, start_line=1) calls helper at call_byte.
        let files = vec![
            ripvec_core::repo_map::FileNode {
                path: tmpfile.path().to_string_lossy().to_string(),
                defs: vec![ripvec_core::repo_map::Definition {
                    name: "main".to_string(),
                    kind: "function_item".to_string(),
                    start_line: 1,
                    end_line: 10,
                    scope: String::new(),
                    signature: None,
                    start_byte: 0,
                    end_byte: u32::try_from(source.len()).unwrap(),
                    calls: vec![ripvec_core::repo_map::CallRef {
                        name: "helper".to_string(),
                        byte_offset: u32::try_from(call_byte).unwrap(),
                        resolved: Some((1, 0)),
                        ..Default::default()
                    }],
                }],
                imports: vec![],
            },
            ripvec_core::repo_map::FileNode {
                path: "helper.rs".to_string(),
                defs: vec![ripvec_core::repo_map::Definition {
                    name: "helper".to_string(),
                    kind: "function_item".to_string(),
                    start_line: 1,
                    end_line: 3,
                    scope: String::new(),
                    signature: None,
                    start_byte: 0,
                    end_byte: 50,
                    calls: vec![],
                }],
                imports: vec![],
            },
        ];
        let def_offsets = vec![0, 1];
        let def_callers = vec![vec![], vec![(0u32, 0u16)]];
        let def_callees = vec![vec![(1u32, 0u16)], vec![]];
        let graph = ripvec_core::repo_map::RepoGraph {
            files,
            edges: vec![],
            base_ranks: vec![0.5, 0.5],
            callers: vec![vec![], vec![0]],
            callees: vec![vec![1], vec![]],
            def_edges: vec![((0, 0), (1, 0), 1)],
            def_ranks: vec![0.5, 0.5],
            def_callers,
            def_callees,
            def_offsets,
            alpha: 0.3,
        };

        // Use the file path as caller_file=0 (main) targeting helper (file=1, def=0).
        let root = tmpfile.path().parent().unwrap();
        let ranges = call_site_ranges_with_source(&graph, 0, 0, 1, 0, root);
        assert_eq!(ranges.len(), 1, "expected exactly one call site range");
        // "helper" is on 1-based line 5 → 0-based line 4.
        assert_eq!(
            ranges[0].start.line, 4,
            "call site must map to 0-based line 4 (source line 5), got {}",
            ranges[0].start.line
        );
    }

    // R2.4: wide-line correctness — old heuristic (40 bytes/line) would give wrong line.
    #[test]
    fn call_site_ranges_handle_wide_lines() {
        use std::io::Write;
        use tempfile::NamedTempFile;

        // Line 0: 200 char line (a wide JSON literal or similar)
        // Line 1: "helper();\n" — call target is here (0-based line 1 = 1-based line 2)
        let wide_line = "a".repeat(200) + "\n"; // 201 bytes
        let call_line = "helper();\n";
        let source = format!("{wide_line}{call_line}");

        let mut tmpfile = NamedTempFile::new().unwrap();
        tmpfile.write_all(source.as_bytes()).unwrap();

        // Call byte offset = start of "helper" on line 1 (0-based) = 201.
        let call_byte: u32 = 201;

        let files = vec![
            ripvec_core::repo_map::FileNode {
                path: tmpfile.path().to_string_lossy().to_string(),
                defs: vec![ripvec_core::repo_map::Definition {
                    name: "main".to_string(),
                    kind: "function_item".to_string(),
                    start_line: 1,
                    end_line: 2,
                    scope: String::new(),
                    signature: None,
                    start_byte: 0,
                    end_byte: u32::try_from(source.len()).unwrap(),
                    calls: vec![ripvec_core::repo_map::CallRef {
                        name: "helper".to_string(),
                        byte_offset: call_byte,
                        resolved: Some((1, 0)),
                        ..Default::default()
                    }],
                }],
                imports: vec![],
            },
            ripvec_core::repo_map::FileNode {
                path: "helper.rs".to_string(),
                defs: vec![ripvec_core::repo_map::Definition {
                    name: "helper".to_string(),
                    kind: "function_item".to_string(),
                    start_line: 1,
                    end_line: 2,
                    scope: String::new(),
                    signature: None,
                    start_byte: 0,
                    end_byte: 20,
                    calls: vec![],
                }],
                imports: vec![],
            },
        ];
        let def_offsets = vec![0, 1];
        let def_callers = vec![vec![], vec![(0u32, 0u16)]];
        let def_callees = vec![vec![(1u32, 0u16)], vec![]];
        let graph = ripvec_core::repo_map::RepoGraph {
            files,
            edges: vec![],
            base_ranks: vec![0.5, 0.5],
            callers: vec![vec![], vec![0]],
            callees: vec![vec![1], vec![]],
            def_edges: vec![((0, 0), (1, 0), 1)],
            def_ranks: vec![0.5, 0.5],
            def_callers,
            def_callees,
            def_offsets,
            alpha: 0.3,
        };

        let root = tmpfile.path().parent().unwrap();
        let ranges = call_site_ranges_with_source(&graph, 0, 0, 1, 0, root);
        assert_eq!(ranges.len(), 1);
        // "helper" is on 0-based line 1.
        // Old heuristic: offset_bytes=201, approx_line = 0 + 201/40 = 5 → WRONG.
        // Correct: line 1 (0-based).
        assert_eq!(
            ranges[0].start.line, 1,
            "wide-line: call site must be on 0-based line 1, got {} (heuristic gave wrong answer)",
            ranges[0].start.line
        );
    }

    /// `outgoing` with an item whose embedded root (/tmp/A) differs from the
    /// session root (/tmp/B) must return Ok(None).
    #[tokio::test]
    async fn outgoing_rejects_item_from_different_root() {
        let graph = test_graph();
        let root_a = PathBuf::from("/tmp/ripvec_test_root_A");
        let root_b = PathBuf::from("/tmp/ripvec_test_root_B");

        // Build an item stamped with root_a.
        let item = def_to_item(&graph, 0, 0, &root_a).unwrap();

        let repo_graph: Arc<tokio::sync::RwLock<Option<RepoGraph>>> =
            Arc::new(tokio::sync::RwLock::new(None));
        let root_graphs: Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<RepoGraph>>>> =
            Arc::new(tokio::sync::RwLock::new(HashMap::new()));

        let params = CallHierarchyOutgoingCallsParams {
            item,
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
        };
        // Session root is root_b — should reject the item from root_a.
        let result = outgoing(params, &repo_graph, &root_graphs, &root_b).await;
        assert!(result.is_ok());
        assert!(
            result.unwrap().is_none(),
            "outgoing should return None when item root differs from session root"
        );
    }

    // ── H2 tests: call hierarchy must not leak across roots ───────────────────
    //
    // I#15: lsp_incoming_calls returns callers from completely different projects.
    // The (def_idx, file_idx) in the item's data is global to whichever index is
    // loaded. When two roots are indexed (e.g., ripvec + aurora), root-B's items
    // with file_idx=5 may resolve against root-A's graph which also has 5+ files,
    // returning callers from root-A.
    //
    // Fix: the in-memory root_graphs loop in `incoming` and `outgoing` must only
    // try the graph whose key matches the item's embedded root (extract_root),
    // not all graphs.

    /// H2: incoming_calls with a graph for root_a in root_graphs, but the item
    /// is stamped with root_b. The root_graphs loop must NOT resolve the item
    /// against root_a's graph (cross-root contamination).
    ///
    /// Baseline (RED): the root_graphs loop calls `incoming_from_graph` for
    /// every (alt_root, graph) pair. A root_a graph can produce callers even
    /// when the item was stamped with root_b, because the (file_idx, def_idx)
    /// indices happen to be in bounds in root_a's graph.
    ///
    /// After fix (GREEN): the loop skips any graph whose key is not the
    /// canonicalized item root.
    #[tokio::test]
    async fn incoming_calls_rejects_item_from_other_root_silently_no_results() {
        // Build graph_a for root_a. main (file=0, def=0) is called by nobody,
        // but helper (file=1, def=0) IS called by main.
        let graph_a = test_graph();
        let root_a = PathBuf::from("/tmp/h2_root_a");
        let root_b = PathBuf::from("/tmp/h2_root_b");

        // Build an item stamped with root_b (file=1, def=0 = "helper").
        // helper has a caller in graph_a (main). If the loop doesn't filter by
        // root, it will find graph_a and return callers from root_a — cross-contamination.
        let item = def_to_item(&graph_a, 1, 0, &root_b).unwrap();

        // Inject graph_a under root_a.
        let repo_graph: Arc<tokio::sync::RwLock<Option<RepoGraph>>> =
            Arc::new(tokio::sync::RwLock::new(None));
        let root_graphs: Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<RepoGraph>>>> =
            Arc::new(tokio::sync::RwLock::new(HashMap::new()));
        root_graphs
            .write()
            .await
            .insert(root_a.clone(), Arc::new(graph_a));

        let params = CallHierarchyIncomingCallsParams {
            item,
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
        };
        // Session root is root_b. root_graphs has root_a → root_b mismatch.
        // The on-disk fallback will also fail (root_b doesn't exist → build_graph fails).
        // Expected: Ok(None) — item not found in any root_b-scoped graph.
        let result = incoming(params, &repo_graph, &root_graphs, &root_b).await;
        assert!(result.is_ok(), "incoming must not error");
        assert!(
            result.unwrap().is_none(),
            "incoming must return None when item root (root_b) has no graph in root_graphs \
             and must not contaminate from root_a's graph; I#15 cross-root leak"
        );
    }

    /// H2: when two roots are indexed and both have graphs in root_graphs,
    /// incoming for a root_a item must ONLY return callers from root_a's graph,
    /// not root_b's.
    #[tokio::test]
    async fn incoming_calls_two_indexed_roots_yields_disjoint_callers() {
        // graph_a: main (f=0,d=0) calls helper (f=1,d=0).
        // graph_b: main_b (f=0,d=0) calls util (f=1,d=0).
        // Both graphs have the SAME (file_idx=1, def_idx=0) for their callee.
        // An item stamped with root_a for (f=1, d=0) must ONLY return root_a callers.
        let graph_a = test_graph(); // helper at (1,0) has caller main at (0,0)
        let graph_b = test_graph(); // same structure, but logically in root_b

        let root_a = PathBuf::from("/tmp/h2_two_roots_a");
        let root_b = PathBuf::from("/tmp/h2_two_roots_b");

        // Item stamped with root_a for "helper" (file=1, def=0).
        let item = def_to_item(&graph_a, 1, 0, &root_a).unwrap();

        let repo_graph: Arc<tokio::sync::RwLock<Option<RepoGraph>>> =
            Arc::new(tokio::sync::RwLock::new(None));
        let root_graphs: Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<RepoGraph>>>> =
            Arc::new(tokio::sync::RwLock::new(HashMap::new()));
        {
            let mut rg = root_graphs.write().await;
            rg.insert(root_a.clone(), Arc::new(graph_a));
            rg.insert(root_b.clone(), Arc::new(graph_b));
        }

        let params = CallHierarchyIncomingCallsParams {
            item,
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
        };
        // Session root is root_a. Must use root_a's graph.
        let result = incoming(params, &repo_graph, &root_graphs, &root_a).await;
        assert!(result.is_ok(), "incoming must not error");
        let calls = result.unwrap();
        // Must get Some (root_a's graph found "helper" with callers).
        assert!(
            calls.is_some(),
            "incoming must return Some callers from root_a's graph"
        );
        let calls = calls.unwrap();
        // Exactly one caller: main (file=0, def=0) from root_a.
        assert_eq!(
            calls.len(),
            1,
            "must return exactly 1 caller from root_a, not 2 (from both graphs); I#15"
        );
        assert_eq!(calls[0].from.name, "main");
    }

    // ── N1: cursor-position resilience for prepare_call_hierarchy ─────────────

    /// Build a graph that models the go-stdlib `os_beforeExit` scenario:
    /// - One callable def (`os_beforeExit`) with `start_line=339, end_line=339`
    ///   (only the header is recorded; the body, including `Gosched`, is not a
    ///   separate def in the index).
    /// - Cursor at 0-based line 387 (== 1-based line 388, inside Gosched's body).
    ///
    /// On baseline: the range check [338..338] does not contain 387 → returns empty.
    /// After N1 fallback: largest start_line (338) <= cursor (387) → returns `os_beforeExit`.
    fn header_only_graph() -> RepoGraph {
        let files = vec![FileNode {
            path: "src/proc.go".to_string(),
            defs: vec![Definition {
                name: "os_beforeExit".to_string(),
                kind: "function_definition".to_string(),
                start_line: 339,
                end_line: 339, // header-only: body not captured in this def
                scope: String::new(),
                signature: Some("func os_beforeExit(code int)".to_string()),
                start_byte: 10000,
                end_byte: 10050,
                calls: vec![],
            }],
            imports: vec![],
        }];

        let def_offsets = vec![0];
        let def_callers = vec![vec![]];
        let def_callees = vec![vec![]];

        RepoGraph {
            files,
            edges: vec![],
            base_ranks: vec![0.5],
            callers: vec![vec![]],
            callees: vec![vec![]],
            def_edges: vec![],
            def_ranks: vec![0.5],
            def_callers,
            def_callees,
            def_offsets,
            alpha: 0.3,
        }
    }

    /// N1: cursor inside a function body (0-based 387 = 1-based 388) where only
    /// the function header (start_line=339, end_line=339) is indexed.
    ///
    /// **RED on baseline**: range check [338..338] excludes cursor 387 → empty.
    /// **GREEN after N1 fallback**: largest callable start_line (338) <= 387
    /// → returns `os_beforeExit` item.
    #[test]
    fn prepare_call_hierarchy_walks_to_enclosing_def_when_cursor_in_body() {
        let graph = header_only_graph();
        let root = PathBuf::from("/tmp/project");
        let req_path = root.join("src/proc.go");
        // 0-based line 387 is inside os_beforeExit's body (1-based: 388).
        let cursor_line = 387u32;

        let items = prepare_in_graph(&graph, &req_path, &root, cursor_line);
        assert!(
            !items.is_empty(),
            "prepare_in_graph must return the enclosing def (os_beforeExit) when cursor \
             is inside the body but def end_line covers only the header; N1 fallback"
        );
        assert_eq!(
            items[0].name, "os_beforeExit",
            "returned item must be os_beforeExit (the enclosing callable def)"
        );
    }

    /// N1: cursor exactly at the chunk header line (0-based 338 = 1-based 339).
    ///
    /// **Green on baseline and after fix**: the existing range check covers this
    /// when start_line == end_line == cursor_line. The fallback must not change
    /// this behavior.
    #[test]
    fn prepare_call_hierarchy_at_chunk_header_unchanged() {
        let graph = header_only_graph();
        let root = PathBuf::from("/tmp/project");
        let req_path = root.join("src/proc.go");
        // 0-based line 338 == 1-based line 339 (the header line itself).
        let cursor_line = 338u32;

        let items = prepare_in_graph(&graph, &req_path, &root, cursor_line);
        assert!(
            !items.is_empty(),
            "prepare_in_graph must return the def when cursor is at the header line; \
             existing behavior must be preserved (N1 non-regression)"
        );
        assert_eq!(
            items[0].name, "os_beforeExit",
            "returned item must be os_beforeExit when cursor is at header"
        );
    }
}
