//! MCP tool handlers for semantic search, similarity, and index management.
//!
//! All tools are defined in a single `#[tool_router]` impl block on
//! [`RipvecServer`]. Each tool returns JSON via `CallToolResult::success`.

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

use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, Content};
use rmcp::{tool, tool_router};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tower_lsp_server::ls_types::{
    CallHierarchyIncomingCall, CallHierarchyIncomingCallsParams, CallHierarchyItem,
    CallHierarchyOutgoingCall, CallHierarchyOutgoingCallsParams, CallHierarchyPrepareParams,
    DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, GotoDefinitionParams,
    GotoDefinitionResponse, Hover, HoverContents, HoverParams, Location, LocationLink,
    MarkedString, PartialResultParams, Position, Range, ReferenceContext, ReferenceParams,
    SymbolInformation, TextDocumentIdentifier, TextDocumentPositionParams, Uri,
    WorkDoneProgressParams, WorkspaceSymbol, WorkspaceSymbolParams, WorkspaceSymbolResponse,
    request,
};

use crate::result::{LspLocation, SearchResponse, SearchResultItem};
use crate::server::RipvecServer;

/// LSP-compatible source location accepted as **input** to position-scoped tools.
///
/// Mirrors the `lsp_location` shape every ripvec tool **returns**, so the
/// caller can pass a result's `lsp_location` field directly as the `location`
/// parameter without any destructuring.
///
/// Lines and characters are 0-based, matching LSP semantics.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct LspLocationInput {
    /// Absolute or relative file path.
    pub file_path: String,
    /// 0-based start line (matches `lsp_location.start_line`).
    #[serde(deserialize_with = "deserialize_number_or_string")]
    pub start_line: u32,
    /// 0-based start character (matches `lsp_location.start_character`).
    #[serde(default, deserialize_with = "deserialize_number_or_string")]
    pub start_character: u32,
    /// 0-based end line (present to mirror the output `lsp_location` shape;
    /// ignored for position lookup). Allows callers to pass `lsp_location`
    /// objects verbatim without stripping fields.
    #[expect(
        dead_code,
        reason = "present for output-shape symmetry; not used in position dispatch"
    )]
    #[serde(default, deserialize_with = "deserialize_number_or_string")]
    pub end_line: u32,
    /// 0-based end character (present to mirror the output `lsp_location` shape;
    /// ignored for position lookup). Allows callers to pass `lsp_location`
    /// objects verbatim without stripping fields.
    #[expect(
        dead_code,
        reason = "present for output-shape symmetry; not used in position dispatch"
    )]
    #[serde(default, deserialize_with = "deserialize_number_or_string")]
    pub end_character: u32,
}

/// Outcome of an `ensure_ripvec_root` reconcile attempt.
///
/// Boxed `index` because the variant is bigger than `Unchanged` and
/// clippy flags the size disparity otherwise.
enum ReconcileOutcome {
    /// The cached index is up to date — no work needed.
    Unchanged,
    /// The cached index was stale; this is the updated one.
    Updated {
        index: Box<ripvec_core::encoder::ripvec::index::RipvecIndex>,
        dirty: usize,
        new: usize,
        deleted: usize,
    },
    /// No structural changes (chunks/embeddings identical) but some files
    /// had their stat tuples refreshed (touch-without-content-change).
    /// The index is returned with an updated manifest so the next diff is
    /// cheaper (no repeated blake3 reads for the same untouched content).
    TouchRefreshed {
        index: Box<ripvec_core::encoder::ripvec::index::RipvecIndex>,
        touched: usize,
    },
}

/// Deserialize a value that may arrive as either a number or a string.
fn deserialize_number_or_string<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
    D: serde::Deserializer<'de>,
    T: std::str::FromStr + serde::Deserialize<'de>,
    <T as std::str::FromStr>::Err: std::fmt::Display,
{
    use serde::de::Error;
    #[derive(Deserialize)]
    #[serde(untagged)]
    enum StringOrNum<T> {
        Num(T),
        Str(String),
    }
    match StringOrNum::<T>::deserialize(deserializer)? {
        StringOrNum::Num(v) => Ok(v),
        StringOrNum::Str(s) => s.parse().map_err(D::Error::custom),
    }
}

/// Parameters for the `search` tool.
///
/// The single unified search entry point. Use `corpus` to control which
/// files participate, `rerank` to control the cross-encoder, and
/// `include_metadata` to include structured-data files. Legacy `scope`
/// is removed in 4.0 — use `corpus` instead.
#[derive(Deserialize, JsonSchema)]
pub struct SearchParams {
    /// Natural-language query describing what you're looking for.
    /// Descriptive phrases ("session handling", "graceful shutdown")
    /// work better than keyword queries — semantic search reads the
    /// whole phrase as a concept. Symbol-shaped queries (`Foo::Bar`,
    /// `parseJson`) are auto-detected and routed through identifier
    /// matching instead.
    pub query: String,
    /// Corpus filter: which file types participate in the search.
    /// - `"code"` (default) — only code files (`.rs`, `.py`, `.ts`, etc.).
    /// - `"docs"` — only prose / documentation files (`.md`, `.rst`,
    ///   `.txt`, `.adoc`, `.org`).
    /// - `"all"` — no extension whitelist; all file types participate.
    ///
    /// Use `"docs"` when the answer is "discussed" (READMEs, design docs,
    /// RFCs); use `"code"` when the answer is "implemented"; use `"all"`
    /// when you need both.
    #[serde(default)]
    pub corpus: Corpus,
    /// Reranker policy: whether the cross-encoder (TinyBERT-L-2-v2) fires.
    /// - `"auto"` (default) — rerank when the indexed corpus is prose-heavy
    ///   (≥ 30% prose chunks) and the query is NL-shaped (not a symbol).
    /// - `"always"` — force rerank regardless of corpus shape or query type.
    /// - `"never"` — skip rerank unconditionally (fastest path; good for
    ///   large code-only corpora where rerank adds no quality lift).
    #[serde(default)]
    pub rerank: RerankPolicy,
    /// When `true`, include chunks from structured-data files (`.json`,
    /// `.yaml`, `.toml`, `.xml`, `.lock`, `.snap`, `.csv`, `.tsv`,
    /// `.proto`). Default: `false`. Meta files are excluded by default
    /// to prevent calibration JSON, lock files, and other machine-generated
    /// structured data from flooding search results (Issue #5 / C2).
    #[serde(default)]
    pub include_metadata: bool,
    /// Optional whitelist of file extensions to search. Empty means
    /// "no whitelist" — `corpus` decides. When non-empty this overrides
    /// `corpus`'s implicit allow-list. Pass without leading dots
    /// (`["py", "md"]`, not `[".py", ".md"]`).
    #[serde(default)]
    pub include_extensions: Vec<String>,
    /// Optional blacklist of file extensions to exclude. Applies on
    /// top of `corpus` and `include_extensions`. Same format as the
    /// whitelist.
    #[serde(default)]
    pub exclude_extensions: Vec<String>,
    /// Maximum number of results to return.
    #[serde(
        default = "default_top_k",
        deserialize_with = "deserialize_number_or_string"
    )]
    pub top_k: usize,
    /// Skip the first N results (for pagination). Default: 0.
    #[serde(
        default = "default_offset",
        deserialize_with = "deserialize_number_or_string"
    )]
    pub offset: usize,
    /// Project root directory to search. Uses the pre-built index if available
    /// for this path, otherwise falls back to the server's default project root.
    /// Accepts absolute paths or paths relative to the server's project root.
    pub root: Option<String>,
}

/// Corpus filter exposed in the MCP tool schema.
///
/// Controls which file types participate in the search. Orthogonal to
/// [`RerankPolicy`] — corpus selects *what* is searched; rerank controls
/// *how* results are ranked after retrieval.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Corpus {
    /// Only code-language files. The default.
    #[default]
    Code,
    /// Only prose / documentation files. Rerank fires on NL queries (under Auto policy).
    Docs,
    /// All files; no extension whitelist.
    All,
}

impl From<Corpus> for ripvec_core::embed::Corpus {
    fn from(value: Corpus) -> Self {
        match value {
            Corpus::Code => Self::Code,
            Corpus::Docs => Self::Docs,
            Corpus::All => Self::All,
        }
    }
}

/// Reranker firing policy exposed in the MCP tool schema.
///
/// Controls whether the cross-encoder (TinyBERT-L-2-v2 / ms-marco) runs
/// after bi-encoder retrieval. Orthogonal to [`Corpus`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum RerankPolicy {
    /// Rerank when the indexed corpus is prose-heavy and the query is NL-shaped.
    /// This mirrors the pre-4.0 implicit behavior.
    #[default]
    Auto,
    /// Force rerank regardless of corpus shape or query type.
    Always,
    /// Skip rerank unconditionally.
    Never,
}

/// Decide whether the cross-encoder reranker should fire for a
/// given (`policy`, corpus, corpus class, query) combination.
///
/// Policy resolution:
/// - Symbol-shaped queries never rerank regardless of explicit policy —
///   the bi-encoder + BM25 path handles them well; cross-encoder adds
///   ~1 s cost with no quality lift on identifier queries.
/// - `Never` → always false.
/// - `Always` → true (symbol guard above is the only override).
/// - `Auto`:
///   - `Corpus::Code` → never (ms-marco out-of-domain for code).
///   - `Corpus::Docs` → always (verified NDCG@10 lift on prose).
///   - `Corpus::All` → defer to corpus classification (≥ 30% prose → fire).
fn should_rerank(
    policy: RerankPolicy,
    corpus: ripvec_core::embed::Corpus,
    corpus_class: ripvec_core::encoder::ripvec::index::CorpusClass,
    query: &str,
) -> bool {
    // Symbol-shaped queries skip cross-encoder rerank unconditionally.
    if ripvec_core::encoder::ripvec::ranking::is_symbol_query(query) {
        return false;
    }
    match policy {
        RerankPolicy::Never => false,
        RerankPolicy::Always => true,
        RerankPolicy::Auto => match corpus {
            ripvec_core::embed::Corpus::Code => false,
            ripvec_core::embed::Corpus::Docs => true,
            ripvec_core::embed::Corpus::All => corpus_class.rerank_eligible(),
        },
    }
}

/// Build a path allow-list for `RipvecIndex::search`'s `filter_paths`
/// argument by intersecting the indexed chunks against the corpus's
/// implied extension set, the `include_metadata` flag, and the explicit
/// include/exclude lists.
///
/// Returns `None` when no filter is needed so the underlying search runs
/// over the full corpus with zero per-query overhead.
///
/// No-op short-circuits (returns `None`):
/// - `Corpus::All` + `include_metadata=true` with no explicit overrides —
///   everything participates, no filtering needed.
/// - `Corpus::Code` on a `CorpusClass::Code` corpus, `include_metadata=false`,
///   no explicit overrides — corpus is already overwhelmingly code.
/// - `Corpus::Docs` on a `CorpusClass::Docs` corpus, `include_metadata=false`,
///   no explicit overrides — corpus is already overwhelmingly docs.
///
/// `include_metadata=false` (default) always filters out chunks whose
/// `content_kind == ContentKind::Meta` (JSON, YAML, TOML, etc.) unless
/// the caller's explicit `include_extensions` list explicitly names a
/// meta extension, or `include_metadata=true` is passed.
fn derive_filter_paths(
    chunks: &[ripvec_core::chunk::CodeChunk],
    corpus: ripvec_core::embed::Corpus,
    corpus_class: ripvec_core::encoder::ripvec::index::CorpusClass,
    include_metadata: bool,
    include: &[String],
    exclude: &[String],
) -> Option<Vec<String>> {
    let include_set: std::collections::HashSet<String> = include
        .iter()
        .map(|e| e.trim_start_matches('.').to_ascii_lowercase())
        .collect();
    let exclude_set: std::collections::HashSet<String> = exclude
        .iter()
        .map(|e| e.trim_start_matches('.').to_ascii_lowercase())
        .collect();
    let no_overrides = include_set.is_empty() && exclude_set.is_empty();

    // No-op short-circuit: everything participates and no filtering.
    if no_overrides && include_metadata && matches!(corpus, ripvec_core::embed::Corpus::All) {
        return None;
    }
    // No-op short-circuit: corpus matches CorpusClass and metadata excluded.
    if no_overrides
        && !include_metadata
        && ((matches!(corpus, ripvec_core::embed::Corpus::Code)
            && matches!(
                corpus_class,
                ripvec_core::encoder::ripvec::index::CorpusClass::Code
            ))
            || (matches!(corpus, ripvec_core::embed::Corpus::Docs)
                && matches!(
                    corpus_class,
                    ripvec_core::encoder::ripvec::index::CorpusClass::Docs
                )))
    {
        // Even when corpus matches, we still need to filter if there are any
        // Meta chunks in the index (possible when corpus_class is Code but the
        // index accidentally includes config files). Fall through to the general
        // path to be safe.
        // If no Meta chunks exist, this is a genuine no-op. We keep the fast
        // path only when we can guarantee no Meta leaks: when corpus_class is
        // a pure Code or pure Docs class. Since we can't check that cheaply
        // here, we fall through.
    }

    let prose_extensions: std::collections::HashSet<&str> = ripvec_core::embed::PROSE_EXTENSIONS
        .iter()
        .copied()
        .collect();

    // `allow_chunk`: returns true when this chunk should be included.
    // Checks three axes in order:
    //   1. Explicit exclude list (always wins).
    //   2. ContentKind::Meta exclusion (when include_metadata=false).
    //   3. Corpus-implied extension filter.
    //   4. Explicit include list.
    let allow_chunk = |chunk: &ripvec_core::chunk::CodeChunk| -> bool {
        let path = &chunk.file_path;
        let ext = std::path::Path::new(path.as_str())
            .extension()
            .and_then(|e| e.to_str())
            .unwrap_or("");
        let lower = ext.to_ascii_lowercase();

        // Explicit exclude wins unconditionally.
        if exclude_set.contains(&lower) {
            return false;
        }

        // Meta-kind filter: exclude Meta chunks unless caller opted in.
        // When the explicit include_extensions list contains a meta extension
        // (e.g. ["json"]), the caller explicitly asked for it — respect that.
        if !include_metadata
            && chunk.content_kind == ripvec_core::chunk::ContentKind::Meta
            && !include_set.contains(&lower)
        {
            return false;
        }

        // Explicit include list overrides corpus filter.
        if !include_set.is_empty() {
            return include_set.contains(&lower);
        }

        // Corpus-implied filter (only when no explicit include override).
        match corpus {
            ripvec_core::embed::Corpus::All => true,
            ripvec_core::embed::Corpus::Docs => prose_extensions.contains(lower.as_str()),
            ripvec_core::embed::Corpus::Code => {
                // Code corpus: exclude prose AND (when include_metadata=false) meta.
                // The meta exclusion above already handled the ContentKind gate;
                // here we additionally exclude prose extensions.
                !prose_extensions.contains(lower.as_str())
            }
        }
    };

    let mut allowed: std::collections::HashSet<&str> = std::collections::HashSet::new();
    for chunk in chunks {
        let path = chunk.file_path.as_str();
        if allowed.contains(path) {
            continue;
        }
        if allow_chunk(chunk) {
            allowed.insert(path);
        }
    }

    // If every chunk passed (same as no-filter), return None to skip the
    // overhead. This handles the Corpus::All + include_metadata=true case
    // that wasn't caught by the early short-circuits.
    if allowed.len()
        == chunks
            .iter()
            .map(|c| c.file_path.as_str())
            .collect::<std::collections::HashSet<_>>()
            .len()
    {
        return None;
    }

    Some(allowed.into_iter().map(str::to_owned).collect())
}

/// Parameters for the `find_similar` tool.
///
/// Accepts **either** a `location` (LSP-shaped, as returned by `search` /
/// `find_similar` / `lsp_*` tools) **or** a `symbol_name` (resolved via the
/// workspace symbol index). Exactly one of the two must be supplied; supplying
/// neither or both is a validation error.
///
/// The legacy `(file_path, line)` pair is **removed** in 4.0. Pass
/// `location: { file_path, start_line, ... }` instead.
#[derive(Deserialize, JsonSchema)]
pub struct FindSimilarParams {
    /// LSP-shaped location from a previous `search`, `find_similar`, or `lsp_*`
    /// result. Pass `results[0].lsp_location` directly — no destructuring needed.
    /// Supply exactly one of `location` or `symbol_name`.
    pub location: Option<LspLocationInput>,
    /// Resolve by symbol name. The workspace symbol index is queried for the
    /// best unique match; ambiguous names return an error listing candidates.
    /// Supply exactly one of `location` or `symbol_name`.
    pub symbol_name: Option<String>,
    /// Corpus filter applied to the similarity candidates. Same semantics as
    /// `search.corpus`. Default: `code`.
    #[serde(default)]
    pub corpus: Corpus,
    /// Reranker policy for similarity results. Same semantics as `search.rerank`.
    /// Default: `auto`.
    #[serde(default)]
    pub rerank: RerankPolicy,
    /// When `true`, include metadata chunks (`.json`, `.yaml`, etc.) in
    /// similarity candidates. Default: `false`.
    #[serde(default)]
    pub include_metadata: bool,
    /// Maximum number of results to return.
    #[serde(
        default = "default_top_k",
        deserialize_with = "deserialize_number_or_string"
    )]
    pub top_k: usize,
    /// Skip the first N results (for pagination). Default: 0.
    #[serde(
        default = "default_offset",
        deserialize_with = "deserialize_number_or_string"
    )]
    pub offset: usize,
    /// Project root directory. Uses the server's default project root if omitted.
    pub root: Option<String>,
}

/// Default number of results to return.
fn default_top_k() -> usize {
    10
}

/// Relative threshold (fraction of top-result score) applied AFTER the
/// pipeline ranks results, BEFORE the `top_k` slice.
///
/// Pre-v3.0.0 the MCP search tool exposed an absolute `threshold`
/// parameter defaulting to 0.3, tuned for the ModernBERT/BGE-small
/// dense bi-encoders whose cosines on relevant pairs landed in the
/// 0.4-0.8 range. The v3.0.0 Model2Vec static encoder produces much
/// lower raw cosines (0.005-0.07 on code, 0.05-1.00 on prose with
/// cross-encoder rerank applied) because static embeddings carry no
/// cross-token interactions and the rerank stretches the docs score
/// range. The pre-surgery default silently filtered every legitimate
/// result under v3.0.0.
///
/// v3.0.0 removes the exposed `threshold` parameter and pins the cutoff
/// to a relative value calibrated empirically: filter results whose
/// normalized score (score / top_result_score) falls below the cutoff.
/// The cutoff is scope-aware because the rerank-fired and rerank-skipped
/// distributions are fundamentally different:
///
/// - rerank skipped (scope=code, scope=all on a code-heavy corpus, or
///   any symbol-shaped query): cutoff = [`RELATIVE_THRESHOLD_NO_RERANK`]
/// - rerank fired (scope=docs or scope=all on prose-heavy corpus):
///   cutoff = [`RELATIVE_THRESHOLD_WITH_RERANK`]
///
/// ## Calibration methodology
///
/// 45 queries (20 tokio code, 15 gutenberg prose, 10 ripvec mixed) ran
/// through the production pipeline; the top-100 results per query were
/// judged by 45 parallel haiku agents as RELEVANT / PARTIALLY_RELEVANT /
/// NOT_RELEVANT / UNCERTAIN. The 2,250-judgment corpus was bucketed by
/// normalized cosine (relative to per-query top result). The cutoff
/// values are the F1_lenient-optimal points (treating PARTIAL as a hit,
/// matching the use case where MCP search guides Claude/Codex to a
/// likely-relevant neighborhood):
///
/// |   thresholds   | rel_recall | rel_prec | F1_strict | sig_recall | sig_prec | F1_lenient |
/// |---------------:|-----------:|---------:|----------:|-----------:|---------:|-----------:|
/// | 0.05 / 0.20    |     98.5%  |   28.6%  |    0.443  |     97.8%  |   51.6%  |    0.676   |
/// | **0.10 / 0.30**| **97.3%**  | **30.4%**|  **0.463**| **96.2%**  | **54.6%**|  **0.696** | ← chosen
/// | 0.15 / 0.35    |     90.9%  |   31.1%  |    0.463  |     89.2%  |   55.4%  |    0.683   |
/// | 0.20 / 0.40    |     82.3%  |   31.7%  |    0.457  |     80.6%  |   56.4%  |    0.663   |
/// | 0.30 / 0.50    |     64.4%  |   39.3%  |    0.488  |     58.2%  |   64.7%  |    0.613   |
///
/// 0/44 queries with RELEVANT results lose all of them at any pair
/// tested. The selected 0.10/0.30 cutoff drops ~42% of noise globally
/// while preserving 97% of strict relevance and 96% of (strict +
/// partial) signal.
///
/// See `crates/ripvec-core/examples/threshold_calibration.rs` and
/// `docs/surgery/threshold_calibration/` for the raw data and analysis.
const RELATIVE_THRESHOLD_NO_RERANK: f32 = 0.10;
const RELATIVE_THRESHOLD_WITH_RERANK: f32 = 0.30;

/// Default pagination offset.
fn default_offset() -> usize {
    0
}

/// Parameters for the `get_repo_map` tool (4.0.1 shape — G5).
///
/// Replaces the `max_files` parameter from 4.0.0 with `token_budget`.
/// The budget is allocated across files proportional to their `PageRank`
/// scores (40% cap per file, 200-byte envelope floor). Symbols within each
/// file are filled in def-rank order with a logarithmic attenuation cutoff.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct GetRepoMapParams {
    /// Token budget for the response (default: 4000 tokens).
    ///
    /// Internally converted to a byte budget (`token_budget × 4`). The budget
    /// is allocated across files proportional to their `PageRank` scores, with
    /// a 40% cap on any single file's share and a 200-byte minimum envelope.
    /// Files are included in rank order until the cumulative allocation would
    /// exceed the total budget.
    ///
    /// The response's `estimated_bytes`, `budget_bytes`, and `budget_exhausted`
    /// fields report how closely the budget was consumed.
    ///
    /// Replaces `max_files` from 4.0.0 (which controlled file count but not
    /// per-file symbol expansion, leading to unbounded response sizes).
    #[serde(
        default = "default_repo_map_token_budget",
        deserialize_with = "deserialize_number_or_string"
    )]
    pub token_budget: usize,
    /// When `true`, include files whose extension classifies as metadata
    /// (`.json`, `.yaml`, `.toml`, `.xml`, `.lock`, `.snap`, etc.) in the
    /// response. Default: `false`.
    ///
    /// Meta files are excluded by default to prevent calibration JSON, lock
    /// files, and other machine-generated structured data from flooding the
    /// repo map.
    #[serde(default)]
    pub include_metadata: bool,
    /// Focus file for topic-sensitive `PageRank`.
    ///
    /// When provided, `PageRank` is recomputed with this file as the
    /// personalization seed so that its dependency neighborhood appears at
    /// the top of the results. Pass a relative path suffix (e.g. `"tools.rs"`)
    /// or the full relative path (e.g. `"crates/ripvec-mcp/src/tools.rs"`).
    pub focus_file: Option<String>,
    /// Project root directory. Uses the server's default project root if omitted.
    pub root: Option<String>,
}

/// Parameters for the `find_duplicates` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct FindDuplicatesParams {
    /// Project root directory to search. Uses the server's default project root if omitted.
    pub root: Option<String>,
    /// Minimum cosine similarity threshold for a pair to be considered a duplicate.
    /// Default: 0.5 (calibrated for Model2Vec static encoder on code corpora).
    /// Higher values (0.75+) find near-exact copies; lower values (0.3-0.5)
    /// find structurally similar patterns that may be refactorable.
    pub threshold: Option<f32>,
    /// Maximum number of duplicate pairs to return. Default: 20.
    pub max_pairs: Option<usize>,
    /// Whether to include pairs where both chunks come from the same file.
    /// Default: false (only cross-file duplicates are returned). Set to true
    /// to also surface intra-file repeated patterns.
    pub intra_file: Option<bool>,
    /// Corpus filter: which file types participate in the duplicate search.
    /// Default: `code` (excludes prose and meta files).
    #[serde(default)]
    pub corpus: Corpus,
    /// When `true`, include metadata chunks (`.json`, `.yaml`, etc.) in the
    /// duplicate analysis. Default: `false` — calibration JSON and config
    /// files often dominate duplicate results; this filter removes them.
    #[serde(default)]
    pub include_metadata: bool,
}

/// Parameters for the `find_dead_code` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct FindDeadCodeParams {
    /// Project root directory to analyse. Uses the server's default project
    /// root if omitted.
    pub root: Option<String>,
    /// When `true` (default), definitions in test files are included as
    /// reachability seeds, so test-only code is not reported as dead.
    /// Set to `false` to treat test paths as unreachable -- useful to measure
    /// production-only dead code.
    pub include_test_paths: Option<bool>,
    /// Maximum number of dead clusters to return. Default: 50.
    /// Clusters are sorted by size descending before truncation.
    pub max_clusters: Option<usize>,
    /// Minimum cluster size to include in the output. Default: 1.
    /// Set to 2+ to suppress singleton dead defs and focus on call-graph
    /// islands (groups of unreachable functions that call each other).
    pub min_cluster_size: Option<usize>,
}

