//! Suggest — auto-detect note-worthy patterns in the codebase
//!
//! Scans the index for anti-patterns (dead code clusters, untested hotspots,
//! high-risk functions, stale note mentions) and suggests notes to add.

use std::collections::HashMap;
use std::path::Path;

use crate::impact::find_hotspots;
use crate::limits::{dead_cluster_min_size, hotspot_min_callers, suggest_hotspot_pool};
use crate::store::StoreError;
use crate::{compute_risk_batch, normalize_slashes, RiskLevel, Store};

// Detector thresholds scale with corpus size via `crate::limits::*`. Env
// overrides — `CQS_HOTSPOT_MIN_CALLERS`, `CQS_DEAD_CLUSTER_MIN_SIZE`,
// `CQS_SUGGEST_HOTSPOT_POOL` — pin exact values when projects need
// policy-stable thresholds across reindexes.

/// A suggested note from pattern detection.
#[derive(Debug, Clone, serde::Serialize)]
pub struct SuggestedNote {
    pub text: String,
    pub sentiment: f32,
    pub mentions: Vec<String>,
    /// Which detector generated this suggestion
    pub reason: String,
}

/// Scan the index for anti-patterns and suggest notes.
/// Each detector runs independently — if one fails, the others still produce results.
///
/// The sequence is inlined rather than driven by a fn-pointer registry:
/// `Store` is generic over `Mode`, so fn pointer registries that pin a
/// concrete `Mode` don't compose. Inlining keeps the "each detector
/// independent, errors non-fatal" contract while letting callers pass either
/// `Store<ReadOnly>` or `Store<ReadWrite>`.
pub fn suggest_notes<Mode>(
    store: &Store<Mode>,
    root: &Path,
) -> Result<Vec<SuggestedNote>, StoreError> {
    let _span = tracing::info_span!("suggest_notes").entered();

    // Fetch corpus size once; the detectors scale their thresholds off it.
    // If stats fail, degrade to chunk_count=0, which yields the
    // minimum-floor defaults.
    let chunk_count = match store.stats() {
        Ok(s) => s.total_chunks as usize,
        Err(e) => {
            tracing::warn!(error = %e, "suggest_notes: stats() failed, using chunk_count=0");
            0
        }
    };

    let mut suggestions = Vec::new();

    for (name, result) in [
        (
            "detect_dead_clusters",
            detect_dead_clusters(store, chunk_count),
        ),
        (
            "detect_risk_patterns",
            detect_risk_patterns(store, chunk_count),
        ),
        ("detect_stale_mentions", detect_stale_mentions(store, root)),
    ] {
        let _span = tracing::info_span!("detector", name).entered();
        match result {
            Ok(mut s) => suggestions.append(&mut s),
            Err(e) => tracing::warn!(error = %e, detector = name, "Detector failed"),
        }
    }

    // Deduplicate against existing notes
    let existing = store.list_notes_summaries()?;

    let existing_texts: Vec<&str> = existing.iter().map(|n| n.text.as_str()).collect();
    suggestions.retain(|s| {
        !existing_texts.iter().any(|existing_text| {
            // Substring match in either direction
            existing_text.contains(&s.text) || s.text.contains(existing_text)
        })
    });

    tracing::info!(count = suggestions.len(), "Suggestions generated");
    Ok(suggestions)
}

/// Detect files with many dead (uncalled) functions. Threshold scales with
/// corpus size via `dead_cluster_min_size(chunk_count)`.
fn detect_dead_clusters<Mode>(
    store: &Store<Mode>,
    chunk_count: usize,
) -> Result<Vec<SuggestedNote>, StoreError> {
    let (confident, _) = store.find_dead_code(true)?;

    // Group by file
    let mut by_file: HashMap<String, usize> = HashMap::new();
    for dead in &confident {
        // Emit forward-slash paths so JSON consumers don't see Windows backslashes.
        let file = crate::normalize_path(&dead.chunk.file);
        *by_file.entry(file).or_default() += 1;
    }

    let min_size = dead_cluster_min_size(chunk_count);
    Ok(by_file
        .into_iter()
        .filter(|(_, count)| *count >= min_size)
        .map(|(file, count)| SuggestedNote {
            text: format!("{file} has {count} dead functions — consider cleanup"),
            sentiment: -0.5,
            mentions: vec![file],
            reason: "dead_code_cluster".to_string(),
        })
        .collect())
}

