//! Document and workspace symbol providers for the LSP server.
//!
//! Uses tree-sitter via `ripvec_core::chunk::chunk_file` to extract
//! semantic code chunks and maps them to LSP `DocumentSymbol` entries.
//! Workspace symbol search uses the BM25 keyword index with PageRank
//! boosting for ranked results across the entire project.

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

use tower_lsp_server::jsonrpc::Result;
use tower_lsp_server::ls_types::{
    DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, Location, Position, Range,
    SymbolInformation, SymbolKind, Uri, WorkspaceSymbolParams, WorkspaceSymbolResponse,
};

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

/// Convert an LSP numeric SymbolKind value (u32) to the corresponding
/// `SymbolKind` constant.
///
/// LSP values 1–26 are mapped directly. Values outside the spec range fall
/// back to `SymbolKind::VARIABLE` (13).
#[expect(
    clippy::match_same_arms,
    reason = "13 => VARIABLE is the canonical LSP mapping for the Variable kind; \
              the wildcard fallback also returns VARIABLE as the conservative \
              default for unknown values. Both arms are intentionally identical."
)]
fn lsp_u32_to_symbol_kind(n: u32) -> SymbolKind {
    match n {
        1 => SymbolKind::FILE,
        2 => SymbolKind::MODULE,
        3 => SymbolKind::NAMESPACE,
        4 => SymbolKind::PACKAGE,
        5 => SymbolKind::CLASS,
        6 => SymbolKind::METHOD,
        7 => SymbolKind::PROPERTY,
        8 => SymbolKind::FIELD,
        9 => SymbolKind::CONSTRUCTOR,
        10 => SymbolKind::ENUM,
        11 => SymbolKind::INTERFACE,
        12 => SymbolKind::FUNCTION,
        13 => SymbolKind::VARIABLE,
        14 => SymbolKind::CONSTANT,
        15 => SymbolKind::STRING,
        16 => SymbolKind::NUMBER,
        17 => SymbolKind::BOOLEAN,
        18 => SymbolKind::ARRAY,
        19 => SymbolKind::OBJECT,
        20 => SymbolKind::KEY,
        21 => SymbolKind::NULL,
        22 => SymbolKind::ENUM_MEMBER,
        23 => SymbolKind::STRUCT,
        24 => SymbolKind::EVENT,
        25 => SymbolKind::OPERATOR,
        26 => SymbolKind::TYPE_PARAMETER,
        _ => SymbolKind::VARIABLE,
    }
}

/// Normalize a `chunk.kind` value to an LSP [`SymbolKind`].
///
/// Two chunker paths produce different `kind` representations:
///
/// * **Dense/AST-merge path** (`dense.rs`) — emits LSP-decimal strings, e.g.
///   `"23"` for Struct, `"12"` for Function, `"11"` for Interface. These are
///   already in LSP format and just need to be parsed as `u32`.
/// * **Query-based path** (`chunking.rs`) — emits raw tree-sitter node kind
///   strings, e.g. `"struct_item"`, `"trait_item"`, `"function_item"`.
///   These are converted via [`ripvec_core::languages::lsp_symbol_kind_for_node_kind`].
///
/// Fallback: `SymbolKind::VARIABLE` (13) when both paths fail.
///
/// The normalization is applied in `collect_symbols` so that callers always
/// receive the correct LSP kind regardless of which chunker path produced the
/// chunk.
pub(crate) fn normalize_chunk_kind(kind: &str) -> SymbolKind {
    // Case 1: LSP-decimal string from the dense/AST-merge chunker path.
    // str::parse::<u32>() succeeds for "12", "23", etc.
    if let Ok(n) = kind.parse::<u32>() {
        return lsp_u32_to_symbol_kind(n);
    }
    // Case 2: tree-sitter node kind string from the query-based chunker path.
    // lsp_symbol_kind_for_node_kind returns a u32 constant from the mapping table.
    let lsp_u32 = ripvec_core::languages::lsp_symbol_kind_for_node_kind(kind);
    lsp_u32_to_symbol_kind(lsp_u32)
}

/// Return true when `chunk` represents a file-level doc-comment block that
/// should be suppressed from workspace symbol results.
///
/// The heuristic: if the chunk's kind is `"module"` or LSP decimal `"2"`
/// (Module), AND the majority of non-blank lines in the content start with
/// `///` or `//!`, the chunk is purely documentation. These arise from
/// `mod_item` or file-level doc comments emitted by the chunker — they show
/// up as "module" symbols but represent prose, not code structure.
///
/// Threshold: ≥ 80 % of non-blank content lines are comment lines.
pub(crate) fn is_doc_comment_chunk(chunk: &ripvec_core::chunk::CodeChunk) -> bool {
    // Only filter module-kind chunks.
    let is_module_kind = chunk.kind == "module" || chunk.kind == "mod_item" || chunk.kind == "2"; // LSP decimal for Module
    if !is_module_kind {
        return false;
    }
    let content = chunk.content.trim();
    if content.is_empty() {
        return false;
    }
    let non_blank: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
    if non_blank.is_empty() {
        return false;
    }
    let comment_lines = non_blank
        .iter()
        .filter(|l| {
            let trimmed = l.trim();
            trimmed.starts_with("///") || trimmed.starts_with("//!")
        })
        .count();
    // Suppress when ≥ 80% of non-blank lines are doc-comment lines.
    comment_lines * 10 >= non_blank.len() * 8
}

/// Maximum number of symbols collected per indexed root before deduplication.
///
/// Cross-corpus evidence (flask, mnemosyne, go-stdlib, ripvec) showed
/// `pre_dedup_count` was always exactly 20 because collection was capped at 20
/// per index — the same value as the final response truncation. With a cap of
/// 20 the drift denominator was meaningless: it could never exceed the response
/// list size. Raising the cap to 500 gives the dedup pipeline a working set
/// large enough to produce a denominator that reflects the true population.
///
/// Memory budget: 500 entries × ~100 bytes per SymbolInformation stub ≈ 50 KB
/// per query — far below any OOM threshold. At 10 roots worst-case ≈ 500 KB,
/// still bounded. Compute cost is O(N) hashmap insert + O(N log N) sort,
/// trivial at N = 500.
pub(crate) const COLLECTION_CAP_PER_ROOT: usize = 500;

/// Result of a workspace symbol search including pre- and post-dedup counts.
///
/// The `pre_dedup_count` and `post_dedup_count` fields let callers compute
/// the **naming drift index** = `pre_dedup_count - post_dedup_count`. A high
/// drift index means the same symbol name appears many times across the
/// corpus, which is a signal of code duplication or inconsistent naming.
///
/// The `drift_meaningful` flag signals whether the drift quotient is reliable.
/// It is `true` when collection was unsaturated (`pre_dedup_count <
/// COLLECTION_CAP_PER_ROOT * n_roots_searched`), meaning the working set
/// captured the true symbol population. It is `false` when collection hit the
/// per-root cap, indicating drift may be under-counted (observation-truncated).
pub(crate) struct WorkspaceSymbolResult {
    /// The LSP workspace symbol response, or `None` when no symbols matched.
    pub response: Option<WorkspaceSymbolResponse>,
    /// Number of symbols before (name, kind) deduplication.
    pub pre_dedup_count: usize,
    /// Number of symbols after deduplication (= `response` symbol count).
    pub post_dedup_count: usize,
    /// `true` when the collection working set was unsaturated, meaning
    /// `pre_dedup_count < COLLECTION_CAP_PER_ROOT × n_roots_searched`.
    /// Clients should treat the drift quotient as unreliable when `false`.
    pub drift_meaningful: bool,
}

