//! Chunk extraction from tree-sitter parse trees

use std::path::Path;

use super::types::{
    capture_name_to_chunk_type, Chunk, ChunkType, Language, ParserError, SignatureStyle,
};
use super::Parser;

/// Parser version stamp embedded on every emitted [`Chunk`].
///
/// Bumped any time a chunk-level field can change without the source bytes
/// changing — currently only `doc` (PR #1040 added the leading-comment
/// fallback for short chunks). Persisted via the `chunks.parser_version`
/// SQLite column so incremental UPSERT can refresh rows whose `content_hash`
/// is unchanged but whose `doc` would now be enriched. See P2 #29 in
/// `docs/audit-findings.md` for the full failure mode.
///
/// Bump this when extraction logic changes the value of any non-content
/// field (`doc`, `signature`, `parent_type_name`, etc.) for a chunk whose
/// source bytes are identical to a previously-indexed version. The
/// store-side UPSERT clause must include
/// `OR chunks.parser_version != excluded.parser_version` so the bump
/// triggers a refresh.
pub const PARSER_VERSION: u32 = 1;

impl Parser {
    /// Extracts a code chunk from a source file using Tree-sitter query matches.
    /// # Arguments
    /// * `source` - The source code text to extract from
    /// * `m` - The Tree-sitter query match containing captured nodes
    /// * `query` - The Tree-sitter query that produced the match
    /// * `language` - The programming language of the source
    /// * `path` - The file path being parsed
    /// # Returns
    /// A `Chunk` containing the extracted code definition (function, struct, class, enum, trait, interface, constant, section, property, delegate, event, module, macro, object, or typealias) along with its metadata.
    /// # Errors
    /// Returns `ParserError::ParseFailed` if no definition capture is found in the match, or if required captures are missing.
    pub(crate) fn extract_chunk(
        &self,
        source: &str,
        m: &tree_sitter::QueryMatch<'_, '_>,
        query: &tree_sitter::Query,
        language: Language,
        path: &Path,
    ) -> Result<Chunk, ParserError> {
        // Find which definition capture matched and get its node.
        // Uses ChunkType::CAPTURE_NAMES (generated by define_chunk_types!) as the
        // single source of truth — no separate list to keep in sync.
        let (node, base_chunk_type) = ChunkType::CAPTURE_NAMES
            .iter()
            .find_map(|name| {
                let chunk_type = capture_name_to_chunk_type(name)?;
                query
                    .capture_index_for_name(name)
                    .and_then(|idx| m.captures.iter().find(|c| c.index == idx))
                    .map(|c| (c.node, chunk_type))
            })
            .ok_or_else(|| {
                ParserError::ParseFailed("No definition capture found in match".into())
            })?;

        // Get name capture
        let name_idx = query.capture_index_for_name("name");
        let name_capture = name_idx.and_then(|idx| m.captures.iter().find(|c| c.index == idx));
        let mut name = name_capture
            .map(|c| {
                let raw = source[c.node.byte_range()].to_string();
                // Names should never span multiple lines — error recovery in grammars
                // (especially SQL) can extend nodes past the actual name.
                raw.lines().next().unwrap_or(&raw).trim().to_string()
            })
            .unwrap_or_else(|| "<anonymous>".to_string());

        // Extract content
        let content = source[node.byte_range()].to_string();

        // Validate name position: if the @name capture is far from the definition
        // start, tree-sitter error recovery likely matched the wrong node.
        // Fall back to extracting the name from the content text.
        if let Some(nc) = name_capture {
            let name_line = nc.node.start_position().row;
            let def_line = node.start_position().row;
            if name_line.saturating_sub(def_line) > 5 {
                if let Some(extracted) = extract_name_fallback(&content) {
                    name = extracted;
                }
            }
        }

        // Line numbers (1-indexed for display)
        let line_start = node.start_position().row as u32 + 1;
        let line_end = node.end_position().row as u32 + 1;

        // Extract signature
        let signature = extract_signature(&content, language);

        // Extract doc comments. For short chunks (typically tree-sitter
        // captures of `CREATE TABLE`, type aliases, or thin helper fns) the
        // tree-sitter sibling walk often misses the leading comment block
        // because the grammar surfaces blank-line gaps as siblings that stop
        // the walk. Fall back to a comment-only preceding-lines scan in that
        // case so the embedding gets a richer NL signal.
        let doc = extract_doc_comment(node, source, language).or_else(|| {
            extract_doc_fallback_for_short_chunk(node, source, line_start, line_end, language, path)
        });

        // Determine chunk type - only infer for functions (to detect methods)
        let (chunk_type, parent_type_name) = if base_chunk_type == ChunkType::Function {
            infer_chunk_type(node, language, source)
        } else {
            (base_chunk_type, None)
        };

        if let Some(ref ptn) = parent_type_name {
            tracing::debug!(parent_type = %ptn, method = %name, "Extracted parent type for method");
        }

        // Content hash for deduplication (BLAKE3 produces 64 hex chars)
        // ID collision requires same path + line_start + 8-hex-char prefix (32 bits).
        // For injection, outer container chunks at the same line are REMOVED before
        // inner chunks are added, so no collision between outer/inner at the same line.
        let content_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
        let hash_prefix = content_hash.get(..8).unwrap_or(&content_hash);
        let id = format!("{}:{}:{}", path.display(), line_start, hash_prefix);

        Ok(Chunk {
            id,
            file: path.to_path_buf(),
            language,
            chunk_type,
            name,
            signature,
            content,
            doc,
            line_start,
            line_end,
            content_hash,
            parent_id: None,
            window_idx: None,
            parent_type_name,
            parser_version: PARSER_VERSION,
        })
    }
}

/// Extracts a function or method signature from source code content based on the language's signature style.
/// # Arguments
/// * `content` - The source code content to extract the signature from
/// * `language` - The programming language, which determines the signature extraction style
/// # Returns
/// A `String` containing the extracted signature. The signature is determined by the language's signature style:
/// - `UntilBrace`: text up to the first `{`
/// - `UntilColon`: text up to the first `:`
/// - `FirstLine`: text up to the first newline
/// - `UntilAs`: text up to a word boundary followed by the keyword "as" (case-insensitive)
/// - `Bread`: returns the full content (for markdown, typically unused)
/// # Panics
/// Panics if the signature style is not one of the above variants (incomplete pattern match).
pub(crate) fn extract_signature(content: &str, language: Language) -> String {
    let sig_end = match language.def().signature_style {
        SignatureStyle::UntilBrace => content.find('{').unwrap_or(content.len()),
        SignatureStyle::UntilColon => content.find(':').unwrap_or(content.len()),
        SignatureStyle::FirstLine => content.find('\n').unwrap_or(content.len()),
        SignatureStyle::UntilAs => {
            // ASCII case-insensitive search preserves byte offsets
            // (to_uppercase() can change byte lengths for non-ASCII like ß→SS)
            let bytes = content.as_bytes();
            let is_boundary = |b: u8| b == b' ' || b == b'\n' || b == b'\t' || b == b'\r';
            let mut pos = None;
            for i in 0..bytes.len().saturating_sub(2) {
                if bytes[i].eq_ignore_ascii_case(&b'A') && bytes[i + 1].eq_ignore_ascii_case(&b'S')
                {
                    let left_ok = i == 0 || is_boundary(bytes[i - 1]);
                    let right_ok = i + 2 >= bytes.len() || is_boundary(bytes[i + 2]);
                    if left_ok && right_ok {
                        // Position at the boundary before AS (or 0 if AS starts the string)
                        pos = Some(if i > 0 { i - 1 } else { 0 });
                        break;
                    }
                }
            }
            pos.unwrap_or(content.len())
        }
        // Markdown builds its own signatures in the custom parser; this arm
        // satisfies exhaustiveness but is never reached via extract_chunk().
        SignatureStyle::Breadcrumb => content.len(),
    };
    let sig = &content[..sig_end];
    // Normalize whitespace
    sig.split_whitespace().collect::<Vec<_>>().join(" ")
}