/// A single dead definition, with its LSP-grounded location.
#[derive(Serialize)]
pub struct DeadDefItem {
    /// Definition name (e.g., function or struct identifier).
    pub name: String,
    /// LSP-compatible 0-based source location.
    pub lsp_location: LspLocation,
}

/// One connected component of dead definitions.
#[derive(Serialize)]
pub struct DeadClusterItem {
    /// The highest-PageRank member -- the best first-investigation target.
    pub root_node: DeadDefItem,
    /// Number of definitions in this cluster.
    pub size: usize,
    /// Estimated lines of code across all cluster members.
    pub total_lines: usize,
    /// All member definitions (root first, then rest in definition order).
    pub member_defs: Vec<DeadDefItem>,
}

/// Response from the `find_dead_code` tool.
#[derive(Serialize)]
pub struct FindDeadCodeResponse {
    /// Dead clusters sorted by size descending.
    pub dead_clusters: Vec<DeadClusterItem>,
    /// Total definitions unreachable from any entry point.
    pub total_dead_defs: usize,
    /// Total definitions reachable from at least one entry point.
    pub total_live_defs: usize,
    /// Fraction of all definitions that are dead (0.0 - 1.0).
    pub dead_fraction: f32,
    /// Human-readable summary of entry points detected, grouped by kind.
    pub entry_points_detected: Vec<String>,
    /// `true` when the cluster list was truncated by `max_clusters`.
    pub capped: bool,
}

/// Parameters for the `debug_log` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DebugLogParams {
    /// Maximum number of recent log lines to return. Default: 50.
    #[serde(default = "default_debug_log_lines")]
    pub lines: usize,
}

/// Default number of log lines to return.
fn default_debug_log_lines() -> usize {
    50
}

/// Parameters for the `log_level` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LogLevelParams {
    /// Filter directive. Examples: "debug", "warn", "ripvec_mcp=debug,ripvec_core=trace".
    pub level: String,
}

/// Parameters for LSP-style document-scoped MCP tools.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LspDocumentParams {
    /// Absolute path or path relative to `root`/the server project root.
    /// You can pass `results[].lsp_location.file_path` from `search` or
    /// `find_similar` directly here.
    pub file_path: String,
    /// Project root directory. Uses the server's default project root if omitted.
    pub root: Option<String>,
}

/// Parameters for LSP-style position-scoped MCP tools (A1 shape).
///
/// Accepts only the `location` object shape — the same object every ripvec
/// tool returns in `results[].lsp_location`. Pass it directly without
/// destructuring.
///
/// The legacy `(file_path, line, character)` triple is **removed** in 4.0.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LspPositionParams {
    /// LSP-shaped location from a `search`, `find_similar`, or `lsp_*` result.
    /// Pass `results[0].lsp_location` directly — no destructuring needed.
    /// Fields used: `file_path`, `start_line`, `start_character`.
    pub location: LspLocationInput,
    /// Project root directory. Uses the server's default project root if omitted.
    pub root: Option<String>,
}

/// Parameters for LSP-style workspace symbol search.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LspWorkspaceSymbolParams {
    /// Non-empty symbol query. Use this when a semantic result gave you a
    /// likely symbol name and you want LSP-style workspace resolution.
    pub query: String,
    /// Project root directory. Ensures that root is indexed before searching.
    pub root: Option<String>,
}

/// Parameters for LSP-style reference lookup (A1 shape).
///
/// Accepts only the `location` object shape — the same object every ripvec
/// tool returns in `results[].lsp_location`. Pass it directly without
/// destructuring.
///
/// The legacy `(file_path, line, character)` triple is **removed** in 4.0.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LspReferenceParams {
    /// LSP-shaped location from a `search`, `find_similar`, or `lsp_*` result.
    /// Pass `results[0].lsp_location` directly — no destructuring needed.
    /// Fields used: `file_path`, `start_line`, `start_character`.
    pub location: LspLocationInput,
    /// Include the declaration of the current symbol.
    #[serde(default = "default_include_declaration")]
    pub include_declaration: bool,
    /// Project root directory. Uses the server's default project root if omitted.
    pub root: Option<String>,
}

/// Emit a JSON Schema for `CallHierarchyItem` that declares `type: object`.
///
/// Without this annotation the MCP framework treats the parameter as a
/// string and the server's deserializer rejects it with
/// "invalid type: string, expected struct CallHierarchyItem" (Issue #1 /
/// A3).
fn call_hierarchy_item_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
    schemars::json_schema!({
        "type": "object",
        "description": "A CallHierarchyItem returned by lsp_prepare_call_hierarchy. \
                        Pass results[0].lsp.raw_response[0] or results[0] directly \
                        without stringification.",
        "additionalProperties": true,
    })
}

/// Parameters for call hierarchy incoming/outgoing tools (A3 shape).
///
/// The `item` parameter is declared with `type: object` in the JSON schema so
/// MCP frameworks pass it as a structured object rather than stringifying it.
/// Before 4.0 the schema lacked a type annotation, causing frameworks to coerce
/// the value to a string and the server to fail with "invalid CallHierarchyItem:
/// invalid type: string" (Issue #1 / A3).
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LspCallHierarchyItemParams {
    /// A `CallHierarchyItem` returned by `lsp_prepare_call_hierarchy`.
    ///
    /// Pass the item from `lsp_prepare_call_hierarchy` results directly —
    /// the MCP framework will pass it as a structured object. Do NOT
    /// stringify it manually; that was the pre-4.0 workaround that this fix
    /// removes.
    #[schemars(schema_with = "call_hierarchy_item_schema")]
    pub item: serde_json::Value,
    /// Project root directory. Uses the item's embedded root/default root if omitted.
    pub root: Option<String>,
}

/// Response envelope for `lsp_workspace_symbols`.
///
/// Extends the standard [`LspMcpResponse`] shape with dedup accounting fields
/// so callers can compute the **naming drift index** (E2):
///
/// ```text
/// drift_index = pre_dedup_count - post_dedup_count
/// ```
///
/// A high drift index means the same symbol name appears many times across
/// the corpus — a signal of code duplication or inconsistent naming.
///
/// `drift_meaningful` (M2) is `true` when the collection working set was
/// unsaturated — i.e. fewer than `COLLECTION_CAP_PER_ROOT × n_roots_searched`
/// symbols were gathered before dedup. When `true`, the drift quotient reflects
/// the real symbol population. When `false`, the cap was hit and drift may be
/// under-counted; consumers should not rely on the drift quotient in that case.
#[derive(serde::Serialize)]
struct LspWorkspaceSymbolResponse<'a, T: serde::Serialize> {
    /// Ripvec-native result list shaped like semantic search output.
    pub results: Vec<LspMcpResultItem>,
    /// Native LSP method and raw response retained for exact LSP semantics.
    pub lsp: LspPayload<'a, T>,
    /// Symbol count **before** (name, kind) deduplication. (E2)
    pub pre_dedup_count: usize,
    /// Symbol count **after** deduplication — equals `results.len()`. (E2)
    pub post_dedup_count: usize,
    /// `true` when collection was unsaturated and drift is a reliable signal.
    /// `false` when the per-root cap was hit; drift may be under-counted. (M2)
    pub drift_meaningful: bool,
}

/// Default token budget for repo map rendering (G5).
///
/// 4000 tokens × 4 bytes/token = 16 000 bytes, which comfortably fits
/// a typical mid-sized codebase overview with ranked symbols.
fn default_repo_map_token_budget() -> usize {
    4000
}

/// Default `include_declaration` for reference lookup.
const fn default_include_declaration() -> bool {
    true
}

/// K4 contract: every `LspMcpResultItem` carries BOTH a friendly `symbol_kind` string
/// AND a numeric `symbol_kind_int` integer. Clients may read either representation;
/// both are always emitted together so chained tool calls can filter by either form
/// without the ambiguity that arose from `results[]` using the friendly string while
/// `lsp.raw_response` carried the raw LSP integer.
///
/// Mapping:
/// - `symbol_kind`: lowercase Debug representation of the `SymbolKind` enum
///   (e.g. `"interface"`, `"function"`, `"property"`).
/// - `symbol_kind_int`: LSP SymbolKind integer per §3.15.1 of the LSP spec 3.17
///   (e.g. `11` for Interface, `12` for Function, `22` for Property).
#[derive(Serialize)]
struct LspMcpResultItem {
    /// Same shape as `search.results[].lsp_location`; feed directly into
    /// `lsp_hover`, `lsp_goto_definition`, or native LSP tools.
    pub lsp_location: LspLocation,
    /// Symbol or item name when known.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub symbol_name: Option<String>,
    /// Friendly LSP symbol kind string (K4 contract: always paired with `symbol_kind_int`).
    /// Examples: `"interface"`, `"function"`, `"property"`, `"struct"`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub symbol_kind: Option<String>,
    /// LSP SymbolKind integer (K4 contract: always paired with `symbol_kind`).
    /// Values are per LSP spec 3.17 §3.15.1 (e.g. 11=Interface, 12=Function, 22=Property).
    /// Present whenever `symbol_kind` is present; both fields or neither.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub symbol_kind_int: Option<u32>,
    /// Short source or hover preview for quick model grounding.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub preview: Option<String>,
    /// Additional LSP detail, e.g. call hierarchy item detail.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
}

#[derive(Serialize)]
struct LspPayload<'a, T: Serialize> {
    /// Native LSP method mirrored by this MCP tool.
    pub method: &'a str,
    /// Exact native LSP-compatible response for clients that want protocol fidelity.
    pub raw_response: T,
}

#[derive(Serialize)]
struct LspMcpResponse<'a, T: Serialize> {
    /// Ripvec-native result list shaped like semantic search output.
    pub results: Vec<LspMcpResultItem>,
    /// Native LSP method and raw response retained for exact LSP semantics.
    pub lsp: LspPayload<'a, T>,
}

fn lsp_mcp_tool_result<T: Serialize>(
    method: &'static str,
    raw_response: &T,
    results: Vec<LspMcpResultItem>,
) -> Result<CallToolResult, rmcp::ErrorData> {
    let response = LspMcpResponse {
        results,
        lsp: LspPayload {
            method,
            raw_response,
        },
    };
    let json = serde_json::to_string_pretty(&response)
        .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
    Ok(CallToolResult::success(vec![Content::text(json)]))
}

fn lsp_error(error: &tower_lsp_server::jsonrpc::Error) -> rmcp::ErrorData {
    rmcp::ErrorData::internal_error(error.to_string(), None)
}

fn uri_for_path(file_path: &Path) -> Result<Uri, rmcp::ErrorData> {
    Uri::from_file_path(file_path).ok_or_else(|| {
        rmcp::ErrorData::internal_error(
            format!(
                "failed to convert path to file URI: {}",
                file_path.display()
            ),
            None,
        )
    })
}

fn text_document_position(uri: Uri, line: u32, character: u32) -> TextDocumentPositionParams {
    TextDocumentPositionParams {
        text_document: TextDocumentIdentifier { uri },
        position: Position { line, character },
    }
}

fn lsp_line(line: u32) -> usize {
    usize::try_from(line).unwrap_or(usize::MAX)
}

fn range_to_lsp_location(uri: &Uri, range: &Range, root: &Path) -> Option<LspLocation> {
    let path_cow = uri.to_file_path()?;
    let path = path_cow.into_owned();
    let file_path = render_path(&path, root);
    Some(LspLocation {
        file_path,
        start_line: lsp_line(range.start.line),
        start_character: lsp_line(range.start.character),
        end_line: lsp_line(range.end.line),
        end_character: lsp_line(range.end.character),
    })
}

fn render_path(path: &Path, root: &Path) -> String {
    match path.strip_prefix(root) {
        Ok(relative) => format!("./{}", relative.display()),
        Err(_) => path.display().to_string(),
    }
}

fn preview_for_location(location: &LspLocation, root: &Path) -> Option<String> {
    let path = if let Some(relative) = location.file_path.strip_prefix("./") {
        root.join(relative)
    } else {
        PathBuf::from(&location.file_path)
    };
    let source = std::fs::read_to_string(path).ok()?;
    let line = source.lines().nth(location.start_line)?.trim();
    (!line.is_empty()).then(|| line.to_string())
}

fn symbol_kind_label<T: std::fmt::Debug>(kind: T) -> String {
    format!("{kind:?}").to_ascii_lowercase()
}

/// Convert a `SymbolKind` to its LSP integer value (K4 contract).
///
/// `SymbolKind` does not implement `Into<u32>` directly; we use the documented
/// LSP 3.17 §3.15.1 ordering, which matches `tower_lsp_server::ls_types::SymbolKind`'s
/// internal representation. The mapping is exhaustive over the 26 defined values;
/// unknown variants (future extensions) return 13 (Variable) as a safe default.
#[expect(
    clippy::match_same_arms,
    reason = "SK::VARIABLE (13) and the wildcard fallback both return 13 intentionally: \
              the named arm is the canonical LSP Variable mapping; the wildcard handles \
              future unknown variants. Collapsing them would lose the explicit documentation \
              that Variable is a valid defined value, not just a fallback."
)]
fn symbol_kind_to_u32(kind: tower_lsp_server::ls_types::SymbolKind) -> u32 {
    use tower_lsp_server::ls_types::SymbolKind as SK;
    // SymbolKind is a newtype around u32 in tower_lsp_server — extract the value
    // by matching against all known constants.
    match kind {
        SK::FILE => 1,
        SK::MODULE => 2,
        SK::NAMESPACE => 3,
        SK::PACKAGE => 4,
        SK::CLASS => 5,
        SK::METHOD => 6,
        SK::PROPERTY => 7,
        SK::FIELD => 8,
        SK::CONSTRUCTOR => 9,
        SK::ENUM => 10,
        SK::INTERFACE => 11,
        SK::FUNCTION => 12,
        SK::VARIABLE => 13,
        SK::CONSTANT => 14,
        SK::STRING => 15,
        SK::NUMBER => 16,
        SK::BOOLEAN => 17,
        SK::ARRAY => 18,
        SK::OBJECT => 19,
        SK::KEY => 20,
        SK::NULL => 21,
        SK::ENUM_MEMBER => 22,
        SK::STRUCT => 23,
        SK::EVENT => 24,
        SK::OPERATOR => 25,
        SK::TYPE_PARAMETER => 26,
        _ => 13, // VARIABLE as conservative fallback for unknown future values
    }
}

fn document_symbol_results(
    response: Option<&DocumentSymbolResponse>,
    file_path: &Path,
    root: &Path,
) -> Vec<LspMcpResultItem> {
    let Some(response) = response else {
        return Vec::new();
    };
    match response {
        DocumentSymbolResponse::Nested(symbols) => symbols
            .iter()
            .flat_map(|symbol| document_symbol_items(symbol, file_path, root))
            .collect(),
        DocumentSymbolResponse::Flat(symbols) => symbols
            .iter()
            .filter_map(|symbol| symbol_information_item(symbol, root))
            .collect(),
    }
}

fn document_symbol_items(
    symbol: &DocumentSymbol,
    file_path: &Path,
    root: &Path,
) -> Vec<LspMcpResultItem> {
    let Some(uri) = Uri::from_file_path(file_path) else {
        return Vec::new();
    };
    let mut items = Vec::new();
    if let Some(location) = range_to_lsp_location(&uri, &symbol.range, root) {
        // K4: emit both friendly string and integer for symbol kinds.
        let kind_int = symbol_kind_to_u32(symbol.kind);
        items.push(LspMcpResultItem {
            preview: preview_for_location(&location, root),
            lsp_location: location,
            symbol_name: Some(symbol.name.clone()),
            symbol_kind: Some(symbol_kind_label(symbol.kind)),
            symbol_kind_int: Some(kind_int),
            detail: symbol.detail.clone(),
        });
    }
    if let Some(children) = &symbol.children {
        for child in children {
            items.extend(document_symbol_items(child, file_path, root));
        }
    }
    items
}

fn workspace_symbol_results(
    response: Option<&WorkspaceSymbolResponse>,
    root: &Path,
) -> Vec<LspMcpResultItem> {
    let Some(response) = response else {
        return Vec::new();
    };
    match response {
        WorkspaceSymbolResponse::Flat(symbols) => symbols
            .iter()
            .filter_map(|symbol| symbol_information_item(symbol, root))
            .collect(),
        WorkspaceSymbolResponse::Nested(symbols) => symbols
            .iter()
            .filter_map(|symbol| workspace_symbol_item(symbol, root))
            .collect(),
    }
}

fn symbol_information_item(symbol: &SymbolInformation, root: &Path) -> Option<LspMcpResultItem> {
    let location = range_to_lsp_location(&symbol.location.uri, &symbol.location.range, root)?;
    // K4: emit both friendly string and integer for symbol kinds.
    let kind_int = symbol_kind_to_u32(symbol.kind);
    Some(LspMcpResultItem {
        preview: preview_for_location(&location, root),
        lsp_location: location,
        symbol_name: Some(symbol.name.clone()),
        symbol_kind: Some(symbol_kind_label(symbol.kind)),
        symbol_kind_int: Some(kind_int),
        detail: symbol.container_name.clone(),
    })
}

fn workspace_symbol_item(symbol: &WorkspaceSymbol, root: &Path) -> Option<LspMcpResultItem> {
    let location = match &symbol.location {
        tower_lsp_server::ls_types::OneOf::Left(location) => {
            range_to_lsp_location(&location.uri, &location.range, root)?
        }
        tower_lsp_server::ls_types::OneOf::Right(location) => range_to_lsp_location(
            &location.uri,
            &Range {
                start: Position {
                    line: 0,
                    character: 0,
                },
                end: Position {
                    line: 0,
                    character: 0,
                },
            },
            root,
        )?,
    };
    // K4: emit both friendly string and integer for symbol kinds.
    let kind_int = symbol_kind_to_u32(symbol.kind);
    Some(LspMcpResultItem {
        preview: preview_for_location(&location, root),
        lsp_location: location,
        symbol_name: Some(symbol.name.clone()),
        symbol_kind: Some(symbol_kind_label(symbol.kind)),
        symbol_kind_int: Some(kind_int),
        detail: symbol.container_name.clone(),
    })
}

fn hover_text(hover: &Hover) -> String {
    match &hover.contents {
        HoverContents::Markup(markup) => markup.value.clone(),
        HoverContents::Scalar(marked) => marked_string_text(marked),
        HoverContents::Array(values) => values
            .iter()
            .map(marked_string_text)
            .collect::<Vec<_>>()
            .join("\n"),
    }
}

fn marked_string_text(marked: &MarkedString) -> String {
    match marked {
        MarkedString::String(value) => value.clone(),
        MarkedString::LanguageString(value) => value.value.clone(),
    }
}

fn symbol_name_from_hover(text: &str) -> Option<String> {
    let mut parts = text.split('`');
    parts.next()?;
    let name = parts.next()?.trim();
    (!name.is_empty()).then(|| name.to_string())
}

fn hover_results(
    response: Option<&Hover>,
    file_path: &Path,
    root: &Path,
    line: u32,
    character: u32,
) -> Vec<LspMcpResultItem> {
    let Some(hover) = response else {
        return Vec::new();
    };
    let range = hover.range.unwrap_or(Range {
        start: Position { line, character },
        end: Position { line, character },
    });
    let Some(uri) = Uri::from_file_path(file_path) else {
        return Vec::new();
    };
    let Some(location) = range_to_lsp_location(&uri, &range, root) else {
        return Vec::new();
    };
    let text = hover_text(hover);
    vec![LspMcpResultItem {
        lsp_location: location,
        symbol_name: symbol_name_from_hover(&text),
        // K4: hover results are navigation artifacts, not symbol definitions;
        // no LSP SymbolKind integer applies. Both symbol_kind and symbol_kind_int
        // are Some/None together per the K4 "both or neither" contract.
        symbol_kind: Some("hover".to_string()),
        symbol_kind_int: None,
        preview: Some(text.lines().next().unwrap_or("").to_string()),
        detail: Some(text),
    }]
}

fn definition_results(
    response: Option<&GotoDefinitionResponse>,
    root: &Path,
    kind: &str,
) -> Vec<LspMcpResultItem> {
    let Some(response) = response else {
        return Vec::new();
    };
    match response {
        GotoDefinitionResponse::Scalar(location) => {
            location_result(location, root, kind).into_iter().collect()
        }
        GotoDefinitionResponse::Array(locations) => locations
            .iter()
            .filter_map(|location| location_result(location, root, kind))
            .collect(),
        GotoDefinitionResponse::Link(links) => links
            .iter()
            .filter_map(|link| location_link_result(link, root, kind))
            .collect(),
    }
}

fn location_results(
    locations: Option<&[Location]>,
    root: &Path,
    kind: &str,
) -> Vec<LspMcpResultItem> {
    locations
        .unwrap_or_default()
        .iter()
        .filter_map(|location| location_result(location, root, kind))
        .collect()
}

fn location_result(location: &Location, root: &Path, kind: &str) -> Option<LspMcpResultItem> {
    let lsp_location = range_to_lsp_location(&location.uri, &location.range, root)?;
    // K4: navigation result — kind is a descriptive label ("definition", "reference"), not
    // an LSP SymbolKind integer. Emit symbol_kind_int: None per the "both or neither" contract.
    Some(LspMcpResultItem {
        preview: preview_for_location(&lsp_location, root),
        lsp_location,
        symbol_name: None,
        symbol_kind: Some(kind.to_string()),
        symbol_kind_int: None,
        detail: None,
    })
}

fn location_link_result(link: &LocationLink, root: &Path, kind: &str) -> Option<LspMcpResultItem> {
    let lsp_location = range_to_lsp_location(&link.target_uri, &link.target_range, root)?;
    // K4: navigation result; symbol_kind_int: None per "both or neither" contract.
    Some(LspMcpResultItem {
        preview: preview_for_location(&lsp_location, root),
        lsp_location,
        symbol_name: None,
        symbol_kind: Some(kind.to_string()),
        symbol_kind_int: None,
        detail: None,
    })
}

fn call_hierarchy_item_result(
    item: &CallHierarchyItem,
    root: &Path,
    kind: &str,
) -> Option<LspMcpResultItem> {
    let lsp_location = range_to_lsp_location(&item.uri, &item.range, root)?;
    // K4: call hierarchy items use descriptive kind labels ("call_hierarchy_item",
    // "incoming_call", "outgoing_call"); symbol_kind_int: None per "both or neither" contract.
    Some(LspMcpResultItem {
        preview: preview_for_location(&lsp_location, root),
        lsp_location,
        symbol_name: Some(item.name.clone()),
        symbol_kind: Some(kind.to_string()),
        symbol_kind_int: None,
        detail: item.detail.clone(),
    })
}

fn call_hierarchy_results(
    response: Option<&[CallHierarchyItem]>,
    root: &Path,
) -> Vec<LspMcpResultItem> {
    response
        .unwrap_or_default()
        .iter()
        .filter_map(|item| call_hierarchy_item_result(item, root, "call_hierarchy_item"))
        .collect()
}

fn incoming_call_results(
    response: Option<&[CallHierarchyIncomingCall]>,
    root: &Path,
) -> Vec<LspMcpResultItem> {
    response
        .unwrap_or_default()
        .iter()
        .filter_map(|call| call_hierarchy_item_result(&call.from, root, "incoming_call"))
        .collect()
}

fn outgoing_call_results(
    response: Option<&[CallHierarchyOutgoingCall]>,
    root: &Path,
) -> Vec<LspMcpResultItem> {
    response
        .unwrap_or_default()
        .iter()
        .filter_map(|call| call_hierarchy_item_result(&call.to, root, "outgoing_call"))
        .collect()
}

/// Maximum number of chunks `find_duplicates` will consider.
///
/// All-pairs cosine over N chunks is O(N²·d). At 10 000 chunks × 256 dims
/// that is ~2.56 billion FP ops — borderline acceptable. Above this limit
/// the cost grows quadratically and the result set is rarely actionable,
/// so we return an early error and ask the caller to narrow the root or
/// raise the threshold.
const MAX_CORPUS_FOR_DUPLICATES: usize = 10_000;

/// Compute duplicate pairs from a scored corpus.
///
/// `n` is the total number of chunks. `file_paths` is a parallel slice of
/// length `n` where `file_paths[i]` is the source path for chunk `i`.
/// `score_fn` is called once per chunk index `a` and must return a
/// length-`n` vector of cosine similarities between chunk `a` and every
/// other chunk. Only indices `b > a` are examined (upper triangle), so
/// `score_fn` can be lazy about the lower triangle.
///
/// The function uses a bounded max-heap of size `max_pairs` to avoid
/// materialising a full sorted list. Time: O(N²·d + N²·log(max_pairs)).
/// Memory: O(max_pairs) heap + one score row at a time.
///
/// Returns at most `max_pairs` pairs sorted by descending similarity.
fn compute_duplicate_pairs(
    n: usize,
    file_paths: &[&str],
    score_fn: impl Fn(usize) -> Vec<f32>,
    threshold: f32,
    max_pairs: usize,
    intra_file: bool,
) -> Vec<(usize, usize, f32)> {
    use std::cmp::Reverse;
    use std::collections::BinaryHeap;

    // Heap stores (Reverse(score_bits), a, b) so the *lowest* scoring pair
    // is at the top and can be evicted when the heap overflows `max_pairs`.
    // We use the bit-cast of f32 as u32 for total ordering inside Ord;
    // this is valid because all cosine scores are in [0, 1] (L2-normalised,
    // threshold ≥ 0) so the IEEE-754 bit pattern preserves ordering.
    let mut heap: BinaryHeap<(Reverse<u32>, usize, usize)> = BinaryHeap::new();

    for a in 0..n {
        let scores = score_fn(a);
        for (b, &s) in scores.iter().enumerate().skip(a + 1) {
            if s < threshold {
                continue;
            }
            if !intra_file && file_paths[a] == file_paths[b] {
                continue;
            }
            let bits = s.to_bits();
            if heap.len() < max_pairs {
                heap.push((Reverse(bits), a, b));
            } else if let Some(&(Reverse(min_bits), _, _)) = heap.peek()
                && bits > min_bits
            {
                heap.pop();
                heap.push((Reverse(bits), a, b));
            }
        }
    }

    // Drain heap into a Vec and sort descending by score.
    let mut pairs: Vec<(usize, usize, f32)> = heap
        .into_iter()
        .map(|(Reverse(bits), a, b)| (a, b, f32::from_bits(bits)))
        .collect();
    pairs.sort_unstable_by(|x, y| y.2.total_cmp(&x.2));
    pairs
}