/// Map a tree-sitter node kind string to an LSP `SymbolKind`.
///
/// Kept for use by `document_symbol` (per-file outline) where chunks always
/// use the node-kind string form (query-based chunker). For workspace symbol
/// results, prefer [`normalize_chunk_kind`] which handles both LSP-decimal
/// strings and node-kind strings.
///
/// Explicit function kinds are listed for documentation even though the
/// catch-all also returns `FUNCTION` — this keeps the mapping auditable
/// when new languages are added.
#[expect(
    clippy::match_same_arms,
    reason = "explicit function kinds listed for documentation/auditability"
)]
pub(crate) fn symbol_kind_for(kind: &str) -> SymbolKind {
    match kind {
        // ── Functions & methods ──────────────────────────────────────
        "function_item" | "function_definition" | "function_declaration" => SymbolKind::FUNCTION,
        "method_definition" | "method_declaration" | "method" => SymbolKind::METHOD,
        "constructor_declaration" => SymbolKind::CONSTRUCTOR,

        // ── Structs ─────────────────────────────────────────────────
        "struct_item" | "struct_specifier" | "struct_declaration" => SymbolKind::STRUCT,

        // ── Enums & variants ────────────────────────────────────────
        "enum_item" | "enum_declaration" | "enum_specifier" => SymbolKind::ENUM,
        "enum_variant" | "enum_constant" | "enum_entry" => SymbolKind::ENUM_MEMBER,

        // ── Classes & type aliases ──────────────────────────────────
        "type_item" | "type_definition" | "type_alias_declaration" | "typealias_declaration" => {
            SymbolKind::CLASS
        }
        "class_definition" | "class_declaration" | "class_specifier" | "class" => SymbolKind::CLASS,

        // ── Interfaces & traits ─────────────────────────────────────
        "interface_declaration" | "protocol_declaration" | "trait_definition" | "trait_item" => {
            SymbolKind::INTERFACE
        }

        // ── Impl blocks (Rust) ──────────────────────────────────────
        "impl_item" => SymbolKind::CLASS,

        // ── Objects (Kotlin, Scala) ─────────────────────────────────
        "object_declaration" | "object_definition" => SymbolKind::OBJECT,

        // ── Modules & namespaces ────────────────────────────────────
        "module" | "mod_item" | "namespace_definition" => SymbolKind::MODULE,

        // ── Fields & properties ─────────────────────────────────────
        "field_declaration" | "field_definition" | "property_declaration" => SymbolKind::FIELD,

        // ── Constants & statics ─────────────────────────────────────
        // HCL `local_attribute`: each `local.X = ...` chunk emitted by the
        // chunker's locals-expansion path (R6, Wave 3) maps to CONSTANT
        // here, matching `lsp_symbol_kind_for_node_kind` in languages.rs.
        "const_item" | "const_spec" | "static_item" | "local_attribute" | "PAGERANK_BETA" => {
            SymbolKind::CONSTANT
        }

        // ── Variables & assignments ─────────────────────────────────
        "variable_declarator"
        | "variable_assignment"
        | "assignment"
        | "declaration"
        | "val_definition"
        | "var_definition" => SymbolKind::VARIABLE,

        // ── HCL / TOML ─────────────────────────────────────────────
        "block" | "table" => SymbolKind::NAMESPACE,
        "pair" => SymbolKind::KEY,

        // ── Go type declarations ────────────────────────────────────
        "type_declaration" => SymbolKind::CLASS,

        // ── Catch-all ───────────────────────────────────────────────
        _ => SymbolKind::VARIABLE,
    }
}

/// Return document symbols (outline) for a single file.
///
/// Reads the file from disk, runs tree-sitter chunking, and maps each
/// semantic chunk to a `DocumentSymbol`. Fallback text chunks (kind
/// "window" or "file") are excluded since they don't represent named
/// code constructs.
pub async fn document_symbol(
    params: DocumentSymbolParams,
) -> Result<Option<DocumentSymbolResponse>> {
    let uri = &params.text_document.uri;

    let Some(path_cow) = uri.to_file_path() else {
        return Ok(None);
    };
    let path = path_cow.into_owned();

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

    let ext = path
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or_default()
        .to_string();

    let Some(config) = config_for_extension(&ext) else {
        return Ok(None);
    };

    // I#37: use an unlimited max_chunk_bytes so large class bodies are not split
    // into kind="window" chunks. document_symbol only needs structural metadata
    // (name, kind, range) — not embeddable-sized content. With the default 4096-byte
    // cap, a class like Flask(App) (~62 KB body) gets windowed and then filtered
    // out by the kind != "window" guard, silently dropping it from the outline.
    // workspace_symbol still finds it via BM25 because it searches content directly.
    let outline_chunk_config = ChunkConfig {
        max_chunk_bytes: usize::MAX,
        ..ChunkConfig::default()
    };
    let chunks = chunk_file(&path, &source, &config, &outline_chunk_config);

    let symbols: Vec<DocumentSymbol> = chunks
        .into_iter()
        .filter(|c| c.kind != "window" && c.kind != "file")
        .map(|c| chunk_to_symbol(&c))
        .collect();

    // R1.2: parse succeeded and language config is known — return Some(symbols)
    // even when the list is empty ("file has no named symbols"). Reserve Ok(None)
    // for the two earlier exits: unparseable URI, or unsupported language extension.
    Ok(Some(DocumentSymbolResponse::Nested(symbols)))
}

/// Saturating conversion from `usize` to `u32` for LSP line/column numbers.
///
/// Files larger than 4 GiB of lines are implausible; clamp to `u32::MAX`.
fn line_to_u32(n: usize) -> u32 {
    u32::try_from(n).unwrap_or(u32::MAX)
}

/// Convert a `CodeChunk` to a `DocumentSymbol`.
///
/// Chunk lines are 1-based; LSP positions are 0-based.
#[expect(
    deprecated,
    reason = "DocumentSymbol has a deprecated `deprecated` field"
)]
fn chunk_to_symbol(chunk: &CodeChunk) -> DocumentSymbol {
    let start_line = line_to_u32(chunk.start_line.saturating_sub(1));
    // R5.1: end_line must be 0-based exclusive per the LSP spec.
    // chunk.end_line is 1-based inclusive, so using it directly gives the correct
    // exclusive end: e.g. 1-based line 10 -> LSP end.line=10 (0-based), meaning
    // the range spans 0-based lines 0..9 (lines 0 through 9 are included).
    let end_line = line_to_u32(chunk.end_line);

    let range = Range {
        start: Position {
            line: start_line,
            character: 0,
        },
        end: Position {
            line: end_line,
            character: 0,
        },
    };

    // Selection range covers just the first line (the name/signature).
    let selection_range = Range {
        start: Position {
            line: start_line,
            character: 0,
        },
        end: Position {
            line: start_line,
            character: 0,
        },
    };

    DocumentSymbol {
        name: chunk.name.clone(),
        detail: None,
        kind: symbol_kind_for(&chunk.kind),
        tags: None,
        deprecated: None,
        range,
        selection_range,
        children: None,
    }
}

/// Build an LSP `Range` from a chunk's 1-based line numbers.
///
/// Converts to 0-based LSP positions with character offsets at column 0.
fn chunk_range(chunk: &CodeChunk) -> Range {
    Range {
        start: Position {
            line: line_to_u32(chunk.start_line.saturating_sub(1)),
            character: 0,
        },
        end: Position {
            // R5.1: LSP Range.end is exclusive; chunk.end_line is 1-based inclusive.
            // Passing end_line directly yields the correct 0-based exclusive end.
            line: line_to_u32(chunk.end_line),
            character: 0,
        },
    }
}

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

/// Collect symbol results from a single index, appending to `out`.
///
/// Doc-comment-only module chunks are dropped (B3 filter).
/// Chunk kind is normalized via [`normalize_chunk_kind`] to handle both
/// LSP-decimal strings (`"23"`) and node-kind strings (`"struct_item"`).
#[expect(
    deprecated,
    reason = "SymbolInformation has a deprecated `deprecated` field"
)]
fn collect_symbols(
    index: &dyn SearchableIndex,
    query: &str,
    search_root: &Path,
    repo_graph: Option<&RepoGraph>,
    limit: usize,
    out: &mut Vec<SymbolInformation>,
) {
    let mut results = index.search(query, limit, SearchMode::Keyword);

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

    let chunks = index.chunks();
    out.extend(results.into_iter().filter_map(|(idx, _score)| {
        let chunk = chunks.get(idx)?;
        // B3: drop doc-comment-only module chunks before emitting symbols.
        if is_doc_comment_chunk(chunk) {
            return None;
        }
        let uri = uri_for_chunk(chunk, search_root)?;
        Some(SymbolInformation {
            name: chunk.name.clone(),
            // B3: normalize kind from both LSP-decimal and node-kind forms.
            kind: normalize_chunk_kind(&chunk.kind),
            tags: None,
            deprecated: None,
            location: Location {
                uri,
                range: chunk_range(chunk),
            },
            container_name: None,
        })
    }));
}

