//! Hint computation and risk scoring

use crate::store::{CallGraph, StoreError};
use crate::Store;

use super::bfs::reverse_bfs;
use super::types::{FunctionHints, RiskLevel, RiskScore};
use super::DEFAULT_MAX_TEST_SEARCH_DEPTH;

/// Risk score threshold above which a function is classified as high risk.
pub const RISK_THRESHOLD_HIGH: f32 = 5.0;
/// Risk score threshold above which a function is classified as medium risk.
pub const RISK_THRESHOLD_MEDIUM: f32 = 2.0;

/// Core implementation — accepts pre-loaded graph and test chunks.
///
/// Use this when processing multiple functions to avoid loading the graph
/// N times (e.g., scout, which processes 10+ functions).
pub fn compute_hints_with_graph(
    graph: &CallGraph,
    test_chunks: &[crate::store::ChunkSummary],
    function_name: &str,
    prefetched_caller_count: Option<usize>,
) -> FunctionHints {
    // Note: prefetched_caller_count (from get_caller_counts_batch / get_callers_full)
    // counts DB rows which may include duplicate caller names from different files.
    // graph.reverse counts unique caller names per the in-memory graph. These can
    // diverge slightly. We prefer the prefetched count when available since it matches
    // what the caller already displayed, avoiding confusing mismatches.
    let caller_count = match prefetched_caller_count {
        Some(n) => n,
        None => graph
            .reverse
            .get(function_name)
            .map(|v| v.len())
            .unwrap_or(0),
    };
    let ancestors = reverse_bfs(graph, function_name, DEFAULT_MAX_TEST_SEARCH_DEPTH);
    let test_count = test_chunks
        .iter()
        .filter(|t| ancestors.get(&t.name).is_some_and(|&d| d > 0))
        .count();

    FunctionHints {
        caller_count,
        test_count,
    }
}

/// Compute caller count and test count for a single function.
///
/// Convenience wrapper that loads graph internally. Pass `prefetched_caller_count`
/// to avoid re-querying callers when the caller already has them (e.g., `explain`
/// fetches callers before this).
pub fn compute_hints(
    store: &Store,
    function_name: &str,
    prefetched_caller_count: Option<usize>,
) -> Result<FunctionHints, StoreError> {
    let _span = tracing::info_span!("compute_hints", function = function_name).entered();
    let caller_count = match prefetched_caller_count {
        Some(n) => n,
        None => store.get_callers_full(function_name)?.len(),
    };
    let graph = store.get_call_graph()?;
    let test_chunks = store.find_test_chunks()?;
    Ok(compute_hints_with_graph(
        &graph,
        &test_chunks,
        function_name,
        Some(caller_count),
    ))
}

/// Compute risk scores for a batch of function names.
///
/// Uses pre-loaded call graph and test chunks to avoid repeated queries.
/// Formula: `score = caller_count * (1.0 - coverage)` where
/// `coverage = min(test_count / max(caller_count, 1), 1.0)`.
///
/// Entry-point handling: functions with 0 callers and 0 tests get `Medium`
/// risk (likely entry points that should have tests).
pub fn compute_risk_batch(
    names: &[&str],
    graph: &CallGraph,
    test_chunks: &[crate::store::ChunkSummary],
) -> Vec<RiskScore> {
    let _span = tracing::info_span!("compute_risk_batch", count = names.len()).entered();

    names
        .iter()
        .map(|name| {
            let hints = compute_hints_with_graph(graph, test_chunks, name, None);
            let caller_count = hints.caller_count;
            let test_count = hints.test_count;
            let coverage = if caller_count == 0 {
                if test_count > 0 {
                    1.0
                } else {
                    0.0
                }
            } else {
                (test_count as f32 / caller_count as f32).min(1.0)
            };
            let score = caller_count as f32 * (1.0 - coverage);
            let risk_level = if caller_count == 0 && test_count == 0 {
                // Entry point with no tests — flag as medium
                RiskLevel::Medium
            } else if score >= RISK_THRESHOLD_HIGH {
                RiskLevel::High
            } else if score >= RISK_THRESHOLD_MEDIUM {
                RiskLevel::Medium
            } else {
                RiskLevel::Low
            };
            let blast_radius = match caller_count {
                0..=2 => RiskLevel::Low,
                3..=10 => RiskLevel::Medium,
                _ => RiskLevel::High,
            };
            RiskScore {
                caller_count,
                test_count,
                coverage,
                risk_level,
                blast_radius,
                score,
            }
        })
        .collect()
}