/// Extracts documentation comments associated with a syntax node.
/// Searches backwards through sibling nodes to find documentation comments
/// matching the language's doc node types. Skips over non-doc comments AND
/// whitespace-only siblings (some grammars surface blank-line gaps as explicit
/// nodes that would otherwise stop the walk before the comment block). Stops
/// at the first sibling whose text contains substantive non-whitespace,
/// non-comment content. For Python, also checks for a docstring as the first
/// statement in the node's body if no preceding comments are found.
fn extract_doc_comment(
    node: tree_sitter::Node,
    source: &str,
    language: Language,
) -> Option<String> {
    let doc_nodes = language.def().doc_nodes;

    // Walk backwards through siblings looking for comments
    let mut comments = Vec::new();
    let mut current = node.prev_sibling();
    let mut tolerated_blanks = 0usize;

    while let Some(sibling) = current {
        let kind = sibling.kind();

        if doc_nodes.contains(&kind) {
            let text = &source[sibling.byte_range()];
            comments.push(text.to_string());
            current = sibling.prev_sibling();
        } else if kind.contains("comment") {
            // Keep looking past non-doc comments
            current = sibling.prev_sibling();
        } else {
            // Tolerate whitespace-only siblings — some grammars (notably the
            // SQL grammar's handling of blank lines between file-level `--`
            // comments and the next CREATE TABLE) emit gap nodes that would
            // otherwise prematurely stop the walk before the doc block.
            // Cap at MAX_TOLERATED_BLANK_SIBLINGS so a malformed file with
            // thousands of empty siblings can't make us walk to the start of
            // the file.
            let text = &source[sibling.byte_range()];
            if text.chars().all(char::is_whitespace)
                && tolerated_blanks < MAX_TOLERATED_BLANK_SIBLINGS
            {
                tolerated_blanks += 1;
                tracing::trace!(
                    skipped_kind = kind,
                    blanks = tolerated_blanks,
                    "extract_doc_comment: tolerated whitespace sibling"
                );
                current = sibling.prev_sibling();
            } else {
                break;
            }
        }
    }

    if comments.is_empty() {
        // For Python, also check for docstring as first statement in body
        if language == Language::Python {
            if let Some(body) = node.child_by_field_name("body") {
                if let Some(first) = body.named_child(0) {
                    if first.kind() == "expression_statement" {
                        if let Some(string) = first.named_child(0) {
                            if string.kind() == "string" {
                                return Some(source[string.byte_range()].to_string());
                            }
                        }
                    }
                }
            }
        }
        return None;
    }

    comments.reverse();
    Some(comments.join("\n"))
}

/// Maximum line count for a chunk to be considered "short" and eligible for
/// the leading-comment fallback. The check is `line_end - line_start <
/// SHORT_CHUNK_LINE_THRESHOLD`, so chunks spanning **5 or fewer lines**
/// qualify (a 5-line chunk has `line_end - line_start = 4`). Matches the
/// `truncated_gold` failure-mode threshold from
/// `evals/audit_r5_failure_modes.py`.
const SHORT_CHUNK_LINE_THRESHOLD: u32 = 5;

/// Maximum number of preceding source lines to capture as a fallback `doc`
/// for short chunks. Bounded so the fallback can't pull in a large preceding
/// function body when the chunk happens to follow non-comment code.
const FALLBACK_DOC_MAX_LINES: usize = 8;

/// Maximum whitespace-only siblings that `extract_doc_comment` will skip past
/// while walking backwards in search of a doc-comment block. Bounds the walk
/// so a malformed file with thousands of empty siblings can't make us walk
/// to the start of the file, while still tolerating the blank-line gap nodes
/// some grammars (notably the SQL grammar) emit between file-level comments
/// and the next CREATE TABLE.
const MAX_TOLERATED_BLANK_SIBLINGS: usize = 4;

/// Returns true if a single source line looks like the start of a
/// comment in the given language.
///
/// Consults `lang.def().line_comment_prefixes` first (per-language precision
/// for languages with non-default comment syntax — Erlang `%`, Lua `--`, etc.).
/// If the language declares no prefixes (default `&[]`), falls back to the
/// conservative global union of common comment sigils so unknown / grammar-less
/// languages still get reasonable behaviour.
///
/// Trims the leading whitespace before checking; `*` alone is also accepted
/// for the inner lines of a `/** ... */` block.
///
/// The bare-`#` and bare-`*` arms are tightened to require a separator so
/// `#[derive]` (Rust attribute), `#include`/`#define`/`#pragma` (C
/// preprocessor), `#region` (C# pragma), `*ptr = 0` (C deref), and
/// `*x = y` are NOT classified as comment-like. This separator rule applies
/// to ambiguous single-char prefixes regardless of whether they came from
/// the per-language list or the global fallback (P1 #3 hardening).
fn line_looks_comment_like(line: &str, lang: Language) -> bool {
    let t = line.trim_start();
    if t.is_empty() {
        return true;
    }

    // Per-language prefixes take precedence. Empty `&[]` means "no
    // language-specific list registered" — fall through to the global fallback.
    let prefixes = lang.def().line_comment_prefixes;
    if !prefixes.is_empty() {
        for prefix in prefixes {
            if !t.starts_with(prefix) {
                continue;
            }
            // Apply separator rule for ambiguous single-char prefixes so
            // attribute / preprocessor / pointer-deref lines stay rejected.
            if matches!(*prefix, "#" | "*" | "'") {
                let rest = &t[prefix.len()..];
                if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') {
                    return true;
                }
                continue;
            }
            return true;
        }
        // Block-comment continuation markers — accept if any per-language
        // prefix included `/*`. Inner-`*` lines (` * doc`) and the closing
        // `*/` line are part of a `/** ... */` block.
        if prefixes.contains(&"/*") {
            if t.starts_with("*/") {
                return true;
            }
            if let Some(rest) = t.strip_prefix('*') {
                if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') {
                    return true;
                }
            }
        }
        return false;
    }

    // Global fallback: conservative union for unknown languages and the
    // grammar-less default routing path.
    if t.starts_with("//")
        || t.starts_with("--")
        || t.starts_with("/*")
        || t.starts_with("*/")
        || t.starts_with("<!--")
        || t.starts_with("<%--")
        || t.starts_with("(*")
    {
        return true;
    }
    if let Some(rest) = t.strip_prefix('#') {
        return rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t');
    }
    if let Some(rest) = t.strip_prefix('*') {
        return rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t');
    }
    false
}