/// Detect untested hotspots and high-risk functions.
fn detect_risk_patterns<Mode>(
    store: &Store<Mode>,
    chunk_count: usize,
) -> Result<Vec<SuggestedNote>, StoreError> {
    let graph = store.get_call_graph()?;
    let test_chunks = store.find_test_chunks()?;
    let pool = suggest_hotspot_pool(chunk_count);
    let hotspots = find_hotspots(&graph, pool);

    if hotspots.is_empty() {
        return Ok(Vec::new());
    }

    let names: Vec<&str> = hotspots.iter().map(|h| h.name.as_str()).collect();
    let risks = compute_risk_batch(&names, &graph, &test_chunks);

    let mut suggestions = Vec::new();
    let min_callers = hotspot_min_callers(chunk_count);

    for (risk, hotspot) in risks.iter().zip(hotspots.iter()) {
        let name = &hotspot.name;
        let caller_count = hotspot.caller_count;
        let mentions = vec![name.to_string()];

        // Untested hotspot: min_callers+ callers, 0 tests.
        if risk.caller_count >= min_callers && risk.test_count == 0 {
            suggestions.push(SuggestedNote {
                text: format!("{name} has {caller_count} callers but no tests"),
                sentiment: -0.5,
                mentions,
                reason: "untested_hotspot".to_string(),
            });
        }
        // High-risk: many callers, few tests relative to blast radius
        else if risk.risk_level == RiskLevel::High {
            suggestions.push(SuggestedNote {
                text: format!(
                    "{name} is high-risk: {caller_count} callers, {} tests",
                    risk.test_count
                ),
                sentiment: -1.0,
                mentions,
                reason: "high_risk".to_string(),
            });
        }
    }

    Ok(suggestions)
}

// ─── Mention classification ──────────────────────────────────────────────────

/// How a note mention should be verified.
#[derive(Debug, PartialEq)]
pub(crate) enum MentionKind {
    /// Contains `.` or `/` — check filesystem
    File,
    /// Contains `_` or `::` or is PascalCase — check index
    Symbol,
    /// Everything else — not verifiable
    Concept,
}

/// Classify a mention string for staleness checking.
pub(crate) fn classify_mention(mention: &str) -> MentionKind {
    if mention.contains('.') || mention.contains('/') || mention.contains('\\') {
        MentionKind::File
    } else if mention.contains('_') || mention.contains("::") || is_pascal_case(mention) {
        MentionKind::Symbol
    } else {
        MentionKind::Concept
    }
}

/// Check if a string is PascalCase (starts uppercase, has lowercase chars, len > 1).
pub(crate) fn is_pascal_case(s: &str) -> bool {
    s.len() > 1
        && s.chars().next().is_some_and(|c| c.is_uppercase())
        && s.chars().any(|c| c.is_lowercase())
}