/// Compute risk scores and collect deduplicated tests in a single pass.
///
/// Shares BFS results across risk scoring and test collection, avoiding the
/// duplicate `reverse_bfs` that occurs when calling `compute_risk_batch` and
/// `find_affected_tests_with_chunks` separately.
pub fn compute_risk_and_tests(
    targets: &[&str],
    graph: &CallGraph,
    test_chunks: &[crate::store::ChunkSummary],
) -> (Vec<RiskScore>, Vec<super::TestInfo>) {
    let _span = tracing::info_span!("compute_risk_and_tests", targets = targets.len()).entered();

    let mut scores = Vec::with_capacity(targets.len());
    let mut all_tests = Vec::new();
    let mut seen_tests = std::collections::HashSet::new();

    for &name in targets {
        // Single BFS per target — reused for both risk and tests
        let ancestors = reverse_bfs(graph, name, DEFAULT_MAX_TEST_SEARCH_DEPTH);

        // Risk scoring (same logic as compute_risk_batch)
        let caller_count = graph.reverse.get(name).map(|v| v.len()).unwrap_or(0);
        let test_count = test_chunks
            .iter()
            .filter(|t| ancestors.get(&t.name).is_some_and(|&d| d > 0))
            .count();
        let coverage = if caller_count == 0 {
            if test_count > 0 {
                1.0
            } else {
                0.0
            }
        } else {
            (test_count as f32 / caller_count as f32).min(1.0)
        };
        let score = caller_count as f32 * (1.0 - coverage);
        let risk_level = if caller_count == 0 && test_count == 0 {
            RiskLevel::Medium
        } else if score >= RISK_THRESHOLD_HIGH {
            RiskLevel::High
        } else if score >= RISK_THRESHOLD_MEDIUM {
            RiskLevel::Medium
        } else {
            RiskLevel::Low
        };
        let blast_radius = match caller_count {
            0..=2 => RiskLevel::Low,
            3..=10 => RiskLevel::Medium,
            _ => RiskLevel::High,
        };
        scores.push(RiskScore {
            caller_count,
            test_count,
            coverage,
            risk_level,
            blast_radius,
            score,
        });

        // Test collection — same BFS, deduplicated across targets
        for test in test_chunks {
            if let Some(&depth) = ancestors.get(&test.name) {
                if depth > 0 && seen_tests.insert((test.name.clone(), test.file.clone())) {
                    all_tests.push(super::TestInfo {
                        name: test.name.clone(),
                        file: test.file.clone(),
                        line: test.line_start,
                        call_depth: depth,
                    });
                }
            }
        }
    }

    all_tests.sort_by_key(|t| t.call_depth);
    (scores, all_tests)
}