/// Fallback `doc` enrichment for short chunks (chunks spanning 5 or fewer
/// source lines, gated by [`SHORT_CHUNK_LINE_THRESHOLD`]) that have no
/// tree-sitter-attached doc comment. Walks back up to
/// [`FALLBACK_DOC_MAX_LINES`] preceding source lines from
/// `node.start_position` and returns them as a single string, but only if
/// every captured line looks comment-like — otherwise returns `None` so we
/// never pull arbitrary code in.
///
/// This addresses the `truncated_gold` failure mode (~13 queries on
/// `v3_test.v2`, 11 on `v3_dev.v2`): SQL `CREATE TABLE` definitions, short
/// type aliases, and tiny helper functions that ship with leading file-level
/// comments which the normal sibling walk fails to surface (typically because
/// of grammar-emitted blank-line gap nodes).
///
/// **Performance:** Only the last `FALLBACK_DOC_MAX_LINES + MAX_CONSECUTIVE_BLANKS`
/// lines of the prefix are split into a `Vec<&str>`. We locate that tail by
/// walking the prefix bytes in reverse from `start_byte`, counting newlines,
/// and stopping once enough are seen. This bounds per-chunk work to O(8
/// lines) regardless of file size — the previous implementation split the
/// entire prefix per call and was O(N²) on files with many short chunks
/// (P2 #43 in `docs/audit-findings.md`).
fn extract_doc_fallback_for_short_chunk(
    node: tree_sitter::Node,
    source: &str,
    line_start: u32,
    line_end: u32,
    language: Language,
    path: &Path,
) -> Option<String> {
    if line_end.saturating_sub(line_start) >= SHORT_CHUNK_LINE_THRESHOLD {
        return None;
    }
    let _span = tracing::trace_span!(
        "extract_doc_fallback_for_short_chunk",
        line_start,
        line_end,
        path = %path.display()
    )
    .entered();

    // Cap the run of consecutive blanks so a malformed file with kilobytes
    // of empty siblings can't stall.
    const MAX_CONSECUTIVE_BLANKS: usize = 16;

    // Walk the prefix bytes in REVERSE only as far back as the
    // (FALLBACK_DOC_MAX_LINES + MAX_CONSECUTIVE_BLANKS + 1)-th newline. We
    // grant the blank budget on top of the comment budget so a leading
    // comment block separated from the chunk by up to MAX_CONSECUTIVE_BLANKS
    // blanks is still reachable — matching the walk-back loop's tolerance
    // below. Any prefix beyond that tail is unreachable and not split.
    //
    // P3 #84 / #133: tree-sitter occasionally surfaces non-codepoint-boundary
    // `start_byte` values on injection-rooted nodes; we previously dropped
    // this case silently via `?`. Emit a warn so a pathological grammar
    // change (or a malformed input) is visible in the journal.
    let start_byte = node.start_byte();
    let prefix = match source.get(..start_byte) {
        Some(p) => p,
        None => {
            tracing::warn!(
                start_byte,
                path = %path.display(),
                "extract_doc_fallback_for_short_chunk: start_byte not on UTF-8 boundary — \
                 skipping fallback for this chunk"
            );
            return None;
        }
    };
    let bytes = prefix.as_bytes();
    let max_newlines = FALLBACK_DOC_MAX_LINES + MAX_CONSECUTIVE_BLANKS + 1;
    let mut newline_count = 0usize;
    let mut tail_start = 0usize;
    for i in (0..bytes.len()).rev() {
        if bytes[i] == b'\n' {
            newline_count += 1;
            if newline_count >= max_newlines {
                tail_start = i + 1;
                break;
            }
        }
    }
    let tail = &prefix[tail_start..];
    // P3 #140: `str::lines()` already strips trailing `\r` from `\r\n` line
    // terminators, so the previous `.map(|l| l.trim_end_matches('\r'))` was
    // redundant.
    let lines: Vec<&str> = tail.lines().collect();
    if lines.is_empty() {
        return None;
    }

    // Walk back from the line immediately preceding the chunk. Spend the
    // FALLBACK_DOC_MAX_LINES budget only on non-blank comment-like lines so
    // a blank gap between the chunk and the leading comment block doesn't
    // silently truncate the captured doc.
    let mut start = lines.len();
    let mut comment_lines_taken = 0usize;
    let mut consecutive_blanks = 0usize;
    while start > 0 && comment_lines_taken < FALLBACK_DOC_MAX_LINES {
        let candidate = lines[start - 1];
        if !line_looks_comment_like(candidate, language) {
            break;
        }
        if candidate.trim().is_empty() {
            if consecutive_blanks >= MAX_CONSECUTIVE_BLANKS {
                break;
            }
            consecutive_blanks += 1;
        } else {
            consecutive_blanks = 0;
            comment_lines_taken += 1;
        }
        start -= 1;
    }
    // Strip leading blank lines from the selection so we don't return an
    // empty/whitespace-only `doc`.
    while start < lines.len() && lines[start].trim().is_empty() {
        start += 1;
    }
    if start >= lines.len() {
        return None;
    }
    let captured: String = lines[start..].join("\n");
    if captured.trim().is_empty() {
        return None;
    }
    tracing::debug!(
        prepended_lines = lines.len() - start,
        line_start,
        path = %path.display(),
        "extract_doc_fallback_for_short_chunk: filled empty doc for short chunk"
    );
    Some(captured)
}

/// Extract a name from chunk content when tree-sitter's @name capture is wrong.
/// Looks for `PROCEDURE name`, `FUNCTION name`, `VIEW name`, or `TRIGGER name`
/// patterns in the first few lines.
fn extract_name_fallback(content: &str) -> Option<String> {
    let bytes = content.as_bytes();
    for keyword in &[b"PROCEDURE" as &[u8], b"FUNCTION", b"VIEW", b"TRIGGER"] {
        // ASCII case-insensitive search preserves byte offsets
        // (to_uppercase() can change byte lengths for non-ASCII like ß→SS)
        if let Some(pos) = bytes.windows(keyword.len()).position(|w| {
            w.iter()
                .zip(keyword.iter())
                .all(|(a, b)| a.eq_ignore_ascii_case(b))
        }) {
            let after_keyword = pos + keyword.len();
            if after_keyword >= content.len() {
                continue;
            }
            let rest = content[after_keyword..].trim_start();
            // Name ends at whitespace, '(', '@', or newline
            let name_end = rest
                .find(|c: char| c.is_whitespace() || c == '(' || c == '@')
                .unwrap_or(rest.len());
            let name = rest[..name_end].trim();
            if !name.is_empty() {
                return Some(name.to_string());
            }
        }
    }
    None
}

/// Infers whether a syntax tree node represents a function or method, and determines the parent type for methods.
/// # Arguments
/// * `node` - The syntax tree node to analyze
/// * `language` - The programming language definition for the node
/// * `source` - The source code text for extracting type information
/// # Returns
/// A tuple containing:
/// * `ChunkType` - Either `Function` or `Method`
/// * `Option<String>` - The parent type name if the node is a method, `None` otherwise
fn infer_chunk_type(
    node: tree_sitter::Node,
    language: Language,
    source: &str,
) -> (ChunkType, Option<String>) {
    let def = language.def();

    // Check if the node itself is a method kind (e.g., Go's "method_declaration")
    if def.method_node_kinds.contains(&node.kind()) {
        let parent_type = extract_method_receiver_type(node, language, source);
        return (ChunkType::Method, parent_type);
    }

    // Check if the function has a qualified name (e.g., C++ out-of-class methods)
    if let Some(extractor) = def.extract_qualified_method {
        if let Some(class_name) = extractor(node, source) {
            tracing::debug!(class = %class_name, "Qualified method inference");
            return (ChunkType::Method, Some(class_name));
        }
    }

    // Walk parents looking for method containers (e.g., impl blocks, class bodies)
    let mut current = node.parent();
    while let Some(parent) = current {
        if def.method_containers.contains(&parent.kind()) {
            let parent_type = extract_container_type_name(parent, language, source);
            return (ChunkType::Method, parent_type);
        }
        current = parent.parent();
    }

    (ChunkType::Function, None)
}