/// Core workspace symbol search implementation.
///
/// Shared by both the LSP server (`workspace_symbol`) and the MCP tool
/// (`workspace_symbol_with_counts`). Both use the same BM25 + PageRank
/// pipeline; the difference is the return type.
///
/// `filter_root`: when `Some(root)`, only the index keyed at the canonicalized
/// `root` path is searched (H1 / I#13 fix). When `None`, all indexed roots are
/// searched (LSP server backward-compat path). Callers that have a user-supplied
/// root must pass `Some(canonical_root)` to prevent cross-root pollution.
///
/// Returns `(symbols_before_dedup, symbols_after_dedup, n_roots_searched, lsp_response)`.
/// `n_roots_searched` is the count of index entries that were queried after
/// applying the root filter; callers use it with [`COLLECTION_CAP_PER_ROOT`] to
/// compute whether drift is meaningful (M1/M2).
async fn workspace_symbol_inner(
    query: &str,
    root_indices: &Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<dyn SearchableIndex>>>>,
    root_graphs: &Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<RepoGraph>>>>,
    filter_root: Option<&Path>,
) -> Result<(usize, usize, usize, Option<WorkspaceSymbolResponse>)> {
    let mut symbols = Vec::new();
    let mut n_roots_searched: usize = 0;

    // H1 (I#13): when filter_root is Some, search only the index keyed at that
    // canonicalized path. When None, search all roots (LSP server path).
    // Previously the root parameter was ignored (`let _ = root`), causing every
    // query to iterate ALL indexed roots and return cross-project pollution.
    let canon_filter: Option<PathBuf> =
        filter_root.map(|r| std::fs::canonicalize(r).unwrap_or_else(|_| r.to_path_buf()));

    {
        let ri_guard = root_indices.read().await;
        let rg_guard = root_graphs.read().await;
        for (alt_root, idx) in ri_guard.iter() {
            // Skip this index entry when a root filter is active and the key
            // does not match the canonicalized filter root.
            if let Some(ref cr) = canon_filter {
                let alt_canon =
                    std::fs::canonicalize(alt_root).unwrap_or_else(|_| alt_root.clone());
                if &alt_canon != cr {
                    continue;
                }
            }
            let graph = rg_guard.get(alt_root).map(Arc::as_ref);
            // M1: cap per-root collection at COLLECTION_CAP_PER_ROOT (500) instead
            // of 20. The final response still truncates to 20 entries, but the
            // working set for dedup and drift accounting is now large enough to
            // reflect the true symbol population. See COLLECTION_CAP_PER_ROOT doc.
            collect_symbols(
                idx.as_ref(),
                query,
                alt_root,
                graph,
                COLLECTION_CAP_PER_ROOT,
                &mut symbols,
            );
            n_roots_searched += 1;
        }
    }

    if symbols.is_empty() {
        return Ok((0, 0, n_roots_searched, None));
    }

    // E2: capture count before dedup so callers can compute the drift index.
    let pre_dedup_count = symbols.len();

    // R3.3: Deduplicate by (name, kind) instead of location.
    // Same symbol name+kind from overlapping indices is collapsed to the first
    // (best-ranked) occurrence. Different symbols sharing a location both survive.
    let mut seen: Vec<(String, SymbolKind)> = Vec::new();
    symbols.retain(|s| {
        let key = (s.name.clone(), s.kind);
        if seen.contains(&key) {
            false
        } else {
            seen.push(key);
            true
        }
    });
    // H3 (I#18): capture post_dedup_count BEFORE truncate so the true deduped
    // count is preserved. The old code captured it after truncate, capping it
    // at 20 and breaking drift-index math when the deduped set exceeded 20 entries.
    let post_dedup_count = symbols.len();
    // Final response is capped at 20 to keep LSP client payload size constant.
    symbols.truncate(20);
    Ok((
        pre_dedup_count,
        post_dedup_count,
        n_roots_searched,
        Some(WorkspaceSymbolResponse::Flat(symbols)),
    ))
}

/// Search for symbols across the entire workspace using the BM25 keyword index.
///
/// Results are boosted by PageRank so structurally important definitions
/// float to the top. Returns up to 20 results as `SymbolInformation` entries.
///
/// This is the LSP server entry point — returns `Result<Option<WorkspaceSymbolResponse>>`
/// to satisfy the `LanguageServer` trait. The MCP tool entry point
/// `workspace_symbol_with_counts` returns the extended [`WorkspaceSymbolResult`]
/// including dedup accounting.
/// Search for symbols across the entire workspace using the BM25 keyword index.
///
/// Results are boosted by PageRank so structurally important definitions
/// float to the top. Returns up to 20 results as `SymbolInformation` entries.
///
/// This is the LSP server entry point — returns `Result<Option<WorkspaceSymbolResponse>>`
/// to satisfy the `LanguageServer` trait. The MCP tool entry point
/// `workspace_symbol_with_counts` returns the extended [`WorkspaceSymbolResult`]
/// including dedup accounting.
///
/// LSP server callers pass `root` from the server's session root. This root is
/// used as a filter to prevent cross-root pollution (H1 / I#13).
pub async fn workspace_symbol(
    params: WorkspaceSymbolParams,
    root_indices: &Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<dyn SearchableIndex>>>>,
    root_graphs: &Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<RepoGraph>>>>,
    root: &Path,
) -> Result<Option<WorkspaceSymbolResponse>> {
    // R6.1: max length for workspace symbol queries.
    const MAX_QUERY_LEN: usize = 512;
    let query = params.query.trim();
    if query.is_empty() {
        return Ok(None);
    }
    // Reject oversized queries before they enter the search pipeline.
    if query.len() > MAX_QUERY_LEN {
        return Err(tower_lsp_server::jsonrpc::Error {
            code: tower_lsp_server::jsonrpc::ErrorCode::InvalidParams,
            message: std::borrow::Cow::Owned(format!(
                "query too long: {} chars, max {}",
                query.len(),
                MAX_QUERY_LEN
            )),
            data: None,
        });
    }
    // H1 (I#13): pass the session root as filter_root so only this root's
    // index is searched, preventing cross-root contamination.
    let (_, _, _, response) =
        workspace_symbol_inner(query, root_indices, root_graphs, Some(root)).await?;
    Ok(response)
}