/// Core logic: find stale mentions across all notes.
/// Returns `(note_text, stale_mentions)` pairs for each note with at least one
/// stale mention. Shared by `detect_stale_mentions` and `check_note_staleness`.
fn find_stale_mentions<Mode>(
    store: &Store<Mode>,
    root: &Path,
) -> Result<Vec<(String, Vec<String>)>, StoreError> {
    let notes = store.list_notes_summaries()?;

    // Batch all symbol mentions for one query
    let mut symbol_mentions: Vec<&str> = Vec::new();
    for note in &notes {
        for mention in &note.mentions {
            if matches!(classify_mention(mention), MentionKind::Symbol) {
                symbol_mentions.push(mention.as_str());
            }
        }
    }
    symbol_mentions.sort_unstable();
    symbol_mentions.dedup(); // dedup requires sorted input — sort_unstable above ensures this

    let symbol_results = if symbol_mentions.is_empty() {
        HashMap::new()
    } else {
        store.search_by_names_batch(&symbol_mentions, 1)?
    };

    let mut result = Vec::new();

    for note in &notes {
        let mut stale = Vec::new();
        for mention in &note.mentions {
            match classify_mention(mention) {
                MentionKind::File => {
                    // Normalize backslashes to forward slashes for cross-platform path joining
                    let normalized = normalize_slashes(mention);
                    if !root.join(&normalized).exists() {
                        stale.push(mention.clone());
                    }
                }
                MentionKind::Symbol => {
                    if symbol_results
                        .get(mention.as_str())
                        .is_none_or(|v| v.is_empty())
                    {
                        stale.push(mention.clone());
                    }
                }
                MentionKind::Concept => {} // skip — not verifiable
            }
        }
        if !stale.is_empty() {
            result.push((note.text.clone(), stale));
        }
    }

    Ok(result)
}

/// Detect notes with stale mentions (deleted files, removed functions).
fn detect_stale_mentions<Mode>(
    store: &Store<Mode>,
    root: &Path,
) -> Result<Vec<SuggestedNote>, StoreError> {
    let stale_pairs = find_stale_mentions(store, root)?;

    Ok(stale_pairs
        .into_iter()
        .map(|(text, stale)| {
            let preview = if text.len() > 80 {
                format!("{}...", &text[..text.floor_char_boundary(77)])
            } else {
                text
            };
            SuggestedNote {
                text: format!(
                    "Note has stale mentions [{}]: \"{}\"",
                    stale.join(", "),
                    preview,
                ),
                sentiment: -0.5,
                mentions: stale,
                reason: "stale_mention".to_string(),
            }
        })
        .collect())
}