/// Extract type name from a method container node.
/// Uses LanguageDef fields: `container_body_kinds` and `extract_container_name`.
fn extract_container_type_name(
    container: tree_sitter::Node,
    language: Language,
    source: &str,
) -> Option<String> {
    let def = language.def();

    // If language provides a custom extractor, use it
    if let Some(extractor) = def.extract_container_name {
        return extractor(container, source);
    }

    // Default algorithm:
    // 1. If container is a body node (e.g., class_body, declaration_list), walk up
    // 2. Read the "name" field from the resulting node
    let type_node = if def.container_body_kinds.contains(&container.kind()) {
        container.parent()
    } else {
        Some(container)
    };

    type_node.and_then(|n| {
        n.child_by_field_name("name")
            .map(|name| source[name.byte_range()].to_string())
    })
}

/// Extract receiver type from a Go method_declaration.
/// Go methods: `func (r *Server) Handle()` → "Server"
fn extract_method_receiver_type(
    node: tree_sitter::Node,
    language: Language,
    source: &str,
) -> Option<String> {
    if language != Language::Go {
        return None;
    }
    // method_declaration → receiver (parameter_list) → parameter_declaration → type
    let receiver = node.child_by_field_name("receiver")?;
    let first_param = receiver.named_child(0)?;
    // type_identifier may be nested in pointer_type
    find_type_identifier_recursive(first_param, source)
}