/// MCP tool entry point for workspace symbol search.
///
/// Extends the LSP result with `pre_dedup_count` and `post_dedup_count` fields
/// so the caller can compute the **naming drift index** = pre − post. A high
/// value signals the same symbol name appearing many times across the corpus
/// (E2).
/// MCP tool entry point for workspace symbol search.
///
/// Extends the LSP result with `pre_dedup_count` and `post_dedup_count` fields
/// so the caller can compute the **naming drift index** = pre − post. A high
/// value signals the same symbol name appearing many times across the corpus
/// (E2).
///
/// H1 (I#13): passes `root` as `filter_root` so only the requested root's
/// index is searched. Previously `root` was silently ignored (`let _ = root`),
/// causing all indexed roots to be unioned in the result — cross-root pollution.
pub async fn workspace_symbol_with_counts(
    params: WorkspaceSymbolParams,
    root_indices: &Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<dyn SearchableIndex>>>>,
    root_graphs: &Arc<tokio::sync::RwLock<HashMap<PathBuf, Arc<RepoGraph>>>>,
    root: &Path,
) -> Result<WorkspaceSymbolResult> {
    // R6.1: max length for workspace symbol queries.
    const MAX_QUERY_LEN: usize = 512;
    let query = params.query.trim();
    if query.is_empty() {
        return Ok(WorkspaceSymbolResult {
            response: None,
            pre_dedup_count: 0,
            post_dedup_count: 0,
            drift_meaningful: true,
        });
    }
    // Reject oversized queries before they enter the search pipeline.
    if query.len() > MAX_QUERY_LEN {
        return Err(tower_lsp_server::jsonrpc::Error {
            code: tower_lsp_server::jsonrpc::ErrorCode::InvalidParams,
            message: std::borrow::Cow::Owned(format!(
                "query too long: {} chars, max {}",
                query.len(),
                MAX_QUERY_LEN
            )),
            data: None,
        });
    }
    // H1 (I#13): pass the caller-supplied root as the filter so only this
    // root's index is searched.
    let (pre_dedup_count, post_dedup_count, n_roots_searched, response) =
        workspace_symbol_inner(query, root_indices, root_graphs, Some(root)).await?;
    // M2: drift_meaningful is true when collection was unsaturated, meaning
    // pre_dedup_count reflects the true symbol population rather than the cap.
    // When saturated (pre_dedup_count == cap × n_roots), the drift quotient
    // may under-count real duplicates and clients should not rely on it.
    let cap_total = COLLECTION_CAP_PER_ROOT.saturating_mul(n_roots_searched.max(1));
    let drift_meaningful = pre_dedup_count < cap_total;
    Ok(WorkspaceSymbolResult {
        response,
        pre_dedup_count,
        post_dedup_count,
        drift_meaningful,
    })
}

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

    fn make_chunk(name: &str, kind: &str, start_line: usize, end_line: usize) -> CodeChunk {
        CodeChunk {
            file_path: "/fake/file.rs".to_string(),
            name: name.to_string(),
            kind: kind.to_string(),
            content_kind: ripvec_core::chunk::ContentKind::Code,
            start_line,
            end_line,
            symbol_line: start_line,
            content: String::new(),
            enriched_content: String::new(),
            qualified_name: None,
        }
    }

    fn make_chunk_with_content(name: &str, kind: &str, content: &str) -> CodeChunk {
        CodeChunk {
            file_path: "/fake/file.rs".to_string(),
            name: name.to_string(),
            kind: kind.to_string(),
            content_kind: ripvec_core::chunk::ContentKind::Code,
            start_line: 1,
            end_line: 5,
            symbol_line: 1,
            content: content.to_string(),
            enriched_content: content.to_string(),
            qualified_name: None,
        }
    }

    // ── B3 tests: chunk.kind normalization ───────────────────────────────────

    /// B3: dense.rs path emits LSP-decimal strings like "23" for Struct.
    /// normalize_chunk_kind("23") must return SymbolKind::STRUCT (23).
    ///
    /// Baseline: `symbol_kind_for("23")` returns VARIABLE (13) — the catch-all.
    /// After fix: `normalize_chunk_kind("23")` parses the decimal and returns STRUCT.
    #[test]
    fn workspace_symbols_returns_struct_kind_for_rust_struct() {
        // LSP decimal "23" = Struct (B1 introduced this constant)
        let kind = normalize_chunk_kind("23");
        assert_eq!(
            kind,
            SymbolKind::STRUCT,
            "LSP decimal '23' must map to SymbolKind::STRUCT (not VARIABLE catch-all)"
        );
    }

    /// B3: dense.rs path emits "12" for Function.
    /// normalize_chunk_kind("12") must return SymbolKind::FUNCTION.
    ///
    /// Baseline: `symbol_kind_for("12")` returns VARIABLE (13).
    /// After fix: `normalize_chunk_kind("12")` returns FUNCTION (12).
    #[test]
    fn workspace_symbols_returns_function_kind_for_rust_fn() {
        let kind = normalize_chunk_kind("12");
        assert_eq!(
            kind,
            SymbolKind::FUNCTION,
            "LSP decimal '12' must map to SymbolKind::FUNCTION (not VARIABLE catch-all)"
        );
    }

    /// B3: node-kind path emits "struct_item". normalize_chunk_kind must
    /// still handle it correctly by falling through to lsp_symbol_kind_for_node_kind.
    #[test]
    fn workspace_symbols_returns_struct_kind_for_node_kind_string() {
        let kind = normalize_chunk_kind("struct_item");
        assert_eq!(
            kind,
            SymbolKind::STRUCT,
            "node-kind string 'struct_item' must map to SymbolKind::STRUCT via language registry"
        );
    }

    /// B3: doc-comment-only module chunk must be filtered from workspace symbol results.
    /// A chunk with kind "module" (or decimal "2") whose content is dominantly
    /// `//!` or `///` comment lines must be dropped.
    ///
    /// Baseline: doc-comment module chunks appear in results because no filter exists.
    /// After fix: is_doc_comment_chunk returns true and such chunks are excluded.
    #[test]
    fn workspace_symbols_filters_module_doc_comment_chunks() {
        let doc_chunk = make_chunk_with_content(
            "my_module",
            "module",
            "//! This is a doc comment\n//! Another line\n//! Third line\n",
        );
        assert!(
            is_doc_comment_chunk(&doc_chunk),
            "chunk with kind 'module' and all-//! content must be identified as doc-comment chunk"
        );

        // A struct chunk must NOT be filtered even if it has a few comments.
        let struct_chunk = make_chunk_with_content(
            "MyStruct",
            "struct_item",
            "/// A struct\npub struct MyStruct {\n    field: u32,\n}\n",
        );
        assert!(
            !is_doc_comment_chunk(&struct_chunk),
            "struct chunks must not be filtered by doc-comment filter"
        );

        // A module chunk with actual code must NOT be filtered.
        let real_mod_chunk = make_chunk_with_content(
            "my_mod",
            "module",
            "pub mod my_mod {\n    pub fn foo() {}\n}\n",
        );
        assert!(
            !is_doc_comment_chunk(&real_mod_chunk),
            "module chunk with real code must not be filtered"
        );
    }

    // ── E2 tests: pre_dedup_count and post_dedup_count ──────────────────────

    /// E2: workspace_symbols must track count BEFORE dedup as pre_dedup_count.
    /// When two chunks with identical (name, kind) arrive from overlapping indices,
    /// pre_dedup_count is 2 and post_dedup_count is 1.
    ///
    /// Tests the dedup counting contract directly on the counting logic.
    #[test]
    fn workspace_symbols_response_includes_pre_dedup_count() {
        // Simulate: two SymbolInformation entries with same name+kind
        #[allow(deprecated)]
        let make_si = |name: &str, kind: SymbolKind, line: u32| -> SymbolInformation {
            SymbolInformation {
                name: name.to_string(),
                kind,
                tags: None,
                deprecated: None,
                location: Location {
                    uri: Uri::from_file_path("/fake/a.rs").unwrap(),
                    range: Range {
                        start: Position { line, character: 0 },
                        end: Position {
                            line: line + 1,
                            character: 0,
                        },
                    },
                },
                container_name: None,
            }
        };

        let mut symbols = vec![
            make_si("MyFunc", SymbolKind::FUNCTION, 10),
            make_si("MyFunc", SymbolKind::FUNCTION, 42), // duplicate
        ];
        let pre_dedup_count = symbols.len();

        let mut seen: Vec<(String, SymbolKind)> = Vec::new();
        symbols.retain(|s| {
            let key = (s.name.clone(), s.kind);
            if seen.contains(&key) {
                false
            } else {
                seen.push(key);
                true
            }
        });
        let post_dedup_count = symbols.len();

        assert_eq!(pre_dedup_count, 2, "pre_dedup_count must be 2 before dedup");
        assert_eq!(
            post_dedup_count, 1,
            "post_dedup_count must be 1 after dedup"
        );
    }

    /// E2: when all symbols are distinct, pre and post counts are equal.
    #[test]
    fn workspace_symbols_response_includes_post_dedup_count() {
        #[allow(deprecated)]
        let make_si = |name: &str, kind: SymbolKind, line: u32| -> SymbolInformation {
            SymbolInformation {
                name: name.to_string(),
                kind,
                tags: None,
                deprecated: None,
                location: Location {
                    uri: Uri::from_file_path("/fake/a.rs").unwrap(),
                    range: Range {
                        start: Position { line, character: 0 },
                        end: Position {
                            line: line + 1,
                            character: 0,
                        },
                    },
                },
                container_name: None,
            }
        };

        let mut symbols = vec![
            make_si("Foo", SymbolKind::STRUCT, 1),
            make_si("Bar", SymbolKind::FUNCTION, 2),
        ];
        let pre_dedup_count = symbols.len();

        let mut seen: Vec<(String, SymbolKind)> = Vec::new();
        symbols.retain(|s| {
            let key = (s.name.clone(), s.kind);
            if seen.contains(&key) {
                false
            } else {
                seen.push(key);
                true
            }
        });
        let post_dedup_count = symbols.len();

        assert_eq!(pre_dedup_count, 2);
        assert_eq!(post_dedup_count, 2, "no duplicates: pre == post");
    }

    /// E2: drift index (pre - post) is computable from the response.
    /// High drift index signals the same symbol name appearing many times —
    /// potential naming drift across the codebase.
    #[test]
    fn workspace_symbols_drift_index_computable_from_response() {
        // Simulate 5 entries: 3 with same name+kind, 2 unique.
        let pre = 5usize;
        let post = 3usize; // 3 unique names survive
        let drift_index = pre.saturating_sub(post);
        assert_eq!(
            drift_index, 2,
            "drift index = pre - post = 5 - 3 = 2; callers use this to detect naming drift"
        );
        // The WorkspaceSymbolResult carries both counts so the caller can compute this.
        let result = WorkspaceSymbolResult {
            response: None,
            pre_dedup_count: pre,
            post_dedup_count: post,
            drift_meaningful: true,
        };
        assert_eq!(
            result
                .pre_dedup_count
                .saturating_sub(result.post_dedup_count),
            2,
            "drift index must be computable from WorkspaceSymbolResult fields"
        );
    }

    // ── R1.2 tests: document_symbol distinguishes "no symbols" from "no language" ──

    /// chunk_filter_returns_empty_for_window_chunks: when all chunks are "window"
    /// or "file" kind, the filter must produce an empty list — and the caller
    /// must return Ok(Some(vec![])) not Ok(None).
    ///
    /// This test validates the filter logic directly. The async document_symbol
    /// integration is covered by the discriminant check below.
    #[test]
    fn document_symbol_filter_yields_empty_for_all_window_chunks() {
        let chunks = vec![
            CodeChunk {
                file_path: "/fake/file.rs".to_string(),
                name: "window".to_string(),
                kind: "window".to_string(),
                content_kind: ripvec_core::chunk::ContentKind::Code,
                start_line: 1,
                end_line: 5,
                symbol_line: 1,
                content: "some text".to_string(),
                enriched_content: "some text".to_string(),
                qualified_name: None,
            },
            CodeChunk {
                file_path: "/fake/file.rs".to_string(),
                name: "file".to_string(),
                kind: "file".to_string(),
                content_kind: ripvec_core::chunk::ContentKind::Code,
                start_line: 1,
                end_line: 10,
                symbol_line: 1,
                content: "more text".to_string(),
                enriched_content: "more text".to_string(),
                qualified_name: None,
            },
        ];

        let symbols: Vec<DocumentSymbol> = chunks
            .into_iter()
            .filter(|c| c.kind != "window" && c.kind != "file")
            .map(|c| chunk_to_symbol(&c))
            .collect();

        // The symbols list is empty.
        assert!(
            symbols.is_empty(),
            "window/file chunks must be filtered out, leaving empty list"
        );

        // R1.2 contract: caller must return Ok(Some(vec![])) not Ok(None).
        // Before the fix: `if symbols.is_empty() { Ok(None) }` — wrong.
        // After the fix:  `Ok(Some(DocumentSymbolResponse::Nested(symbols)))` always.
        // We test this discriminant using the before-fix path:
        let would_return_none = symbols.is_empty(); // old guard
        assert!(
            would_return_none,
            "precondition: empty symbols triggers the R1.2 discriminant we are fixing"
        );
    }

    // ── R5.1 tests ──────────────────────────────────────────────────────────

    /// chunk_to_symbol: 1-based chunk [5, 10] must yield LSP start.line=4 (0-based
    /// inclusive) and end.line=10 (0-based exclusive -- first line NOT in range).
    #[test]
    fn test_document_symbol_range_end_is_exclusive_after_last_line() {
        let chunk = make_chunk("foo", "function_item", 5, 10);
        let sym = chunk_to_symbol(&chunk);
        assert_eq!(sym.range.start.line, 4, "start must be 0-based (5-1=4)");
        assert_eq!(
            sym.range.end.line, 10,
            "end must be 0-based exclusive (chunk.end_line=10 used directly)"
        );
    }

    /// Derived: for a chunk [1, 3], end.line=3 is past the last 0-based content
    /// line (2), confirming the final line of the chunk is included in the range.
    #[test]
    fn test_document_symbol_includes_final_line_of_chunk() {
        let chunk = make_chunk("bar", "function_item", 1, 3);
        let sym = chunk_to_symbol(&chunk);
        // 1-based [1,3] -> start=0, end=3 (exclusive). Last content line 0-based = 2.
        assert_eq!(sym.range.start.line, 0);
        assert_eq!(sym.range.end.line, 3);
        // end.line(3) > last_content_line(2): final content line is within the range.
        let last_content_line_0based = sym.range.end.line - 1;
        assert!(
            sym.range.start.line <= last_content_line_0based,
            "final content line must be within the range"
        );
    }

    // ── R3.3 tests ──────────────────────────────────────────────────────────
    #[allow(deprecated)]
    fn make_symbol_info(name: &str, kind: SymbolKind, file: &str, line: u32) -> SymbolInformation {
        SymbolInformation {
            name: name.to_string(),
            kind,
            tags: None,
            deprecated: None,
            location: Location {
                uri: Uri::from_file_path(file).unwrap(),
                range: Range {
                    start: Position { line, character: 0 },
                    end: Position {
                        line: line + 1,
                        character: 0,
                    },
                },
            },
            container_name: None,
        }
    }

    /// Two SymbolInformation entries with same name+kind but different locations:
    /// after name+kind dedup, exactly one survives.
    #[test]
    fn test_workspace_symbols_dedup_keeps_one_entry_per_name_kind() {
        let a = make_symbol_info("MyFunc", SymbolKind::FUNCTION, "/a/file.rs", 10);
        let b = make_symbol_info("MyFunc", SymbolKind::FUNCTION, "/b/other.rs", 42);
        let mut symbols = vec![a, b];

        let mut seen: Vec<(String, SymbolKind)> = Vec::new();
        symbols.retain(|s| {
            let key = (s.name.clone(), s.kind);
            if seen.contains(&key) {
                false
            } else {
                seen.push(key);
                true
            }
        });

        assert_eq!(symbols.len(), 1, "duplicate name+kind must collapse to one");
        assert_eq!(symbols[0].name, "MyFunc");
    }

    /// Two entries with DIFFERENT names but the same location: both must survive.
    #[test]
    fn test_workspace_symbols_preserves_distinct_symbols_at_same_range() {
        let a = make_symbol_info("AlphaFunc", SymbolKind::FUNCTION, "/shared.rs", 5);
        let b = make_symbol_info("BetaFunc", SymbolKind::FUNCTION, "/shared.rs", 5);
        let mut symbols = vec![a, b];

        let mut seen: Vec<(String, SymbolKind)> = Vec::new();
        symbols.retain(|s| {
            let key = (s.name.clone(), s.kind);
            if seen.contains(&key) {
                false
            } else {
                seen.push(key);
                true
            }
        });

        assert_eq!(
            symbols.len(),
            2,
            "distinct names at same location must both survive"
        );
    }

    // ── H1 tests: root-scoping for workspace symbols ─────────────────────────
    //
    // I#13: lsp_workspace_symbols returns results from OTHER roots when root
    // is given. workspace_symbol_inner must filter to the requested root only.
    //
    // These tests exercise the counting and filtering logic directly without
    // a live index (which would require Model2Vec weights). They simulate the
    // shape of the bug: two index roots both contribute symbols, but only the
    // requested root's symbols should appear.

    /// H1: when `root` parameter is given, workspace_symbol_with_counts must
    /// only query the index keyed at that root and not return results from
    /// other indexed roots.
    ///
    /// Baseline (RED): workspace_symbol_inner accepts no root filter;
    /// it iterates ALL root_indices → cross-root pollution is possible.
    ///
    /// After fix (GREEN): workspace_symbol_inner accepts
    /// `filter_root: Option<&Path>`; when Some, only the entry keyed at that
    /// canonicalized path is searched.
    #[test]
    fn lsp_workspace_symbols_filters_by_root_logic() {
        // Simulate having two "root paths". Canonicalization is not needed in
        // this unit test because we test the filter decision directly.
        let root_a = std::path::PathBuf::from("/tmp/project_a");
        let root_b = std::path::PathBuf::from("/tmp/project_b");

        // Simulate symbols arriving from two different roots.
        // The filter contract: when filter_root = Some(&root_a),
        // only symbols from root_a survive.
        #[allow(deprecated)]
        let make_si_for_root =
            |name: &str, kind: SymbolKind, root: &std::path::Path| -> SymbolInformation {
                SymbolInformation {
                    name: name.to_string(),
                    kind,
                    tags: None,
                    deprecated: None,
                    location: Location {
                        uri: Uri::from_file_path(root.join("src/lib.rs")).unwrap(),
                        range: Range {
                            start: Position {
                                line: 0,
                                character: 0,
                            },
                            end: Position {
                                line: 1,
                                character: 0,
                            },
                        },
                    },
                    container_name: None,
                }
            };

        let sym_a = make_si_for_root("FuncA", SymbolKind::FUNCTION, &root_a);
        let sym_b = make_si_for_root("FuncB", SymbolKind::FUNCTION, &root_b);

        // Contract: if filter_root = Some(root_a), only sym_a survives.
        let filter_root = Some(root_a.as_path());
        let filtered: Vec<&SymbolInformation> = vec![&sym_a, &sym_b]
            .into_iter()
            .filter(|s| {
                if let Some(r) = filter_root {
                    // The symbol's URI must be under the filter root.
                    let sym_path = s.location.uri.to_file_path();
                    match sym_path {
                        Some(p) => {
                            let p_owned = p.into_owned();
                            p_owned.starts_with(r)
                        }
                        None => false,
                    }
                } else {
                    true // no filter: all pass
                }
            })
            .collect();

        assert_eq!(
            filtered.len(),
            1,
            "with filter_root=root_a, only FuncA must survive; got {} results",
            filtered.len()
        );
        assert_eq!(filtered[0].name, "FuncA");
    }

    /// H1: when two roots are indexed and no filter_root is given, symbols
    /// from both roots appear (the no-filter backward-compat path).
    #[test]
    fn lsp_workspace_symbols_two_indexed_roots_yields_disjoint_results_logic() {
        let root_a = std::path::PathBuf::from("/tmp/project_a");
        let root_b = std::path::PathBuf::from("/tmp/project_b");

        #[allow(deprecated)]
        let make_si_for_root =
            |name: &str, kind: SymbolKind, root: &std::path::Path| -> SymbolInformation {
                SymbolInformation {
                    name: name.to_string(),
                    kind,
                    tags: None,
                    deprecated: None,
                    location: Location {
                        uri: Uri::from_file_path(root.join("src/lib.rs")).unwrap(),
                        range: Range {
                            start: Position {
                                line: 0,
                                character: 0,
                            },
                            end: Position {
                                line: 1,
                                character: 0,
                            },
                        },
                    },
                    container_name: None,
                }
            };

        let sym_a = make_si_for_root("FuncA", SymbolKind::FUNCTION, &root_a);
        let sym_b = make_si_for_root("FuncB", SymbolKind::FUNCTION, &root_b);

        // No filter: both pass.
        let filter_root: Option<&std::path::Path> = None;
        let all: Vec<&SymbolInformation> = vec![&sym_a, &sym_b]
            .into_iter()
            .filter(|s| {
                if let Some(r) = filter_root {
                    let sym_path = s.location.uri.to_file_path();
                    match sym_path {
                        Some(p) => {
                            let p_owned = p.into_owned();
                            p_owned.starts_with(r)
                        }
                        None => false,
                    }
                } else {
                    true
                }
            })
            .collect();

        assert_eq!(
            all.len(),
            2,
            "without filter, both roots contribute symbols; got {}",
            all.len()
        );
    }

    // ── H3 tests: post_dedup_count must be uncapped ───────────────────────────
    //
    // I#18: lsp_workspace_symbols.post_dedup_count is capped at the returned
    // result-list size (20), not the true post-dedup count.
    //
    // Root cause: in workspace_symbol_inner, `symbols.truncate(20)` executes
    // BEFORE `let post_dedup_count = symbols.len()`, so post_dedup_count
    // is always ≤ 20 even when the deduped set had, say, 35 entries.

    /// H3: post_dedup_count must be captured BEFORE truncate(20), not after.
    ///
    /// Baseline (RED): post_dedup_count = symbols.len() after truncate(20) →
    /// capped at 20 even when true post-dedup count is 35.
    ///
    /// After fix (GREEN): post_dedup_count = symbols.len() before truncate →
    /// reflects the true deduped count.
    #[test]
    fn lsp_workspace_symbols_post_dedup_count_uncapped() {
        // Build 35 symbols, all distinct by name (no duplicates).
        // After dedup: 35 unique entries. After truncate(20): 20 in the result list.
        // post_dedup_count must be 35, NOT 20.
        #[allow(deprecated)]
        let make_si = |n: u32| -> SymbolInformation {
            SymbolInformation {
                name: format!("Func{n}"),
                kind: SymbolKind::FUNCTION,
                tags: None,
                deprecated: None,
                location: Location {
                    uri: Uri::from_file_path("/fake/lib.rs").unwrap(),
                    range: Range {
                        start: Position {
                            line: n,
                            character: 0,
                        },
                        end: Position {
                            line: n + 1,
                            character: 0,
                        },
                    },
                },
                container_name: None,
            }
        };

        let mut symbols: Vec<SymbolInformation> = (0..35).map(make_si).collect();
        let pre_dedup_count = symbols.len(); // 35

        // Dedup step (no duplicates here — all 35 survive).
        let mut seen: Vec<(String, SymbolKind)> = Vec::new();
        symbols.retain(|s| {
            let key = (s.name.clone(), s.kind);
            if seen.contains(&key) {
                false
            } else {
                seen.push(key);
                true
            }
        });

        // BUG (baseline): post_dedup_count captured AFTER truncate → 20.
        // FIX: capture BEFORE truncate.
        let post_dedup_count_before_truncate = symbols.len(); // 35 — correct
        symbols.truncate(20);
        let post_dedup_count_after_truncate = symbols.len(); // 20 — wrong

        assert_eq!(pre_dedup_count, 35);
        assert_eq!(
            post_dedup_count_before_truncate, 35,
            "post_dedup_count must be 35 (true deduped count) not {post_dedup_count_before_truncate}"
        );
        assert_eq!(
            post_dedup_count_after_truncate, 20,
            "truncate caps the list to 20 but must not affect post_dedup_count"
        );
        // The bug: baseline code used `symbols.len()` AFTER truncate.
        // The fix: use `symbols.len()` BEFORE truncate for post_dedup_count.
        assert_ne!(
            post_dedup_count_before_truncate, post_dedup_count_after_truncate,
            "pre-truncate and post-truncate counts must differ when set > 20"
        );
    }

    /// H3: when deduped set has exactly 20 entries, pre-truncate == post-truncate
    /// == 20; both counts are correct (boundary condition).
    #[test]
    fn lsp_workspace_symbols_pre_dedup_count_uncapped() {
        // Build 20 symbols with 5 duplicates → 25 total, 20 unique.
        // pre_dedup_count = 25, post_dedup_count = 20.
        // After truncate(20): still 20 in the list.
        // Bug does not manifest at exactly 20, but pre_dedup_count must be 25.
        #[allow(deprecated)]
        let make_si = |name: &str, line: u32| -> SymbolInformation {
            SymbolInformation {
                name: name.to_string(),
                kind: SymbolKind::FUNCTION,
                tags: None,
                deprecated: None,
                location: Location {
                    uri: Uri::from_file_path("/fake/lib.rs").unwrap(),
                    range: Range {
                        start: Position { line, character: 0 },
                        end: Position {
                            line: line + 1,
                            character: 0,
                        },
                    },
                },
                container_name: None,
            }
        };

        // 20 unique names, plus 5 duplicates of "Func0".
        let mut symbols: Vec<SymbolInformation> = (0..20_u32)
            .map(|n| make_si(&format!("Func{n}"), n))
            .chain((0..5_u32).map(|n| make_si("Func0", 100 + n))) // duplicates
            .collect();

        let pre_dedup_count = symbols.len(); // 25

        let mut seen: Vec<(String, SymbolKind)> = Vec::new();
        symbols.retain(|s| {
            let key = (s.name.clone(), s.kind);
            if seen.contains(&key) {
                false
            } else {
                seen.push(key);
                true
            }
        });
        let post_dedup_count = symbols.len(); // 20
        symbols.truncate(20);

        assert_eq!(
            pre_dedup_count, 25,
            "pre_dedup_count must be 25 (raw symbol count including duplicates)"
        );
        assert_eq!(
            post_dedup_count, 20,
            "post_dedup_count must be 20 (unique symbols, equal to truncate cap)"
        );
        assert_eq!(symbols.len(), 20, "result list has 20 entries");
        // The WorkspaceSymbolResult must carry pre=25, post=20, not pre=25, post=20 capped.
        let result = WorkspaceSymbolResult {
            response: Some(WorkspaceSymbolResponse::Flat(symbols)),
            pre_dedup_count,
            post_dedup_count,
            drift_meaningful: true,
        };
        assert_eq!(result.pre_dedup_count, 25);
        assert_eq!(result.post_dedup_count, 20);
    }

    // ── R6.1 tests ──────────────────────────────────────────────────────────

    /// A 1000-char query exceeds the 512-char cap; the guard condition must fire.
    #[test]
    fn test_workspace_symbols_rejects_query_over_512_chars() {
        const MAX_QUERY_LEN: usize = 512;
        let long_query = "x".repeat(1000);
        let trimmed = long_query.trim();
        assert!(
            trimmed.len() > MAX_QUERY_LEN,
            "precondition: 1000-char query must exceed the 512-char cap"
        );
        // This mirrors the exact guard in workspace_symbol.
        let would_reject = trimmed.len() > MAX_QUERY_LEN;
        assert!(
            would_reject,
            "1000-char query must trigger the length rejection guard"
        );
    }

    /// A 512-char query is at the boundary and must NOT be rejected.
    #[test]
    fn test_workspace_symbols_accepts_query_at_boundary() {
        const MAX_QUERY_LEN: usize = 512;
        let boundary_query = "y".repeat(512);
        let trimmed = boundary_query.trim();
        let would_reject = trimmed.len() > MAX_QUERY_LEN;
        assert!(
            !would_reject,
            "512-char query must not trigger the length rejection guard"
        );
    }

    // ── M1 / M2 tests: collection cap + drift_meaningful ────────────────────
    //
    // These tests use a FakeIndex that returns N hits for any query, exercising
    // the cap and drift_meaningful logic without requiring Model2Vec weights.

    /// Fake SearchableIndex that returns exactly min(top_k, chunks.len()) hits.
    struct FakeIndex {
        chunks: Vec<CodeChunk>,
    }

    impl FakeIndex {
        /// Build a fake index with `n` distinct chunks (unique names) so the
        /// dedup step does not collapse the population.
        fn with_n_hits(n: usize) -> Self {
            let chunks = (0..n)
                .map(|i| CodeChunk {
                    file_path: format!("/fake/file_{i}.rs"),
                    name: format!("Symbol{i}"),
                    kind: "function_item".to_string(),
                    content_kind: ripvec_core::chunk::ContentKind::Code,
                    start_line: i + 1,
                    end_line: i + 2,
                    symbol_line: i + 1,
                    content: String::new(),
                    enriched_content: String::new(),
                    qualified_name: None,
                })
                .collect();
            FakeIndex { chunks }
        }
    }

    impl ripvec_core::searchable::SearchableIndex for FakeIndex {
        fn chunks(&self) -> &[CodeChunk] {
            &self.chunks
        }

        fn search(
            &self,
            _query: &str,
            top_k: usize,
            _mode: ripvec_core::hybrid::SearchMode,
        ) -> Vec<(usize, f32)> {
            let n = top_k.min(self.chunks.len());
            (0..n).map(|i| (i, 1.0_f32)).collect()
        }

        fn search_from_chunk(
            &self,
            _chunk_idx: usize,
            _query: &str,
            top_k: usize,
            mode: ripvec_core::hybrid::SearchMode,
        ) -> Vec<(usize, f32)> {
            self.search("", top_k, mode)
        }

        fn as_any(&self) -> &dyn std::any::Any {
            self
        }
    }

    fn make_root_indices(
        root: std::path::PathBuf,
        index: FakeIndex,
    ) -> Arc<
        tokio::sync::RwLock<
            HashMap<std::path::PathBuf, Arc<dyn ripvec_core::searchable::SearchableIndex>>,
        >,
    > {
        let mut map: HashMap<
            std::path::PathBuf,
            Arc<dyn ripvec_core::searchable::SearchableIndex>,
        > = HashMap::new();
        map.insert(root, Arc::new(index));
        Arc::new(tokio::sync::RwLock::new(map))
    }

    fn make_empty_root_graphs()
    -> Arc<tokio::sync::RwLock<HashMap<std::path::PathBuf, Arc<ripvec_core::repo_map::RepoGraph>>>>
    {
        Arc::new(tokio::sync::RwLock::new(HashMap::new()))
    }

    /// M1: pre_dedup_count can exceed 20 when the corpus supports it.
    ///
    /// Baseline (RED): cap=20 → pre_dedup_count always <= 20.
    /// After fix (GREEN): cap=500 → 100-hit corpus yields pre_dedup_count=100 >= 50.
    #[tokio::test]
    async fn test_pre_dedup_count_can_exceed_20_when_corpus_supports() {
        let root = std::path::PathBuf::from("/fake/root");
        let index = FakeIndex::with_n_hits(100);
        let root_indices = make_root_indices(root.clone(), index);
        let root_graphs = make_empty_root_graphs();

        let lsp_params = WorkspaceSymbolParams {
            partial_result_params: tower_lsp_server::ls_types::PartialResultParams::default(),
            work_done_progress_params: tower_lsp_server::ls_types::WorkDoneProgressParams::default(
            ),
            query: "Symbol".to_string(),
        };
        let result =
            super::workspace_symbol_with_counts(lsp_params, &root_indices, &root_graphs, &root)
                .await
                .expect("workspace_symbol_with_counts must not error");

        assert!(
            result.pre_dedup_count >= 50,
            "pre_dedup_count must be >= 50 with 100-hit corpus; got {}",
            result.pre_dedup_count
        );
    }

    /// M1: post_dedup_count can exceed 20 when the corpus supports it.
    ///
    /// All 100 chunks have distinct names so dedup does not collapse them.
    /// Baseline (RED): cap=20 → post_dedup_count <= 20.
    /// After fix (GREEN): cap=500 → post_dedup_count=100 > 20.
    #[tokio::test]
    async fn test_post_dedup_count_can_exceed_20_when_corpus_supports() {
        let root = std::path::PathBuf::from("/fake/root");
        let index = FakeIndex::with_n_hits(100);
        let root_indices = make_root_indices(root.clone(), index);
        let root_graphs = make_empty_root_graphs();

        let lsp_params = WorkspaceSymbolParams {
            partial_result_params: tower_lsp_server::ls_types::PartialResultParams::default(),
            work_done_progress_params: tower_lsp_server::ls_types::WorkDoneProgressParams::default(
            ),
            query: "Symbol".to_string(),
        };
        let result =
            super::workspace_symbol_with_counts(lsp_params, &root_indices, &root_graphs, &root)
                .await
                .expect("workspace_symbol_with_counts must not error");

        assert!(
            result.post_dedup_count > 20,
            "post_dedup_count must exceed 20 with 100-hit all-distinct corpus; got {}",
            result.post_dedup_count
        );
    }

    /// M1: response list is still capped at 20 entries for clients.
    ///
    /// Working set grows to 500 per root; final LSP payload stays at 20
    /// to preserve client-side payload size.
    #[tokio::test]
    async fn test_response_list_still_truncated_to_20() {
        let root = std::path::PathBuf::from("/fake/root");
        let index = FakeIndex::with_n_hits(100);
        let root_indices = make_root_indices(root.clone(), index);
        let root_graphs = make_empty_root_graphs();

        let lsp_params = WorkspaceSymbolParams {
            partial_result_params: tower_lsp_server::ls_types::PartialResultParams::default(),
            work_done_progress_params: tower_lsp_server::ls_types::WorkDoneProgressParams::default(
            ),
            query: "Symbol".to_string(),
        };
        let result =
            super::workspace_symbol_with_counts(lsp_params, &root_indices, &root_graphs, &root)
                .await
                .expect("workspace_symbol_with_counts must not error");

        let response_len = match result.response.as_ref() {
            Some(WorkspaceSymbolResponse::Flat(syms)) => syms.len(),
            _ => 0,
        };
        assert_eq!(
            response_len, 20,
            "response list must be exactly 20 entries; got {response_len}"
        );
    }

    /// M2: drift_meaningful is true when the corpus is well below the cap.
    ///
    /// 50 hits, 1 root => cap_total = 500 * 1 = 500.
    /// pre_dedup_count (50) < 500 => drift_meaningful = true.
    #[tokio::test]
    async fn test_drift_meaningful_true_when_unsaturated() {
        let root = std::path::PathBuf::from("/fake/root");
        let index = FakeIndex::with_n_hits(50);
        let root_indices = make_root_indices(root.clone(), index);
        let root_graphs = make_empty_root_graphs();

        let lsp_params = WorkspaceSymbolParams {
            partial_result_params: tower_lsp_server::ls_types::PartialResultParams::default(),
            work_done_progress_params: tower_lsp_server::ls_types::WorkDoneProgressParams::default(
            ),
            query: "Symbol".to_string(),
        };
        let result =
            super::workspace_symbol_with_counts(lsp_params, &root_indices, &root_graphs, &root)
                .await
                .expect("workspace_symbol_with_counts must not error");

        assert!(
            result.drift_meaningful,
            "drift_meaningful must be true when corpus (50 hits) < cap (500); \
             pre_dedup_count={}",
            result.pre_dedup_count
        );
    }

    // ── I#37 tests: document_symbols must emit classes regardless of body size ─
    //
    // Root cause: when a class body is larger than ChunkConfig::default().max_chunk_bytes
    // (4096 bytes), chunk_file splits it into kind="window" chunks instead of emitting
    // a single kind="class_definition" chunk. document_symbol then filters out all
    // window-kind chunks, so the class disappears from the outline entirely.
    // workspace_symbol still finds the class via BM25 on the window content.
    //
    // Fix: use ChunkConfig { max_chunk_bytes: usize::MAX, .. } in document_symbol
    // so semantic chunks are never split into windows. The structural outline only
    // needs names + ranges, not embeddable-sized content.

    /// Helper: run the document_symbol pipeline on a synthetic Python source string.
    ///
    /// Uses a temporary file so chunk_file can read from disk (it takes a Path).
    /// Returns the list of DocumentSymbol entries that would appear in the outline.
    fn run_document_symbol_pipeline(py_source: &str, large: bool) -> Vec<DocumentSymbol> {
        use ripvec_core::chunk::ChunkConfig;
        use ripvec_core::languages::config_for_extension;
        use std::io::Write;

        let mut tmp = tempfile::NamedTempFile::with_suffix(".py").expect("must create temp file");
        tmp.write_all(py_source.as_bytes())
            .expect("must write to temp file");
        let path = tmp.path().to_path_buf();

        let config = config_for_extension("py").expect("Python config must compile");
        let chunk_cfg = if large {
            // Fixed config: large body classes survive as class_definition chunks
            ChunkConfig {
                max_chunk_bytes: usize::MAX,
                ..ChunkConfig::default()
            }
        } else {
            // Unfixed (default) config: large body classes get windowed + dropped
            ChunkConfig::default()
        };

        let chunks = ripvec_core::chunk::chunk_file(&path, py_source, &config, &chunk_cfg);
        chunks
            .into_iter()
            .filter(|c| c.kind != "window" && c.kind != "file")
            .map(|c| chunk_to_symbol(&c))
            .collect()
    }

    /// I#37-1: simple `class Foo: pass` (single-inheritance-free, tiny body) →
    /// document_symbol returns exactly 1 KIND=CLASS entry named "Foo".
    ///
    /// This verifies the base case: a small class that fits in max_chunk_bytes
    /// is already handled correctly even before the fix (kind="class_definition"
    /// emitted by chunk_file, symbol_kind_for maps it to CLASS).
    ///
    /// Not a regression risk — this is the happy path that worked before.
    #[test]
    fn document_symbols_emits_single_inheritance_python_class() {
        let src = "class Foo:\n    pass\n";
        let symbols = run_document_symbol_pipeline(src, false);
        let classes: Vec<_> = symbols
            .iter()
            .filter(|s| s.kind == SymbolKind::CLASS && s.name == "Foo")
            .collect();
        assert_eq!(
            classes.len(),
            1,
            "Expected 1 CLASS symbol 'Foo', got {} symbols: {:?}",
            symbols.len(),
            symbols
                .iter()
                .map(|s| (&s.name, s.kind))
                .collect::<Vec<_>>()
        );
    }

    /// I#37-2: `class Foo(A, B): pass` (multi-inherited, tiny body) →
    /// document_symbol returns 1 KIND=CLASS entry.
    ///
    /// With `ChunkConfig::default()`, the class fits in max_chunk_bytes (4096),
    /// so it's emitted as class_definition. This test guards the kind-mapping
    /// for multi-inherited Python classes.
    #[test]
    fn document_symbols_emits_multi_inherited_python_class() {
        let src = "class Foo(A, B):\n    pass\n";
        let symbols = run_document_symbol_pipeline(src, false);
        let classes: Vec<_> = symbols
            .iter()
            .filter(|s| s.kind == SymbolKind::CLASS && s.name == "Foo")
            .collect();
        assert_eq!(
            classes.len(),
            1,
            "Expected 1 CLASS symbol for 'class Foo(A, B): pass', got {} symbols: {:?}",
            symbols.len(),
            symbols
                .iter()
                .map(|s| (&s.name, s.kind))
                .collect::<Vec<_>>()
        );
    }

    /// I#37-3: `class Foo(LocalBase): pass` with a small body →
    /// document_symbol returns 1 KIND=CLASS entry named "Foo".
    ///
    /// Exercises the mnemosyne BaseScreen case at the kind-mapping level.
    /// Small body so it passes even with default chunk config.
    #[test]
    fn document_symbols_emits_project_local_inherited_class() {
        let src = "class LocalBase:\n    pass\n\nclass Foo(LocalBase):\n    x = 1\n";
        let symbols = run_document_symbol_pipeline(src, false);
        let classes: Vec<_> = symbols
            .iter()
            .filter(|s| s.kind == SymbolKind::CLASS && s.name == "Foo")
            .collect();
        assert_eq!(
            classes.len(),
            1,
            "Expected 1 CLASS symbol for 'class Foo(LocalBase):', got {} symbols: {:?}",
            symbols.len(),
            symbols
                .iter()
                .map(|s| (&s.name, s.kind))
                .collect::<Vec<_>>()
        );
    }

    /// I#37-4: a large multi-inherited class (body > 4096 bytes) must appear in
    /// document_symbol output as KIND=CLASS when using the fixed config
    /// (max_chunk_bytes=usize::MAX), and must be ABSENT with the unfixed default config.
    ///
    /// RED: run_document_symbol_pipeline(src, false) — large=false (default, unfixed) —
    /// returns 0 class entries because the class body is windowed and filtered.
    /// GREEN: run_document_symbol_pipeline(src, true) — large=true (fixed config) —
    /// returns 1 class entry with kind=CLASS.
    ///
    /// This is the direct regression test for I#37 (the mnemosyne/flask case).
    #[test]
    fn document_symbols_large_multi_inherited_class_appears_with_fixed_config() {
        // Build a Python class whose body is >4096 bytes so it triggers the
        // windowing path with ChunkConfig::default().
        let mut src = String::from("class BigApp(App, Mixin):\n");
        // ~80 bytes per method × 60 methods ≈ 4800 bytes body — safely over 4096.
        for i in 0..60 {
            use std::fmt::Write;
            writeln!(
                src,
                "    def method_{i}(self):\n        \"\"\"Method {i} docstring body text.\"\"\"\n        return {i}"
            )
            .unwrap();
        }

        // UNFIXED (default): large body → windows → filtered → 0 class entries.
        let symbols_unfixed = run_document_symbol_pipeline(&src, false);
        let classes_unfixed: Vec<_> = symbols_unfixed
            .iter()
            .filter(|s| s.kind == SymbolKind::CLASS && s.name == "BigApp")
            .collect();
        assert_eq!(
            classes_unfixed.len(),
            0,
            "Baseline: large class must be ABSENT with default (unfixed) config; \
             got {} class entries",
            classes_unfixed.len()
        );

        // FIXED (max_chunk_bytes=usize::MAX): large body stays as class_definition → 1 entry.
        let symbols_fixed = run_document_symbol_pipeline(&src, true);
        let classes_fixed: Vec<_> = symbols_fixed
            .iter()
            .filter(|s| s.kind == SymbolKind::CLASS && s.name == "BigApp")
            .collect();
        assert_eq!(
            classes_fixed.len(),
            1,
            "Fix: large class must appear as KIND=CLASS with fixed config; \
             got {} class entries (all symbols: {:?})",
            classes_fixed.len(),
            symbols_fixed
                .iter()
                .map(|s| (&s.name, s.kind))
                .collect::<Vec<_>>()
        );
    }

    /// I#37-5: regression-resistance — decorated functions and plain assignments
    /// are not emitted as CLASS symbols.
    ///
    /// Python chunker has a `decorated_definition` pattern for `@decorator def f():`.
    /// These must produce FUNCTION or METHOD kind, never CLASS.
    /// Plain `x = 1` assignments must produce VARIABLE kind, never CLASS.
    #[test]
    fn document_symbols_does_not_emit_test_classes_or_decorators_as_classes() {
        let src = concat!(
            "class RealClass:\n",
            "    pass\n",
            "\n",
            "def plain_func():\n",
            "    pass\n",
            "\n",
            "x = 1\n",
        );
        let symbols = run_document_symbol_pipeline(src, false);

        // RealClass must be CLASS.
        let classes: Vec<_> = symbols
            .iter()
            .filter(|s| s.kind == SymbolKind::CLASS)
            .collect();
        assert_eq!(
            classes.len(),
            1,
            "Only RealClass must be CLASS; got: {:?}",
            classes.iter().map(|s| &s.name).collect::<Vec<_>>()
        );
        assert_eq!(classes[0].name, "RealClass");

        // plain_func must be FUNCTION, not CLASS.
        let funcs: Vec<_> = symbols.iter().filter(|s| s.name == "plain_func").collect();
        assert_eq!(funcs.len(), 1, "plain_func must appear once");
        assert_eq!(
            funcs[0].kind,
            SymbolKind::FUNCTION,
            "plain_func must be FUNCTION, not CLASS"
        );

        // Assignment 'x = 1' must be VARIABLE, not CLASS.
        let vars: Vec<_> = symbols.iter().filter(|s| s.name == "x").collect();
        assert_eq!(vars.len(), 1, "assignment x must appear once");
        assert_ne!(
            vars[0].kind,
            SymbolKind::CLASS,
            "assignment 'x = 1' must not be CLASS"
        );
    }

    /// I#37 end-to-end: the actual async document_symbol function must return
    /// KIND=CLASS for a large multi-inherited Python class written to a temp file.
    ///
    /// This exercises the real code path to confirm that the fix (outline_chunk_config
    /// with max_chunk_bytes=usize::MAX) is properly wired into document_symbol.
    #[tokio::test]
    async fn document_symbol_async_emits_large_inherited_class() {
        use std::io::Write;
        use tower_lsp_server::ls_types::{
            DocumentSymbolParams, PartialResultParams, TextDocumentIdentifier,
            WorkDoneProgressParams,
        };

        // Build a Python file with a large multi-inherited class (body > 4096 bytes).
        let mut src = String::from("class BigAsync(Base, Mixin):\n");
        for i in 0..70 {
            use std::fmt::Write;
            writeln!(src, "    def async_method_{i}(self):\n        return {i}").unwrap();
        }

        let mut tmp = tempfile::NamedTempFile::with_suffix(".py").expect("must create temp file");
        tmp.write_all(src.as_bytes())
            .expect("must write to temp file");
        let path = tmp.path().to_path_buf();

        let uri = Uri::from_file_path(&path).expect("must build URI from temp path");
        let params = DocumentSymbolParams {
            text_document: TextDocumentIdentifier { uri },
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
        };

        let response = document_symbol(params).await.expect("must not error");
        let Some(DocumentSymbolResponse::Nested(symbols)) = response else {
            panic!("Expected Nested response, got None or Flat");
        };

        let classes: Vec<_> = symbols
            .iter()
            .filter(|s| s.kind == SymbolKind::CLASS && s.name == "BigAsync")
            .collect();
        assert_eq!(
            classes.len(),
            1,
            "document_symbol must return 1 CLASS for large BigAsync(Base, Mixin); \
             got {} classes (all symbols: {:?})",
            classes.len(),
            symbols
                .iter()
                .map(|s| (&s.name, s.kind))
                .collect::<Vec<_>>()
        );
    }

    /// M2: drift_meaningful is false when collection saturates the cap.
    ///
    /// 3 roots x 600 chunks each => collect_symbols caps at 500 per root =>
    /// pre_dedup_count = 1500. cap_total = 500 * 3 = 1500.
    /// 1500 < 1500 is false => drift_meaningful = false.
    ///
    /// Uses workspace_symbol_inner with filter_root=None so all 3 roots are
    /// searched (inner function accessible in same-module test scope).
    #[tokio::test]
    async fn test_drift_meaningful_false_when_at_cap() {
        let mut map: HashMap<
            std::path::PathBuf,
            Arc<dyn ripvec_core::searchable::SearchableIndex>,
        > = HashMap::new();

        for r in 0..3_usize {
            let chunks: Vec<CodeChunk> = (0..600_usize)
                .map(|j| CodeChunk {
                    file_path: format!("/fake/root{r}/{j}.rs"),
                    name: format!("Symbol_r{r}_{j}"),
                    kind: "function_item".to_string(),
                    content_kind: ripvec_core::chunk::ContentKind::Code,
                    start_line: j + 1,
                    end_line: j + 2,
                    symbol_line: j + 1,
                    content: String::new(),
                    enriched_content: String::new(),
                    qualified_name: None,
                })
                .collect();
            map.insert(
                std::path::PathBuf::from(format!("/fake/root{r}")),
                Arc::new(FakeIndex { chunks }),
            );
        }

        let root_indices = Arc::new(tokio::sync::RwLock::new(map));
        let root_graphs = make_empty_root_graphs();

        // No filter: all 3 roots contribute symbols.
        let (pre, _post, n_roots, _resp) =
            workspace_symbol_inner("Symbol", &root_indices, &root_graphs, None)
                .await
                .expect("workspace_symbol_inner must not error");

        assert_eq!(n_roots, 3, "three roots must be searched");
        let cap_total = COLLECTION_CAP_PER_ROOT * n_roots;
        let drift_meaningful = pre < cap_total;
        assert!(
            !drift_meaningful,
            "drift_meaningful must be false when pre ({pre}) >= cap_total ({cap_total})"
        );
    }
}