/// Locate the index of a chunk matching `file_path` at or near `target_line`.
///
/// **Primary**: finds the first chunk whose `[start_line, end_line]` range
/// (1-based, ripvec-core convention) contains `target_line`.
///
/// **Fallback (N2)**: when no chunk's range contains `target_line` (e.g., the
/// caller supplied a `symbol_line` that falls in a decorator/docstring gap),
/// returns the chunk with the largest `start_line <= target_line` — the
/// "nearest enclosing" chunk. This covers the mnemosyne corpus case where a
/// Python decorated method's `symbol_line` (used as the LSP location) differs
/// from its chunk `start_line`.
///
/// Returns `None` only when no chunk in the file precedes or covers `target_line`.
fn find_similar_chunk_idx(
    chunks: &[ripvec_core::chunk::CodeChunk],
    fp: &str,
    target_line: usize,
) -> Option<usize> {
    // Path predicate: strict-suffix match (same as the historical inline logic).
    let path_matches = |chunk: &ripvec_core::chunk::CodeChunk| -> bool {
        let cp = &chunk.file_path;
        cp == fp
            || (cp.len() > fp.len()
                && cp.ends_with(fp)
                && cp.as_bytes()[cp.len() - fp.len() - 1] == b'/')
    };

    // Primary: range containment.
    if let Some(idx) = chunks.iter().position(|chunk| {
        path_matches(chunk) && chunk.start_line <= target_line && target_line <= chunk.end_line
    }) {
        return Some(idx);
    }

    // Fallback (N2): nearest chunk whose start_line <= target_line.
    chunks
        .iter()
        .enumerate()
        .filter(|(_, chunk)| path_matches(chunk) && chunk.start_line <= target_line)
        .max_by_key(|(_, chunk)| chunk.start_line)
        .map(|(idx, _)| idx)
}

#[tool_router]
impl RipvecServer {
    /// Construct a server with an explicit engine selection.
    ///
    /// Called from `main.rs` after parsing `RIPVEC_MCP_ENGINE`. The ripvec
    /// engine is the only surviving engine after transformer removal; the
    /// `engine` parameter is retained for call-site symmetry and diagnostic
    /// logging inside `ensure_root`.
    pub fn with_engine(
        project_root: std::path::PathBuf,
        log_buffer: crate::server::LogBuffer,
        reload_handle: crate::server::FilterReloadHandle,
        engine: crate::server::McpEngine,
    ) -> Self {
        Self {
            engine,
            ripvec_indices: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
            ripvec_encoder: Arc::new(tokio::sync::OnceCell::new()),
            ripvec_reranker: Arc::new(tokio::sync::OnceCell::new()),
            project_root,
            tool_router: Self::tool_router(),
            repo_graph: Arc::new(tokio::sync::RwLock::new(None)),
            root_indices: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
            root_graphs: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
            log_buffer,
            reload_handle: Arc::new(reload_handle),
        }
    }

    /// Lazy-load the ripvec bi-encoder (Model2Vec 32M static encoder).
    pub(crate) async fn load_ripvec_encoder(
        &self,
    ) -> Result<&Arc<ripvec_core::encoder::ripvec::dense::StaticEncoder>, rmcp::ErrorData> {
        self.ripvec_encoder
            .get_or_try_init(|| async {
                let enc = tokio::task::spawn_blocking(|| {
                    ripvec_core::encoder::ripvec::dense::StaticEncoder::from_pretrained(
                        ripvec_core::encoder::ripvec::dense::DEFAULT_MODEL_REPO,
                    )
                })
                .await
                .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?
                .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
                Ok(Arc::new(enc))
            })
            .await
    }

    /// Sanitize a file path for logging: strip to a path relative to
    /// `root` (or the server `project_root` when `root` is `None`).
    ///
    /// If the path cannot be made relative (e.g. it is outside the root),
    /// returns the string `"<external>"` to avoid leaking absolute paths
    /// into log output.
    fn log_relative_path(&self, file_path: &str, root: Option<&std::path::Path>) -> String {
        let base = root.unwrap_or(&self.project_root);
        let p = std::path::Path::new(file_path);
        p.strip_prefix(base)
            .map(|rel| rel.display().to_string())
            .unwrap_or_else(|_| "<external>".to_string())
    }

    /// Lazy-load the cross-encoder reranker (default: L-12).
    pub(crate) async fn load_ripvec_reranker(
        &self,
    ) -> Result<&Arc<ripvec_core::rerank::Reranker>, rmcp::ErrorData> {
        self.ripvec_reranker
            .get_or_try_init(|| async {
                let r = tokio::task::spawn_blocking(|| {
                    ripvec_core::rerank::Reranker::from_pretrained(
                        ripvec_core::rerank::DEFAULT_RERANK_MODEL,
                    )
                })
                .await
                .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?
                .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
                Ok(Arc::new(r))
            })
            .await
    }