/// Recursively find a type_identifier node and return its text.
/// Used for Go where the type may be wrapped in pointer_type.
fn find_type_identifier_recursive(node: tree_sitter::Node, source: &str) -> Option<String> {
    if node.kind() == "type_identifier" {
        return Some(source[node.byte_range()].to_string());
    }
    let mut cursor = node.walk();
    for child in node.children(&mut cursor) {
        if let Some(name) = find_type_identifier_recursive(child, source) {
            return Some(name);
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    mod signature_tests {
        use super::*;

        #[test]
        fn test_rust_signature_stops_at_brace() {
            let content = "fn process(x: i32) -> Result<(), Error> {\n    body\n}";
            let sig = extract_signature(content, Language::Rust);
            assert_eq!(sig, "fn process(x: i32) -> Result<(), Error>");
        }

        #[test]
        fn test_rust_signature_normalizes_whitespace() {
            let content = "fn   process(  x: i32  )   -> i32 {";
            let sig = extract_signature(content, Language::Rust);
            assert_eq!(sig, "fn process( x: i32 ) -> i32");
        }

        #[test]
        fn test_python_signature_stops_at_colon() {
            let content = "def calculate(x, y):\n    return x + y";
            let sig = extract_signature(content, Language::Python);
            assert_eq!(sig, "def calculate(x, y)");
        }

        #[test]
        fn test_go_signature_stops_at_brace() {
            let content = "func (s *Server) Handle(r Request) error {\n\treturn nil\n}";
            let sig = extract_signature(content, Language::Go);
            assert_eq!(sig, "func (s *Server) Handle(r Request) error");
        }

        #[test]
        fn test_typescript_signature_stops_at_brace() {
            let content = "function processData(input: string): Promise<Result> {\n  return ok;\n}";
            let sig = extract_signature(content, Language::TypeScript);
            assert_eq!(sig, "function processData(input: string): Promise<Result>");
        }

        #[test]
        fn test_ruby_signature_stops_at_newline() {
            let content = "def calculate(x, y)\n  x + y\nend";
            let sig = extract_signature(content, Language::Ruby);
            assert_eq!(sig, "def calculate(x, y)");
        }

        #[test]
        fn test_signature_without_terminator() {
            let content = "fn abstract_decl()";
            let sig = extract_signature(content, Language::Rust);
            assert_eq!(sig, "fn abstract_decl()");
        }
        /// Verifies that extract_signature correctly preserves Unicode characters in SQL VIEW names and stops before the AS keyword.
        /// # Arguments
        /// This is a test function with no parameters.
        /// # Returns
        /// Returns nothing; this is a test assertion function.
        /// # Panics
        /// Panics if the extracted signature does not contain the Unicode character "ß" in "straße" or if it incorrectly includes the "AS" keyword.

        #[test]
        fn extract_signature_until_as_preserves_unicode() {
            let content = "CREATE VIEW stra\u{00DF}e AS SELECT 1";
            let sig = extract_signature(content, Language::Sql);
            assert!(sig.contains("stra\u{00DF}e"));
            assert!(!sig.contains("AS"));
        }
    }

    fn write_temp_file(content: &str, ext: &str) -> NamedTempFile {
        let mut file = tempfile::Builder::new()
            .suffix(&format!(".{}", ext))
            .tempfile()
            .unwrap();
        file.write_all(content.as_bytes()).unwrap();
        file.flush().unwrap();
        file
    }

    mod parse_tests {
        use super::*;

        #[test]
        fn test_parse_rust_function() {
            let content = r#"
/// Adds two numbers
fn add(a: i32, b: i32) -> i32 {
    a + b
}
"#;
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            assert_eq!(chunks.len(), 1);
            assert_eq!(chunks[0].name, "add");
            assert_eq!(chunks[0].chunk_type, ChunkType::Function);
            assert!(chunks[0].doc.as_ref().unwrap().contains("Adds two numbers"));
        }

        #[test]
        fn test_parse_rust_method_in_impl() {
            let content = r#"
struct Counter { value: i32 }

impl Counter {
    fn increment(&mut self) {
        self.value += 1;
    }
}
"#;
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            let method = chunks.iter().find(|c| c.name == "increment").unwrap();
            assert_eq!(method.chunk_type, ChunkType::Method);
        }

        #[test]
        fn test_parse_python_class_method() {
            let content = r#"
class Calculator:
    """A simple calculator."""

    def add(self, a, b):
        """Add two numbers."""
        return a + b
"#;
            let file = write_temp_file(content, "py");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            let class = chunks.iter().find(|c| c.name == "Calculator").unwrap();
            assert_eq!(class.chunk_type, ChunkType::Class);

            let method = chunks.iter().find(|c| c.name == "add").unwrap();
            assert_eq!(method.chunk_type, ChunkType::Method);
        }

        #[test]
        fn test_parse_go_method_vs_function() {
            let content = r#"
package main

func standalone() {
    println("standalone")
}

func (s *Server) method() {
    println("method")
}
"#;
            let file = write_temp_file(content, "go");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            let standalone = chunks.iter().find(|c| c.name == "standalone").unwrap();
            assert_eq!(standalone.chunk_type, ChunkType::Function);

            let method = chunks.iter().find(|c| c.name == "method").unwrap();
            assert_eq!(method.chunk_type, ChunkType::Method);
        }

        #[test]
        fn test_parse_typescript_interface() {
            let content = r#"
interface User {
    name: string;
    age: number;
}
"#;
            let file = write_temp_file(content, "ts");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            assert_eq!(chunks.len(), 1);
            assert_eq!(chunks[0].name, "User");
            assert_eq!(chunks[0].chunk_type, ChunkType::Interface);
        }

        #[test]
        fn test_parse_c_function() {
            let content = r#"
/* Adds two integers */
int add(int a, int b) {
    return a + b;
}
"#;
            let file = write_temp_file(content, "c");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            assert_eq!(chunks.len(), 1);
            assert_eq!(chunks[0].name, "add");
            assert_eq!(chunks[0].chunk_type, ChunkType::Function);
            assert!(chunks[0]
                .doc
                .as_ref()
                .unwrap()
                .contains("Adds two integers"));
        }

        #[test]
        fn test_parse_c_struct_and_enum() {
            let content = r#"
struct Point {
    int x;
    int y;
};

enum Color {
    RED,
    GREEN,
    BLUE
};
"#;
            let file = write_temp_file(content, "c");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            let point = chunks.iter().find(|c| c.name == "Point").unwrap();
            assert_eq!(point.chunk_type, ChunkType::Struct);

            let color = chunks.iter().find(|c| c.name == "Color").unwrap();
            assert_eq!(color.chunk_type, ChunkType::Enum);
        }

        #[test]
        fn test_parse_java_class_with_method() {
            let content = r#"
public class Calculator {
    /**
     * Adds two numbers
     */
    public int add(int a, int b) {
        return a + b;
    }
}
"#;
            let file = write_temp_file(content, "java");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            let class = chunks.iter().find(|c| c.name == "Calculator").unwrap();
            assert_eq!(class.chunk_type, ChunkType::Class);

            let method = chunks.iter().find(|c| c.name == "add").unwrap();
            assert_eq!(method.chunk_type, ChunkType::Method);
            assert!(method.doc.as_ref().unwrap().contains("Adds two numbers"));
        }

        #[test]
        fn test_parse_java_interface_and_enum() {
            let content = r#"
interface Printable {
    void print();
}

enum Direction {
    NORTH,
    SOUTH,
    EAST,
    WEST
}
"#;
            let file = write_temp_file(content, "java");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            let iface = chunks.iter().find(|c| c.name == "Printable").unwrap();
            assert_eq!(iface.chunk_type, ChunkType::Interface);

            let dir = chunks.iter().find(|c| c.name == "Direction").unwrap();
            assert_eq!(dir.chunk_type, ChunkType::Enum);
        }

        #[test]
        #[cfg(feature = "lang-csharp")]
        fn test_parse_csharp_class_and_method() {
            let content = r#"
public class Calculator {
    public int Add(int a, int b) {
        return a + b;
    }
}
"#;
            let file = write_temp_file(content, "cs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            let class = chunks.iter().find(|c| c.name == "Calculator").unwrap();
            assert_eq!(class.chunk_type, ChunkType::Class);

            let method = chunks.iter().find(|c| c.name == "Add").unwrap();
            assert_eq!(method.chunk_type, ChunkType::Method);
        }

        #[test]
        #[cfg(feature = "lang-csharp")]
        fn test_parse_csharp_property() {
            let content = r#"
public class Foo {
    public int Value { get; set; }
}
"#;
            let file = write_temp_file(content, "cs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            assert!(chunks
                .iter()
                .any(|c| c.name == "Value" && c.chunk_type == ChunkType::Property));
        }

        #[test]
        #[cfg(feature = "lang-csharp")]
        fn test_parse_csharp_delegate() {
            let content = "public delegate void OnComplete(int result);";
            let file = write_temp_file(content, "cs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            assert!(chunks
                .iter()
                .any(|c| c.name == "OnComplete" && c.chunk_type == ChunkType::Delegate));
        }

        #[test]
        #[cfg(feature = "lang-csharp")]
        fn test_parse_csharp_event() {
            let content = r#"
public class Foo {
    public event EventHandler Changed;
}
"#;
            let file = write_temp_file(content, "cs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            assert!(chunks
                .iter()
                .any(|c| c.name == "Changed" && c.chunk_type == ChunkType::Event));
        }

        #[test]
        #[cfg(feature = "lang-csharp")]
        fn test_parse_csharp_interface_and_enum() {
            let content = r#"
public interface ICalculator {
    int Add(int a, int b);
}

public enum Color { Red, Green, Blue }
"#;
            let file = write_temp_file(content, "cs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            assert!(chunks
                .iter()
                .any(|c| c.name == "ICalculator" && c.chunk_type == ChunkType::Interface));
            assert!(chunks
                .iter()
                .any(|c| c.name == "Color" && c.chunk_type == ChunkType::Enum));
        }

        #[test]
        #[cfg(feature = "lang-csharp")]
        fn test_parse_csharp_record_maps_to_struct() {
            let content = "public record Person(string Name, int Age);";
            let file = write_temp_file(content, "cs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            assert!(chunks
                .iter()
                .any(|c| c.name == "Person" && c.chunk_type == ChunkType::Struct));
        }

        #[test]
        #[cfg(feature = "lang-csharp")]
        fn test_parse_csharp_constructor_inferred_constructor() {
            let content = r#"
public class Foo {
    public Foo(int x) { }
}
"#;
            let file = write_temp_file(content, "cs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            // Constructor → Function → inferred to Method → post_process to Constructor
            let ctors: Vec<_> = chunks
                .iter()
                .filter(|c| c.name == "Foo" && c.chunk_type == ChunkType::Constructor)
                .collect();
            assert!(
                !ctors.is_empty(),
                "Constructor should be classified as Constructor"
            );
        }

        #[test]
        #[cfg(feature = "lang-csharp")]
        fn test_parse_csharp_local_function() {
            let content = r#"
public class Foo {
    public void Bar() {
        int Helper(int x) { return x + 1; }
        Helper(5);
    }
}
"#;
            let file = write_temp_file(content, "cs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            assert!(chunks.iter().any(|c| c.name == "Helper"));
        }

        #[test]
        fn test_parse_rust_macro() {
            let content = r#"
macro_rules! my_macro {
    ($x:expr) => {
        println!("{}", $x);
    };
}
"#;
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();

            assert_eq!(chunks.len(), 1);
            assert_eq!(chunks[0].name, "my_macro");
            assert_eq!(chunks[0].chunk_type, ChunkType::Macro);
            assert!(chunks[0].signature.contains("macro_rules! my_macro"));
        }
    }

    mod parent_type_tests {
        use super::*;

        #[test]
        fn test_rust_method_has_parent_type_name() {
            let content = r#"
struct Counter { value: i32 }
impl Counter {
    fn increment(&mut self) { self.value += 1; }
}
"#;
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let method = chunks.iter().find(|c| c.name == "increment").unwrap();
            assert_eq!(method.chunk_type, ChunkType::Method);
            assert_eq!(method.parent_type_name.as_deref(), Some("Counter"));
        }

        #[test]
        fn test_rust_trait_method_has_parent_type_name() {
            let content = r#"
trait Drawable {
    fn draw(&self) {}
}
"#;
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let method = chunks.iter().find(|c| c.name == "draw").unwrap();
            assert_eq!(method.chunk_type, ChunkType::Method);
            assert_eq!(method.parent_type_name.as_deref(), Some("Drawable"));
        }

        #[test]
        fn test_rust_impl_trait_for_type() {
            let content = r#"
struct Foo;
trait Display { fn fmt(&self) -> String; }
impl Display for Foo {
    fn fmt(&self) -> String { String::new() }
}
"#;
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let method = chunks
                .iter()
                .find(|c| c.name == "fmt" && c.chunk_type == ChunkType::Method)
                .unwrap();
            // Should extract the target type (Foo), not the trait (Display)
            assert_eq!(method.parent_type_name.as_deref(), Some("Foo"));
        }

        #[test]
        fn test_rust_generic_impl() {
            let content = r#"
struct Container<T> { items: Vec<T> }
impl<T> Container<T> {
    fn push(&mut self, item: T) {}
}
"#;
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let method = chunks.iter().find(|c| c.name == "push").unwrap();
            assert_eq!(method.chunk_type, ChunkType::Method);
            // Should extract the base type name, not the full generic
            assert_eq!(method.parent_type_name.as_deref(), Some("Container"));
        }

        #[test]
        fn test_python_method_has_parent_type_name() {
            let content = r#"
class Calculator:
    def add(self, a, b):
        return a + b
"#;
            let file = write_temp_file(content, "py");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let method = chunks.iter().find(|c| c.name == "add").unwrap();
            assert_eq!(method.chunk_type, ChunkType::Method);
            assert_eq!(method.parent_type_name.as_deref(), Some("Calculator"));
        }

        #[test]
        fn test_go_method_pointer_receiver() {
            let content = r#"
package main
type Server struct{}
func (s *Server) Handle() {}
"#;
            let file = write_temp_file(content, "go");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let method = chunks.iter().find(|c| c.name == "Handle").unwrap();
            assert_eq!(method.chunk_type, ChunkType::Method);
            assert_eq!(method.parent_type_name.as_deref(), Some("Server"));
        }

        #[test]
        fn test_go_method_value_receiver() {
            let content = r#"
package main
type Point struct{ x, y int }
func (p Point) Distance() float64 { return 0.0 }
"#;
            let file = write_temp_file(content, "go");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let method = chunks.iter().find(|c| c.name == "Distance").unwrap();
            assert_eq!(method.chunk_type, ChunkType::Method);
            assert_eq!(method.parent_type_name.as_deref(), Some("Point"));
        }

        #[test]
        fn test_js_method_has_parent_type_name() {
            let content = r#"
class Cache {
    get(key) { return this.data[key]; }
}
"#;
            let file = write_temp_file(content, "js");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let method = chunks.iter().find(|c| c.name == "get").unwrap();
            assert_eq!(method.chunk_type, ChunkType::Method);
            assert_eq!(method.parent_type_name.as_deref(), Some("Cache"));
        }

        #[test]
        fn test_ts_method_has_parent_type_name() {
            let content = r#"
class TypedCache {
    get(key: string): string { return ""; }
}
"#;
            let file = write_temp_file(content, "ts");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let method = chunks.iter().find(|c| c.name == "get").unwrap();
            assert_eq!(method.chunk_type, ChunkType::Method);
            assert_eq!(method.parent_type_name.as_deref(), Some("TypedCache"));
        }

        #[test]
        fn test_java_method_has_parent_type_name() {
            let content = r#"
public class Calculator {
    public int add(int a, int b) { return a + b; }
}
"#;
            let file = write_temp_file(content, "java");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let method = chunks.iter().find(|c| c.name == "add").unwrap();
            assert_eq!(method.chunk_type, ChunkType::Method);
            assert_eq!(method.parent_type_name.as_deref(), Some("Calculator"));
        }

        #[test]
        fn test_standalone_function_no_parent() {
            let content = "fn standalone() {}";
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            assert_eq!(chunks[0].chunk_type, ChunkType::Function);
            assert!(chunks[0].parent_type_name.is_none());
        }
    }
    /// Validates that `extract_name_fallback` correctly identifies function names containing Unicode characters that appear before SQL keywords.
    /// # Arguments
    /// This is a test function with no parameters.
    /// # Returns
    /// Returns nothing (`()`). The function validates behavior through assertions.
    /// # Panics
    /// Panics if the assertion fails, indicating that `extract_name_fallback` did not correctly extract "café_func" from a CREATE FUNCTION statement containing Unicode characters.

    #[test]
    fn extract_name_fallback_with_unicode_before_keyword() {
        let content = "CREATE FUNCTION caf\u{00e9}_func() RETURNS void";
        let name = extract_name_fallback(content);
        assert_eq!(name, Some("caf\u{00e9}_func".to_string()));
    }

    /// Tests for the `truncated_gold` fix: leading-comment fallback for short
    /// chunks plus blank-line tolerance in the sibling walk. Targets the
    /// failure mode catalogued in `evals/audit_r5_failure_modes.py` and the
    /// recommendation in `docs/audit-r5-failure-modes.md`.
    mod doc_fallback_tests {
        use super::*;

        // ─── line_looks_comment_like (pure unit) ────────────────────────────

        #[test]
        fn comment_like_accepts_common_prefixes() {
            // Each sigil tested under a language that legitimately uses it
            // (per `lang.def().line_comment_prefixes`). Per-language precision
            // means `--` belongs to SQL/Lua/Haskell, `#` to Python/Bash, etc.
            for (line, lang) in [
                ("// rust", Language::Rust),
                ("/// outer doc", Language::Rust),
                ("//! inner doc", Language::Rust),
                ("-- sql", Language::Sql),
                ("# python", Language::Python),
                ("/* block", Language::Rust),
                (" * inner of /** */ block", Language::Rust),
                ("*/ block end", Language::Rust),
                ("<!-- html -->", Language::Html),
                ("<%-- jsp --%>", Language::Aspx),
                ("(* fsharp", Language::FSharp),
            ] {
                assert!(
                    line_looks_comment_like(line, lang),
                    "expected comment-like: {line:?} (lang={lang})"
                );
            }
        }

        #[test]
        fn comment_like_accepts_blank_and_whitespace() {
            assert!(line_looks_comment_like("", Language::Rust));
            assert!(line_looks_comment_like("   ", Language::Rust));
            assert!(line_looks_comment_like("\t  ", Language::Rust));
        }

        #[test]
        fn comment_like_rejects_code() {
            for line in [
                "fn main() {",
                "let x = 5;",
                "CREATE TABLE foo (",
                "def add(a, b):",
                "package main",
            ] {
                assert!(
                    !line_looks_comment_like(line, Language::Rust),
                    "expected NOT comment-like: {line:?}"
                );
            }
        }

        // ─── extract_doc_fallback_for_short_chunk via Parser end-to-end ─────

        /// Happy path 1: SQL `CREATE TABLE` with leading `--` comment block
        /// and a blank line gap (the canonical `truncated_gold` shape).
        /// Either the sibling-walk tolerance OR the preceding-lines fallback
        /// must surface the comment.
        #[test]
        fn sql_short_table_with_blank_line_gap_gets_doc() {
            let content = "\
-- Stores per-row metadata for cqs chunks.
-- Indexed by chunk id; bumped on every reindex.

CREATE TABLE metadata (
    chunk_id TEXT PRIMARY KEY,
    value TEXT
);
";
            let file = write_temp_file(content, "sql");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let chunk = chunks
                .iter()
                .find(|c| c.name == "metadata")
                .expect("metadata chunk should exist");
            let doc = chunk.doc.as_deref().unwrap_or("");
            assert!(
                doc.contains("per-row metadata"),
                "expected leading comment to be captured, got: {doc:?}"
            );
        }

        /// Happy path 2: short Rust type alias with adjacent leading
        /// comment — already worked via the normal sibling walk; pin it so
        /// the fallback doesn't regress the happy path.
        #[test]
        fn rust_short_type_alias_with_adjacent_comment_gets_doc() {
            let content = "\
// Marker for things resolved at query time.
type ResolvedAt = u32;
";
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let chunk = chunks
                .iter()
                .find(|c| c.name == "ResolvedAt")
                .expect("ResolvedAt chunk should exist");
            assert!(chunk.doc.is_some(), "expected doc for short type alias");
        }

        /// Sad path 1: short chunk follows arbitrary code (no leading
        /// comment). The fallback MUST NOT pull in random preceding code.
        #[test]
        fn short_chunk_following_code_keeps_doc_none() {
            let content = "\
fn unrelated() {
    let x = 5;
    println!(\"{x}\");
}
type Tiny = u8;
";
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let chunk = chunks
                .iter()
                .find(|c| c.name == "Tiny")
                .expect("Tiny chunk should exist");
            assert!(
                chunk.doc.is_none(),
                "fallback must not capture non-comment-like preceding lines, got: {:?}",
                chunk.doc
            );
        }

        /// Sad path 2: short chunk at the very top of a file (no preceding
        /// source at all). Must not panic and must not invent a doc.
        #[test]
        fn short_chunk_at_top_of_file_keeps_doc_none() {
            let content = "type AtTop = u8;\n";
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let chunk = chunks
                .iter()
                .find(|c| c.name == "AtTop")
                .expect("AtTop chunk should exist");
            assert!(chunk.doc.is_none());
        }

        /// Sad path 3: long chunk with no doc must not trigger the fallback
        /// (the threshold is short-chunks-only on purpose).
        #[test]
        fn long_chunk_does_not_get_fallback_doc() {
            let content = "\
fn longish() {
    let a = 1;
    let b = 2;
    let c = 3;
    let d = 4;
    let e = 5;
    let _ = a + b + c + d + e;
}
";
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let chunk = chunks
                .iter()
                .find(|c| c.name == "longish")
                .expect("longish chunk should exist");
            assert!(
                chunk.doc.is_none(),
                "long chunk should not trigger fallback, got: {:?}",
                chunk.doc
            );
        }

        /// Sad path 4: UTF-8 multi-byte character in the leading comment
        /// must not crash the byte-prefix slice. Covers the chunker hardening
        /// per Reranker V2 / WSL session learnings.
        #[test]
        fn fallback_handles_utf8_in_leading_comment() {
            let content = "\
-- caf\u{00e9} table — s\u{00e9}rieux

CREATE TABLE notes (id TEXT);
";
            let file = write_temp_file(content, "sql");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let chunk = chunks
                .iter()
                .find(|c| c.name == "notes")
                .expect("notes chunk should exist");
            let doc = chunk.doc.as_deref().unwrap_or("");
            assert!(
                doc.contains("caf\u{00e9}"),
                "UTF-8 should round-trip through the fallback, got: {doc:?}"
            );
        }

        /// Sad path 5: chunk with explicit doc comment from tree-sitter must
        /// surface that doc verbatim. The fallback only runs when
        /// `extract_doc_comment` returns None (`or_else` short-circuits), so
        /// confirming the explicit `///` content reaches the chunk
        /// demonstrates the fallback didn't replace it.
        #[test]
        fn explicit_doc_comment_takes_precedence_over_fallback() {
            let content = "\
/// real doc for the alias
type WithDoc = u8;
";
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let chunk = chunks
                .iter()
                .find(|c| c.name == "WithDoc")
                .expect("WithDoc chunk should exist");
            let doc = chunk.doc.as_deref().unwrap_or("");
            assert!(
                doc.contains("real doc"),
                "expected explicit /// doc to reach chunk, got: {doc:?}"
            );
        }

        // ─── A.1: tightened line_looks_comment_like (attribute / preprocessor) ─

        #[test]
        fn line_looks_comment_like_rejects_attribute_and_preprocessor_lines() {
            for line in [
                "#[derive(Debug, Clone)]",
                "#[serde(rename = \"foo\")]",
                "#[cfg(test)]",
                "#[allow(dead_code)]",
                "#include <stdio.h>",
                "#define MAX 100",
                "#pragma once",
                "#region Helpers",
                "#endregion",
                "#![feature(let_chains)]",
                "*ptr = 0;",
                "*x = y * z;",
            ] {
                assert!(
                    !line_looks_comment_like(line, Language::Rust),
                    "expected NOT comment-like: {line:?}"
                );
            }
        }

        #[test]
        fn line_looks_comment_like_still_accepts_real_comment_prefixes() {
            // Rust-specific real comment prefixes — per-language list is
            // `&["//", "/*"]` plus inner-`*` of `/** */` blocks.
            for line in [
                "// rust",
                "/// outer doc",
                "/* block",
                " * inner of /** */ block",
                "* ",
                "*",
            ] {
                assert!(
                    line_looks_comment_like(line, Language::Rust),
                    "expected comment-like: {line:?}"
                );
            }
            // `#` belongs to Python/Bash/Ruby, not Rust.
            for line in ["# python comment", "# ", "#"] {
                assert!(
                    line_looks_comment_like(line, Language::Python),
                    "expected comment-like in Python: {line:?}"
                );
            }
        }

        #[test]
        fn rust_short_struct_after_derive_keeps_doc_none() {
            let content = "#[derive(Debug)]\nstruct Tiny { a: u32 }\n";
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let chunk = chunks
                .iter()
                .find(|c| c.name == "Tiny")
                .expect("Tiny chunk");
            assert!(
                chunk.doc.is_none(),
                "fallback must not capture #[derive] line as doc, got: {:?}",
                chunk.doc
            );
        }

        #[test]
        fn c_short_function_after_include_keeps_doc_none() {
            let content = "#include <stdio.h>\n#define X 1\nint main(void) { return 0; }\n";
            let file = write_temp_file(content, "c");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            if let Some(chunk) = chunks.iter().find(|c| c.name == "main") {
                assert!(
                    chunk.doc.is_none(),
                    "fallback must not capture #include/#define as doc, got: {:?}",
                    chunk.doc
                );
            }
        }

        // ─── P2 #53: per-language line_comment_prefixes ────────────────────

        /// Pin that the registry-driven prefix list is populated for the
        /// languages most likely to surface in eval data. A language landing
        /// without prefixes silently falls back to the global heuristic; the
        /// audit's intent is that common languages get precision.
        #[test]
        fn line_comment_prefixes_populated_for_common_languages() {
            for lang in [
                Language::Rust,
                Language::Python,
                Language::Sql,
                Language::Java,
                Language::Go,
                Language::Bash,
                Language::Yaml,
            ] {
                assert!(
                    !lang.def().line_comment_prefixes.is_empty(),
                    "expected {lang} to populate line_comment_prefixes",
                );
            }
        }

        /// Pinned decision: `#` is NOT comment-like in Rust. The audit's
        /// per-language precision intent overrides the previous global
        /// fallback. `#` belongs to Python/Bash/Ruby/Yaml; in Rust it is an
        /// attribute (`#[derive]`) or shebang prefix.
        #[test]
        fn python_line_comment_does_not_match_in_rust_context() {
            assert!(
                !line_looks_comment_like("# python comment", Language::Rust),
                "`#` must not classify as comment-like in Rust",
            );
            assert!(
                line_looks_comment_like("# python comment", Language::Python),
                "`#` must classify as comment-like in Python",
            );
        }

        // ─── A.2: walk-back blank-line budget ──────────────────────────────

        #[test]
        fn fallback_walks_past_blank_gap_to_capture_earlier_comments() {
            let content = "\
-- header comment 1
-- header comment 2

-- header comment 3
CREATE TABLE x (id TEXT);
";
            let file = write_temp_file(content, "sql");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let chunk = chunks.iter().find(|c| c.name == "x").expect("x chunk");
            let doc = chunk.doc.as_deref().unwrap_or("");
            assert!(
                doc.contains("header comment 1"),
                "expected header comment 1 to survive blank gap, got: {doc:?}"
            );
            assert!(
                doc.contains("header comment 3"),
                "expected header comment 3 to be captured, got: {doc:?}"
            );
        }

        // ─── P2 #52: bound the perf-fix's correctness ──────────────────────

        /// FALLBACK_DOC_MAX_LINES cap: feed a 12-line `--` comment block
        /// before a short SQL chunk. Only the last 8 lines must end up in
        /// the captured doc — earlier lines are out of budget.
        ///
        /// Uses unique tokens (`mark-NN-tail`) per line so substring matching
        /// can't false-positive (`"line 1"` would otherwise match `"line 11"`).
        #[test]
        #[allow(non_snake_case)]
        fn fallback_caps_at_FALLBACK_DOC_MAX_LINES() {
            let mut content = String::new();
            for i in 0..12 {
                content.push_str(&format!("-- mark-{i:02}-tail\n"));
            }
            content.push_str("CREATE TABLE capped (id TEXT);\n");
            let file = write_temp_file(&content, "sql");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let chunk = chunks
                .iter()
                .find(|c| c.name == "capped")
                .expect("capped chunk");
            let doc = chunk.doc.as_deref().unwrap_or("");
            // The eight most-recent comment lines must be present.
            for i in 4..12 {
                assert!(
                    doc.contains(&format!("mark-{i:02}-tail")),
                    "expected mark-{i:02}-tail in doc, got: {doc:?}"
                );
            }
            // Earlier lines must NOT be present (over budget).
            for i in 0..4 {
                assert!(
                    !doc.contains(&format!("mark-{i:02}-tail")),
                    "mark-{i:02}-tail must NOT be in doc (cap = {FALLBACK_DOC_MAX_LINES}), got: {doc:?}"
                );
            }
        }

        /// Exact 5-line-span boundary: a chunk whose `line_end - line_start`
        /// is exactly 4 (a 5-line CREATE TABLE) MUST get the fallback. See
        /// P2 #52 (boundary coverage gap) and the documentation finding
        /// flagging "<5 lines" wording vs `>= 5` skip semantics.
        #[test]
        fn fallback_runs_for_chunk_spanning_exactly_5_lines() {
            // 5-line body so `line_end - line_start == 4`. Three columns +
            // CREATE line + closing `)` line = 5 lines total.
            let content = "\
-- doc for 5-line table
CREATE TABLE five_line (
    id TEXT,
    name TEXT,
    note TEXT
);
";
            let file = write_temp_file(content, "sql");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let chunk = chunks
                .iter()
                .find(|c| c.name == "five_line")
                .expect("five_line chunk");
            assert_eq!(
                chunk.line_end - chunk.line_start,
                4,
                "chunk must span exactly 5 lines (line_end - line_start == 4) to pin the boundary"
            );
            assert!(
                chunk
                    .doc
                    .as_deref()
                    .unwrap_or("")
                    .contains("doc for 5-line table"),
                "5-line chunk must trigger the fallback, got doc: {:?}",
                chunk.doc
            );
        }

        /// Empty-only-comment-marker-lines case: a leading block of three
        /// bare `//` lines (each a valid comment marker but with no content)
        /// must result in the captured fallback being either `None` or
        /// containing only the bare markers — never an empty/whitespace
        /// `doc`. The `captured.trim().is_empty()` early-return guards this
        /// for the fallback path; the sibling-walk may surface markers
        /// themselves, which is also acceptable.
        #[test]
        fn fallback_skips_when_only_comment_marker_lines_precede() {
            let content = "//\n//\n//\ntype X = u8;\n";
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let chunk = chunks.iter().find(|c| c.name == "X").expect("X chunk");
            if let Some(doc) = chunk.doc.as_deref() {
                assert!(
                    doc.trim().is_empty() || doc.contains("//"),
                    "doc should be empty/None or contain only the bare markers, got: {doc:?}"
                );
            }
        }

        /// Mid-UTF-8 start_byte safety: a leading comment containing
        /// multi-byte chars must round-trip the byte slice without panic
        /// and without mid-codepoint truncation. Pins the
        /// `source.get(..start_byte)?` boundary contract end-to-end and
        /// covers the new reverse-walk-by-bytes tail computation.
        #[test]
        fn fallback_handles_mid_utf8_in_preceding_comment() {
            // Multi-byte chars in the leading comment exercise byte offsets
            // that don't align with character boundaries; `prefix.lines()`
            // must still split correctly and the captured doc must contain
            // the multi-byte codepoint intact.
            let content = "// caf\u{00e9} \u{1f389} short\ntype WithEmoji = u8;\n";
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let chunk = chunks
                .iter()
                .find(|c| c.name == "WithEmoji")
                .expect("WithEmoji chunk");
            let doc = chunk.doc.as_deref().unwrap_or("");
            assert!(
                doc.contains("caf\u{00e9}"),
                "multi-byte char must round-trip, got: {doc:?}"
            );
            assert!(
                doc.contains("\u{1f389}"),
                "multi-byte emoji must round-trip, got: {doc:?}"
            );
        }

        /// Mixed-prefix walk: P2 #53 made `line_looks_comment_like` consult
        /// `lang.def().line_comment_prefixes` instead of a global union, so
        /// a Rust chunk preceded by SQL-style `--` comments no longer walks
        /// past the `--` line. Pin this deliberate tightening so a future
        /// regression (or a permissive rewrite) surfaces as a loud diff.
        #[test]
        fn fallback_does_not_mix_comment_styles() {
            // Under Rust (`line_comment_prefixes: &["//", "/*"]`), the `--`
            // line is NOT comment-like — the walk stops there. The `//`
            // sibling gap between the `//` header and `type` is not surfaced
            // because the walk's contiguous comment run is broken.
            let content = "// rust-style header\n-- sql-style header\ntype Mixed = u8;\n";
            let file = write_temp_file(content, "rs");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let chunk = chunks
                .iter()
                .find(|c| c.name == "Mixed")
                .expect("Mixed chunk");
            let doc = chunk.doc.as_deref().unwrap_or("");
            assert!(
                !doc.contains("sql-style header"),
                "per-language walk must NOT capture `--` line in Rust context, got: {doc:?}"
            );
        }

        // ─── P2 #43: smoke test that walk-back stays linear in chunk count ─

        /// Smoke test for the O(N²) → O(8 lines) fix: parsing 200 short
        /// CREATE TABLE chunks in one file must not allocate or split the
        /// entire prefix per call. We don't assert wall-clock here (CI
        /// flakiness), but we do assert we get the expected chunk count
        /// without panic and without OOM. The cost of the prior
        /// implementation on this fixture was ~200 vec allocations of
        /// growing size (chunk N walked N×~3 lines of prefix); the fix
        /// caps each at the FALLBACK_DOC_MAX_LINES tail.
        #[test]
        fn parses_200_short_chunks_in_one_file_without_quadratic_blowup() {
            let mut content = String::new();
            for i in 0..200 {
                // Each chunk preceded by a leading comment so the fallback
                // path actually fires.
                content.push_str(&format!("-- doc for table_{i}\n"));
                content.push_str(&format!("CREATE TABLE table_{i} (id TEXT);\n"));
            }
            let file = write_temp_file(&content, "sql");
            let parser = Parser::new().unwrap();
            let chunks = parser.parse_file(file.path()).unwrap();
            let table_chunks: Vec<_> = chunks
                .iter()
                .filter(|c| c.name.starts_with("table_"))
                .collect();
            assert_eq!(
                table_chunks.len(),
                200,
                "expected 200 table chunks, got {}",
                table_chunks.len()
            );
            // Verify the fallback delivered a doc to a representative late
            // chunk — confirms the bounded walk-back still finds the
            // immediate leading comment.
            let last = table_chunks
                .iter()
                .find(|c| c.name == "table_199")
                .expect("table_199 chunk");
            assert!(
                last.doc
                    .as_deref()
                    .unwrap_or("")
                    .contains("doc for table_199"),
                "bounded walk-back must still find immediate leading comment, got: {:?}",
                last.doc
            );
        }
    }
}