/// Find the most-called functions in the codebase (hotspots).
///
/// Returns [`Hotspot`] entries sorted by caller count descending.
pub fn find_hotspots(graph: &CallGraph, top_n: usize) -> Vec<crate::health::Hotspot> {
    let _span = tracing::info_span!("find_hotspots", top_n).entered();

    let mut hotspots: Vec<crate::health::Hotspot> = graph
        .reverse
        .iter()
        .map(|(name, callers)| crate::health::Hotspot {
            name: name.clone(),
            caller_count: callers.len(),
        })
        .collect();
    hotspots.sort_by(|a, b| b.caller_count.cmp(&a.caller_count));
    hotspots.truncate(top_n);
    hotspots
}

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

    // ===== compute_hints_with_graph tests =====

    #[test]
    fn test_compute_hints_with_graph_stale_callers() {
        let mut reverse = HashMap::new();
        reverse.insert(
            "target".to_string(),
            vec!["ghost_caller".to_string(), "another_ghost".to_string()],
        );
        let graph = CallGraph {
            forward: HashMap::new(),
            reverse,
        };
        let test_chunks: Vec<crate::store::ChunkSummary> = Vec::new();
        let hints = compute_hints_with_graph(&graph, &test_chunks, "target", None);
        assert_eq!(hints.caller_count, 2, "Should count callers from graph");
        assert_eq!(hints.test_count, 0, "No test chunks means no tests");
    }

    #[test]
    fn test_compute_hints_with_graph_stale_test_ancestor() {
        let mut reverse = HashMap::new();
        reverse.insert("target".to_string(), vec!["middle".to_string()]);
        let graph = CallGraph {
            forward: HashMap::new(),
            reverse,
        };
        let test_chunks = vec![crate::store::ChunkSummary {
            id: "test.rs:1:abcd1234".to_string(),
            file: PathBuf::from("test.rs"),
            language: crate::parser::Language::Rust,
            chunk_type: crate::language::ChunkType::Function,
            name: "test_fn".to_string(),
            signature: "fn test_fn()".to_string(),
            content: "#[test] fn test_fn() {}".to_string(),
            doc: None,
            line_start: 1,
            line_end: 5,
            parent_id: None,
            parent_type_name: None,
        }];
        let hints = compute_hints_with_graph(&graph, &test_chunks, "target", None);
        assert_eq!(hints.test_count, 0, "Unreachable test should not count");
        assert_eq!(hints.caller_count, 1, "middle is a caller");
    }

    #[test]
    fn test_compute_hints_with_graph_prefetched_caller_count() {
        let graph = CallGraph {
            forward: HashMap::new(),
            reverse: HashMap::new(),
        };
        let test_chunks: Vec<crate::store::ChunkSummary> = Vec::new();
        let hints = compute_hints_with_graph(&graph, &test_chunks, "target", Some(99));
        assert_eq!(hints.caller_count, 99, "Should use prefetched value");
    }

    // ===== Risk Scoring Tests =====

    #[test]
    fn test_risk_high_many_callers_no_tests() {
        let mut reverse = HashMap::new();
        reverse.insert(
            "target".to_string(),
            vec!["a", "b", "c", "d", "e", "f", "g"]
                .into_iter()
                .map(String::from)
                .collect(),
        );
        let graph = CallGraph {
            forward: HashMap::new(),
            reverse,
        };
        let test_chunks: Vec<crate::store::ChunkSummary> = Vec::new();
        let scores = compute_risk_batch(&["target"], &graph, &test_chunks);
        assert_eq!(scores.len(), 1);
        assert_eq!(scores[0].risk_level, RiskLevel::High);
        assert_eq!(scores[0].caller_count, 7);
        assert_eq!(scores[0].test_count, 0);
        assert!((scores[0].score - 7.0).abs() < 0.01);
    }

    #[test]
    fn test_risk_low_with_tests() {
        let mut reverse = HashMap::new();
        reverse.insert(
            "target".to_string(),
            vec!["a".to_string(), "test_target".to_string()],
        );
        let graph = CallGraph {
            forward: HashMap::new(),
            reverse,
        };
        let test_chunks = vec![crate::store::ChunkSummary {
            id: "test_id".to_string(),
            file: PathBuf::from("tests/test.rs"),
            language: crate::parser::Language::Rust,
            chunk_type: crate::language::ChunkType::Function,
            name: "test_target".to_string(),
            signature: String::new(),
            content: String::new(),
            doc: None,
            line_start: 1,
            line_end: 10,
            parent_id: None,
            parent_type_name: None,
        }];
        let scores = compute_risk_batch(&["target"], &graph, &test_chunks);
        assert_eq!(scores[0].risk_level, RiskLevel::Low);
        // 2 callers, 1 test -> coverage = 0.5 -> score = 2 * 0.5 = 1.0
        assert!((scores[0].score - 1.0).abs() < 0.01);
    }

    #[test]
    fn test_risk_entry_point_no_callers_no_tests() {
        let graph = CallGraph {
            forward: HashMap::new(),
            reverse: HashMap::new(),
        };
        let test_chunks: Vec<crate::store::ChunkSummary> = Vec::new();
        let scores = compute_risk_batch(&["main"], &graph, &test_chunks);
        assert_eq!(scores[0].risk_level, RiskLevel::Medium);
        assert_eq!(scores[0].caller_count, 0);
        assert_eq!(scores[0].test_count, 0);
    }

    #[test]
    fn test_risk_coverage_capped_at_one() {
        let mut reverse = HashMap::new();
        reverse.insert(
            "target".to_string(),
            vec![
                "a".to_string(),
                "test_a".to_string(),
                "test_b".to_string(),
                "test_c".to_string(),
            ],
        );
        let mut forward = HashMap::new();
        forward.insert("test_a".to_string(), vec!["target".to_string()]);
        forward.insert("test_b".to_string(), vec!["target".to_string()]);
        forward.insert("test_c".to_string(), vec!["target".to_string()]);
        let graph = CallGraph { forward, reverse };
        let test_chunks = vec![
            crate::store::ChunkSummary {
                id: "t1".to_string(),
                file: PathBuf::from("tests/t.rs"),
                language: crate::parser::Language::Rust,
                chunk_type: crate::language::ChunkType::Function,
                name: "test_a".to_string(),
                signature: String::new(),
                content: String::new(),
                doc: None,
                line_start: 1,
                line_end: 5,
                parent_id: None,
                parent_type_name: None,
            },
            crate::store::ChunkSummary {
                id: "t2".to_string(),
                file: PathBuf::from("tests/t.rs"),
                language: crate::parser::Language::Rust,
                chunk_type: crate::language::ChunkType::Function,
                name: "test_b".to_string(),
                signature: String::new(),
                content: String::new(),
                doc: None,
                line_start: 6,
                line_end: 10,
                parent_id: None,
                parent_type_name: None,
            },
            crate::store::ChunkSummary {
                id: "t3".to_string(),
                file: PathBuf::from("tests/t.rs"),
                language: crate::parser::Language::Rust,
                chunk_type: crate::language::ChunkType::Function,
                name: "test_c".to_string(),
                signature: String::new(),
                content: String::new(),
                doc: None,
                line_start: 11,
                line_end: 15,
                parent_id: None,
                parent_type_name: None,
            },
        ];
        let scores = compute_risk_batch(&["target"], &graph, &test_chunks);
        assert!(
            scores[0].coverage <= 1.0,
            "Coverage should be capped at 1.0, got {}",
            scores[0].coverage
        );
        assert_eq!(scores[0].risk_level, RiskLevel::Low);
    }

    #[test]
    fn test_risk_batch_empty_input() {
        let graph = CallGraph {
            forward: HashMap::new(),
            reverse: HashMap::new(),
        };
        let test_chunks: Vec<crate::store::ChunkSummary> = Vec::new();
        let scores = compute_risk_batch(&[], &graph, &test_chunks);
        assert!(scores.is_empty());
    }

    #[test]
    fn test_blast_radius_thresholds() {
        let mut reverse = HashMap::new();
        // 2 callers → blast Low
        reverse.insert(
            "low_blast".to_string(),
            vec!["a", "b"].into_iter().map(String::from).collect(),
        );
        // 3 callers → blast Medium
        reverse.insert(
            "med_blast".to_string(),
            vec!["a", "b", "c"].into_iter().map(String::from).collect(),
        );
        // 11 callers → blast High
        reverse.insert(
            "high_blast".to_string(),
            (0..11).map(|i| format!("c{i}")).collect(),
        );
        let graph = CallGraph {
            forward: HashMap::new(),
            reverse,
        };
        let test_chunks: Vec<crate::store::ChunkSummary> = Vec::new();
        let scores = compute_risk_batch(
            &["low_blast", "med_blast", "high_blast"],
            &graph,
            &test_chunks,
        );

        assert_eq!(scores[0].blast_radius, RiskLevel::Low);
        assert_eq!(scores[1].blast_radius, RiskLevel::Medium);
        assert_eq!(scores[2].blast_radius, RiskLevel::High);
    }

    #[test]
    fn test_blast_radius_differs_from_risk() {
        // High blast radius (many callers) but low risk (full test coverage)
        let mut reverse = HashMap::new();
        let callers: Vec<String> = (0..15).map(|i| format!("caller_{i}")).collect();
        let mut all: Vec<String> = callers.clone();
        all.push("test_target".to_string());
        reverse.insert("target".to_string(), all);

        let mut forward = HashMap::new();
        forward.insert("test_target".to_string(), vec!["target".to_string()]);
        let graph = CallGraph { forward, reverse };

        let test_chunks = vec![crate::store::ChunkSummary {
            id: "t1".to_string(),
            file: PathBuf::from("tests/t.rs"),
            language: crate::parser::Language::Rust,
            chunk_type: crate::language::ChunkType::Function,
            name: "test_target".to_string(),
            signature: String::new(),
            content: String::new(),
            doc: None,
            line_start: 1,
            line_end: 5,
            parent_id: None,
            parent_type_name: None,
        }];

        let scores = compute_risk_batch(&["target"], &graph, &test_chunks);
        // 16 callers total, so blast_radius is High
        assert_eq!(scores[0].blast_radius, RiskLevel::High);
        // But risk_level should be lower due to test coverage
        // caller_count=16, test_count=1 → coverage ~0.06 → score ~15.0 → High risk still
        // Actually with only 1 test this will still be high risk
        // Let's just verify blast_radius is set correctly
        assert_eq!(scores[0].caller_count, 16);
    }

    #[test]
    fn test_find_hotspots() {
        let mut reverse = HashMap::new();
        reverse.insert(
            "hot".to_string(),
            vec!["a", "b", "c"].into_iter().map(String::from).collect(),
        );
        reverse.insert(
            "warm".to_string(),
            vec!["a", "b"].into_iter().map(String::from).collect(),
        );
        reverse.insert("cold".to_string(), vec!["a".to_string()]);
        let graph = CallGraph {
            forward: HashMap::new(),
            reverse,
        };
        let hotspots = find_hotspots(&graph, 2);
        assert_eq!(hotspots.len(), 2);
        assert_eq!(hotspots[0].name, "hot");
        assert_eq!(hotspots[0].caller_count, 3);
        assert_eq!(hotspots[1].name, "warm");
        assert_eq!(hotspots[1].caller_count, 2);
    }
}