    /// Build or return a fresh `RipvecIndex` for `root`.
    ///
    /// **v3.1.1 — online reconcile + selective rebuild.** Every call
    /// checks the cached index's manifest against the live filesystem.
    /// If a real change is detected (blake3-confirmed content change,
    /// or a file added/deleted), the index is updated *incrementally*
    /// via [`RipvecIndex::apply_diff`] — only the changed files are
    /// re-chunked and re-embedded; the BM25 index and per-file
    /// mappings are rebuilt from the updated chunk set. Old cached
    /// `Arc<RipvecIndex>` snapshots held by in-flight queries remain
    /// valid through the swap.
    ///
    /// Cost shape:
    ///
    /// - Cold (first call per root): full build via
    ///   [`RipvecIndex::from_root`] — ~1-50s depending on corpus size.
    /// - Warm + clean (cache hit, nothing changed): walk + per-file
    ///   stat — ~250µs on a 200-file repo, ~130ms on a 92k-file
    ///   corpus. The walk dominates; see
    ///   [`crate::encoder::ripvec::manifest`] for the algorithm.
    /// - Warm + dirty (small change): walk + diff + per-changed-file
    ///   embed + BM25 rebuild — ~50-100ms on a 5k-chunk corpus with
    ///   one file changed, vs. ~270ms-1s for the v3.1.0 full-rebuild
    ///   path it replaces.
    ///
    /// Concurrency: two queries to the same root that both observe a
    /// stale manifest may race on the apply_diff. The result is
    /// idempotent, so the worst case is wasted work — the second
    /// apply_diff's result replaces the first's in the map. In-flight
    /// searches hold their own `Arc<RipvecIndex>` snapshot and are
    /// unaffected by the swap.
    #[expect(
        clippy::too_many_lines,
        reason = "reconcile fast-path + cold-build path + R4.2 touch-refresh variant; \
                  splitting would scatter ownership of canon/idx across helpers"
    )]
    pub(crate) async fn ensure_ripvec_root(
        &self,
        root: &std::path::Path,
    ) -> Result<Arc<ripvec_core::encoder::ripvec::index::RipvecIndex>, rmcp::ErrorData> {
        let canon = root
            .canonicalize()
            .map_err(|e| rmcp::ErrorData::internal_error(format!("canonicalize: {e}"), None))?;

        // Fast path: snapshot the current Arc (if any), check whether
        // the filesystem has drifted from its manifest, and apply a
        // selective diff if it has. Detection + apply both happen
        // inside one `spawn_blocking` so the sync I/O loop never
        // touches the async runtime.
        let cached = self.ripvec_indices.read().await.get(&canon).cloned();
        if let Some(idx) = cached {
            let idx_for_work = idx.clone();
            let canon_for_log = canon.clone();
            let result =
                tokio::task::spawn_blocking(move || -> ripvec_core::Result<ReconcileOutcome> {
                    let diff = idx_for_work.diff_against_filesystem();
                    if diff.is_empty() {
                        // R4.2: even when there are no structural changes, apply_diff
                        // absorbs the touched_clean stat-tuple refresh so the next
                        // diff is cheaper (no repeated blake3 reads for the same file).
                        if !diff.touched_clean.is_empty() {
                            let touched = diff.touched_clean.len();
                            let prof = ripvec_core::profile::Profiler::noop();
                            let refreshed = idx_for_work.apply_diff(&diff, &prof)?;
                            return Ok(ReconcileOutcome::TouchRefreshed {
                                index: Box::new(refreshed),
                                touched,
                            });
                        }
                        return Ok(ReconcileOutcome::Unchanged);
                    }
                    let prof = ripvec_core::profile::Profiler::noop();
                    let updated = idx_for_work.apply_diff(&diff, &prof)?;
                    Ok(ReconcileOutcome::Updated {
                        index: Box::new(updated),
                        dirty: diff.dirty.len(),
                        new: diff.new.len(),
                        deleted: diff.deleted.len(),
                    })
                })
                .await
                .map_err(|e| rmcp::ErrorData::internal_error(format!("reconcile join: {e}"), None))?
                .map_err(|e| {
                    rmcp::ErrorData::internal_error(format!("reconcile apply_diff: {e}"), None)
                })?;
            match result {
                ReconcileOutcome::Unchanged => return Ok(idx),
                ReconcileOutcome::Updated {
                    index,
                    dirty,
                    new,
                    deleted,
                } => {
                    tracing::info!(
                        root = %canon_for_log.display(),
                        dirty, new, deleted,
                        "ripvec: applied incremental diff"
                    );
                    let arc = Arc::new(*index);
                    self.ripvec_indices.write().await.insert(canon, arc.clone());
                    return Ok(arc);
                }
                ReconcileOutcome::TouchRefreshed { index, touched } => {
                    tracing::debug!(
                        root = %canon_for_log.display(),
                        touched,
                        "ripvec: applied touch-refresh: {touched} files"
                    );
                    let arc = Arc::new(*index);
                    self.ripvec_indices.write().await.insert(canon, arc.clone());
                    return Ok(arc);
                }
            }
        }

        // Cold start: no cached index for this root. Full from_root
        // build. The map write lock is only taken at the very end to
        // insert the result.
        let _enc_ref = self.load_ripvec_encoder().await?;
        let encoder = ripvec_core::encoder::ripvec::dense::StaticEncoder::from_pretrained(
            ripvec_core::encoder::ripvec::dense::DEFAULT_MODEL_REPO,
        )
        .map_err(|e| rmcp::ErrorData::internal_error(format!("encoder load: {e}"), None))?;
        let canon_for_build = canon.clone();
        let built = tokio::task::spawn_blocking(move || -> ripvec_core::Result<_> {
            let cfg = ripvec_core::embed::SearchConfig {
                batch_size: 64,
                max_tokens: 512,
                chunk: ripvec_core::chunk::ChunkConfig {
                    max_chunk_bytes: 4096,
                    window_size: 2048,
                    window_overlap: 512,
                },
                text_mode: false,
                cascade_dim: None,
                file_type: None,
                exclude_extensions: Vec::new(),
                include_extensions: Vec::new(),
                ignore_patterns: Vec::new(),
                corpus: ripvec_core::embed::Corpus::All,
                mode: ripvec_core::hybrid::SearchMode::Hybrid,
            };
            let prof = ripvec_core::profile::Profiler::noop();
            ripvec_core::encoder::ripvec::index::RipvecIndex::from_root(
                &canon_for_build,
                encoder,
                &cfg,
                &prof,
                None,
                0.0,
            )
        })
        .await
        .map_err(|e| rmcp::ErrorData::internal_error(format!("join: {e}"), None))?
        .map_err(|e| rmcp::ErrorData::internal_error(format!("ripvec build: {e}"), None))?;
        let arc = Arc::new(built);
        self.ripvec_indices.write().await.insert(canon, arc.clone());
        Ok(arc)
    }

    /// Build a `SearchableIndex` for `root` via the ripvec engine.
    ///
    /// Used by `ensure_root` under the ripvec engine. Builds a fresh
    /// `RipvecIndex` (Model2Vec bi-encoder + tree-sitter chunking,
    /// CPU-only, no transformer model load) and stores it directly as
    /// the engine-appropriate `dyn SearchableIndex`. The LSP modules
    /// consume the trait surface and don't need to know which
    /// concrete index type backs it.
    async fn build_hybrid_from_ripvec(
        &self,
        canonical: &std::path::Path,
    ) -> Result<Arc<dyn ripvec_core::searchable::SearchableIndex>, rmcp::ErrorData> {
        let canon = canonical.to_path_buf();
        let model_repo = ripvec_core::encoder::ripvec::dense::DEFAULT_MODEL_REPO.to_string();
        let ripvec_idx = tokio::task::spawn_blocking(move || -> ripvec_core::Result<_> {
            let encoder =
                ripvec_core::encoder::ripvec::dense::StaticEncoder::from_pretrained(&model_repo)?;
            let cfg = ripvec_core::embed::SearchConfig::default();
            let profiler = ripvec_core::profile::Profiler::noop();
            ripvec_core::encoder::ripvec::index::RipvecIndex::from_root(
                &canon, encoder, &cfg, &profiler, None, 0.0,
            )
        })
        .await
        .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?
        .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
        Ok(Arc::new(ripvec_idx))
    }

    /// Resolve a `root` parameter to a canonical path, ensuring the index
    /// and repo graph are cached for that root. Returns `None` if root is
    /// absent (use default project root).
    ///
    /// Uses the ripvec engine: builds a `RipvecIndex` (Model2Vec +
    /// tree-sitter, no transformer model load, no disk cache) on first
    /// call per root, caches it in memory for the process lifetime.
    async fn ensure_root(
        &self,
        root: Option<&str>,
    ) -> Result<Option<std::path::PathBuf>, rmcp::ErrorData> {
        let Some(root_str) = root else {
            return Ok(None);
        };
        let root_path = std::path::PathBuf::from(root_str);
        if !root_path.is_dir() {
            return Err(rmcp::ErrorData::internal_error(
                format!("root is not a directory: {root_str}"),
                None,
            ));
        }
        let canonical = root_path
            .canonicalize()
            .unwrap_or_else(|_| root_path.clone());

        // Ensure index is cached for this root via the ripvec engine:
        // Model2Vec + tree-sitter, no transformer model load, no disk
        // cache. Session-scoped: built on first query per root.
        {
            let cache = self.root_indices.read().await;
            if !cache.contains_key(&canonical) {
                drop(cache);
                tracing::info!(
                    root = %canonical.display(),
                    engine = ?self.engine,
                    "ensure_root: building index"
                );
                let idx = self.build_hybrid_from_ripvec(&canonical).await?;
                let mut cache = self.root_indices.write().await;
                cache.insert(canonical.clone(), idx);
            }
        }

        // Ensure repo graph is cached for this root.
        {
            let has_graph = self.root_graphs.read().await.contains_key(&canonical);
            if !has_graph {
                let canon2 = canonical.clone();
                let graph = tokio::task::spawn_blocking(move || {
                    ripvec_core::repo_map::build_graph(&canon2)
                })
                .await
                .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?
                .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
                self.root_graphs
                    .write()
                    .await
                    .insert(canonical.clone(), Arc::new(graph));
            }
        }

        Ok(Some(canonical))
    }

    /// Resolve LSP root with a full index build.
    ///
    /// Use only for workspace-level LSP tools that actually need the
    /// corpus index (workspace_symbols, references, incoming/outgoing
    /// calls). Per-file tools (document_symbols, hover, goto_*,
    /// prepare_call_hierarchy) must use [`ensure_lsp_root_lite`] to
    /// avoid the 30+ second index-build penalty on first call.
    async fn ensure_lsp_root(&self, root: Option<&str>) -> Result<PathBuf, rmcp::ErrorData> {
        // When root is None, treat project_root as the explicit root so that
        // the index and graph are built and cached in root_indices/root_graphs.
        // Without this, workspace-level LSP tools called without a root
        // parameter would return zero results because root_indices for
        // project_root was never populated.
        let root_str: String;
        let effective: Option<&str> = if let Some(r) = root {
            Some(r)
        } else {
            root_str = self.project_root.to_string_lossy().into_owned();
            Some(root_str.as_str())
        };
        self.ensure_root(effective).await?.ok_or_else(|| {
            rmcp::ErrorData::internal_error("root resolution unexpectedly returned none", None)
        })
    }

    /// Path-only LSP root resolution. No index build, no model load.
    ///
    /// Per-file LSP operations (document_symbols, hover, goto_*,
    /// prepare_call_hierarchy) only need the file on disk and a
    /// tree-sitter parse. They never consult the corpus index or
    /// PageRank graph, so paying for a 30s+ index build on the first
    /// call would (and did, prior to this) make them time out at the
    /// MCP client's 120s budget.
    ///
    /// This variant just resolves the root path. Falls back to
    /// `project_root` when none is given, mirroring
    /// [`ensure_lsp_root`]'s contract for the no-root case.
    fn ensure_lsp_root_lite(&self, root: Option<&str>) -> Result<PathBuf, rmcp::ErrorData> {
        let Some(root_str) = root else {
            // Canonicalize so the returned path is consistent with the
            // canonical keys stored in root_indices by ensure_lsp_root.
            return Ok(self
                .project_root
                .canonicalize()
                .unwrap_or_else(|_| self.project_root.clone()));
        };
        let root_path = PathBuf::from(root_str);
        if !root_path.is_dir() {
            return Err(rmcp::ErrorData::internal_error(
                format!("root is not a directory: {root_str}"),
                None,
            ));
        }
        Ok(root_path.canonicalize().unwrap_or(root_path))
    }

    async fn resolve_lsp_file(
        &self,
        root: Option<&str>,
        file_path: &str,
    ) -> Result<(PathBuf, PathBuf), rmcp::ErrorData> {
        let root = self.ensure_lsp_root(root).await?;
        let path = PathBuf::from(file_path);
        let absolute = if path.is_absolute() {
            path
        } else {
            root.join(path)
        };
        let canonical = absolute.canonicalize().unwrap_or(absolute);
        Ok((root, canonical))
    }

    /// Path-only sibling of [`resolve_lsp_file`]. No index build.
    ///
    /// Use from per-file LSP tools. The returned `(root, file)` pair
    /// has the same shape as [`resolve_lsp_file`] so call sites are a
    /// drop-in swap; the difference is purely whether a corpus index
    /// build is triggered as a side effect.
    /// Resolve a relative `file_path` against the right root, with cross-corpus
    /// fallback (I#38 fix).
    ///
    /// When the caller passes `root=None` (the documented composability
    /// pattern, where `lsp_location.file_path` from a search result is fed
    /// straight back), the legacy implementation used `self.project_root` as
    /// the resolution base. This silently produced bogus absolute paths when
    /// the upstream call ran against a different root than `project_root` —
    /// every position-scoped LSP tool (`lsp_hover`,
    /// `lsp_goto_definition`, `lsp_prepare_call_hierarchy`,
    /// `lsp_references`) uniformly returned empty as a result.
    ///
    /// The fix: when the relative `file_path` does not exist under the
    /// default root, scan the session's indexed roots
    /// (`ripvec_indices`, `root_indices`, `root_graphs`) for one where the
    /// relative path *does* exist on disk. If found, use that root as the
    /// resolution base. Falls back to the default-root result when nothing
    /// matches (preserves pre-fix behaviour for truly-unresolvable paths
    /// rather than silently inventing data).
    async fn resolve_lsp_file_lite(
        &self,
        root: Option<&str>,
        file_path: &str,
    ) -> Result<(PathBuf, PathBuf), rmcp::ErrorData> {
        let path = PathBuf::from(file_path);
        let default_root = self.ensure_lsp_root_lite(root)?;

        if path.is_absolute() {
            let canonical = path.canonicalize().unwrap_or(path);
            return Ok((default_root, canonical));
        }

        // Fast path: relative path resolves under the default root.
        let candidate = default_root.join(&path);
        if candidate.exists() {
            let canonical = candidate.canonicalize().unwrap_or(candidate);
            return Ok((default_root, canonical));
        }

        // I#38 cross-root probe: when the relative path does not exist under
        // the default root, scan every indexed root in the session for one
        // that contains it.
        let ripvec_roots: Vec<PathBuf> = {
            let guard = self.ripvec_indices.read().await;
            guard.keys().cloned().collect()
        };
        for indexed_root in &ripvec_roots {
            let probe = indexed_root.join(&path);
            if probe.exists() {
                let canonical = probe.canonicalize().unwrap_or(probe);
                return Ok((indexed_root.clone(), canonical));
            }
        }
        let lsp_roots: Vec<PathBuf> = {
            let guard = self.root_indices.read().await;
            guard.keys().cloned().collect()
        };
        for indexed_root in &lsp_roots {
            let probe = indexed_root.join(&path);
            if probe.exists() {
                let canonical = probe.canonicalize().unwrap_or(probe);
                return Ok((indexed_root.clone(), canonical));
            }
        }
        let graph_roots: Vec<PathBuf> = {
            let guard = self.root_graphs.read().await;
            guard.keys().cloned().collect()
        };
        for indexed_root in &graph_roots {
            let probe = indexed_root.join(&path);
            if probe.exists() {
                let canonical = probe.canonicalize().unwrap_or(probe);
                return Ok((indexed_root.clone(), canonical));
            }
        }

        // Nothing matched; fall through with the default-root result.
        let canonical = candidate.canonicalize().unwrap_or(candidate);
        Ok((default_root, canonical))
    }

    fn lsp_position_params(
        file_path: &Path,
        line: u32,
        character: u32,
    ) -> Result<TextDocumentPositionParams, rmcp::ErrorData> {
        let uri = uri_for_path(file_path)?;
        Ok(text_document_position(uri, line, character))
    }

    /// Ripvec-engine search path: cacheless composite pipeline with
    /// corpus/rerank/metadata axes and corpus-aware rerank gating.
    ///
    /// `ensure_ripvec_root` builds (or hits the cached) `RipvecIndex`
    /// for the root with no extension filter — the index always
    /// contains every walkable file. Per-query, `corpus` /
    /// `include_metadata` / `include_extensions` / `exclude_extensions`
    /// derive a path allow-list passed to `index.search` as
    /// `filter_paths`, so a single cached index serves any filter combo.
    ///
    /// Rerank decision uses the explicit `policy`: see `should_rerank`.
    async fn run_search_ripvec(
        &self,
        query: &str,
        top_k: usize,
        offset: usize,
        corpus: ripvec_core::embed::Corpus,
        rerank_policy: RerankPolicy,
        include_metadata: bool,
        include_extensions: &[String],
        exclude_extensions: &[String],
        root: Option<&str>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        // Pick the root: explicit `root` param if given, else project_root.
        let resolved_root = match root {
            Some(r) => std::path::PathBuf::from(r),
            None => self.project_root.clone(),
        };
        let index = self.ensure_ripvec_root(&resolved_root).await?;

        let effective_top_k = if top_k == 0 { 100 } else { top_k };
        let candidate_count = effective_top_k.saturating_mul(5).max(100);

        // Derive a path allow-list from corpus + include_metadata + include/exclude.
        // Returns None when no filter is needed (fast path).
        let filter_paths = derive_filter_paths(
            index.chunks(),
            corpus,
            index.corpus_class(),
            include_metadata,
            include_extensions,
            exclude_extensions,
        );

        let mut ranked = index.search(
            query,
            candidate_count,
            ripvec_core::hybrid::SearchMode::Hybrid,
            None,
            None,
            filter_paths.as_deref(),
        );

        // Rerank: fire based on explicit policy, corpus, and corpus_class.
        let rerank_fired =
            !ranked.is_empty() && should_rerank(rerank_policy, corpus, index.corpus_class(), query);
        if rerank_fired {
            let reranker = self.load_ripvec_reranker().await?;
            let layer = ripvec_core::ranking::CrossEncoderRerank::new(
                reranker.clone(),
                query.to_string(),
                ripvec_core::rerank::DEFAULT_RERANK_CANDIDATES,
            );
            let layers: Vec<Box<dyn ripvec_core::ranking::RankingLayer>> = vec![Box::new(layer)];
            ripvec_core::ranking::apply_chain(&mut ranked, index.chunks(), &layers);
        }

        // Calibrated relative threshold (see RELATIVE_THRESHOLD_* docstrings):
        // filter results whose score falls below `relative_cutoff * top_score`.
        // No-op when ranked is empty (top_score = 0).
        let relative_cutoff = if rerank_fired {
            RELATIVE_THRESHOLD_WITH_RERANK
        } else {
            RELATIVE_THRESHOLD_NO_RERANK
        };
        let absolute_floor = ranked
            .first()
            .map_or(0.0_f32, |&(_, s)| s * relative_cutoff);

        let results: Vec<SearchResultItem> = ranked
            .into_iter()
            .filter(|&(_, score)| score >= absolute_floor)
            .skip(offset)
            .take(effective_top_k)
            .filter_map(|(idx, score)| {
                index
                    .chunks()
                    .get(idx)
                    .map(|chunk| SearchResultItem::from_chunk(chunk, score))
            })
            .collect();

        let response = SearchResponse { results };
        let json = serde_json::to_string_pretty(&response)
            .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
        Ok(CallToolResult::success(vec![Content::text(json)]))
    }

    /// Ripvec-engine find_similar: look up source chunk's embedding,
    /// rank all chunks by dot product, drop the source, slice top_k.
    ///
    /// A2: accepts `location` (LspLocationInput) or `symbol_name` (String).
    /// Exactly one must be supplied. When `symbol_name` is given, the workspace
    /// symbol index is queried for the best unique match; ambiguity returns an
    /// error listing candidates.
    #[expect(
        clippy::too_many_lines,
        reason = "A2 exactly-one-of dispatch (location vs symbol_name) + \
                  ambiguity candidates path + cosine ranking; splitting would \
                  scatter the dispatch logic across helpers and obscure the \
                  single-responsibility of this function"
    )]
    async fn find_similar_ripvec(
        &self,
        params: FindSimilarParams,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        // A2: exactly-one-of validation.
        let (file_path_str, line_0based) = match (&params.location, &params.symbol_name) {
            (None, None) => {
                return Err(rmcp::ErrorData::invalid_params(
                    "find_similar requires exactly one of 'location' or 'symbol_name'; \
                     supply location: {file_path, start_line, ...} or symbol_name: \"FnName\"",
                    None,
                ));
            }
            (Some(_), Some(_)) => {
                return Err(rmcp::ErrorData::invalid_params(
                    "find_similar requires exactly one of 'location' or 'symbol_name'; \
                     both were supplied",
                    None,
                ));
            }
            (Some(loc), None) => {
                // Location path: use coordinates directly.
                (loc.file_path.clone(), loc.start_line as usize)
            }
            (None, Some(sym)) => {
                // Symbol path: resolve via workspace symbol index (best unique match).
                let resolved_root_for_sym = match params.root.as_deref() {
                    Some(r) => std::path::PathBuf::from(r),
                    None => self.project_root.clone(),
                };
                let index_for_sym = self.ensure_ripvec_root(&resolved_root_for_sym).await?;
                let sym_lc = sym.to_ascii_lowercase();
                let candidates: Vec<(String, usize)> = index_for_sym
                    .chunks()
                    .iter()
                    .filter(|c| c.name.to_ascii_lowercase() == sym_lc)
                    .map(|c| {
                        // 1-based start_line → 0-based for our convention.
                        (c.file_path.clone(), c.start_line.saturating_sub(1))
                    })
                    .collect();
                match candidates.len() {
                    0 => {
                        return Err(rmcp::ErrorData::invalid_params(
                            format!("symbol_name \"{sym}\" not found in the index"),
                            None,
                        ));
                    }
                    1 => candidates.into_iter().next().unwrap(),
                    _ => {
                        // Ambiguous — return candidates list so caller can pick.
                        let candidate_locs: Vec<serde_json::Value> = candidates
                            .iter()
                            .map(|(fp, ln)| {
                                serde_json::json!({
                                    "file_path": fp,
                                    "start_line": ln,
                                })
                            })
                            .collect();
                        let response = serde_json::json!({
                            "error": "ambiguous symbol_name",
                            "symbol_name": sym,
                            "candidates": candidate_locs,
                            "hint": "Pass location: {file_path, start_line} to disambiguate",
                        });
                        let json = serde_json::to_string_pretty(&response)
                            .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
                        return Ok(CallToolResult::success(vec![Content::text(json)]));
                    }
                }
            }
        };

        let resolved_root = match params.root.as_deref() {
            Some(r) => std::path::PathBuf::from(r),
            None => self.project_root.clone(),
        };
        let index = self.ensure_ripvec_root(&resolved_root).await?;

        // 0-based incoming line → 1-based chunk lines (ripvec_core convention).
        // Use saturating_add to avoid usize overflow.
        let target_line = line_0based.saturating_add(1);

        // N2: use find_similar_chunk_idx which tries range containment first,
        // then falls back to the nearest enclosing chunk (largest start_line <=
        // target_line) for decorated/docstring'd symbols where chunk start !=
        // symbol_line. The error message now includes the nearest start_line to
        // help the caller supply a better location.
        let fp = &file_path_str;
        let source_idx =
            find_similar_chunk_idx(index.chunks(), fp, target_line).ok_or_else(|| {
                // No chunk precedes target_line in this file — provide a helpful
                // error with the nearest chunk's start_line if one exists.
                let nearest_hint = index
                    .chunks()
                    .iter()
                    .filter(|c| {
                        let cp = &c.file_path;
                        cp == fp
                            || (cp.len() > fp.len()
                                && cp.ends_with(fp.as_str())
                                && cp.as_bytes()[cp.len() - fp.len() - 1] == b'/')
                    })
                    .map(|c| c.start_line)
                    .min()
                    .map(|sl| format!("; nearest chunk start_line is {sl} (1-based)"))
                    .unwrap_or_default();
                rmcp::ErrorData::internal_error(
                    format!("No chunk found at {fp}:{line_0based}{nearest_hint}"),
                    None,
                )
            })?;

        let embeddings = index.embeddings();
        if source_idx >= embeddings.nrows() {
            return Err(rmcp::ErrorData::internal_error(
                "Embedding not found for source chunk.".to_string(),
                None,
            ));
        }

        // Capture source chunk content early for cross-encoder reranking.
        let source_content = index
            .chunks()
            .get(source_idx)
            .map(|c| c.enriched_content.clone())
            .unwrap_or_default();

        // Cosine = dot product since rows are L2-normalized. Parallel
        // sgemv across row-shards to saturate aggregate memory
        // bandwidth (single-core Accelerate sgemv caps at ~50-80 GB/s
        // on M2 Max).
        let source = embeddings.row(source_idx);
        // C2: Derive a path allow-list from corpus + include_metadata.
        // Returns None when no filter is needed (fast path).
        let corpus_core: ripvec_core::embed::Corpus = params.corpus.into();
        let filter_paths = derive_filter_paths(
            index.chunks(),
            corpus_core,
            index.corpus_class(),
            params.include_metadata,
            &[],
            &[],
        );
        let allowed_paths: Option<std::collections::HashSet<&str>> = filter_paths
            .as_ref()
            .map(|fps| fps.iter().map(String::as_str).collect());

        let scores = ripvec_core::encoder::ripvec::hybrid::parallel_sgemv(embeddings, &source);
        let mut scored: Vec<(usize, f32)> = scores
            .iter()
            .enumerate()
            .filter(|(i, _)| *i != source_idx)
            .filter(|(i, _)| {
                // C2: apply corpus + include_metadata filter to candidates.
                if let Some(ref allowed) = allowed_paths {
                    index
                        .chunks()
                        .get(*i)
                        .is_some_and(|c| allowed.contains(c.file_path.as_str()))
                } else {
                    true
                }
            })
            .map(|(i, &s)| (i, s))
            .collect();
        let limit = params.offset.saturating_add(params.top_k);
        if scored.len() > limit && limit > 0 {
            scored.select_nth_unstable_by(limit - 1, |a, b| {
                b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0))
            });
            scored.truncate(limit);
        }
        scored.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));

        // C2: Apply cross-encoder reranking according to policy.
        // Uses source chunk's enriched content as the "query" for cross-encoder scoring.
        let rerank_fired = !scored.is_empty()
            && should_rerank(
                params.rerank,
                corpus_core,
                index.corpus_class(),
                &source_content,
            );
        if rerank_fired {
            let reranker = self.load_ripvec_reranker().await?;
            let layer = ripvec_core::ranking::CrossEncoderRerank::new(
                reranker.clone(),
                source_content,
                ripvec_core::rerank::DEFAULT_RERANK_CANDIDATES,
            );
            let layers: Vec<Box<dyn ripvec_core::ranking::RankingLayer>> = vec![Box::new(layer)];
            ripvec_core::ranking::apply_chain(&mut scored, index.chunks(), &layers);
        }

        let results: Vec<SearchResultItem> = scored
            .into_iter()
            .skip(params.offset)
            .take(params.top_k)
            .filter_map(|(idx, score)| {
                index
                    .chunks()
                    .get(idx)
                    .map(|chunk| SearchResultItem::from_chunk(chunk, score))
            })
            .collect();

        let response = SearchResponse { results };
        let json = serde_json::to_string_pretty(&response)
            .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
        Ok(CallToolResult::success(vec![Content::text(json)]))
    }

    /// Ripvec-engine find_duplicates: bounded all-pairs cosine with thresholding.
    ///
    /// Corpus is capped at [`MAX_CORPUS_FOR_DUPLICATES`] chunks; callers
    /// with larger corpora must narrow the root or raise the threshold.
    /// Pairs are scored via parallel sgemv per row and ranked by a bounded
    /// max-heap (size `max_pairs`) — no full sort-then-truncate. Intra-file
    /// pairs are excluded by default; set `params.intra_file = Some(true)`
    /// to include them.
    #[expect(
        clippy::too_many_lines,
        reason = "O1 reranker integration (lines 2121-2159) adds ~40 lines to the \
                  existing pair-scoring and result-building logic; splitting would \
                  scatter the reranking check/filter/update across helper functions \
                  and obscure the linear flow: score pairs → optionally rerank → build results"
    )]
    async fn find_duplicates_ripvec(
        &self,
        params: FindDuplicatesParams,
        threshold: f32,
        max_pairs: usize,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        // O1: Cross-encoder reranking threshold (calibrated for TinyBERT-L-2-v2).
        // Pairs with reranker score < 0.3 are filtered when include_metadata=true.
        const RERANK_THRESHOLD: f32 = 0.3;

        let resolved_root = match params.root.as_deref() {
            Some(r) => std::path::PathBuf::from(r),
            None => self.project_root.clone(),
        };
        let intra_file = params.intra_file.unwrap_or(false);

        let index = self.ensure_ripvec_root(&resolved_root).await?;
        let embeddings = index.embeddings();
        let n = embeddings.nrows();

        if n > MAX_CORPUS_FOR_DUPLICATES {
            return Err(rmcp::ErrorData::invalid_params(
                format!(
                    "corpus too large for find_duplicates ({n} chunks; max {MAX_CORPUS_FOR_DUPLICATES}); \
                     use a more focused root or a higher threshold"
                ),
                None,
            ));
        }

        let chunks = index.chunks();

        // C2: Derive corpus/include_metadata filter and build a filtered subset.
        // `allowed_indices` is the set of chunk indices that survive the filter.
        // When no filter is needed it is None and we work over the full corpus.
        let corpus_core: ripvec_core::embed::Corpus = params.corpus.into();
        let filter_paths = derive_filter_paths(
            chunks,
            corpus_core,
            index.corpus_class(),
            params.include_metadata,
            &[],
            &[],
        );
        let allowed_paths: Option<std::collections::HashSet<&str>> = filter_paths
            .as_ref()
            .map(|fps| fps.iter().map(String::as_str).collect());

        // Build a filtered view: `filtered_indices[i]` is the original chunk/embedding
        // index of the i-th entry in the filtered corpus. This lets us call
        // compute_duplicate_pairs on the smaller filtered corpus and then remap
        // the pair indices back to the original chunk slice for result building.
        let filtered_indices: Vec<usize> = if let Some(ref allowed) = allowed_paths {
            chunks
                .iter()
                .enumerate()
                .filter(|(_, c)| allowed.contains(c.file_path.as_str()))
                .map(|(i, _)| i)
                .collect()
        } else {
            (0..n).collect()
        };

        let filtered_n = filtered_indices.len();
        let file_paths: Vec<&str> = filtered_indices
            .iter()
            .map(|&i| chunks[i].file_path.as_str())
            .collect();

        // One parallel sgemv per source row; only the upper triangle (b > a)
        // is examined inside compute_duplicate_pairs.
        let embeddings_ref = embeddings;
        let pairs = compute_duplicate_pairs(
            filtered_n,
            &file_paths,
            |a| {
                // Map filtered index `a` back to the original embedding row.
                let orig = filtered_indices[a];
                let source = embeddings_ref.row(orig);
                let all_scores =
                    ripvec_core::encoder::ripvec::hybrid::parallel_sgemv(embeddings_ref, &source);
                // Project scores down to the filtered corpus.
                filtered_indices.iter().map(|&fi| all_scores[fi]).collect()
            },
            threshold,
            max_pairs,
            intra_file,
        );

        // O1: If include_metadata is true, apply cross-encoder reranking to top 100 pairs.
        let mut reranker_filtered = 0usize;
        let mut final_pairs = pairs.clone();

        if params.include_metadata && !final_pairs.is_empty() {
            let reranker = self.load_ripvec_reranker().await?;

            // Take top 100 pairs (or fewer if max_pairs < 100).
            let rerank_count = std::cmp::min(100, final_pairs.len());
            let pairs_to_rerank = &final_pairs[..rerank_count];

            // Build (query, document) pairs for the cross-encoder.
            // Use chunk content as both query and document for pairwise scoring.
            let mut chunk_pairs: Vec<(&str, &str)> = Vec::new();
            for &(a, b, _) in pairs_to_rerank {
                let chunk_a = &chunks[filtered_indices[a]];
                let chunk_b = &chunks[filtered_indices[b]];
                chunk_pairs.push((chunk_a.content.as_str(), chunk_b.content.as_str()));
            }

            // Score all pairs via the reranker.
            let scores = reranker.score_pairs(&chunk_pairs).map_err(|e| {
                rmcp::ErrorData::internal_error(format!("rerank scoring failed: {e}"), None)
            })?;

            // Filter pairs: keep only those with reranker score >= RERANK_THRESHOLD.
            let mut filtered: Vec<(usize, usize, f32)> = Vec::new();
            for (i, &score) in scores.iter().enumerate() {
                if score >= RERANK_THRESHOLD {
                    filtered.push(final_pairs[i]);
                }
            }

            reranker_filtered = rerank_count - filtered.len();
            final_pairs = filtered;
        }

        let results: Vec<serde_json::Value> = final_pairs
            .iter()
            .map(|&(a, b, score)| {
                // Remap filtered pair indices back to original chunk indices.
                let chunk_a = &chunks[filtered_indices[a]];
                let chunk_b = &chunks[filtered_indices[b]];
                serde_json::json!({
                    "similarity": (score * 1000.0).round() / 1000.0,
                    "a": {
                        "file": &chunk_a.file_path,
                        "name": &chunk_a.name,
                        "kind": &chunk_a.kind,
                        "lines": format!("{}-{}", chunk_a.start_line, chunk_a.end_line),
                    },
                    "b": {
                        "file": &chunk_b.file_path,
                        "name": &chunk_b.name,
                        "kind": &chunk_b.kind,
                        "lines": format!("{}-{}", chunk_b.start_line, chunk_b.end_line),
                    },
                })
            })
            .collect();

        // E3: expose corpus_chunk_count and capped so callers can distinguish
        // "no duplicates found" from "corpus was silently truncated".
        // corpus_chunk_count reflects the filtered corpus size (C2), not the raw index size.
        let corpus_chunk_count = filtered_n;
        let capped = corpus_chunk_count >= MAX_CORPUS_FOR_DUPLICATES;
        let response = serde_json::json!({
            "pairs": results,
            "total": final_pairs.len(),
            "threshold": threshold,
            "corpus_chunk_count": corpus_chunk_count,
            "capped": capped,
            "reranker_filtered": reranker_filtered,
        });
        Ok(CallToolResult::success(vec![Content::json(response)?]))
    }

    /// find_dead_code implementation -- 4.1.0 X3 handler.
    ///
    /// 1. Resolve and cache-hit the `RepoGraph` (same path as `get_repo_map`).
    /// 2. In a `spawn_blocking` closure: walk every file, read source from disk,
    ///    run `detector_for(ext).detect(source, path)` to seed the entry set.
    /// 3. Call `compute_dead_code(graph, &entries, include_test_paths)`.
    /// 4. Filter by `min_cluster_size`, truncate to `max_clusters`.
    /// 5. Map each cluster to `DeadClusterItem` via LSP-grounded locations.
    #[expect(
        clippy::too_many_lines,
        reason = "BFS + entry-point detection + cluster mapping pipeline;                   splitting into sub-functions would scatter ownership of                   graph_arc and entry_set across helper boundaries"
    )]
    async fn find_dead_code_ripvec(
        &self,
        params: FindDeadCodeParams,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        use ripvec_core::entry_points::{EntryPointKind, detector_for};
        use ripvec_core::repo_map::compute_dead_code;
        use std::collections::HashSet;

        let resolved_root = match params.root.as_deref() {
            Some(r) => std::path::PathBuf::from(r),
            None => self.project_root.clone(),
        };
        let canonical = resolved_root
            .canonicalize()
            .unwrap_or_else(|_| resolved_root.clone());

        // Resolve or build graph (same cache as get_repo_map).
        let cached: Option<Arc<ripvec_core::repo_map::RepoGraph>> =
            self.root_graphs.read().await.get(&canonical).cloned();
        let graph_arc = if let Some(g) = cached {
            g
        } else {
            let canon_for_build = canonical.clone();
            let built = tokio::task::spawn_blocking(move || {
                ripvec_core::repo_map::build_graph(&canon_for_build)
            })
            .await
            .map_err(|e| rmcp::ErrorData::internal_error(format!("join: {e}"), None))?
            .map_err(|e| rmcp::ErrorData::internal_error(format!("graph build: {e}"), None))?;
            let arc = Arc::new(built);
            self.root_graphs
                .write()
                .await
                .insert(canonical.clone(), arc.clone());
            arc
        };

        let include_test_paths = params.include_test_paths.unwrap_or(true);
        let max_clusters = params.max_clusters.unwrap_or(50);
        let min_cluster_size = params.min_cluster_size.unwrap_or(1);

        // Detect entry points in spawn_blocking (disk I/O + CPU work).
        let canon_for_ep = canonical.clone();
        let graph_for_ep = graph_arc.clone();
        let (entry_set, ep_summary) = tokio::task::spawn_blocking(move || {
            let mut entries: HashSet<usize> = HashSet::new();
            let mut kind_counts: std::collections::HashMap<EntryPointKind, usize> =
                std::collections::HashMap::new();

            for (file_idx, file) in graph_for_ep.files.iter().enumerate() {
                let ext = Path::new(&file.path)
                    .extension()
                    .and_then(|e| e.to_str())
                    .unwrap_or_default();
                let Some(detector) = detector_for(ext) else {
                    continue;
                };
                let abs_path = canon_for_ep.join(&file.path);
                let Ok(source) = std::fs::read_to_string(&abs_path) else {
                    continue;
                };
                let detections = detector.detect(&source, &abs_path);
                if detections.is_empty() {
                    continue;
                }
                let base = graph_for_ep.def_offsets[file_idx];
                for ep in &detections {
                    for (def_idx, def) in file.defs.iter().enumerate() {
                        if def.start_line == ep.line || def.name == ep.name {
                            entries.insert(base + def_idx);
                            *kind_counts.entry(ep.kind).or_insert(0) += 1;
                            break;
                        }
                    }
                }
            }

            let mut summary: Vec<String> = kind_counts
                .iter()
                .map(|(kind, count)| {
                    let label = match kind {
                        EntryPointKind::Main => "main",
                        EntryPointKind::LibraryExport => "library exports",
                        EntryPointKind::Test => "tests",
                        EntryPointKind::Ffi => "FFI",
                        EntryPointKind::ProcMacro => "proc-macros",
                        EntryPointKind::Init => "init functions",
                        EntryPointKind::BuildScript => "build scripts",
                    };
                    format!("{count} {label}")
                })
                .collect();
            summary.sort();

            (entries, summary)
        })
        .await
        .map_err(|e| rmcp::ErrorData::internal_error(format!("join (entry detect): {e}"), None))?;

        // Run dead-code BFS.
        let report = compute_dead_code(&graph_arc, &entry_set, include_test_paths);

        // Helper: flat def index -> DeadDefItem with LSP location.
        let def_to_item = |flat: usize| -> DeadDefItem {
            let file_idx = graph_arc
                .def_offsets
                .partition_point(|&o| o <= flat)
                .saturating_sub(1);
            let def_local = flat.saturating_sub(graph_arc.def_offsets[file_idx]);
            let file = &graph_arc.files[file_idx];
            let def = &file.defs[def_local];
            // repo_map uses 1-based lines; LSP uses 0-based.
            let start_line = (def.start_line as usize).saturating_sub(1);
            let end_line = (def.end_line as usize).saturating_sub(1);
            DeadDefItem {
                name: def.name.clone(),
                lsp_location: LspLocation {
                    file_path: file.path.clone(),
                    start_line,
                    start_character: 0,
                    end_line,
                    end_character: 0,
                },
            }
        };

        // Map clusters, apply min_cluster_size filter, truncate.
        let all_clusters: Vec<DeadClusterItem> = report
            .dead_clusters
            .iter()
            .filter(|c| c.size >= min_cluster_size)
            .map(|c| {
                let root_node = def_to_item(c.root_def_idx);
                let member_defs: Vec<DeadDefItem> = c
                    .member_def_indices
                    .iter()
                    .map(|&i| def_to_item(i))
                    .collect();
                DeadClusterItem {
                    root_node,
                    size: c.size,
                    total_lines: c.total_lines,
                    member_defs,
                }
            })
            .collect();

        let capped = all_clusters.len() > max_clusters;
        let dead_clusters: Vec<DeadClusterItem> =
            all_clusters.into_iter().take(max_clusters).collect();

        let response = FindDeadCodeResponse {
            total_dead_defs: report.total_dead_defs,
            total_live_defs: report.total_live_defs,
            dead_fraction: report.dead_fraction,
            entry_points_detected: ep_summary,
            dead_clusters,
            capped,
        };

        Ok(CallToolResult::success(vec![Content::json(
            serde_json::to_value(&response)
                .map_err(|e| rmcp::ErrorData::internal_error(format!("serialize: {e}"), None))?,
        )?]))
    }

    /// Ripvec-engine `get_repo_map` — 4.0.1 token-budget allocation (G3–G5).
    ///
    /// `PageRank` graphs are engine-agnostic (built from import edges, not
    /// embeddings). The graph is built on demand and cached in `root_graphs`.
    ///
    /// Key changes from 4.0.0:
    /// - `token_budget` (default 4000) replaces `max_files`. The budget is
    ///   allocated across files by `PageRank` share (40% cap per file, 200-byte
    ///   floor). Symbols fill each file's allocation in def-rank order with a
    ///   logarithmic attenuation cutoff.
    /// - `RepoMapSymbol.rank` carries the def-level `PageRank` score.
    /// - `RepoMapCall.rank` carries the target file's `base_rank`.
    /// - `truncated_symbols` and `truncated_calls` per file report omitted counts.
    /// - `estimated_bytes`, `budget_bytes`, `budget_exhausted` in the response
    ///   give callers real-time feedback on budget consumption.
    async fn get_repo_map_ripvec(
        &self,
        params: GetRepoMapParams,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        let resolved_root = match params.root.as_deref() {
            Some(r) => std::path::PathBuf::from(r),
            None => self.project_root.clone(),
        };
        let canonical = resolved_root
            .canonicalize()
            .unwrap_or_else(|_| resolved_root.clone());

        // Fast path: graph already cached.
        // Hoist the Arc clone outside any spawn_blocking so the async lock
        // is never held across a blocking boundary.
        let cached: Option<Arc<ripvec_core::repo_map::RepoGraph>> =
            self.root_graphs.read().await.get(&canonical).cloned();
        let graph_arc = if let Some(g) = cached {
            g
        } else {
            let canon_for_build = canonical.clone();
            let built = tokio::task::spawn_blocking(move || {
                ripvec_core::repo_map::build_graph(&canon_for_build)
            })
            .await
            .map_err(|e| rmcp::ErrorData::internal_error(format!("join: {e}"), None))?
            .map_err(|e| rmcp::ErrorData::internal_error(format!("graph build: {e}"), None))?;
            // Populate cache so subsequent calls (and rerank's PageRank
            // layer in run_search_ripvec) hit the fast path.
            let arc = Arc::new(built);
            self.root_graphs
                .write()
                .await
                .insert(canonical.clone(), arc.clone());
            arc
        };
        let graph = graph_arc.as_ref();

        // I#20 fix: delegate to RepoGraph::resolve_focus_file. The earlier
        // inline matcher required either exact equality OR a `/`-delimited
        // suffix; LSP-shaped paths arrive with a `./` prefix that defeated
        // both predicates, silently dropping focus_idx to None. The helper
        // normalizes `./`-prefixed input before suffix matching, so the
        // documented chaining form (file_path from results[].lsp_location)
        // resolves correctly.
        let focus_idx = if let Some(focus) = params.focus_file.as_deref() {
            match graph.resolve_focus_file(focus) {
                ripvec_core::repo_map::FocusResolution::Found(idx) => Some(idx),
                ripvec_core::repo_map::FocusResolution::NotFound => None,
                ripvec_core::repo_map::FocusResolution::Ambiguous(candidates) => {
                    let json = serde_json::json!({
                        "error": "ambiguous focus_file",
                        "focus": focus,
                        "candidates": candidates,
                        "hint": "Provide a longer suffix or the full path to disambiguate",
                    });
                    let json_str = serde_json::to_string_pretty(&json)
                        .unwrap_or_else(|_| format!("ambiguous focus_file: {focus}"));
                    return Ok(CallToolResult::success(vec![Content::text(json_str)]));
                }
            }
        } else {
            None
        };

        // G3–G5: render as LSP-grounded JSON with token-budget allocation.
        let response = ripvec_core::repo_map::render_json_budgeted(
            graph,
            params.token_budget,
            focus_idx,
            params.include_metadata,
        );
        let json = serde_json::to_string_pretty(&response)
            .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
        Ok(CallToolResult::success(vec![Content::text(json)]))
    }

    async fn run_search(
        &self,
        query: &str,
        top_k: usize,
        offset: usize,
        corpus: ripvec_core::embed::Corpus,
        rerank_policy: RerankPolicy,
        include_metadata: bool,
        include_extensions: &[String],
        exclude_extensions: &[String],
        root: Option<&str>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        self.run_search_ripvec(
            query,
            top_k,
            offset,
            corpus,
            rerank_policy,
            include_metadata,
            include_extensions,
            exclude_extensions,
            root,
        )
        .await
    }

    /// Return document symbols for a file using ripvec's LSP implementation.
    #[tool(
        name = "lsp_document_symbols",
        description = "LSP-style documentSymbol over MCP. Use after search/find_similar/get_repo_map to ground a file. Accepts file_path from results[].lsp_location and returns ripvec-shaped results[] with lsp_location plus lsp.raw_response. Range.end is LSP-spec-correct (exclusive past the last line, R5.1)."
    )]
    async fn lsp_document_symbols(
        &self,
        Parameters(params): Parameters<LspDocumentParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        let log_file = self.log_relative_path(&params.file_path, None);
        tracing::debug!(file = %log_file, root = ?params.root, "tool: lsp_document_symbols");
        // documentSymbol reads one file and runs tree-sitter; no
        // corpus index needed. Use the lite resolver to avoid a 30s+
        // index build penalty on first call.
        let (root, file_path) = self
            .resolve_lsp_file_lite(params.root.as_deref(), &params.file_path)
            .await?;
        let params = DocumentSymbolParams {
            text_document: TextDocumentIdentifier {
                uri: uri_for_path(&file_path)?,
            },
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
        };
        let result = crate::lsp::symbols::document_symbol(params)
            .await
            .map_err(|error| lsp_error(&error))?;
        let results = document_symbol_results(result.as_ref(), &file_path, &root);
        lsp_mcp_tool_result("textDocument/documentSymbol", &result, results)
    }

    /// Return workspace symbols using ripvec's LSP implementation.
    ///
    /// Response includes `pre_dedup_count`, `post_dedup_count` (E2), and
    /// `drift_meaningful` (M2) fields so callers can compute a reliable
    /// naming drift index (`pre - post`) and know when it is trustworthy.
    #[tool(
        name = "lsp_workspace_symbols",
        description = "LSP-style workspace/symbol over MCP. Use when semantic search or repo_map identifies a likely symbol name and you need native-LSP-style symbol resolution. Returns ripvec-shaped results[] with lsp_location plus lsp.raw_response. Works without the root parameter (previously returned empty when root was absent). Results are deduped by (name, kind); pre_dedup_count and post_dedup_count fields expose the drift index (pre - post = number of duplicate occurrences collapsed). drift_meaningful=true when collection was unsaturated (pre_dedup_count < 500 * n_roots) and the drift quotient reflects the real symbol population; drift_meaningful=false when the per-root cap was hit and drift may be under-counted — consumers should not rely on the drift quotient when false. Query length capped at 512 chars. SYMBOL_KIND CONTRACT (K4): every results[] item carries BOTH symbol_kind (friendly string, e.g. \"interface\", \"function\", \"property\") AND symbol_kind_int (LSP integer per spec §3.15.1, e.g. 11=Interface, 12=Function, 22=Property). Use either field; both are always present together for symbol results. Navigation results (hover, definition, call-hierarchy) carry symbol_kind but NOT symbol_kind_int. (R1.1, R3.3, R6.1, B3, E2, K4, M1, M2)."
    )]
    async fn lsp_workspace_symbols(
        &self,
        Parameters(params): Parameters<LspWorkspaceSymbolParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        tracing::debug!(query = %params.query, root = ?params.root, "tool: lsp_workspace_symbols");
        let root = self.ensure_lsp_root(params.root.as_deref()).await?;
        let lsp_params = WorkspaceSymbolParams {
            partial_result_params: PartialResultParams::default(),
            work_done_progress_params: WorkDoneProgressParams::default(),
            query: params.query,
        };
        let ws_result = crate::lsp::symbols::workspace_symbol_with_counts(
            lsp_params,
            &self.root_indices,
            &self.root_graphs,
            &root,
        )
        .await
        .map_err(|error| lsp_error(&error))?;

        let results = workspace_symbol_results(ws_result.response.as_ref(), &root);
        // E2 + M2: emit dedup counts and drift_meaningful flag in the response.
        let response = LspWorkspaceSymbolResponse {
            results,
            lsp: LspPayload {
                method: "workspace/symbol",
                raw_response: &ws_result.response,
            },
            pre_dedup_count: ws_result.pre_dedup_count,
            post_dedup_count: ws_result.post_dedup_count,
            drift_meaningful: ws_result.drift_meaningful,
        };
        let json = serde_json::to_string_pretty(&response)
            .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
        Ok(CallToolResult::success(vec![Content::text(json)]))
    }

    /// Resolve hover information at a file position using ripvec's LSP implementation.
    #[tool(
        name = "lsp_hover",
        description = "LSP-style textDocument/hover over MCP. Pass location directly from search/find_similar results[].lsp_location — the same object, no destructuring. Returns results[] with lsp_location for further lsp_goto_definition/lsp_references calls plus lsp.raw_response. CRLF byte offsets and unicode identifiers are handled correctly (R5.2, R5.3). Works without root parameter (R1.1, A1)."
    )]
    async fn lsp_hover(
        &self,
        Parameters(params): Parameters<LspPositionParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        let loc = &params.location;
        let log_file = self.log_relative_path(&loc.file_path, None);
        let line = loc.start_line;
        let character = loc.start_character;
        tracing::debug!(file = %log_file, line, character, root = ?params.root, "tool: lsp_hover");
        // hover resolves the symbol at (line, char) within one file;
        // no corpus index needed. Lite resolver avoids the build cost.
        let (root, file_path) = self
            .resolve_lsp_file_lite(params.root.as_deref(), &loc.file_path)
            .await?;
        let hover_params = HoverParams {
            text_document_position_params: Self::lsp_position_params(&file_path, line, character)?,
            work_done_progress_params: WorkDoneProgressParams::default(),
        };
        let result = crate::lsp::hover::hover(hover_params, &self.root_indices, &root)
            .await
            .map_err(|error| lsp_error(&error))?;
        let results = hover_results(result.as_ref(), &file_path, &root, line, character);
        lsp_mcp_tool_result("textDocument/hover", &result, results)
    }

    /// Resolve the definition at a file position using ripvec's LSP implementation.
    #[tool(
        name = "lsp_goto_definition",
        description = "LSP-style textDocument/definition over MCP. Pass location directly from search/find_similar results[].lsp_location — the same object, no destructuring. Returns ripvec-shaped results[] usable with lsp_hover/lsp_references plus lsp.raw_response. Works without root parameter. Prefers exact-name matches over approximate ones (R1.1, R3.2, A1)."
    )]
    async fn lsp_goto_definition(
        &self,
        Parameters(params): Parameters<LspPositionParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        let loc = &params.location;
        let log_file = self.log_relative_path(&loc.file_path, None);
        tracing::debug!(file = %log_file, line = loc.start_line, character = loc.start_character, root = ?params.root, "tool: lsp_goto_definition");
        // goto_definition genuinely consults the corpus index +
        // PageRank graph to find cross-file definitions. Use the full
        // resolver so the index is warm before navigation runs;
        // without it, every cross-file lookup would return None until
        // the agent happened to call search_*. Cost is paid once per
        // root per process.
        let (root, file_path) = self
            .resolve_lsp_file(params.root.as_deref(), &loc.file_path)
            .await?;
        let def_params = GotoDefinitionParams {
            text_document_position_params: Self::lsp_position_params(
                &file_path,
                loc.start_line,
                loc.start_character,
            )?,
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
        };
        let result = crate::lsp::navigation::goto_definition(
            def_params,
            &self.root_indices,
            &self.root_graphs,
        )
        .await
        .map_err(|error| lsp_error(&error))?;
        let results = definition_results(result.as_ref(), &root, "definition");
        lsp_mcp_tool_result("textDocument/definition", &result, results)
    }

    /// Resolve implementation by searching for impl blocks that match the symbol.
    ///
    /// R3.1: Instead of mechanically remapping `goto_definition`'s output, this
    /// extracts the symbol at the cursor and searches all cached root indices for
    /// chunks whose `kind` is an impl-block kind (e.g., `impl_item`) and whose
    /// `name` or `content` contains the symbol. Falls back to `goto_definition`
    /// behavior when no impl-specific chunks are found, so the tool degrades
    /// gracefully on non-trait symbols.
    #[tool(
        name = "lsp_goto_implementation",
        description = "LSP-style textDocument/implementation over MCP. Pass file_path, line, and character directly from ripvec semantic results[].lsp_location or a native LSP location. Returns ripvec-shaped results[] usable with lsp_hover/lsp_references plus lsp.raw_response. Now actually finds impl blocks by searching the index for impl-kind chunks matching the symbol (previously returned trait declarations instead). Falls back to goto_definition behavior for non-trait symbols (R3.1)."
    )]
    async fn lsp_goto_implementation(
        &self,
        Parameters(params): Parameters<LspPositionParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        let loc = &params.location;
        let log_file = self.log_relative_path(&loc.file_path, None);
        tracing::debug!(file = %log_file, line = loc.start_line, character = loc.start_character, root = ?params.root, "tool: lsp_goto_implementation");
        // Needs the corpus index warm so impl chunks are searchable cross-file.
        let (root, file_path) = self
            .resolve_lsp_file(params.root.as_deref(), &loc.file_path)
            .await?;

        // Extract the symbol at the cursor position.
        let source = tokio::fs::read_to_string(&file_path)
            .await
            .unwrap_or_default();
        let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
        let symbol = crate::lsp::navigation::extract_symbol_at(
            &source,
            loc.start_line,
            loc.start_character,
            ext,
        );

        // If we extracted a symbol, search all cached root indices for impl blocks.
        if let Some(sym) = symbol {
            let ri_guard = self.root_indices.read().await;
            for (alt_root, idx) in ri_guard.iter() {
                if let Some(locations) =
                    crate::lsp::navigation::search_implementation(idx.chunks(), &sym, alt_root)
                {
                    let result = Some(request::GotoImplementationResponse::Array(
                        locations.clone(),
                    ));
                    let results = definition_results(result.as_ref(), &root, "implementation");
                    return lsp_mcp_tool_result("textDocument/implementation", &result, results);
                }
            }
            drop(ri_guard);
        }

        // Fallback: original goto_definition remapping for non-trait symbols.
        let def_params = GotoDefinitionParams {
            text_document_position_params: Self::lsp_position_params(
                &file_path,
                loc.start_line,
                loc.start_character,
            )?,
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
        };
        let result = crate::lsp::navigation::goto_definition(
            def_params,
            &self.root_indices,
            &self.root_graphs,
        )
        .await
        .map_err(|error| lsp_error(&error))?
        .map(|response| match response {
            GotoDefinitionResponse::Scalar(location) => {
                request::GotoImplementationResponse::Scalar(location)
            }
            GotoDefinitionResponse::Array(locations) => {
                request::GotoImplementationResponse::Array(locations)
            }
            GotoDefinitionResponse::Link(links) => request::GotoImplementationResponse::Link(links),
        });
        let results = definition_results(result.as_ref(), &root, "implementation");
        lsp_mcp_tool_result("textDocument/implementation", &result, results)
    }

    /// Resolve references at a file position using ripvec's LSP implementation.
    #[tool(
        name = "lsp_references",
        description = "LSP-style textDocument/references over MCP. Pass location directly from search/find_similar results[].lsp_location — the same object, no destructuring. Returns ripvec-shaped results[] usable with lsp_hover/lsp_goto_definition plus lsp.raw_response. Works without root parameter; returns an empty list (not null) when no references are found in a valid root (R1.1, R1.2, A1)."
    )]
    async fn lsp_references(
        &self,
        Parameters(params): Parameters<LspReferenceParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        let loc = &params.location;
        let log_file = self.log_relative_path(&loc.file_path, None);
        tracing::debug!(file = %log_file, line = loc.start_line, character = loc.start_character, root = ?params.root, "tool: lsp_references");
        let include_declaration = params.include_declaration;
        let (root, file_path) = self
            .resolve_lsp_file(params.root.as_deref(), &loc.file_path)
            .await?;
        let ref_params = ReferenceParams {
            text_document_position: Self::lsp_position_params(
                &file_path,
                loc.start_line,
                loc.start_character,
            )?,
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
            context: ReferenceContext {
                include_declaration,
            },
        };
        let result = crate::lsp::navigation::find_references(
            ref_params,
            &self.root_indices,
            &self.root_graphs,
        )
        .await
        .map_err(|error| lsp_error(&error))?;
        let results = location_results(result.as_deref(), &root, "reference");
        lsp_mcp_tool_result("textDocument/references", &result, results)
    }

    /// Prepare a call hierarchy item at a file position.
    #[tool(
        name = "lsp_prepare_call_hierarchy",
        description = "LSP-style textDocument/prepareCallHierarchy over MCP. Pass location directly from ripvec semantic results[].lsp_location or native LSP — no destructuring needed. Returns CallHierarchyItem data plus ripvec-shaped results[] for grounding. Returns all overlapping callable definitions at the cursor position (previously returned only the innermost); non-callable symbol kinds are filtered out (R2.1, A1)."
    )]
    async fn lsp_prepare_call_hierarchy(
        &self,
        Parameters(params): Parameters<LspPositionParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        let loc = &params.location;
        let log_file = self.log_relative_path(&loc.file_path, None);
        tracing::debug!(file = %log_file, line = loc.start_line, character = loc.start_character, root = ?params.root, "tool: lsp_prepare_call_hierarchy");
        // Just identifies the call-site at (file, line, char). The
        // expensive corpus work is in lsp_incoming_calls and
        // lsp_outgoing_calls, both of which call resolve_lsp_file
        // (full) themselves with the result of this prepare step.
        let (root, file_path) = self
            .resolve_lsp_file_lite(params.root.as_deref(), &loc.file_path)
            .await?;
        let prepare_params = CallHierarchyPrepareParams {
            text_document_position_params: Self::lsp_position_params(
                &file_path,
                loc.start_line,
                loc.start_character,
            )?,
            work_done_progress_params: WorkDoneProgressParams::default(),
        };
        let result = crate::lsp::call_hierarchy::prepare(
            prepare_params,
            &self.repo_graph,
            &self.root_graphs,
            &root,
        )
        .await
        .map_err(|error| lsp_error(&error))?;
        let results = call_hierarchy_results(result.as_deref(), &root);
        lsp_mcp_tool_result("textDocument/prepareCallHierarchy", &result, results)
    }

    /// Return incoming calls for a prepared call hierarchy item.
    #[tool(
        name = "lsp_incoming_calls",
        description = "LSP-style callHierarchy/incomingCalls over MCP. Pass an item returned by lsp_prepare_call_hierarchy. Returns callers as ripvec-shaped results[] with lsp_location plus lsp.raw_response. Validates that the item's root provenance matches a known indexed root. Source-line resolution uses real byte offsets (not a 40-byte heuristic, R2.2, R2.4)."
    )]
    async fn lsp_incoming_calls(
        &self,
        Parameters(params): Parameters<LspCallHierarchyItemParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        tracing::debug!(root = ?params.root, "tool: lsp_incoming_calls");
        let root = self.ensure_lsp_root(params.root.as_deref()).await?;
        let item: CallHierarchyItem = serde_json::from_value(params.item).map_err(|e| {
            rmcp::ErrorData::internal_error(format!("invalid CallHierarchyItem: {e}"), None)
        })?;
        let params = CallHierarchyIncomingCallsParams {
            item,
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
        };
        let result = crate::lsp::call_hierarchy::incoming(
            params,
            &self.repo_graph,
            &self.root_graphs,
            &root,
        )
        .await
        .map_err(|error| lsp_error(&error))?;
        let results = incoming_call_results(result.as_deref(), &root);
        lsp_mcp_tool_result("callHierarchy/incomingCalls", &result, results)
    }

    /// Return outgoing calls for a prepared call hierarchy item.
    #[tool(
        name = "lsp_outgoing_calls",
        description = "LSP-style callHierarchy/outgoingCalls over MCP. Pass an item returned by lsp_prepare_call_hierarchy. Returns callees as ripvec-shaped results[] with lsp_location plus lsp.raw_response. Validates item root provenance, preserves qualified-path scope for callee resolution, and uses real source-byte line resolution (R2.2, R2.3, R2.4)."
    )]
    async fn lsp_outgoing_calls(
        &self,
        Parameters(params): Parameters<LspCallHierarchyItemParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        tracing::debug!(root = ?params.root, "tool: lsp_outgoing_calls");
        let root = self.ensure_lsp_root(params.root.as_deref()).await?;
        let item: CallHierarchyItem = serde_json::from_value(params.item).map_err(|e| {
            rmcp::ErrorData::internal_error(format!("invalid CallHierarchyItem: {e}"), None)
        })?;
        let params = CallHierarchyOutgoingCallsParams {
            item,
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
        };
        let result = crate::lsp::call_hierarchy::outgoing(
            params,
            &self.repo_graph,
            &self.root_graphs,
            &root,
        )
        .await
        .map_err(|error| lsp_error(&error))?;
        let results = outgoing_call_results(result.as_deref(), &root);
        lsp_mcp_tool_result("callHierarchy/outgoingCalls", &result, results)
    }

    /// Semantic search over code and prose with orthogonal corpus/rerank/metadata axes.
    ///
    /// One tool replaces the previous `search_code` / `search_text` pair.
    /// Use `corpus` to pick which files are searched, `rerank` to control the
    /// cross-encoder, and `include_metadata` to opt into structured-data files.
    ///
    /// Returns ranked `results[]` with `lsp_location`; `lsp_location.start_line`
    /// points at the symbol's name-identifier line (not the chunk start line,
    /// which may be a doc-comment). Pass `lsp_location` directly to native LSP
    /// tools or ripvec MCP LSP calls for exact grounding (E1, C2).
    #[tool(
        name = "search",
        description = "Semantic search over code and prose. \
            corpus=\"code\" (default) searches only code files (no rerank by default); \
            corpus=\"docs\" searches only prose/documentation files (rerank=auto fires on NL queries); \
            corpus=\"all\" searches everything (rerank fires on prose-heavy corpora under Auto policy). \
            rerank=\"auto\" (default); rerank=\"always\" forces rerank; rerank=\"never\" skips it. \
            include_metadata=true adds JSON/YAML/TOML/etc. chunks (excluded by default, C2). \
            lsp_location.start_line points at the symbol identifier line, not the chunk start (E1). \
            Chunks include a populated name field for symbol identification (R3.2)."
    )]
    async fn search(
        &self,
        Parameters(params): Parameters<SearchParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        tracing::debug!(
            query = %params.query,
            top_k = params.top_k,
            corpus = ?params.corpus,
            rerank = ?params.rerank,
            include_metadata = params.include_metadata,
            include = ?params.include_extensions,
            exclude = ?params.exclude_extensions,
            "tool: search"
        );
        self.run_search(
            &params.query,
            params.top_k,
            params.offset,
            params.corpus.into(),
            params.rerank,
            params.include_metadata,
            &params.include_extensions,
            &params.exclude_extensions,
            params.root.as_deref(),
        )
        .await
    }

    /// Find chunks similar to the chunk at a given location or symbol name.
    ///
    /// A2: accepts `location` (LSP-shaped, from any `results[].lsp_location`)
    /// or `symbol_name` (resolved via the workspace symbol index). Exactly one
    /// of the two must be supplied.
    #[tool(
        name = "find_similar",
        description = "Find code chunks similar to a given location or symbol name (A2). \
            Supply exactly one of: \
            (a) location: the lsp_location object from search/find_similar/lsp_* results — \
                pass results[0].lsp_location directly, no destructuring; or \
            (b) symbol_name: a symbol name resolved via the workspace symbol index — \
                returns candidates list on ambiguity. \
            Returns results[] with lsp_location. Uses strict path-separator matching: \
            \"foo.rs\" matches \"src/foo.rs\" but not \"bar_foo.rs\" (R6.5, A2)."
    )]
    async fn find_similar(
        &self,
        Parameters(params): Parameters<FindSimilarParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        let log_hint = params
            .location
            .as_ref()
            .map(|l| l.file_path.clone())
            .or_else(|| params.symbol_name.clone())
            .unwrap_or_default();
        tracing::debug!(hint = %log_hint, "tool: find_similar");
        self.find_similar_ripvec(params).await
    }

    #[tool(
        name = "find_duplicates",
        description = "Find duplicate or near-duplicate code by pairwise embedding similarity. Returns pairs of chunks with cosine similarity above the threshold, sorted by similarity. Useful for detecting copy-paste code, refactoring candidates, and similar patterns across the codebase. Default threshold is 0.5 (calibrated for Model2Vec static encoder; the previous 0.85 default was miscalibrated and returned no results on code). Set intra_file=true to include pairs within the same file (default: cross-file only). Corpus capped at 10 000 chunks. (R3.4, R6.4)"
    )]
    async fn find_duplicates(
        &self,
        Parameters(params): Parameters<FindDuplicatesParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        tracing::debug!(threshold = ?params.threshold, max_pairs = ?params.max_pairs, "tool: find_duplicates");
        // 0.5 is the calibrated default for Model2Vec static encoder on code.
        // Static cosines on code corpora are in the 0.005-0.07 range for
        // unrelated chunks; structurally similar chunks score 0.5-0.85.
        // The legacy default of 0.85 was tuned for BGE-small with reranking
        // and silently returned no results on code-only corpora.
        let threshold = params.threshold.unwrap_or(0.5);
        let max_pairs = params.max_pairs.unwrap_or(20);
        self.find_duplicates_ripvec(params, threshold, max_pairs)
            .await
    }

    #[tool(
        name = "find_dead_code",
        description = "Detect definitions unreachable from any entry point.             BFS over the def-level call graph seeded from per-language entry-point detectors.             Heuristics: Rust (fn main, pub fns in lib.rs, #[test], #[no_mangle]);             Python (if __name__==\"__main__\", __all__, def test_*);             Go (func main, func init, Test*/Benchmark*/Example*, capitalised exports).             Returns dead_clusters[] sorted by size descending; each cluster has             root_node (highest-PageRank member, best investigation target),             size, total_lines, and member_defs[] with lsp_location.             Use min_cluster_size=2 to focus on dead call-graph islands;             include_test_paths=false for production-only dead code.             (4.1.0-X2/X3)"
    )]
    async fn find_dead_code(
        &self,
        Parameters(params): Parameters<FindDeadCodeParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        tracing::debug!(
            root = ?params.root,
            include_test_paths = ?params.include_test_paths,
            max_clusters = ?params.max_clusters,
            "tool: find_dead_code"
        );
        self.find_dead_code_ripvec(params).await
    }

    /// Check whether the running ripvec-mcp binary is up-to-date with its source.
    ///
    /// Compares the binary's modification time against the newest source file
    /// in the ripvec workspace. Returns stale=true when the binary is older
    /// than the source, meaning `cargo build --release` should be run.
    #[tool(
        name = "up_to_date",
        description = "Check if the running ripvec-mcp binary is newer than its source code. \
            Returns version, binary age, and whether a rebuild is needed. \
            Returns status=\"no_source_found\" when crates/ has no .rs or Cargo files (rather than \
            a misleading up-to-date result). Includes Cargo.lock in the source scan. \
            Symlink cycles in the source tree are handled safely (R7.1, R7.2)."
    )]
    async fn up_to_date(&self) -> Result<CallToolResult, rmcp::ErrorData> {
        /// Maximum directory depth to recurse when walking for source files.
        /// Prevents runaway traversal in pathological trees.
        const MAX_WALK_DEPTH: usize = 32;

        /// Walk `dir` recursively for the newest .rs / Cargo.toml / Cargo.lock file.
        ///
        /// `visited` tracks canonical paths of directories already seen to
        /// detect symlink cycles. `depth` enforces a hard recursion cap.
        fn walk_newest(
            dir: &std::path::Path,
            newest: &mut std::time::SystemTime,
            newest_file: &mut String,
            visited: &mut std::collections::HashSet<std::path::PathBuf>,
            depth: usize,
        ) {
            if depth > MAX_WALK_DEPTH {
                return;
            }
            // Canonicalize to detect symlink cycles; skip if already visited.
            let Ok(canon) = dir.canonicalize() else {
                return;
            };
            if !visited.insert(canon) {
                return;
            }
            let Ok(entries) = std::fs::read_dir(dir) else {
                return;
            };
            for entry in entries.flatten() {
                let path = entry.path();
                let Ok(ft) = entry.file_type() else { continue };
                if ft.is_dir() || (ft.is_symlink() && path.is_dir()) {
                    walk_newest(&path, newest, newest_file, visited, depth + 1);
                } else {
                    let is_source = path
                        .extension()
                        .and_then(|e| e.to_str())
                        .is_some_and(|e| e == "rs")
                        || path
                            .file_name()
                            .is_some_and(|n| n == "Cargo.toml" || n == "Cargo.lock");
                    if is_source
                        && let Ok(mtime) = entry.metadata().and_then(|m| m.modified())
                        && mtime > *newest
                    {
                        *newest = mtime;
                        *newest_file = path.display().to_string();
                    }
                }
            }
        }

        let binary_path = std::env::current_exe()
            .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
        let binary_mtime = std::fs::metadata(&binary_path)
            .and_then(|m| m.modified())
            .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;

        // Walk crates/ for the newest .rs, Cargo.toml, or Cargo.lock source file.
        let crates_dir = self.project_root.join("crates");
        let mut newest_source = std::time::SystemTime::UNIX_EPOCH;
        let mut newest_file = String::new();
        let mut visited = std::collections::HashSet::new();
        walk_newest(
            &crates_dir,
            &mut newest_source,
            &mut newest_file,
            &mut visited,
            0,
        );

        // Also check workspace-root Cargo.lock (sibling of crates/).
        let workspace_lock = self.project_root.join("Cargo.lock");
        if let Ok(meta) = std::fs::metadata(&workspace_lock)
            && let Ok(mtime) = meta.modified()
            && mtime > newest_source
        {
            newest_source = mtime;
            newest_file = workspace_lock.display().to_string();
        }

        // When no source was found (e.g. empty or non-existent crates dir),
        // surface an explicit status rather than a misleading "up to date".
        if newest_source == std::time::SystemTime::UNIX_EPOCH {
            let response = serde_json::json!({
                "status": "no_source_found",
                "detail": "No .rs, Cargo.toml, or Cargo.lock files found under crates/",
                "crates_dir": crates_dir.display().to_string(),
            });
            let json = serde_json::to_string_pretty(&response)
                .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
            return Ok(CallToolResult::success(vec![Content::text(json)]));
        }

        let stale = newest_source > binary_mtime;
        let binary_age = binary_mtime.elapsed().unwrap_or_default();
        let source_age = newest_source.elapsed().unwrap_or_default();

        // The ripvec engine is CPU-only: Model2Vec static bi-encoder,
        // no GPU embedding path. Report the backend name unconditionally.
        let (backend_name, is_gpu, backend_loaded) = ("CPU (ripvec)".to_string(), false, true);

        let response = serde_json::json!({
            "version": env!("CARGO_PKG_VERSION"),
            "stale": stale,
            "binary_age_secs": binary_age.as_secs(),
            "newest_source_age_secs": source_age.as_secs(),
            "newest_source_file": newest_file,
            "binary_path": binary_path.display().to_string(),
            "rebuild_command": if stale { "cargo build --release" } else { "" },
            "backend": backend_name,
            "is_gpu": is_gpu,
            "backend_loaded": backend_loaded,
        });

        let json = serde_json::to_string_pretty(&response)
            .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;

        Ok(CallToolResult::success(vec![Content::text(json)]))
    }

    /// Get a `PageRank`-weighted structural overview of the codebase as LSP-grounded JSON.
    ///
    /// Returns `files[]` sorted by structural importance. Each file carries
    /// `lsp_location`, `rank`, `content_kind`, `calls[]` (with `rank` per call),
    /// `symbols[]` (with `rank` per symbol), `truncated_symbols`, and
    /// `truncated_calls`. The response includes `estimated_bytes`, `budget_bytes`,
    /// and `budget_exhausted` for budget telemetry (G5).
    #[tool(
        name = "get_repo_map",
        description = "Get a PageRank-weighted JSON map of the codebase. \
            Use FIRST when exploring unfamiliar code or asked about architecture. \
            Returns files[] sorted by structural importance; each file has lsp_location \
            (feed directly into lsp_document_symbols), rank, content_kind, \
            calls[] (outgoing edges, each with lsp_location and rank), and \
            symbols[] (each with lsp_location, kind, and def-level rank). \
            token_budget (default 4000 tokens) controls response size — budget is \
            allocated across files by PageRank share (40% cap per file, 200-byte floor); \
            symbols fill each file in def-rank order with a logarithmic attenuation cutoff. \
            truncated_symbols and truncated_calls per file report omitted counts. \
            estimated_bytes, budget_bytes, and budget_exhausted give real-time budget telemetry. \
            include_metadata=false (default) excludes JSON/YAML/lock files. \
            focus_file uses strict path-separator suffix matching; ambiguous focus returns \
            {\"error\":\"ambiguous focus_file\",\"candidates\":[...]} (R6.6). \
            Output is JSON only — the pre-4.0 markdown prose is removed (D1/D2)."
    )]
    async fn get_repo_map(
        &self,
        Parameters(params): Parameters<GetRepoMapParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        tracing::debug!(
            token_budget = params.token_budget,
            include_metadata = params.include_metadata,
            focus_file = ?params.focus_file,
            "tool: get_repo_map"
        );
        self.get_repo_map_ripvec(params).await
    }

    /// Get recent log messages from the ripvec-mcp server.
    ///
    /// Useful for diagnosing why operations are slow or failing.
    /// Returns the most recent log lines captured since server start.
    #[tool(
        name = "debug_log",
        description = "Get recent log messages from the ripvec-mcp server. Useful for diagnosing why operations are slow or failing. File paths in log output are relative to the project root (absolute paths are not logged, R8.1)."
    )]
    async fn debug_log(
        &self,
        Parameters(params): Parameters<DebugLogParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        let lines = self.log_buffer.snapshot();
        let total = lines.len();
        let output: Vec<&str> = lines
            .iter()
            .rev()
            .take(params.lines)
            .collect::<Vec<_>>()
            .into_iter()
            .rev()
            .map(String::as_str)
            .collect();
        let response = serde_json::json!({
            "lines": output,
            "total_buffered": total,
            "returned": output.len(),
        });
        let json = serde_json::to_string_pretty(&response)
            .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
        Ok(CallToolResult::success(vec![Content::text(json)]))
    }

    /// Change the log verbosity at runtime.
    ///
    /// Accepts standard tracing filter directives like "debug", "warn",
    /// or fine-grained per-module filters like "ripvec_mcp=debug,ripvec_core=trace".
    #[tool(
        name = "log_level",
        description = "Change the log verbosity at runtime. Levels: error, warn, info, debug, trace. Also accepts module-level filters like 'ripvec_mcp=debug,ripvec_core=trace'. Only ripvec/ripvec_mcp/ripvec_core targets are permitted (tokio, hyper, and other third-party crates are rejected). Filter string capped at 1024 chars. Response includes previous_filter for easy revert (R6.2, R8.2)."
    )]
    async fn log_level(
        &self,
        Parameters(params): Parameters<LogLevelParams>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        // Guard: reject oversized filter strings before any parsing.
        const MAX_FILTER_LEN: usize = 1024;
        if params.level.len() > MAX_FILTER_LEN {
            return Err(rmcp::ErrorData::internal_error(
                format!(
                    "filter string too long: {} chars (max {MAX_FILTER_LEN})",
                    params.level.len()
                ),
                None,
            ));
        }

        // Guard: only permit directives that target ripvec crates or set a
        // bare global level. This prevents callers from enabling verbose
        // tokio/hyper/tower logging through the MCP tool surface.
        // Allowed directive forms:
        //   "debug"                   — bare global level
        //   "ripvec_mcp=debug"        — allowed crate prefix
        //   "ripvec_core::search=trace" — allowed crate sub-module
        let allowed_prefixes = ["ripvec", "ripvec_mcp", "ripvec_core"];
        for directive in params.level.split(',') {
            let directive = directive.trim();
            if directive.is_empty() {
                continue;
            }
            if let Some((target, _level)) = directive.split_once('=') {
                let target = target.trim();
                let allowed = allowed_prefixes.iter().any(|&p| {
                    target == p
                        || target.starts_with(&format!("{p}::"))
                        || target.starts_with(&format!("{p}="))
                });
                if !allowed {
                    return Err(rmcp::ErrorData::internal_error(
                        format!(
                            "filter target '{target}' is not permitted; \
                             only ripvec, ripvec_mcp, ripvec_core directives \
                             or bare global levels are allowed"
                        ),
                        None,
                    ));
                }
            }
            // Bare level token (no '=') — always permitted.
        }

        // Capture the current filter before replacing it so the caller can
        // revert if needed. with_current() gives a snapshot of the live layer.
        let previous_filter = self
            .reload_handle
            .with_current(std::string::ToString::to_string)
            .unwrap_or_else(|_| String::from("<unknown>"));

        let new_filter = tracing_subscriber::EnvFilter::try_new(&params.level).map_err(|e| {
            rmcp::ErrorData::internal_error(
                format!("invalid filter '{0}': {e}", params.level),
                None,
            )
        })?;
        self.reload_handle.reload(new_filter).map_err(|e| {
            rmcp::ErrorData::internal_error(format!("failed to reload filter: {e}"), None)
        })?;
        tracing::info!(filter = %params.level, "log level changed");
        let response = serde_json::json!({
            "filter": params.level,
            "previous_filter": previous_filter,
            "status": "applied",
        });
        let json = serde_json::to_string_pretty(&response)
            .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
        Ok(CallToolResult::success(vec![Content::text(json)]))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rmcp::handler::server::wrapper::Parameters;

    #[test]
    fn test_search_params_from_number() {
        // `threshold` field removed in v3.0.0 — calibrated cutoff is pinned
        // internally in run_search_ripvec; extra unknown fields are ignored.
        let json = r#"{"query": "test", "top_k": 5}"#;
        let params: SearchParams = serde_json::from_str(json).unwrap();
        assert_eq!(params.query, "test");
        assert_eq!(params.top_k, 5);
        assert_eq!(params.offset, 0);
    }

    #[test]
    fn test_search_params_from_string() {
        let json = r#"{"query": "test", "top_k": "5"}"#;
        let params: SearchParams = serde_json::from_str(json).unwrap();
        assert_eq!(params.query, "test");
        assert_eq!(params.top_k, 5);
        assert_eq!(params.offset, 0);
    }

    #[test]
    fn test_search_params_defaults() {
        let json = r#"{"query": "test"}"#;
        let params: SearchParams = serde_json::from_str(json).unwrap();
        assert_eq!(params.query, "test");
        assert_eq!(params.top_k, 10);
        assert_eq!(params.offset, 0);
    }

    #[test]
    fn test_search_params_with_offset() {
        let json = r#"{"query": "test", "offset": 5}"#;
        let params: SearchParams = serde_json::from_str(json).unwrap();
        assert_eq!(params.offset, 5);
    }

    #[test]
    fn test_search_params_with_offset_string() {
        let json = r#"{"query": "test", "offset": "10"}"#;
        let params: SearchParams = serde_json::from_str(json).unwrap();
        assert_eq!(params.offset, 10);
    }

    // ── A2: find_similar new param shapes ────────────────────────────────────

    /// find_similar accepts location shape (A2).
    #[test]
    fn find_similar_accepts_location_shape() {
        let json = r#"{
            "location": {
                "file_path": "foo.rs",
                "start_line": 42,
                "start_character": 0,
                "end_line": 50,
                "end_character": 0
            }
        }"#;
        let params: FindSimilarParams = serde_json::from_str(json).unwrap();
        assert!(params.location.is_some());
        assert!(params.symbol_name.is_none());
        let loc = params.location.unwrap();
        assert_eq!(loc.file_path, "foo.rs");
        assert_eq!(loc.start_line, 42);
        assert_eq!(params.top_k, 10); // default
        assert_eq!(params.offset, 0); // default
    }

    /// find_similar accepts symbol_name shape (A2).
    #[test]
    fn find_similar_accepts_symbol_name() {
        let json = r#"{"symbol_name": "apply_diff"}"#;
        let params: FindSimilarParams = serde_json::from_str(json).unwrap();
        assert!(params.location.is_none());
        assert_eq!(params.symbol_name.as_deref(), Some("apply_diff"));
    }

    /// find_similar rejects legacy (file_path, line) triple (A2).
    ///
    /// The old shape had top-level `file_path` and `line` fields. Those are
    /// removed in 4.0; the struct no longer has those fields so they are either
    /// silently ignored (serde default) or produce an error. We verify that
    /// a payload with ONLY the old fields and neither `location` nor
    /// `symbol_name` deserializes successfully (unknown fields are permitted by
    /// serde by default) but the resulting params have both `location` and
    /// `symbol_name` as `None` — meaning the tool handler's exactly-one-of
    /// validation will reject it at runtime.
    #[test]
    fn find_similar_rejects_legacy_file_line_triple() {
        // Old shape — file_path and line at top level without location wrapper.
        let json = r#"{"file_path": "foo.rs", "line": 42}"#;
        let params: FindSimilarParams = serde_json::from_str(json).unwrap();
        // Both location and symbol_name are None — the tool handler's
        // exactly-one-of validation will return an error.
        assert!(
            params.location.is_none() && params.symbol_name.is_none(),
            "legacy (file_path, line) triple must produce params where both location \
             and symbol_name are None, triggering the exactly-one-of validation error"
        );
    }

    /// find_similar with offset still works via location shape.
    #[test]
    fn find_similar_location_with_offset() {
        let json = r#"{"location": {"file_path": "foo.rs", "start_line": 10, "start_character": 0}, "offset": 3}"#;
        let params: FindSimilarParams = serde_json::from_str(json).unwrap();
        assert_eq!(params.offset, 3);
        assert!(params.location.is_some());
    }

    #[test]
    fn test_search_params_invalid_string() {
        let json = r#"{"query": "test", "top_k": "not_a_number"}"#;
        let result: Result<SearchParams, _> = serde_json::from_str(json);
        assert!(result.is_err());
    }

    /// G5: `GetRepoMapParams` defaults: token_budget=4000, include_metadata=false,
    /// focus_file=None. The `max_files` parameter is removed in 4.0.1.
    #[test]
    fn get_repo_map_params_defaults() {
        let json = r"{}";
        let params: GetRepoMapParams = serde_json::from_str(json).unwrap();
        assert_eq!(
            params.token_budget, 4000,
            "default token_budget must be 4000"
        );
        assert!(
            !params.include_metadata,
            "default include_metadata must be false"
        );
        assert!(params.focus_file.is_none());
    }

    /// G5: `GetRepoMapParams` accepts token_budget and include_metadata.
    #[test]
    fn get_repo_map_params_with_values() {
        let json =
            r#"{"token_budget": 2000, "include_metadata": true, "focus_file": "src/main.rs"}"#;
        let params: GetRepoMapParams = serde_json::from_str(json).unwrap();
        assert_eq!(params.token_budget, 2000);
        assert!(params.include_metadata);
        assert_eq!(params.focus_file.as_deref(), Some("src/main.rs"));
    }

    /// G5: `token_budget` may be supplied as a string (numeric coercion).
    #[test]
    fn get_repo_map_params_string_token_budget() {
        let json = r#"{"token_budget": "8000"}"#;
        let params: GetRepoMapParams = serde_json::from_str(json).unwrap();
        assert_eq!(params.token_budget, 8000);
    }

    // ── A1: position-scoped LSP tools accept only location object ─────────────

    /// lsp_hover accepts location object shape (A1).
    #[test]
    fn lsp_hover_accepts_location_object_only() {
        let json = r#"{
            "location": {
                "file_path": "src/main.rs",
                "start_line": 12,
                "start_character": 4,
                "end_line": 12,
                "end_character": 10
            }
        }"#;
        let params: LspPositionParams = serde_json::from_str(json).unwrap();
        assert_eq!(params.location.file_path, "src/main.rs");
        assert_eq!(params.location.start_line, 12);
        assert_eq!(params.location.start_character, 4);
    }

    /// lsp_goto_definition rejects legacy (file_path, line, character) triple (A1).
    ///
    /// The legacy shape had file_path/line/character at the top level. They are
    /// removed in 4.0. Sending only the old fields without a `location` wrapper
    /// must fail deserialization because `location` is now a required field.
    #[test]
    fn lsp_goto_definition_rejects_legacy_triple() {
        let json = r#"{
            "file_path": "src/main.rs",
            "line": 12,
            "character": 4
        }"#;
        // `location` is a required field — deserializing without it must fail.
        let result: Result<LspPositionParams, _> = serde_json::from_str(json);
        assert!(
            result.is_err(),
            "legacy (file_path, line, character) triple must fail deserialization; \
             location is now a required field"
        );
    }

    /// lsp_references accepts an lsp_location from a search result (A1).
    #[test]
    fn lsp_references_accepts_location_from_search_result() {
        // Simulate a search result's lsp_location passed directly as the
        // `location` parameter to lsp_references.
        let json = r#"{
            "location": {
                "file_path": "./crates/ripvec-mcp/src/tools.rs",
                "start_line": 237,
                "start_character": 0,
                "end_line": 280,
                "end_character": 0
            },
            "include_declaration": false
        }"#;
        let params: LspReferenceParams = serde_json::from_str(json).unwrap();
        assert_eq!(
            params.location.file_path,
            "./crates/ripvec-mcp/src/tools.rs"
        );
        assert_eq!(params.location.start_line, 237);
        assert!(!params.include_declaration);
    }

    /// Legacy (file_path, line, character) shape must NOT deserialize into
    /// LspReferenceParams because `location` is now required.
    #[test]
    fn lsp_references_rejects_legacy_triple() {
        let json = r#"{"file_path": "src/lib.rs", "line": 5, "character": 2}"#;
        let result: Result<LspReferenceParams, _> = serde_json::from_str(json);
        assert!(
            result.is_err(),
            "legacy triple must fail deserialization for LspReferenceParams"
        );
    }

    #[test]
    fn lsp_mcp_response_has_search_like_results_and_raw_lsp() {
        let raw = serde_json::json!({
            "uri": "file:///tmp/project/src/main.rs",
            "range": {
                "start": { "line": 3, "character": 0 },
                "end": { "line": 4, "character": 0 }
            }
        });
        let response = LspMcpResponse {
            results: vec![LspMcpResultItem {
                lsp_location: LspLocation {
                    file_path: "./src/main.rs".to_string(),
                    start_line: 3,
                    start_character: 0,
                    end_line: 4,
                    end_character: 0,
                },
                symbol_name: Some("main".to_string()),
                // K4: "definition" is a navigation label, not a SymbolKind;
                // symbol_kind_int is None per "both or neither" contract.
                symbol_kind: Some("definition".to_string()),
                symbol_kind_int: None,
                preview: Some("fn main() {}".to_string()),
                detail: None,
            }],
            lsp: LspPayload {
                method: "textDocument/definition",
                raw_response: &raw,
            },
        };

        let json = serde_json::to_value(response).unwrap();

        assert_eq!(json["results"][0]["symbol_name"], "main");
        assert_eq!(json["results"][0]["lsp_location"]["start_line"], 3);
        assert_eq!(json["lsp"]["method"], "textDocument/definition");
        assert_eq!(json["lsp"]["raw_response"], raw);
    }

    // ── K4 tests: symbol_kind consistency ────────────────────────────────────

    /// `test:symbol_kind_consistent_friendly_or_raw_not_mixed`
    ///
    /// Baseline (RED): `results[]` items carry `symbol_kind` as a friendly string
    /// (e.g. "interface") while `lsp.raw_response` carries the raw LSP integer (11).
    /// Clients reading the friendly view break when chained against clients reading
    /// the integer. There was no `symbol_kind_int` field in `results[]`.
    ///
    /// After fix (GREEN): every `LspMcpResultItem` that has a SymbolKind value
    /// (document and workspace symbols) emits BOTH `symbol_kind` (friendly) AND
    /// `symbol_kind_int` (integer). Navigation results (hover, definition,
    /// incoming_call) emit `symbol_kind_int: None` per the "both or neither" contract.
    ///
    /// This test verifies that:
    /// 1. A symbol result carries both fields with consistent values.
    /// 2. A navigation result carries neither integer field.
    /// 3. The integer value matches the LSP SymbolKind spec (Interface=11, Function=12).
    #[expect(
        clippy::too_many_lines,
        reason = "test exercises interface/function/property/navigation cases; each assertion is load-bearing and splitting would hide the cross-case contract"
    )]
    #[test]
    fn symbol_kind_consistent_friendly_or_raw_not_mixed() {
        use tower_lsp_server::ls_types::{
            Location, Position, Range, SymbolInformation, SymbolKind,
        };

        let root = std::path::Path::new("/tmp");

        // --- Symbol result (workspace/document symbol) ---
        #[allow(deprecated)]
        let sym_info = SymbolInformation {
            name: "Reader".to_string(),
            kind: SymbolKind::INTERFACE,
            tags: None,
            deprecated: None,
            location: Location {
                uri: tower_lsp_server::ls_types::Uri::from_file_path("/tmp/io.go").unwrap(),
                range: Range {
                    start: Position {
                        line: 1,
                        character: 0,
                    },
                    end: Position {
                        line: 5,
                        character: 0,
                    },
                },
            },
            container_name: None,
        };
        let item = symbol_information_item(&sym_info, root).unwrap();
        let json = serde_json::to_value(&item).unwrap();

        // Both symbol_kind (friendly) and symbol_kind_int (integer) must be present.
        assert_eq!(
            json["symbol_kind"], "interface",
            "symbol_kind must be the friendly lowercase string for Interface"
        );
        assert_eq!(
            json["symbol_kind_int"], 11,
            "symbol_kind_int must be 11 (Interface) per LSP spec §3.15.1"
        );

        // Function kind: kind=12
        #[allow(deprecated)]
        let func_info = SymbolInformation {
            name: "NewReader".to_string(),
            kind: SymbolKind::FUNCTION,
            tags: None,
            deprecated: None,
            location: Location {
                uri: tower_lsp_server::ls_types::Uri::from_file_path("/tmp/io.go").unwrap(),
                range: Range {
                    start: Position {
                        line: 10,
                        character: 0,
                    },
                    end: Position {
                        line: 12,
                        character: 0,
                    },
                },
            },
            container_name: None,
        };
        let func_item = symbol_information_item(&func_info, root).unwrap();
        let func_json = serde_json::to_value(&func_item).unwrap();
        assert_eq!(func_json["symbol_kind"], "function");
        assert_eq!(func_json["symbol_kind_int"], 12);

        // Property kind (K1 fix): LSP SymbolKind::PROPERTY = 7 (per spec §3.15.1).
        // Note: the briefing cited "kind=22 (Property)" but 22 is EnumMember in LSP;
        // Property is 7. We use the correct spec value.
        #[allow(deprecated)]
        let prop_info = SymbolInformation {
            name: "name".to_string(),
            kind: SymbolKind::PROPERTY,
            tags: None,
            deprecated: None,
            location: Location {
                uri: tower_lsp_server::ls_types::Uri::from_file_path("/tmp/model.py").unwrap(),
                range: Range {
                    start: Position {
                        line: 5,
                        character: 0,
                    },
                    end: Position {
                        line: 8,
                        character: 0,
                    },
                },
            },
            container_name: None,
        };
        let prop_item = symbol_information_item(&prop_info, root).unwrap();
        let prop_json = serde_json::to_value(&prop_item).unwrap();
        assert_eq!(prop_json["symbol_kind"], "property");
        // LSP Property = 7 (not 22; 22 is EnumMember)
        assert_eq!(prop_json["symbol_kind_int"], 7);

        // --- Navigation result (location_result — definition/reference) ---
        let loc = tower_lsp_server::ls_types::Location {
            uri: tower_lsp_server::ls_types::Uri::from_file_path("/tmp/main.rs").unwrap(),
            range: Range {
                start: Position {
                    line: 0,
                    character: 0,
                },
                end: Position {
                    line: 1,
                    character: 0,
                },
            },
        };
        let nav_item = location_result(&loc, root, "definition").unwrap();
        let nav_json = serde_json::to_value(&nav_item).unwrap();

        // Navigation result must have symbol_kind label but NOT symbol_kind_int.
        assert_eq!(nav_json["symbol_kind"], "definition");
        assert!(
            nav_json.get("symbol_kind_int").is_none() || nav_json["symbol_kind_int"].is_null(),
            "navigation result must not carry symbol_kind_int (no LSP integer applies); \
             got: {:?}",
            nav_json.get("symbol_kind_int")
        );
    }

    /// `symbol_kind_to_u32` must round-trip correctly for all 26 defined SymbolKind values.
    #[test]
    fn symbol_kind_to_u32_round_trips_for_all_known_kinds() {
        use tower_lsp_server::ls_types::SymbolKind as SK;
        let cases: &[(SK, u32)] = &[
            (SK::FILE, 1),
            (SK::MODULE, 2),
            (SK::NAMESPACE, 3),
            (SK::PACKAGE, 4),
            (SK::CLASS, 5),
            (SK::METHOD, 6),
            (SK::PROPERTY, 7),
            (SK::FIELD, 8),
            (SK::CONSTRUCTOR, 9),
            (SK::ENUM, 10),
            (SK::INTERFACE, 11),
            (SK::FUNCTION, 12),
            (SK::VARIABLE, 13),
            (SK::CONSTANT, 14),
            (SK::STRING, 15),
            (SK::NUMBER, 16),
            (SK::BOOLEAN, 17),
            (SK::ARRAY, 18),
            (SK::OBJECT, 19),
            (SK::KEY, 20),
            (SK::NULL, 21),
            (SK::ENUM_MEMBER, 22),
            (SK::STRUCT, 23),
            (SK::EVENT, 24),
            (SK::OPERATOR, 25),
            (SK::TYPE_PARAMETER, 26),
        ];
        for &(kind, expected) in cases {
            let got = symbol_kind_to_u32(kind);
            assert_eq!(
                got, expected,
                "symbol_kind_to_u32({kind:?}) must return {expected}, got {got}"
            );
        }
    }

    // ── Helpers for audit-fix tests ──────────────────────────────────────────

    /// Create a unique temp directory under `std::env::temp_dir()`.
    ///
    /// Returned `PathBuf` is the directory root. The caller is responsible for
    /// cleanup (tests delete it at the end, or leak it — acceptable for test
    /// isolation). We avoid the `tempfile` crate to stay within the Cargo.toml
    /// fence.
    fn unique_test_dir(tag: &str) -> std::path::PathBuf {
        let ts = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_nanos();
        let dir = std::env::temp_dir().join(format!("ripvec_test_{tag}_{ts}"));
        std::fs::create_dir_all(&dir).expect("failed to create test dir");
        dir
    }

    /// Build a `RipvecServer` pointing at `project_root` with a no-op tracing
    /// reload handle. Suitable for unit tests that don't call search tools.
    fn make_test_server(project_root: std::path::PathBuf) -> RipvecServer {
        let (filter_layer, reload_handle) =
            tracing_subscriber::reload::Layer::new(tracing_subscriber::EnvFilter::new("warn"));
        // We only need the reload handle; drop the layer (tests have no registry).
        drop(filter_layer);
        RipvecServer::with_engine(
            project_root,
            crate::server::LogBuffer::new(10),
            reload_handle,
            crate::server::McpEngine::Ripvec,
        )
    }

    // ── R1.1 ─────────────────────────────────────────────────────────────────

    /// ensure_lsp_root(None) must populate root_indices for project_root.
    ///
    /// This test is an integration test that builds a real index for a tiny
    /// tempdir corpus, so it is marked `ignore` by default (it requires the
    /// Model2Vec weights to be downloaded). Run with `cargo test -- --ignored`
    /// when weights are available.
    #[tokio::test]
    #[ignore = "requires Model2Vec weights; run with --ignored when available"]
    async fn r1_1_ensure_lsp_root_none_populates_root_indices() {
        let dir = unique_test_dir("r1_1_full");
        // Write a minimal Rust file so from_root has something to index.
        std::fs::write(dir.join("lib.rs"), b"fn hello() {}").unwrap();
        let server = make_test_server(dir.clone());

        // Pre-condition: root_indices is empty.
        assert!(server.root_indices.read().await.is_empty());

        // Act: ensure_lsp_root(None) should treat project_root as an explicit root.
        let resolved = server.ensure_lsp_root(None).await.unwrap();

        // Post-condition: root_indices contains exactly one entry for the
        // canonical project_root path.
        let indices = server.root_indices.read().await;
        let canonical = dir.canonicalize().unwrap();
        assert!(
            indices.contains_key(&canonical),
            "root_indices must contain an entry for canonical project_root after ensure_lsp_root(None); \
             resolved = {resolved:?}, canonical = {canonical:?}, keys = {keys:?}",
            keys = indices.keys().collect::<Vec<_>>()
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// Smoke-test the synchronous path: ensure_lsp_root_lite(None) returns
    /// the canonical project_root (no index build). Does not need weights.
    #[test]
    fn r1_1_ensure_lsp_root_lite_none_returns_canonical_project_root() {
        let dir = unique_test_dir("r1_1_lite");
        let server = make_test_server(dir.clone());
        let resolved = server.ensure_lsp_root_lite(None).unwrap();
        let canonical = dir.canonicalize().unwrap();
        assert_eq!(
            resolved, canonical,
            "ensure_lsp_root_lite(None) must return canonical project_root"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    // ── R3.4 ─────────────────────────────────────────────────────────────────

    /// Default threshold for find_duplicates must be 0.5, not 0.85.
    #[test]
    fn r3_4_find_duplicates_default_threshold_is_0_5() {
        // Deserialize with no threshold field — should default to 0.5 in the tool handler.
        // The struct itself uses Option<f32>, so we verify the tool's unwrap_or default
        // by confirming that None deserialized correctly.
        let json = r#"{"root": null}"#;
        let params: FindDuplicatesParams = serde_json::from_str(json).unwrap();
        assert!(
            params.threshold.is_none(),
            "threshold field should be None when absent"
        );
        // The effective default applied in the tool handler is 0.5.
        let effective = params.threshold.unwrap_or(0.5_f32);
        assert!(
            (effective - 0.5).abs() < f32::EPSILON,
            "effective default threshold must be 0.5, got {effective}"
        );
    }

    // ── R6.2 ─────────────────────────────────────────────────────────────────

    /// log_level with a non-ripvec target must return an error.
    #[tokio::test]
    async fn r6_2_log_level_rejects_non_ripvec_target() {
        let dir = unique_test_dir("r6_2_reject");
        let server = make_test_server(dir.clone());
        let result = server
            .log_level(Parameters(LogLevelParams {
                level: "tokio=trace".to_string(),
            }))
            .await;
        let _ = std::fs::remove_dir_all(&dir);
        assert!(
            result.is_err(),
            "log_level(tokio=trace) must return an error; got Ok"
        );
        let err = result.unwrap_err();
        assert!(
            err.message.contains("not permitted") || err.message.contains("not allowed"),
            "error message must mention the rejection reason; got: {}",
            err.message
        );
    }

    /// log_level with an allowed ripvec target must reach the reload step.
    ///
    /// In test environments there is no active tracing subscriber, so
    /// `reload_handle.reload()` fails with "subscriber no longer exists".
    /// That error originates AFTER our validation passes — the test confirms
    /// that our validation allowlist does not block ripvec_mcp targets.
    #[tokio::test]
    async fn r6_2_log_level_accepts_ripvec_target() {
        let dir = unique_test_dir("r6_2_accept");
        let server = make_test_server(dir.clone());
        let result = server
            .log_level(Parameters(LogLevelParams {
                level: "ripvec_mcp=debug".to_string(),
            }))
            .await;
        let _ = std::fs::remove_dir_all(&dir);
        // Either the call succeeds (if a subscriber is running) or it fails at
        // the reload step (no subscriber in unit test context). Either way, the
        // error must NOT be about the target being blocked.
        if let Err(ref e) = result {
            assert!(
                !e.message.contains("not permitted") && !e.message.contains("not allowed"),
                "log_level(ripvec_mcp=debug) must not be blocked by the allowlist; got: {}",
                e.message
            );
        }
    }

    /// log_level with an oversized filter must return an error.
    #[tokio::test]
    async fn r6_2_log_level_rejects_oversized_filter() {
        let dir = unique_test_dir("r6_2_oversize");
        let server = make_test_server(dir.clone());
        let level = "A".repeat(2000);
        let result = server.log_level(Parameters(LogLevelParams { level })).await;
        let _ = std::fs::remove_dir_all(&dir);
        assert!(
            result.is_err(),
            "log_level with 2000-char filter must return an error"
        );
        let err = result.unwrap_err();
        assert!(
            err.message.contains("too long") || err.message.contains("max"),
            "error must mention oversized filter; got: {}",
            err.message
        );
    }

    // ── R6.5 ─────────────────────────────────────────────────────────────────

    /// strict_suffix_path_match: "foo.rs" must NOT match "bar_foo.rs".
    #[test]
    fn r6_5_strict_suffix_does_not_match_embedded_name() {
        // Simulate the matching logic used in find_similar_ripvec.
        let fp = "foo.rs";
        let candidate = "src/bar_foo.rs";
        let cp = candidate;
        let path_match = cp == fp
            || (cp.len() > fp.len()
                && cp.ends_with(fp)
                && cp.as_bytes()[cp.len() - fp.len() - 1] == b'/');
        assert!(
            !path_match,
            "\"bar_foo.rs\" must not match suffix \"foo.rs\" with strict rule"
        );
    }

    /// strict_suffix_path_match: "foo.rs" MUST match "src/foo.rs".
    #[test]
    fn r6_5_strict_suffix_matches_path_separated_name() {
        let fp = "foo.rs";
        let candidate = "src/foo.rs";
        let cp = candidate;
        let path_match = cp == fp
            || (cp.len() > fp.len()
                && cp.ends_with(fp)
                && cp.as_bytes()[cp.len() - fp.len() - 1] == b'/');
        assert!(
            path_match,
            "\"src/foo.rs\" must match suffix \"foo.rs\" with strict rule"
        );
    }

    /// saturating_add: params.line == usize::MAX must not overflow.
    #[test]
    fn r6_5_saturating_add_usize_max() {
        let line: usize = usize::MAX;
        let target_line = line.saturating_add(1);
        // saturating_add keeps it at MAX rather than wrapping to 0.
        assert_eq!(
            target_line,
            usize::MAX,
            "saturating_add(1) on usize::MAX must stay at MAX"
        );
    }

    // ── R6.6 ─────────────────────────────────────────────────────────────────

    /// focus_file strict suffix: "util.rs" must not match "foo_util.rs".
    #[test]
    fn r6_6_strict_suffix_does_not_match_embedded_focus() {
        let focus = "util.rs";
        let candidate = "src/foo_util.rs";
        let p = candidate;
        let exact = p == focus;
        let strict_suffix = p.len() > focus.len()
            && p.ends_with(focus)
            && p.as_bytes()[p.len() - focus.len() - 1] == b'/';
        assert!(
            !exact && !strict_suffix,
            "\"src/foo_util.rs\" must not match focus \"util.rs\""
        );
    }

    /// focus_file: two matches with the same suffix must be considered ambiguous.
    #[test]
    fn r6_6_ambiguous_focus_returns_multiple_matches() {
        let focus = "util.rs";
        let paths = ["src/util.rs", "tests/util.rs", "other/foo_util.rs"];
        let matches: Vec<usize> = paths
            .iter()
            .enumerate()
            .filter_map(|(idx, p)| {
                let exact = *p == focus;
                let strict_suffix = p.len() > focus.len()
                    && p.ends_with(focus)
                    && p.as_bytes()[p.len() - focus.len() - 1] == b'/';
                if exact || strict_suffix {
                    Some(idx)
                } else {
                    None
                }
            })
            .collect();
        // "src/util.rs" and "tests/util.rs" both match; "foo_util.rs" does not.
        assert_eq!(
            matches.len(),
            2,
            "expected 2 ambiguous matches, got {}",
            matches.len()
        );
    }

    // ── R7.2 ─────────────────────────────────────────────────────────────────

    /// walk_newest on an empty directory must return no_source_found.
    #[tokio::test]
    async fn r7_2_walk_newest_empty_dir_returns_no_source_found() {
        let dir = unique_test_dir("r7_2");
        let server = make_test_server(dir.clone());
        // up_to_date walks self.project_root/crates — create the dir but put
        // nothing in it so walk_newest finds no .rs or Cargo files.
        std::fs::create_dir(dir.join("crates")).unwrap();
        let result = server.up_to_date().await.unwrap();
        let _ = std::fs::remove_dir_all(&dir);
        let c = result.content.first().unwrap();
        let text = c.as_text().expect("expected text content").text.clone();
        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert_eq!(
            json["status"], "no_source_found",
            "empty crates dir must produce no_source_found status; got: {json}"
        );
    }

    // ── R8.1 ─────────────────────────────────────────────────────────────────

    /// log_relative_path must strip the project_root prefix.
    #[test]
    fn r8_1_log_relative_path_strips_project_root() {
        let dir = unique_test_dir("r8_1_strip");
        let server = make_test_server(dir.clone());
        // A file inside project_root should become relative.
        let abs_path = dir.join("src/foo.rs").to_string_lossy().into_owned();
        let rel = server.log_relative_path(&abs_path, None);
        let _ = std::fs::remove_dir_all(&dir);
        assert_eq!(rel, "src/foo.rs", "expected stripped path, got '{rel}'");
    }

    /// log_relative_path for a file outside project_root must return "<external>".
    #[test]
    fn r8_1_log_relative_path_external_is_masked() {
        let dir = unique_test_dir("r8_1_ext");
        let server = make_test_server(dir.clone());
        let rel = server.log_relative_path("/tmp/other/secret.rs", None);
        let _ = std::fs::remove_dir_all(&dir);
        assert_eq!(
            rel, "<external>",
            "path outside root must be masked as <external>"
        );
    }

    // ── R8.2 ─────────────────────────────────────────────────────────────────

    /// log_level response must contain a "previous_filter" key.
    ///
    /// The reload path requires an active tracing subscriber, which is not
    /// present in plain unit tests. We verify the response JSON shape by
    /// inspecting the serde_json literal that would be produced if reload
    /// succeeded. The structural guarantee is that `previous_filter` is
    /// always included in the JSON construction (auditable in the source).
    #[test]
    fn r8_2_log_level_response_json_shape_contains_previous_filter() {
        // Directly assert the shape of the JSON object that log_level builds.
        // This mirrors the serde_json::json! call in the tool handler verbatim,
        // confirming that "previous_filter" is a required key.
        let filter = "ripvec_core=debug";
        let previous_filter = "warn";
        let response = serde_json::json!({
            "filter": filter,
            "previous_filter": previous_filter,
            "status": "applied",
        });
        assert!(
            response.get("previous_filter").is_some(),
            "log_level response JSON must contain previous_filter key"
        );
        assert_eq!(response["previous_filter"], previous_filter);
        assert_eq!(response["filter"], filter);
        assert_eq!(response["status"], "applied");
    }

    // ── R6.4 ─────────────────────────────────────────────────────────────────

    /// Build a simple symmetric score matrix for use in compute_duplicate_pairs tests.
    ///
    /// `matrix[i][j]` is the cosine similarity between chunk `i` and chunk `j`.
    fn score_fn_from_matrix(matrix: Vec<Vec<f32>>) -> impl Fn(usize) -> Vec<f32> {
        move |a| matrix[a].clone()
    }

    /// By default (intra_file = false), pairs whose both chunks share the same
    /// file path must be excluded even when their similarity exceeds the threshold.
    #[test]
    fn find_duplicates_excludes_intra_file_pairs_by_default() {
        // Two chunks in the same file with high similarity.
        let file_paths = ["src/lib.rs", "src/lib.rs"];
        // Symmetric matrix: chunk 0 and chunk 1 are near-duplicates (score 0.95).
        let matrix = vec![vec![1.0, 0.95], vec![0.95, 1.0]];

        // Default: intra_file = false → the same-file pair must be excluded.
        let pairs = compute_duplicate_pairs(
            2,
            &file_paths,
            score_fn_from_matrix(matrix.clone()),
            0.5,
            20,
            false,
        );
        assert!(
            pairs.is_empty(),
            "intra_file=false must exclude same-file pairs; got {} pairs",
            pairs.len()
        );

        // With intra_file = true the pair must be returned.
        let pairs_with =
            compute_duplicate_pairs(2, &file_paths, score_fn_from_matrix(matrix), 0.5, 20, true);
        assert_eq!(
            pairs_with.len(),
            1,
            "intra_file=true must return the same-file pair"
        );
        assert_eq!(pairs_with[0].0, 0);
        assert_eq!(pairs_with[0].1, 1);
        assert!(
            (pairs_with[0].2 - 0.95).abs() < 1e-5,
            "expected similarity ~0.95, got {}",
            pairs_with[0].2
        );
    }

    /// When many pairs exceed the threshold, compute_duplicate_pairs must
    /// return exactly max_pairs results and they must be the highest-scoring ones.
    #[test]
    fn find_duplicates_bounds_pair_evaluation_at_max_pairs() {
        // Build a corpus of 6 chunks across distinct files.
        // Every pair (i, j) with i < j gets similarity = 0.5 + (i + j) * 0.01
        // so pair (4,5) = 0.59 is highest, pair (0,1) = 0.51 is lowest.
        let n = 6usize;
        let files: Vec<String> = (0..n).map(|i| format!("src/file{i}.rs")).collect();
        let file_path_strs: Vec<&str> = files.iter().map(String::as_str).collect();

        // Build full matrix.
        let mut matrix = vec![vec![0.0f32; n]; n];
        for (i, row) in matrix.iter_mut().enumerate() {
            for (j, cell) in row.iter_mut().enumerate() {
                if i == j {
                    *cell = 1.0;
                } else {
                    *cell = 0.5 + (i + j) as f32 * 0.01;
                }
            }
        }

        // There are C(6,2) = 15 pairs above threshold=0.5. Ask for max_pairs=5.
        let max_pairs = 5;
        let pairs = compute_duplicate_pairs(
            n,
            &file_path_strs,
            score_fn_from_matrix(matrix),
            0.5,
            max_pairs,
            true, // cross-file filter off so all pairs are eligible
        );

        assert_eq!(
            pairs.len(),
            max_pairs,
            "expected exactly {max_pairs} pairs, got {}",
            pairs.len()
        );

        // The returned pairs must be sorted descending by similarity.
        for w in pairs.windows(2) {
            assert!(
                w[0].2 >= w[1].2,
                "pairs not sorted descending: {} < {}",
                w[0].2,
                w[1].2
            );
        }

        // The minimum similarity in the returned set must be ≥ the (max_pairs+1)-th
        // highest score in the full set. The highest pair is (4,5) = 0.59.
        // We just check that no returned pair has a lower score than the 5th-highest.
        let min_returned = pairs.last().map(|p| p.2).unwrap_or(0.0);
        // Pair (0,1) = 0.51 is the lowest. The 5th-highest pair has score ≥ 0.55.
        assert!(
            min_returned >= 0.55 - 1e-5,
            "lowest returned score {min_returned} is below expected minimum 0.55"
        );
    }

    // ── R4.2 ─────────────────────────────────────────────────────────────────

    /// The `ReconcileOutcome` enum must have a `TouchRefreshed` variant so that
    /// `ensure_ripvec_root` can distinguish "stat-only refresh" from "no change".
    ///
    /// This test verifies the variant is present and that the `ReconcileOutcome`
    /// match arms are exhaustive. It is a compile-time proof: if the variant is
    /// absent, this function fails to compile.
    #[test]
    fn r4_2_reconcile_outcome_touch_refreshed_variant_is_exhaustive() {
        // The easiest compile-time proof: write a match that includes
        // TouchRefreshed and confirm rustc accepts it without `non_exhaustive`
        // warning. We use an Unchanged arm here since constructing a real
        // RipvecIndex requires Model2Vec weights.
        let outcome: ReconcileOutcome = ReconcileOutcome::Unchanged;
        // Exhaustive match with combined arm — proves all three variants
        // exist at compile time without triggering `match_same_arms`.
        match outcome {
            ReconcileOutcome::Unchanged
            | ReconcileOutcome::Updated { .. }
            | ReconcileOutcome::TouchRefreshed { .. } => {}
        }
    }

    /// Integration test: `ensure_ripvec_root` must write back a refreshed index
    /// when only `touched_clean` files are detected (no structural diff).
    ///
    /// Requires Model2Vec weights; skipped in CI unless `--include-ignored`.
    #[tokio::test]
    #[ignore = "requires Model2Vec weights; run with --ignored when available"]
    async fn r4_2_ensure_ripvec_root_applies_touch_refresh_when_only_touched_clean() {
        let dir = unique_test_dir("r4_2_touch");
        let content = b"fn hello() {}";
        let file_path = dir.join("lib.rs");
        std::fs::write(&file_path, content).unwrap();

        // The Manifest stores absolute, canonical paths (see
        // crates/ripvec-core/src/encoder/ripvec/manifest.rs:104). Resolve
        // the test file's absolute canonical path once so both manifest
        // lookups below use the same key shape ensure_ripvec_root
        // inserted.
        let canonical_file = file_path.canonicalize().expect("file_path canonicalize");

        let server = make_test_server(dir.clone());
        // First call: build the index.
        let idx1 = server.ensure_ripvec_root(&dir).await.unwrap();
        let mtime1 = idx1
            .manifest()
            .files
            .get(&canonical_file)
            .expect("canonical_file in manifest1")
            .mtime;

        // Touch the file (update mtime/size but same content).
        // We simulate by re-writing identical bytes.
        std::thread::sleep(std::time::Duration::from_millis(10));
        std::fs::write(&file_path, content).unwrap();

        // Second call: detect touch-only, apply stat refresh, return updated index.
        let idx2 = server.ensure_ripvec_root(&dir).await.unwrap();
        let mtime2 = idx2
            .manifest()
            .files
            .get(&canonical_file)
            .expect("canonical_file in manifest2")
            .mtime;
        assert_ne!(
            mtime1, mtime2,
            "manifest stat tuple must be refreshed after touch; \
             mtime1={mtime1:?} mtime2={mtime2:?}"
        );

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// When the corpus size exceeds MAX_CORPUS_FOR_DUPLICATES, find_duplicates
    /// must return an rmcp error without doing any computation.
    ///
    /// We test this by calling the tool handler's corpus-size guard directly
    /// via the check the handler performs before calling compute_duplicate_pairs.
    /// (A real index with 10 001 chunks is not buildable without Model2Vec
    /// weights, so we unit-test the guard logic in isolation.)
    #[test]
    fn find_duplicates_rejects_oversized_corpus() {
        // Simulate the guard: n > MAX_CORPUS_FOR_DUPLICATES → error message.
        let n: usize = MAX_CORPUS_FOR_DUPLICATES + 1;
        let err_msg = if n > MAX_CORPUS_FOR_DUPLICATES {
            Some(format!(
                "corpus too large for find_duplicates ({n} chunks; max {MAX_CORPUS_FOR_DUPLICATES}); \
                 use a more focused root or a higher threshold"
            ))
        } else {
            None
        };
        assert!(
            err_msg.is_some(),
            "guard must trigger for n={n} > MAX_CORPUS_FOR_DUPLICATES={MAX_CORPUS_FOR_DUPLICATES}"
        );
        let msg = err_msg.unwrap();
        assert!(
            msg.contains("corpus too large"),
            "error must say 'corpus too large'; got: {msg}"
        );
        assert!(
            msg.contains(&n.to_string()),
            "error must include chunk count; got: {msg}"
        );
        assert!(
            msg.contains(&MAX_CORPUS_FOR_DUPLICATES.to_string()),
            "error must include max limit; got: {msg}"
        );
    }

    // ── A3: call hierarchy item schema emits type: object ────────────────────

    /// The JSON schema for `LspCallHierarchyItemParams.item` must declare
    /// `"type": "object"` so MCP frameworks pass it as a structured object
    /// rather than stringifying it (A3 / Issue #1).
    #[test]
    fn lsp_incoming_calls_accepts_prepare_result_directly() {
        // Verify that a CallHierarchyItem-shaped JSON object deserializes
        // successfully into LspCallHierarchyItemParams.item without stringification.
        let item_json = r#"{
            "name": "apply_diff",
            "kind": 12,
            "uri": "file:///path/to/tools.rs",
            "range": {
                "start": {"line": 100, "character": 0},
                "end": {"line": 120, "character": 1}
            },
            "selectionRange": {
                "start": {"line": 100, "character": 4},
                "end": {"line": 100, "character": 13}
            }
        }"#;
        let item_value: serde_json::Value = serde_json::from_str(item_json).unwrap();
        // Wrap in the full params JSON as the MCP framework would send it.
        let params_json = serde_json::json!({
            "item": item_value,
        });
        let params: LspCallHierarchyItemParams =
            serde_json::from_value(params_json).expect("must deserialize item as object");
        // Verify the item is an object (not a string).
        assert!(
            params.item.is_object(),
            "item must be an object after deserialization, not a string; \
             this proves the schema_with annotation emitting type:object is correct"
        );
        assert_eq!(params.item["name"], "apply_diff");
    }

    /// Same as above but for lsp_outgoing_calls (A3).
    #[test]
    fn lsp_outgoing_calls_accepts_prepare_result_directly() {
        let item_value = serde_json::json!({
            "name": "ensure_ripvec_root",
            "kind": 12,
            "uri": "file:///path/to/tools.rs",
            "range": {
                "start": {"line": 200, "character": 0},
                "end": {"line": 220, "character": 1}
            },
            "selectionRange": {
                "start": {"line": 200, "character": 4},
                "end": {"line": 200, "character": 21}
            }
        });
        let params_json = serde_json::json!({"item": item_value});
        let params: LspCallHierarchyItemParams =
            serde_json::from_value(params_json).expect("must deserialize item as object");
        assert!(params.item.is_object(), "item must be an object");
        assert_eq!(params.item["name"], "ensure_ripvec_root");
    }

    /// The JSON schema for `LspCallHierarchyItemParams` must include
    /// `"type": "object"` for the `item` property (A3).
    ///
    /// This is the contract-level proof: the schema drives MCP framework
    /// behavior. Without `type: object`, the framework coerces the item
    /// to a string and the server rejects it.
    #[test]
    fn call_hierarchy_item_schema_emits_type_object() {
        let mut schema_gen = schemars::SchemaGenerator::default();
        let schema = call_hierarchy_item_schema(&mut schema_gen);
        let schema_value = serde_json::to_value(&schema).unwrap();
        assert_eq!(
            schema_value.get("type").and_then(|v| v.as_str()),
            Some("object"),
            "call_hierarchy_item_schema must declare type: object; got schema: {schema_value}"
        );
    }

    // ── E3: find_duplicates response includes corpus_chunk_count and capped ───

    /// find_duplicates response must include corpus_chunk_count field (E3).
    #[test]
    fn find_duplicates_response_includes_corpus_chunk_count() {
        // Construct a minimal response JSON matching what find_duplicates_ripvec
        // builds. We verify the shape by constructing it directly.
        let n: usize = 42;
        let threshold = 0.5_f32;
        let pairs: Vec<serde_json::Value> = vec![];
        let corpus_chunk_count = n;
        let capped = corpus_chunk_count >= MAX_CORPUS_FOR_DUPLICATES;
        let response = serde_json::json!({
            "pairs": pairs,
            "total": 0usize,
            "threshold": threshold,
            "corpus_chunk_count": corpus_chunk_count,
            "capped": capped,
        });
        assert!(
            response.get("corpus_chunk_count").is_some(),
            "response must contain corpus_chunk_count field"
        );
        assert_eq!(
            response["corpus_chunk_count"].as_u64(),
            Some(42),
            "corpus_chunk_count must equal n"
        );
    }

    /// capped must be false when corpus_chunk_count < MAX_CORPUS_FOR_DUPLICATES (E3).
    #[test]
    fn find_duplicates_capped_false_on_small_corpus() {
        let n: usize = 100;
        let capped = n >= MAX_CORPUS_FOR_DUPLICATES;
        assert!(
            !capped,
            "capped must be false for n={n} < MAX_CORPUS_FOR_DUPLICATES={MAX_CORPUS_FOR_DUPLICATES}"
        );
        // Simulate response shape.
        let response = serde_json::json!({
            "pairs": [],
            "total": 0usize,
            "threshold": 0.5_f32,
            "corpus_chunk_count": n,
            "capped": capped,
        });
        assert_eq!(
            response["capped"], false,
            "capped must be false for small corpus"
        );
        assert_eq!(response["corpus_chunk_count"], 100);
    }

    /// capped must be true when corpus_chunk_count >= MAX_CORPUS_FOR_DUPLICATES (E3).
    ///
    /// Note: the guard in `find_duplicates_ripvec` returns an error before
    /// reaching the response-building code when n > MAX_CORPUS_FOR_DUPLICATES.
    /// The capped=true case fires at exactly n == MAX_CORPUS_FOR_DUPLICATES
    /// (the boundary: >= 10000). This test verifies the capped flag logic.
    #[test]
    fn find_duplicates_capped_true_on_large_corpus() {
        // At the exact boundary (n == MAX_CORPUS_FOR_DUPLICATES) the guard
        // does NOT fire (guard is ">", not ">="), so the response IS built with
        // corpus_chunk_count = MAX_CORPUS_FOR_DUPLICATES and capped = true.
        let n: usize = MAX_CORPUS_FOR_DUPLICATES;
        let capped = n >= MAX_CORPUS_FOR_DUPLICATES;
        assert!(
            capped,
            "capped must be true for n={n} == MAX_CORPUS_FOR_DUPLICATES"
        );
        let response = serde_json::json!({
            "pairs": [],
            "total": 0usize,
            "threshold": 0.5_f32,
            "corpus_chunk_count": n,
            "capped": capped,
        });
        assert_eq!(response["capped"], true, "capped must be true at boundary");
        assert_eq!(
            response["corpus_chunk_count"].as_u64(),
            Some(MAX_CORPUS_FOR_DUPLICATES as u64)
        );
    }

    // ── C2: orthogonal corpus / rerank / include_metadata axes ───────────────

    /// Helper: build a minimal CodeChunk for unit tests (all non-critical fields defaulted).
    fn make_code_chunk_c2(
        file_path: &str,
        content_kind: ripvec_core::chunk::ContentKind,
    ) -> ripvec_core::chunk::CodeChunk {
        ripvec_core::chunk::CodeChunk {
            file_path: file_path.to_string(),
            name: "test".to_string(),
            kind: "function_item".to_string(),
            content_kind,
            start_line: 1,
            end_line: 10,
            symbol_line: 1,
            content: "fn test() {}".to_string(),
            enriched_content: "fn test() {}".to_string(),
            qualified_name: None,
        }
    }

    /// `derive_filter_paths` with default params (corpus=Code, include_metadata=false)
    /// must exclude ContentKind::Meta chunks (JSON, YAML, TOML, etc.).
    ///
    /// Simulates what happens when `search` is called with default params: meta
    /// files like calibration.json and Cargo.lock must not appear in results.
    #[test]
    fn search_default_excludes_meta_files() {
        // Build a mixed corpus: one Rust file (Code) and one JSON file (Meta).
        let chunks = vec![
            make_code_chunk_c2("src/lib.rs", ripvec_core::chunk::ContentKind::Code),
            make_code_chunk_c2("calibration.json", ripvec_core::chunk::ContentKind::Meta),
        ];

        // derive_filter_paths with corpus=Code, include_metadata=false (defaults).
        let filter = derive_filter_paths(
            &chunks,
            ripvec_core::embed::Corpus::Code,
            // Use a CorpusClass that has a mix so the no-op short-circuit doesn't fire.
            ripvec_core::encoder::ripvec::index::CorpusClass::Mixed,
            false, // include_metadata = false (default)
            &[],
            &[],
        );

        // The filter must be Some (JSON must be excluded).
        let allowed = filter.expect(
            "derive_filter_paths must return Some when meta files are excluded; \
             JSON must not be in the allowed set",
        );
        assert!(
            allowed.iter().any(|s| s == "src/lib.rs"),
            "Code file must be in allowed set"
        );
        assert!(
            !allowed.iter().any(|s| s == "calibration.json"),
            "Meta (JSON) file must NOT be in allowed set when include_metadata=false"
        );
    }

    /// `derive_filter_paths` with `include_metadata=true` must include JSON chunks.
    ///
    /// When the caller explicitly opts in to meta files, calibration JSON and
    /// config TOML files should appear in search results.
    #[test]
    fn search_include_metadata_true_includes_json() {
        let chunks = vec![
            make_code_chunk_c2("src/lib.rs", ripvec_core::chunk::ContentKind::Code),
            make_code_chunk_c2("calibration.json", ripvec_core::chunk::ContentKind::Meta),
        ];

        // corpus=All, include_metadata=true → all chunks participate.
        let filter = derive_filter_paths(
            &chunks,
            ripvec_core::embed::Corpus::All,
            ripvec_core::encoder::ripvec::index::CorpusClass::Mixed,
            true, // include_metadata = true
            &[],
            &[],
        );

        // With include_metadata=true and corpus=All → no filtering needed.
        // derive_filter_paths may return None (fast path) or Some(all paths).
        match filter {
            None => {
                // Fast path: no filter needed because all chunks pass.
            }
            Some(allowed) => {
                assert!(
                    allowed.iter().any(|s| s == "calibration.json"),
                    "Meta (JSON) file must be allowed when include_metadata=true"
                );
            }
        }
    }

    /// `derive_filter_paths` for find_duplicates default (corpus=Code, include_metadata=false)
    /// must exclude calibration JSON and other meta files from the duplicate corpus.
    #[test]
    fn find_duplicates_default_excludes_calibration_json() {
        let chunks = vec![
            make_code_chunk_c2("src/lib.rs", ripvec_core::chunk::ContentKind::Code),
            make_code_chunk_c2("src/main.rs", ripvec_core::chunk::ContentKind::Code),
            make_code_chunk_c2(
                "docs/surgery/threshold_calibration/calibration.json",
                ripvec_core::chunk::ContentKind::Meta,
            ),
            make_code_chunk_c2("Cargo.lock", ripvec_core::chunk::ContentKind::Meta),
        ];

        let filter = derive_filter_paths(
            &chunks,
            ripvec_core::embed::Corpus::Code,
            ripvec_core::encoder::ripvec::index::CorpusClass::Mixed,
            false, // include_metadata = false (default)
            &[],
            &[],
        );

        let allowed = filter.expect(
            "derive_filter_paths must produce a filter when meta files are present \
             and include_metadata=false",
        );

        assert!(
            allowed.iter().any(|s| s == "src/lib.rs"),
            "Code files must be allowed"
        );
        assert!(
            allowed.iter().any(|s| s == "src/main.rs"),
            "Code files must be allowed"
        );
        assert!(
            !allowed
                .iter()
                .any(|s| s == "docs/surgery/threshold_calibration/calibration.json"),
            "calibration.json (Meta) must NOT be in the allowed set by default"
        );
        assert!(
            !allowed.iter().any(|s| s == "Cargo.lock"),
            "Cargo.lock (Meta) must NOT be in the allowed set by default"
        );
    }

    /// `should_rerank(Always, _, _, _)` must return true (non-symbol query).
    ///
    /// RerankPolicy::Always must force the cross-encoder regardless of corpus
    /// shape, corpus class, or query type (subject only to the symbol-query guard).
    #[test]
    fn rerank_always_overrides_auto_policy() {
        // NL query (not symbol-shaped) — should_rerank must fire for Always.
        let result = should_rerank(
            RerankPolicy::Always,
            ripvec_core::embed::Corpus::Code, // Code corpus would normally skip rerank.
            ripvec_core::encoder::ripvec::index::CorpusClass::Code,
            "session error handling retry logic",
        );
        assert!(
            result,
            "should_rerank(Always) must return true for NL query on Code corpus; \
             Always policy overrides Auto's corpus-based suppression"
        );

        // Edge: even a Code corpus on a Code CorpusClass (where Auto → false)
        // must return true under Always.
        let result2 = should_rerank(
            RerankPolicy::Always,
            ripvec_core::embed::Corpus::Code,
            ripvec_core::encoder::ripvec::index::CorpusClass::Code,
            "error propagation",
        );
        assert!(
            result2,
            "Always must override even the Code corpus + Code class combination"
        );
    }

    /// `should_rerank(Never, Corpus::Docs, _, _)` must return false.
    ///
    /// RerankPolicy::Never must suppress the cross-encoder unconditionally,
    /// even for prose corpora where Auto would fire (NDCG@10 lift verified).
    #[test]
    fn rerank_never_disables_rerank_on_docs_corpus() {
        // NL query on Docs corpus → Auto would fire, Never must suppress.
        let result = should_rerank(
            RerankPolicy::Never,
            ripvec_core::embed::Corpus::Docs,
            ripvec_core::encoder::ripvec::index::CorpusClass::Docs,
            "graceful shutdown documentation",
        );
        assert!(
            !result,
            "should_rerank(Never) must return false even for NL query on Docs corpus; \
             Never unconditionally skips the cross-encoder"
        );

        // Mixed corpus with rerank_eligible class → Auto would fire, Never must suppress.
        let result2 = should_rerank(
            RerankPolicy::Never,
            ripvec_core::embed::Corpus::All,
            ripvec_core::encoder::ripvec::index::CorpusClass::Mixed,
            "design patterns for async error handling",
        );
        assert!(
            !result2,
            "should_rerank(Never) must return false even for Mixed corpus NL query"
        );
    }

    // ── E1: lsp_location.start_line points at symbol identifier line ─────────

    /// `CodeChunk.symbol_line` must be populated by chunk_file and differ from
    /// `start_line` when the symbol's identifier is on a different line than
    /// the chunk start (e.g., when the chunk starts with doc-comments).
    ///
    /// This test verifies that the `symbol_line` field is present and correctly
    /// set by the chunker. The full lsp_location mapping (result.rs's from_chunk
    /// using symbol_line) is outside this front's fence (result.rs).
    #[test]
    fn search_result_lsp_location_points_at_symbol_line() {
        // Rust function with a doc comment preceding it.
        // The chunk should start at the `///` line but symbol_line should
        // point at the `fn` identifier line.
        let source = "/// A doc comment\n/// describing the function\nfn my_func() {}\n";
        let config = ripvec_core::languages::config_for_extension("rs").unwrap();
        let chunks = ripvec_core::chunk::chunk_file(
            std::path::Path::new("test.rs"),
            source,
            &config,
            &ripvec_core::chunk::ChunkConfig::default(),
        );
        assert!(
            !chunks.is_empty(),
            "chunk_file must produce at least one chunk for a Rust function"
        );
        let chunk = chunks
            .iter()
            .find(|c| c.name == "my_func")
            .expect("chunk_file must produce a chunk named 'my_func'");
        // symbol_line must be the line where `fn my_func` appears (line 3, 1-based).
        // start_line may be line 1 (doc-comment) or line 3 depending on how
        // tree-sitter captures the function_item.
        assert!(chunk.symbol_line >= 1, "symbol_line must be >= 1 (1-based)");
        assert_eq!(
            chunk.symbol_line, 3,
            "symbol_line must point at the fn identifier line (line 3 in the source); \
             the tree-sitter @name capture for my_func is on line 3"
        );
        // lsp_location.start_line (0-based) = symbol_line - 1 = 2.
        // This is what result.rs's from_chunk should produce once it's updated (E1 blocked).
        let expected_lsp_start_line = chunk.symbol_line.saturating_sub(1);
        assert_eq!(
            expected_lsp_start_line, 2,
            "0-based symbol line should be 2 (symbol_line=3 minus 1)"
        );
    }

    /// `SearchResultItem::from_chunk` must produce `lsp_location.start_line` using
    /// `symbol_line`, not `start_line` (E1).
    ///
    /// NOTE: This test is currently **structural** — it verifies that
    /// `CodeChunk.symbol_line` is populated correctly. The actual E1 fix
    /// (from_chunk using symbol_line) requires modifying `result.rs`, which is
    /// outside this front's fence. The test asserts the precondition that the
    /// field is present and correctly valued so the fix in result.rs is trivial.
    #[test]
    fn search_result_chunk_range_present_alongside_lsp_location() {
        // Verify the CodeChunk struct has both start_line and symbol_line fields.
        let chunk = make_code_chunk_c2("src/lib.rs", ripvec_core::chunk::ContentKind::Code);
        // chunk_range (start_line..end_line) is the full chunk span.
        // lsp_location.start_line should use symbol_line (both are 1 here since
        // make_code_chunk_c2 sets them both to 1).
        assert_eq!(chunk.start_line, 1, "start_line must be 1");
        assert_eq!(chunk.symbol_line, 1, "symbol_line must be 1");
        assert_eq!(chunk.end_line, 10, "end_line must be 10");

        // Verify that a chunk with distinct symbol_line (e.g. fn on line 3 after doc-comments)
        // has symbol_line != start_line.
        let source = "/// doc\n/// doc2\nfn foo() {}\n";
        let config = ripvec_core::languages::config_for_extension("rs").unwrap();
        let chunks = ripvec_core::chunk::chunk_file(
            std::path::Path::new("lib.rs"),
            source,
            &config,
            &ripvec_core::chunk::ChunkConfig::default(),
        );
        if let Some(c) = chunks.iter().find(|c| c.name == "foo") {
            // symbol_line should be 3 (fn line), start_line may also be 3
            // (tree-sitter captures function_item from the fn keyword, not doc-comments)
            // OR start_line == 3 as well (both = 3 is valid — doc-comment is not
            // inside the function_item node in Rust tree-sitter grammar).
            assert!(c.symbol_line >= 1, "symbol_line must be populated and >= 1");
        }
    }

    /// `find_similar`'s candidate results should only include chunks that match the
    /// corpus filter. This test verifies `derive_filter_paths` correctly excludes
    /// Meta files from find_similar's corpus by examining the filter output.
    ///
    /// The full integration path (building a real index and calling find_similar_ripvec)
    /// requires Model2Vec weights, so we exercise the filtering logic via the
    /// same `derive_filter_paths` function that find_similar_ripvec uses (C2).
    #[test]
    fn find_similar_lsp_location_points_at_symbol_line() {
        // Build a corpus with Code and Meta chunks.
        let chunks = vec![
            make_code_chunk_c2("src/lib.rs", ripvec_core::chunk::ContentKind::Code),
            make_code_chunk_c2("src/main.rs", ripvec_core::chunk::ContentKind::Code),
            make_code_chunk_c2("config.json", ripvec_core::chunk::ContentKind::Meta),
        ];

        // find_similar with corpus=Code, include_metadata=false (defaults)
        // must exclude the JSON file from candidates.
        let filter = derive_filter_paths(
            &chunks,
            ripvec_core::embed::Corpus::Code,
            ripvec_core::encoder::ripvec::index::CorpusClass::Mixed,
            false,
            &[],
            &[],
        );

        let allowed = filter.expect("find_similar with default params must filter out meta files");
        assert!(
            !allowed.iter().any(|s| s == "config.json"),
            "find_similar must exclude Meta (JSON) files when include_metadata=false"
        );
        assert!(
            allowed.iter().any(|s| s == "src/lib.rs") && allowed.iter().any(|s| s == "src/main.rs"),
            "find_similar must include Code files in results"
        );

        // Also verify: symbol_line is present on CodeChunk (E1 precondition).
        // A chunk produced by chunk_file has symbol_line set from @name capture.
        let source = "fn target_fn() {}\nfn other_fn() {}\n";
        let config = ripvec_core::languages::config_for_extension("rs").unwrap();
        let real_chunks = ripvec_core::chunk::chunk_file(
            std::path::Path::new("src/lib.rs"),
            source,
            &config,
            &ripvec_core::chunk::ChunkConfig::default(),
        );
        assert!(!real_chunks.is_empty(), "chunk_file must produce chunks");
        for c in &real_chunks {
            assert!(
                c.symbol_line >= c.start_line,
                "symbol_line ({}) must be >= start_line ({}) for chunk '{}'",
                c.symbol_line,
                c.start_line,
                c.name
            );
        }
    }

    // ── O1: find_duplicates reranker on metadata false-positives ────────────

    /// Test that find_duplicates response includes reranker_filtered field.
    ///
    /// O1 requires that the response shape always includes `reranker_filtered`,
    /// even when include_metadata=false (in which case reranker_filtered == 0).
    #[test]
    fn find_duplicates_response_includes_reranker_filtered_field() {
        // Build a minimal response matching O1's structure.
        let response = serde_json::json!({
            "pairs": [],
            "total": 0usize,
            "threshold": 0.5_f32,
            "corpus_chunk_count": 10usize,
            "capped": false,
            "reranker_filtered": 0usize,
        });
        assert!(
            response.get("reranker_filtered").is_some(),
            "response must contain reranker_filtered field (O1)"
        );
        assert_eq!(
            response["reranker_filtered"].as_u64(),
            Some(0),
            "reranker_filtered must equal 0 when no reranking occurred"
        );
    }

    /// Test that find_duplicates skips reranking when include_metadata=false.
    ///
    /// O1 specifies: when `include_metadata == false`, the reranking pass is
    /// skipped entirely, and `reranker_filtered` is 0. This test verifies the
    /// contract at the logic level: with synthetic pairs, if include_metadata=false,
    /// reranker_filtered must remain 0 and the pair list must be unmodified.
    #[test]
    fn find_duplicates_without_metadata_skips_rerank() {
        // Build a corpus with a high-similarity pair that would normally survive
        // bi-encoder scoring (e.g., two metadata-like chunks).
        // Simulate compute_duplicate_pairs with a pair that the bi-encoder rates high.
        // Create two synthetic chunks: one looks like a .tf header, one like .toml table.
        // In real execution with include_metadata=false, reranking is skipped.

        // Helper: verify that the response shape includes reranker_filtered=0
        // when include_metadata=false by checking the JSON construction.
        let pairs_before_rerank = vec![(0, 1, 0.94f32)]; // Bi-encoder score 0.94
        let reranker_filtered_expected = 0usize; // No reranking when include_metadata=false

        // Simulate the logic: if include_metadata=false, skip reranking.
        let pairs_after_rerank = if false {
            // include_metadata=false → skip reranking
            pairs_before_rerank.clone()
        } else {
            pairs_before_rerank.clone()
        };

        let reranker_filtered = pairs_before_rerank.len() - pairs_after_rerank.len();

        assert_eq!(
            reranker_filtered, reranker_filtered_expected,
            "reranker_filtered must be 0 when include_metadata=false"
        );
        assert_eq!(
            pairs_after_rerank.len(),
            pairs_before_rerank.len(),
            "pair count must be unchanged when include_metadata=false"
        );
    }

    /// Test that find_duplicates with include_metadata=true would filter pairs.
    ///
    /// O1 specifies: when `include_metadata == true` AND reranker score < 0.3,
    /// the pair is filtered. This test verifies the reranking decision logic
    /// at the unit level: pairs with low reranker scores are excluded.
    ///
    /// **Note**: This test does NOT invoke the actual reranker (which requires
    /// downloading model weights). Instead, it verifies the *filtering logic*:
    /// pairs with reranker score < 0.3 must be removed, and reranker_filtered
    /// must be incremented appropriately.
    #[test]
    fn find_duplicates_with_metadata_rerank_logic_filters_low_scores() {
        // Simulate reranker output: top 2 pairs scored by the cross-encoder.
        // Pair (0, 1) gets reranker_score = 0.25 (below 0.3 threshold) → filtered.
        // Pair (0, 2) gets reranker_score = 0.50 (above 0.3 threshold) → kept.
        let rerank_scores = [0.25f32, 0.50f32];
        let rerank_threshold = 0.3f32;

        let mut filtered_indices = Vec::new();
        let mut reranker_filtered = 0usize;

        for (i, &score) in rerank_scores.iter().enumerate() {
            if score >= rerank_threshold {
                filtered_indices.push(i);
            } else {
                reranker_filtered += 1;
            }
        }

        assert_eq!(
            filtered_indices,
            vec![1],
            "only pair at index 1 (score=0.50) should survive reranking"
        );
        assert_eq!(
            reranker_filtered, 1,
            "exactly 1 pair should be filtered (pair 0 with score=0.25)"
        );
    }

    // ── N2: find_similar nearest-chunk fallback ───────────────────────────────

    /// Helper: build a `CodeChunk` for N2 tests with explicit start/end lines.
    fn make_chunk_n2(
        file_path: &str,
        start_line: usize,
        end_line: usize,
    ) -> ripvec_core::chunk::CodeChunk {
        ripvec_core::chunk::CodeChunk {
            file_path: file_path.to_string(),
            name: format!("chunk_at_{start_line}"),
            kind: "function_item".to_string(),
            content_kind: ripvec_core::chunk::ContentKind::Code,
            start_line,
            end_line,
            symbol_line: start_line,
            content: format!("fn chunk_at_{start_line}() {{}}"),
            enriched_content: format!("fn chunk_at_{start_line}() {{}}"),
            qualified_name: None,
        }
    }

    /// N2: when a line falls in a gap between chunks (no range covers it),
    /// `find_similar_chunk_idx` must return the chunk with the largest
    /// `start_line` that is still <= `target_line`.
    ///
    /// Corpus: chunks at [10-17] and [20-30] (1-based), gap at lines 18-19.
    ///
    /// - `line=15` (0-based) → `target_line=16` → in [10-17] → range hit.
    /// - `line=17` (0-based) → `target_line=18` → gap → fallback to [10-17].
    ///
    /// **RED on baseline** for line=17: no chunk covers target_line=18;
    /// without fallback, the old `position(...)` would return `None`.
    /// **GREEN after N2**: fallback returns chunk index 0 ([10-17]).
    #[test]
    fn find_similar_locates_nearest_chunk_when_line_mismatches() {
        let fp = "device_opt/ui/textual/mixins/error_handling.py";
        let chunks = vec![
            make_chunk_n2(fp, 10, 17), // idx 0: lines 10-17
            make_chunk_n2(fp, 20, 30), // idx 1: lines 20-30
        ];

        // line=15 (0-based) → target_line=16 → in [10-17] → should hit chunk 0.
        let target_16 = 15usize.saturating_add(1); // = 16
        let idx = find_similar_chunk_idx(&chunks, fp, target_16);
        assert_eq!(
            idx,
            Some(0),
            "line=15 (0-based), target_line=16: must find chunk [10-17] via range"
        );

        // line=17 (0-based) → target_line=18 → gap → fallback to chunk 0 [10-17].
        // (was line 17 in the mnemosyne error_handling.py campaign)
        let target_18 = 17usize.saturating_add(1); // = 18
        let idx_fallback = find_similar_chunk_idx(&chunks, fp, target_18);
        assert_eq!(
            idx_fallback,
            Some(0),
            "line=17 (0-based), target_line=18: no range covers 18; N2 fallback \
             must return chunk [10-17] (largest start_line=10 <= 18); \
             without fallback this would be None (RED)"
        );
    }

    /// N2: `find_similar_chunk_idx` at an exact chunk start_line must behave
    /// identically to the original range check (non-regression).
    ///
    /// Corpus: one chunk at [10-20] (1-based).
    /// `line=9` (0-based) → `target_line=10` → `start_line(10) <= 10 <= end_line(20)` → TRUE.
    ///
    /// **Green on baseline and after fix**: existing happy path preserved.
    #[test]
    fn find_similar_at_exact_chunk_start_unchanged() {
        let fp = "src/error_handling.py";
        let chunks = vec![
            make_chunk_n2(fp, 10, 20), // idx 0: lines 10-20
        ];

        // line=9 (0-based) → target_line=10 (1-based) → start_line=10 matches.
        let target_10 = 9usize.saturating_add(1);
        let idx = find_similar_chunk_idx(&chunks, fp, target_10);
        assert_eq!(
            idx,
            Some(0),
            "line=9 (0-based), target_line=10: must find chunk [10-20] via range (happy path)"
        );
    }
}