/// Check all notes for stale mentions.
/// Returns `(note_text, stale_mentions)` pairs for each note that has at least
/// one stale mention. Reusable by `notes list --check` and `health`.
pub fn check_note_staleness<Mode>(
    store: &Store<Mode>,
    root: &Path,
) -> Result<Vec<(String, Vec<String>)>, StoreError> {
    let _span = tracing::info_span!("check_note_staleness").entered();
    let result = find_stale_mentions(store, root)?;
    tracing::info!(stale_notes = result.len(), "Note staleness check complete");
    Ok(result)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn make_store() -> (Store, TempDir) {
        let dir = TempDir::new().unwrap();
        let db_path = dir.path().join(crate::INDEX_DB_FILENAME);
        let store = Store::open(&db_path).unwrap();
        store.init(&crate::store::ModelInfo::default()).unwrap();
        (store, dir)
    }

    #[test]
    fn test_suggest_empty_store() {
        let (store, dir) = make_store();
        let suggestions = suggest_notes(&store, dir.path()).unwrap();
        assert!(suggestions.is_empty());
    }

    #[test]
    fn test_classify_mention_file() {
        assert_eq!(classify_mention("src/foo.rs"), MentionKind::File);
        assert_eq!(classify_mention("Cargo.toml"), MentionKind::File);
        assert_eq!(classify_mention("path/to/file"), MentionKind::File);
    }

    #[test]
    fn test_classify_mention_symbol() {
        assert_eq!(classify_mention("search_filtered"), MentionKind::Symbol);
        assert_eq!(classify_mention("Store::open"), MentionKind::Symbol);
        assert_eq!(classify_mention("CallGraph"), MentionKind::Symbol);
    }

    #[test]
    fn test_classify_mention_concept() {
        assert_eq!(classify_mention("error handling"), MentionKind::Concept);
        assert_eq!(classify_mention("tree-sitter"), MentionKind::Concept);
        assert_eq!(classify_mention("indexing"), MentionKind::Concept);
    }

    #[test]
    fn test_is_pascal_case() {
        assert!(is_pascal_case("CallGraph"));
        assert!(is_pascal_case("Store"));
        assert!(!is_pascal_case("store"));
        assert!(!is_pascal_case("ALLCAPS"));
        assert!(!is_pascal_case("A")); // too short
    }

    #[test]
    fn test_detect_stale_file_mention() {
        let (store, dir) = make_store();
        // Insert a note with a mention of a nonexistent file
        store
            .replace_notes_for_file(
                &[crate::note::Note {
                    id: "note:test1".to_string(),
                    text: "test note".to_string(),
                    sentiment: 0.0,
                    mentions: vec!["src/nonexistent.rs".to_string()],
                    kind: None,
                }],
                &dir.path().join("notes.toml"),
                0,
            )
            .unwrap();

        let stale = detect_stale_mentions(&store, dir.path()).unwrap();
        assert_eq!(stale.len(), 1);
        assert_eq!(stale[0].reason, "stale_mention");
        assert!(stale[0]
            .mentions
            .contains(&"src/nonexistent.rs".to_string()));
    }

    #[test]
    fn test_detect_stale_no_mentions() {
        let (store, dir) = make_store();
        // Insert a note with no mentions
        store
            .replace_notes_for_file(
                &[crate::note::Note {
                    id: "note:test2".to_string(),
                    text: "no mentions here".to_string(),
                    sentiment: 0.0,
                    mentions: vec![],
                    kind: None,
                }],
                &dir.path().join("notes.toml"),
                0,
            )
            .unwrap();

        let stale = detect_stale_mentions(&store, dir.path()).unwrap();
        assert!(stale.is_empty());
    }

    #[test]
    fn test_suggest_dead_cluster() {
        use crate::language::{ChunkType, Language};
        use crate::parser::Chunk;
        use std::path::PathBuf;

        let (store, dir) = make_store();

        // Insert 6 functions in the SAME file, all without callers.
        // Use names that won't be excluded by entry-point or test heuristics.
        let file = "src/orphans.rs";
        let names = [
            "compute_alpha",
            "compute_beta",
            "compute_gamma",
            "compute_delta",
            "compute_epsilon",
            "compute_zeta",
        ];

        for (i, name) in names.iter().enumerate() {
            let content = format!("fn {}() {{ todo!() }}", name);
            let hash = blake3::hash(content.as_bytes()).to_hex().to_string();
            let line = (i as u32 + 1) * 10;
            let chunk = Chunk {
                id: format!("{}:{}:{}", file, line, &hash[..8]),
                file: PathBuf::from(file),
                language: Language::Rust,
                chunk_type: ChunkType::Function,
                name: name.to_string(),
                signature: format!("fn {}()", name),
                content,
                doc: None,
                line_start: line,
                line_end: line + 5,
                byte_start: 0,
                content_hash: hash,
                canonical_hash: String::new(),
                parent_id: None,
                window_idx: None,
                parent_type_name: None,
                parser_version: 0,
            };
            store
                .upsert_chunk(
                    &chunk,
                    &crate::Embedding::new(vec![0.0; crate::EMBEDDING_DIM]),
                    Some(1000),
                )
                .unwrap();
        }

        // No call graph entries — all are dead code
        let suggestions = suggest_notes(&store, dir.path()).unwrap();

        let dead_cluster = suggestions.iter().find(|s| s.reason == "dead_code_cluster");
        assert!(
            dead_cluster.is_some(),
            "Expected a dead_code_cluster suggestion for {} dead functions in one file. Got: {:?}",
            names.len(),
            suggestions.iter().map(|s| &s.reason).collect::<Vec<_>>()
        );
        let note = dead_cluster.unwrap();
        assert!(
            note.mentions.contains(&file.to_string()),
            "Expected mention of {}, got {:?}",
            file,
            note.mentions
        );
    }

    /// Seam-audit Finding 1: a file whose functions are all reached only by
    /// fn-pointer heuristic edges is NOT a dead cluster. `detect_dead_clusters`
    /// goes through `find_dead_code` (strict zero-edge); a heuristic-live
    /// function must not be counted, so no `dead_code_cluster` suggestion fires
    /// over genuinely-live code.
    ///
    /// Fails before the fix: the dead-candidate query gated on absence of a
    /// TRUSTED edge, so these fn-pointer-only helpers leaked into
    /// `find_dead_code` and `suggest` recommended deleting live code.
    #[test]
    fn test_fn_pointer_only_helpers_are_not_a_dead_cluster() {
        use crate::language::{ChunkType, Language};
        use crate::parser::{CallEdgeKind, CallSite, Chunk, FunctionCalls};
        use std::path::{Path, PathBuf};

        let (store, dir) = make_store();

        let file = "src/handlers.rs";
        let names = [
            "handle_alpha",
            "handle_beta",
            "handle_gamma",
            "handle_delta",
            "handle_epsilon",
            "handle_zeta",
        ];

        for (i, name) in names.iter().enumerate() {
            let content = format!("fn {}() {{ todo!() }}", name);
            let hash = blake3::hash(content.as_bytes()).to_hex().to_string();
            let line = (i as u32 + 1) * 10;
            let chunk = Chunk {
                id: format!("{}:{}:{}", file, line, &hash[..8]),
                file: PathBuf::from(file),
                language: Language::Rust,
                chunk_type: ChunkType::Function,
                name: name.to_string(),
                signature: format!("fn {}()", name),
                content,
                doc: None,
                line_start: line,
                line_end: line + 5,
                byte_start: 0,
                content_hash: hash,
                canonical_hash: String::new(),
                parent_id: None,
                window_idx: None,
                parent_type_name: None,
                parser_version: 0,
            };
            store
                .upsert_chunk(
                    &chunk,
                    &crate::Embedding::new(vec![0.0; crate::EMBEDDING_DIM]),
                    Some(1000),
                )
                .unwrap();
        }

        // Each helper is registered as a function pointer (heuristic edge) — live,
        // not dead.
        let calls: Vec<FunctionCalls> = names
            .iter()
            .enumerate()
            .map(|(i, name)| FunctionCalls {
                name: "register_handlers".to_string(),
                line_start: 1,
                calls: vec![CallSite {
                    callee_name: name.to_string(),
                    line_number: i as u32 + 2,
                    kind: CallEdgeKind::FnPointer,
                }],
            })
            .collect();
        store
            .upsert_function_calls(Path::new("src/registry.rs"), &calls)
            .unwrap();

        let suggestions = suggest_notes(&store, dir.path()).unwrap();
        let dead_cluster = suggestions.iter().find(|s| s.reason == "dead_code_cluster");
        assert!(
            dead_cluster.is_none(),
            "fn-pointer-only helpers are live — no dead_code_cluster suggestion should fire: {:?}",
            suggestions.iter().map(|s| &s.reason).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_suggest_untested_hotspot() {
        use crate::language::{ChunkType, Language};
        use crate::parser::{CallEdgeKind, CallSite, Chunk, FunctionCalls};
        use std::path::PathBuf;

        let (store, dir) = make_store();

        // Insert the hotspot target function
        let target_content = "fn hot_function() { }";
        let target_hash = blake3::hash(target_content.as_bytes()).to_hex().to_string();
        let target = Chunk {
            id: format!("src/core.rs:1:{}", &target_hash[..8]),
            file: PathBuf::from("src/core.rs"),
            language: Language::Rust,
            chunk_type: ChunkType::Function,
            name: "hot_function".to_string(),
            signature: "fn hot_function()".to_string(),
            content: target_content.to_string(),
            doc: None,
            line_start: 1,
            line_end: 5,
            byte_start: 0,
            content_hash: target_hash,
            canonical_hash: String::new(),
            parent_id: None,
            window_idx: None,
            parent_type_name: None,
            parser_version: 0,
        };
        store
            .upsert_chunk(
                &target,
                &crate::Embedding::new(vec![0.0; crate::EMBEDDING_DIM]),
                Some(1000),
            )
            .unwrap();

        // Insert 6 callers that each call hot_function (>= HOTSPOT_MIN_CALLERS)
        // No test chunks — making this an untested hotspot
        for i in 0..6 {
            let caller_name = format!("caller_{}", i);
            let file = format!("src/user{}.rs", i);
            let content = format!("fn {}() {{ hot_function() }}", caller_name);
            let hash = blake3::hash(content.as_bytes()).to_hex().to_string();
            let chunk = Chunk {
                id: format!("{}:1:{}", file, &hash[..8]),
                file: PathBuf::from(&file),
                language: Language::Rust,
                chunk_type: ChunkType::Function,
                name: caller_name.clone(),
                signature: format!("fn {}()", caller_name),
                content,
                doc: None,
                line_start: 1,
                line_end: 5,
                byte_start: 0,
                content_hash: hash,
                canonical_hash: String::new(),
                parent_id: None,
                window_idx: None,
                parent_type_name: None,
                parser_version: 0,
            };
            store
                .upsert_chunk(
                    &chunk,
                    &crate::Embedding::new(vec![0.0; crate::EMBEDDING_DIM]),
                    Some(1000),
                )
                .unwrap();

            store
                .upsert_function_calls(
                    Path::new(&file),
                    &[FunctionCalls {
                        name: caller_name,
                        line_start: 1,
                        calls: vec![CallSite {
                            callee_name: "hot_function".to_string(),
                            line_number: 2,
                            kind: CallEdgeKind::Call,
                        }],
                    }],
                )
                .unwrap();
        }

        let suggestions = suggest_notes(&store, dir.path()).unwrap();

        let untested = suggestions.iter().find(|s| s.reason == "untested_hotspot");
        assert!(
            untested.is_some(),
            "Expected an untested_hotspot suggestion for a function with 6 callers and no tests. Got: {:?}",
            suggestions.iter().map(|s| &s.reason).collect::<Vec<_>>()
        );
        let note = untested.unwrap();
        assert!(
            note.mentions.contains(&"hot_function".to_string()),
            "Expected mention of hot_function, got {:?}",
            note.mentions
        );
    }

    /// Verify the high_risk branch in detect_risk_patterns.
    /// A function with many callers but *some* tests still scores High if
    /// coverage is low enough (score = callers * (1 - coverage) >= 5.0).
    /// With 6 callers and 1 test: score = 6 * (1 - 1/6) = 5.0 → High.
    /// Because test_count > 0, the untested_hotspot branch is skipped and
    /// we land in the high_risk branch.
    #[test]
    fn test_suggest_high_risk_with_few_tests() {
        use crate::language::{ChunkType, Language};
        use crate::parser::{CallEdgeKind, CallSite, Chunk, FunctionCalls};
        use std::path::PathBuf;

        let (store, dir) = make_store();

        // Insert the target function that will be the hotspot
        let target_content = "fn risky_function() { }";
        let target_hash = blake3::hash(target_content.as_bytes()).to_hex().to_string();
        let target = Chunk {
            id: format!("src/risky.rs:1:{}", &target_hash[..8]),
            file: PathBuf::from("src/risky.rs"),
            language: Language::Rust,
            chunk_type: ChunkType::Function,
            name: "risky_function".to_string(),
            signature: "fn risky_function()".to_string(),
            content: target_content.to_string(),
            doc: None,
            line_start: 1,
            line_end: 5,
            byte_start: 0,
            content_hash: target_hash,
            canonical_hash: String::new(),
            parent_id: None,
            window_idx: None,
            parent_type_name: None,
            parser_version: 0,
        };
        store
            .upsert_chunk(
                &target,
                &crate::Embedding::new(vec![0.0; crate::EMBEDDING_DIM]),
                Some(1000),
            )
            .unwrap();

        // Insert 6 non-test callers — gives caller_count = 6
        for i in 0..6 {
            let caller_name = format!("caller_{}", i);
            let file = format!("src/user{}.rs", i);
            let content = format!("fn {}() {{ risky_function() }}", caller_name);
            let hash = blake3::hash(content.as_bytes()).to_hex().to_string();
            let chunk = Chunk {
                id: format!("{}:1:{}", file, &hash[..8]),
                file: PathBuf::from(&file),
                language: Language::Rust,
                chunk_type: ChunkType::Function,
                name: caller_name.clone(),
                signature: format!("fn {}()", caller_name),
                content,
                doc: None,
                line_start: 1,
                line_end: 5,
                byte_start: 0,
                content_hash: hash,
                canonical_hash: String::new(),
                parent_id: None,
                window_idx: None,
                parent_type_name: None,
                parser_version: 0,
            };
            store
                .upsert_chunk(
                    &chunk,
                    &crate::Embedding::new(vec![0.0; crate::EMBEDDING_DIM]),
                    Some(1000),
                )
                .unwrap();

            store
                .upsert_function_calls(
                    Path::new(&file),
                    &[FunctionCalls {
                        name: caller_name,
                        line_start: 1,
                        calls: vec![CallSite {
                            callee_name: "risky_function".to_string(),
                            line_number: 2,
                            kind: CallEdgeKind::Call,
                        }],
                    }],
                )
                .unwrap();
        }

        // Insert 1 test function that calls risky_function.
        // Name starts with "test_" so find_test_chunks picks it up.
        // This gives test_count = 1: coverage = 1/6, score = 5.0 → High.
        // Since test_count > 0, the untested_hotspot branch is skipped.
        let test_name = "test_risky_function";
        let test_file = "src/tests.rs";
        let test_content = format!("#[test] fn {}() {{ risky_function() }}", test_name);
        let test_hash = blake3::hash(test_content.as_bytes()).to_hex().to_string();
        let test_chunk = Chunk {
            id: format!("{}:1:{}", test_file, &test_hash[..8]),
            file: PathBuf::from(test_file),
            language: Language::Rust,
            chunk_type: ChunkType::Function,
            name: test_name.to_string(),
            signature: format!("fn {}()", test_name),
            content: test_content,
            doc: None,
            line_start: 1,
            line_end: 5,
            byte_start: 0,
            content_hash: test_hash,
            canonical_hash: String::new(),
            parent_id: None,
            window_idx: None,
            parent_type_name: None,
            parser_version: 0,
        };
        store
            .upsert_chunk(
                &test_chunk,
                &crate::Embedding::new(vec![0.0; crate::EMBEDDING_DIM]),
                Some(1000),
            )
            .unwrap();
        store
            .upsert_function_calls(
                Path::new(test_file),
                &[FunctionCalls {
                    name: test_name.to_string(),
                    line_start: 1,
                    calls: vec![CallSite {
                        callee_name: "risky_function".to_string(),
                        line_number: 2,
                        kind: CallEdgeKind::Call,
                    }],
                }],
            )
            .unwrap();

        let suggestions = suggest_notes(&store, dir.path()).unwrap();

        let high_risk = suggestions.iter().find(|s| s.reason == "high_risk");
        assert!(
            high_risk.is_some(),
            "Expected a high_risk suggestion for a function with 6 callers and only 1 test \
             (score = 5.0 >= threshold). Got reasons: {:?}",
            suggestions.iter().map(|s| &s.reason).collect::<Vec<_>>()
        );
        let note = high_risk.unwrap();
        assert!(
            note.mentions.contains(&"risky_function".to_string()),
            "Expected mention of risky_function, got {:?}",
            note.mentions
        );
        assert_eq!(
            note.sentiment, -1.0,
            "high_risk notes should have sentiment -1.0"
        );
        // Confirm it was NOT classified as untested_hotspot (test_count > 0)
        let untested = suggestions.iter().find(|s| {
            s.reason == "untested_hotspot" && s.mentions.contains(&"risky_function".to_string())
        });
        assert!(
            untested.is_none(),
            "risky_function should not appear as untested_hotspot because it has 1 test"
        );
    }
}
