use super::super::HealthSort;
use super::super::component_rollup::append_component_rollup_findings;
use super::super::filters::{
    filter_complexity_findings_by_diff, filter_hotspots_by_diff, filter_large_functions_by_diff,
};
use super::super::findings::{
    CollectFindingsInput, CrapFindingMergeInput, collect_findings, collect_findings_with_resolver,
    merge_crap_findings,
};
use super::super::ignore::build_ignore_set;
use super::super::runtime_filter::{RuntimeCoverageFilterContext, apply_runtime_coverage_filters};
use super::super::scoring;
use super::super::sort_findings;
use super::super::threshold_overrides::{
    GlobalHealthThresholds, ThresholdOverrideResolver, ThresholdOverrideStateTracker,
};
use crate::baseline::HealthBaselineData;
use crate::source::ModuleInfo;
use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
use fallow_types::discover::FileId;
use fallow_types::extract::FunctionComplexity;
use rustc_hash::{FxHashMap, FxHashSet};
use std::path::{Path, PathBuf};

/// Build a minimal `ModuleInfo` with only the fields `collect_findings` needs.
fn make_module(file_id: FileId, complexity: Vec<FunctionComplexity>) -> ModuleInfo {
    ModuleInfo {
        file_id,
        exports: vec![],
        imports: vec![],
        re_exports: vec![],
        dynamic_imports: vec![],
        dynamic_import_patterns: vec![],
        require_calls: vec![],
        package_path_references: Box::default(),
        member_accesses: vec![],
        semantic_facts: Box::default(),
        whole_object_uses: Box::default(),
        has_cjs_exports: false,
        has_angular_component_template_url: false,
        content_hash: 0,
        suppressions: vec![],
        unknown_suppression_kinds: vec![],
        unused_import_bindings: vec![],
        type_referenced_import_bindings: vec![],
        value_referenced_import_bindings: vec![],
        line_offsets: vec![0],
        complexity,
        flag_uses: vec![],
        class_heritage: vec![],
        exported_factory_returns: Box::default(),
        injection_tokens: vec![],
        local_type_declarations: Vec::new(),
        public_signature_type_references: Vec::new(),
        namespace_object_aliases: Vec::new(),
        iconify_prefixes: Vec::new(),
        iconify_icon_names: Vec::new(),
        auto_import_candidates: Vec::new(),
        directives: Vec::new(),
        client_only_dynamic_import_spans: Vec::new(),
        security_sinks: Vec::new(),
        security_sinks_skipped: 0,
        security_unresolved_callee_sites: Vec::new(),
        tainted_bindings: Vec::new(),
        sanitized_sink_args: Vec::new(),
        security_control_sites: Vec::new(),
        callee_uses: Vec::new(),
        misplaced_directives: Vec::new(),
        inline_server_action_exports: Vec::new(),
        di_key_sites: Vec::new(),
        has_dynamic_provide: false,
        referenced_import_bindings: Vec::new(),
        component_props: Vec::new(),
        has_props_attrs_fallthrough: false,
        has_define_expose: false,
        has_define_model: false,
        has_unharvestable_props: false,
        component_emits: Vec::new(),
        angular_inputs: Vec::new(),
        angular_outputs: Vec::new(),
        has_unharvestable_emits: false,
        has_dynamic_emit: false,
        has_emit_whole_object_use: false,
        load_return_keys: Vec::new(),
        has_unharvestable_load: false,
        has_load_data_whole_use: false,
        has_page_data_store_whole_use: false,
        component_functions: Vec::new(),
        react_props: Vec::new(),
        hook_uses: Vec::new(),
        render_edges: Vec::new(),
        svelte_dispatched_events: Vec::new(),
        svelte_listened_events: Vec::new(),
        angular_component_selectors: Vec::new(),
        registered_custom_elements: Vec::new(),
        used_custom_element_tags: Vec::new(),
        angular_used_selectors: Vec::new(),
        angular_entry_component_refs: Vec::new(),
        has_dynamic_component_render: false,
        has_dynamic_dispatch: false,
    }
}

fn make_fc(name: &str, cyclomatic: u16, cognitive: u16, line_count: u32) -> FunctionComplexity {
    FunctionComplexity {
        name: name.to_string(),
        line: 1,
        col: 0,
        cyclomatic,
        cognitive,
        line_count,
        param_count: 0,
        react_hook_count: 0,
        react_jsx_max_depth: 0,
        react_prop_count: 0,
        source_hash: None,
        contributions: Vec::new(),
    }
}

fn make_fc_with_contributions(name: &str, cyclomatic: u16, cognitive: u16) -> FunctionComplexity {
    use fallow_types::extract::{
        ComplexityContribution, ComplexityContributionKind, ComplexityMetric,
    };
    let mut fc = make_fc(name, cyclomatic, cognitive, 50);
    fc.contributions = vec![ComplexityContribution {
        line: 2,
        col: 4,
        metric: ComplexityMetric::Cyclomatic,
        kind: ComplexityContributionKind::If,
        weight: 1,
        nesting: 0,
    }];
    fc
}

#[test]
fn collect_findings_omits_contributions_without_breakdown_flag() {
    let path = PathBuf::from("/project/src/a.ts");
    let modules = vec![make_module(
        FileId(0),
        vec![make_fc_with_contributions("complexFn", 25, 5)],
    )];
    let mut file_paths = FxHashMap::default();
    file_paths.insert(FileId(0), &path);
    let (findings, _, _) = collect_findings(
        &modules,
        &file_paths,
        Path::new("/project"),
        &globset::GlobSet::empty(),
        None,
        None,
        20,
        15,
        false,
    );
    assert_eq!(findings.len(), 1);
    assert!(
        findings[0].contributions.is_empty(),
        "contributions must be omitted without the breakdown flag"
    );
}

#[test]
fn collect_findings_includes_contributions_with_breakdown_flag() {
    let path = PathBuf::from("/project/src/a.ts");
    let modules = vec![make_module(
        FileId(0),
        vec![make_fc_with_contributions("complexFn", 25, 5)],
    )];
    let mut file_paths = FxHashMap::default();
    file_paths.insert(FileId(0), &path);
    let (findings, _, _) = collect_findings(
        &modules,
        &file_paths,
        Path::new("/project"),
        &globset::GlobSet::empty(),
        None,
        None,
        20,
        15,
        true,
    );
    assert_eq!(findings.len(), 1);
    assert_eq!(
        findings[0].contributions.len(),
        1,
        "contributions must flow through when the breakdown flag is set"
    );
}

fn threshold_resolver(
    overrides: &[fallow_config::HealthThresholdOverride],
) -> ThresholdOverrideResolver {
    ThresholdOverrideResolver::new(
        overrides,
        GlobalHealthThresholds {
            cyclomatic: 20,
            cognitive: 15,
            crap: 30.0,
        },
    )
}

#[test]
fn collect_findings_uses_threshold_override_as_local_ceiling() {
    let path = PathBuf::from("/project/src/a.ts");
    let modules = vec![make_module(
        FileId(0),
        vec![make_fc("complexFn", 25, 20, 50)],
    )];
    let mut file_paths = FxHashMap::default();
    file_paths.insert(FileId(0), &path);
    let resolver = threshold_resolver(&[fallow_config::HealthThresholdOverride {
        files: vec!["src/a.ts".to_string()],
        functions: vec!["complexFn".to_string()],
        max_cyclomatic: Some(30),
        max_cognitive: Some(25),
        max_crap: None,
        reason: Some("approved assembly".to_string()),
    }]);
    let mut tracker = ThresholdOverrideStateTracker::default();

    let mut input = CollectFindingsInput {
        modules: &modules,
        file_paths: &file_paths,
        config_root: Path::new("/project"),
        ignore_set: &globset::GlobSet::empty(),
        changed_files: None,
        ws_roots: None,
        threshold_resolver: &resolver,
        threshold_state_tracker: &mut tracker,
        complexity_breakdown: false,
    };
    let (findings, _, _) = collect_findings_with_resolver(&mut input);

    assert!(findings.is_empty());
    let states = tracker.into_states();
    assert_eq!(states.len(), 1);
    assert!(matches!(
        states[0].status,
        fallow_output::ThresholdOverrideStatus::Active
    ));
}

#[test]
fn collect_findings_reports_when_local_ceiling_is_exceeded() {
    let path = PathBuf::from("/project/src/a.ts");
    let modules = vec![make_module(
        FileId(0),
        vec![make_fc("complexFn", 31, 20, 50)],
    )];
    let mut file_paths = FxHashMap::default();
    file_paths.insert(FileId(0), &path);
    let resolver = threshold_resolver(&[fallow_config::HealthThresholdOverride {
        files: vec!["src/a.ts".to_string()],
        functions: vec!["complexFn".to_string()],
        max_cyclomatic: Some(30),
        max_cognitive: Some(25),
        max_crap: None,
        reason: None,
    }]);
    let mut tracker = ThresholdOverrideStateTracker::default();

    let mut input = CollectFindingsInput {
        modules: &modules,
        file_paths: &file_paths,
        config_root: Path::new("/project"),
        ignore_set: &globset::GlobSet::empty(),
        changed_files: None,
        ws_roots: None,
        threshold_resolver: &resolver,
        threshold_state_tracker: &mut tracker,
        complexity_breakdown: false,
    };
    let (findings, _, _) = collect_findings_with_resolver(&mut input);

    assert_eq!(findings.len(), 1);
    assert_eq!(findings[0].effective_thresholds.unwrap().max_cyclomatic, 30);
    assert!(matches!(
        findings[0].threshold_source,
        Some(fallow_output::ThresholdSource::Override)
    ));
}

#[test]
fn collect_findings_reports_stale_override_when_under_global_thresholds() {
    let path = PathBuf::from("/project/src/a.ts");
    let modules = vec![make_module(
        FileId(0),
        vec![make_fc("complexFn", 10, 8, 20)],
    )];
    let mut file_paths = FxHashMap::default();
    file_paths.insert(FileId(0), &path);
    let resolver = threshold_resolver(&[fallow_config::HealthThresholdOverride {
        files: vec!["src/a.ts".to_string()],
        functions: vec!["complexFn".to_string()],
        max_cyclomatic: Some(30),
        max_cognitive: None,
        max_crap: None,
        reason: None,
    }]);
    let mut tracker = ThresholdOverrideStateTracker::default();

    let mut input = CollectFindingsInput {
        modules: &modules,
        file_paths: &file_paths,
        config_root: Path::new("/project"),
        ignore_set: &globset::GlobSet::empty(),
        changed_files: None,
        ws_roots: None,
        threshold_resolver: &resolver,
        threshold_state_tracker: &mut tracker,
        complexity_breakdown: false,
    };
    let (findings, _, _) = collect_findings_with_resolver(&mut input);

    assert!(findings.is_empty());
    let states = tracker.into_states();
    assert_eq!(states.len(), 1);
    assert!(matches!(
        states[0].status,
        fallow_output::ThresholdOverrideStatus::Stale
    ));
}

#[test]
fn threshold_override_tracker_reports_no_match_only_when_requested() {
    let resolver = threshold_resolver(&[fallow_config::HealthThresholdOverride {
        files: vec!["src/missing.ts".to_string()],
        functions: vec!["missingFn".to_string()],
        max_cyclomatic: Some(30),
        max_cognitive: None,
        max_crap: None,
        reason: None,
    }]);
    let mut tracker = ThresholdOverrideStateTracker::default();
    tracker.record_no_match_entries(&resolver, false);
    assert!(tracker.into_states().is_empty());

    let mut tracker = ThresholdOverrideStateTracker::default();
    tracker.record_no_match_entries(&resolver, true);
    let states = tracker.into_states();
    assert_eq!(states.len(), 1);
    assert!(matches!(
        states[0].status,
        fallow_output::ThresholdOverrideStatus::NoMatch
    ));
}

#[test]
fn build_ignore_set_empty_patterns() {
    let set = build_ignore_set(&[]);
    assert!(set.is_empty());
}

#[test]
fn build_ignore_set_matches_glob() {
    let patterns = vec!["src/generated/**".to_string()];
    let set = build_ignore_set(&patterns);
    assert!(set.is_match(Path::new("src/generated/types.ts")));
    assert!(!set.is_match(Path::new("src/utils.ts")));
}

#[test]
fn build_ignore_set_multiple_patterns() {
    let patterns = vec!["*.test.ts".to_string(), "dist/**".to_string()];
    let set = build_ignore_set(&patterns);
    assert!(set.is_match(Path::new("foo.test.ts")));
    assert!(set.is_match(Path::new("dist/index.js")));
    assert!(!set.is_match(Path::new("src/index.ts")));
}

#[test]
#[should_panic(expected = "validated at config load time")]
fn build_ignore_set_panics_on_unvalidated_invalid_pattern() {
    let patterns = vec!["[invalid".to_string(), "*.js".to_string()];
    let _ = build_ignore_set(&patterns);
}

fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
    ComplexityViolation {
        path: PathBuf::from("/project/src/a.ts"),
        name: name.to_string(),
        line: 1,
        col: 0,
        cyclomatic: match exceeded {
            ExceededThreshold::Cyclomatic
            | ExceededThreshold::Both
            | ExceededThreshold::CyclomaticCrap
            | ExceededThreshold::All => 25,
            _ => 8,
        },
        cognitive: match exceeded {
            ExceededThreshold::Cognitive
            | ExceededThreshold::Both
            | ExceededThreshold::CognitiveCrap
            | ExceededThreshold::All => 20,
            _ => 5,
        },
        line_count: 10,
        param_count: 0,
        react_hook_count: 0,
        react_jsx_max_depth: 0,
        react_prop_count: 0,
        react_hook_profile: None,
        exceeded,
        severity: FindingSeverity::Moderate,
        crap: exceeded.includes_crap().then_some(30.0),
        coverage_pct: None,
        coverage_tier: None,
        coverage_source: None,
        inherited_from: None,
        component_rollup: None,
        contributions: Vec::new(),
        effective_thresholds: None,
        threshold_source: None,
    }
}

#[test]
fn sort_findings_by_severity_surfaces_crap_before_single_metric_findings() {
    let mut findings = vec![
        make_finding("cyclomatic", ExceededThreshold::Cyclomatic),
        make_finding("cognitive", ExceededThreshold::Cognitive),
        make_finding("both", ExceededThreshold::Both),
        make_finding("crap", ExceededThreshold::Crap),
        make_finding("cyclomatic_crap", ExceededThreshold::CyclomaticCrap),
        make_finding("all", ExceededThreshold::All),
    ];

    sort_findings(&mut findings, HealthSort::Severity);

    let names = findings
        .iter()
        .map(|finding| finding.name.as_str())
        .collect::<Vec<_>>();
    assert_eq!(
        names,
        [
            "all",
            "cyclomatic_crap",
            "crap",
            "both",
            "cyclomatic",
            "cognitive",
        ]
    );
}

#[test]
fn collect_findings_empty_modules() {
    let (findings, files, functions) = collect_findings(
        &[],
        &FxHashMap::default(),
        Path::new("/project"),
        &globset::GlobSet::empty(),
        None,
        None,
        20,
        15,
        false,
    );
    assert!(findings.is_empty());
    assert_eq!(files, 0);
    assert_eq!(functions, 0);
}

#[test]
fn collect_findings_below_threshold() {
    let path = PathBuf::from("/project/src/a.ts");
    let modules = vec![make_module(FileId(0), vec![make_fc("doStuff", 5, 3, 10)])];
    let mut file_paths = FxHashMap::default();
    file_paths.insert(FileId(0), &path);

    let (findings, files, functions) = collect_findings(
        &modules,
        &file_paths,
        Path::new("/project"),
        &globset::GlobSet::empty(),
        None,
        None,
        20,
        15,
        false,
    );
    assert!(findings.is_empty());
    assert_eq!(files, 1);
    assert_eq!(functions, 1);
}

#[test]
fn collect_findings_exceeds_cyclomatic_only() {
    let path = PathBuf::from("/project/src/a.ts");
    let modules = vec![make_module(
        FileId(0),
        vec![make_fc("complexFn", 25, 5, 50)],
    )];
    let mut file_paths = FxHashMap::default();
    file_paths.insert(FileId(0), &path);

    let (findings, _, _) = collect_findings(
        &modules,
        &file_paths,
        Path::new("/project"),
        &globset::GlobSet::empty(),
        None,
        None,
        20,
        15,
        false,
    );
    assert_eq!(findings.len(), 1);
    assert_eq!(findings[0].cyclomatic, 25);
    assert!(matches!(
        findings[0].exceeded,
        ExceededThreshold::Cyclomatic
    ));
}

#[test]
fn collect_findings_exceeds_cognitive_only() {
    let path = PathBuf::from("/project/src/a.ts");
    let modules = vec![make_module(FileId(0), vec![make_fc("nestedFn", 5, 20, 30)])];
    let mut file_paths = FxHashMap::default();
    file_paths.insert(FileId(0), &path);

    let (findings, _, _) = collect_findings(
        &modules,
        &file_paths,
        Path::new("/project"),
        &globset::GlobSet::empty(),
        None,
        None,
        20,
        15,
        false,
    );
    assert_eq!(findings.len(), 1);
    assert!(matches!(findings[0].exceeded, ExceededThreshold::Cognitive));
}

#[test]
fn collect_findings_exceeds_both() {
    let path = PathBuf::from("/project/src/a.ts");
    let modules = vec![make_module(
        FileId(0),
        vec![make_fc("terribleFn", 25, 20, 100)],
    )];
    let mut file_paths = FxHashMap::default();
    file_paths.insert(FileId(0), &path);

    let (findings, _, _) = collect_findings(
        &modules,
        &file_paths,
        Path::new("/project"),
        &globset::GlobSet::empty(),
        None,
        None,
        20,
        15,
        false,
    );
    assert_eq!(findings.len(), 1);
    assert!(matches!(findings[0].exceeded, ExceededThreshold::Both));
}

#[test]
fn collect_findings_multiple_functions_per_file() {
    let path = PathBuf::from("/project/src/a.ts");
    let modules = vec![make_module(
        FileId(0),
        vec![
            make_fc("ok", 5, 3, 10),
            make_fc("bad", 25, 20, 50),
            make_fc("also_bad", 21, 5, 30),
        ],
    )];
    let mut file_paths = FxHashMap::default();
    file_paths.insert(FileId(0), &path);

    let (findings, files, functions) = collect_findings(
        &modules,
        &file_paths,
        Path::new("/project"),
        &globset::GlobSet::empty(),
        None,
        None,
        20,
        15,
        false,
    );
    assert_eq!(findings.len(), 2);
    assert_eq!(files, 1);
    assert_eq!(functions, 3);
}

#[test]
fn collect_findings_ignores_matching_files() {
    let path = PathBuf::from("/project/src/generated/types.ts");
    let modules = vec![make_module(FileId(0), vec![make_fc("genFn", 25, 20, 50)])];
    let mut file_paths = FxHashMap::default();
    file_paths.insert(FileId(0), &path);

    let ignore_set = build_ignore_set(&["src/generated/**".to_string()]);
    let (findings, files, _) = collect_findings(
        &modules,
        &file_paths,
        Path::new("/project"),
        &ignore_set,
        None,
        None,
        20,
        15,
        false,
    );
    assert!(findings.is_empty());
    assert_eq!(files, 0);
}

#[test]
fn collect_findings_filters_by_changed_files() {
    let path_a = PathBuf::from("/project/src/a.ts");
    let path_b = PathBuf::from("/project/src/b.ts");
    let modules = vec![
        make_module(FileId(0), vec![make_fc("fnA", 25, 20, 50)]),
        make_module(FileId(1), vec![make_fc("fnB", 25, 20, 50)]),
    ];
    let mut file_paths = FxHashMap::default();
    file_paths.insert(FileId(0), &path_a);
    file_paths.insert(FileId(1), &path_b);

    let mut changed = FxHashSet::default();
    changed.insert(PathBuf::from("/project/src/a.ts"));

    let (findings, files, _) = collect_findings(
        &modules,
        &file_paths,
        Path::new("/project"),
        &globset::GlobSet::empty(),
        Some(&changed),
        None,
        20,
        15,
        false,
    );
    assert_eq!(findings.len(), 1);
    assert_eq!(findings[0].name, "fnA");
    assert_eq!(files, 1);
}

fn build_diff(text: &str) -> fallow_output::DiffIndex {
    fallow_output::DiffIndex::from_unified_diff(text)
}

#[test]
fn filter_complexity_findings_by_diff_keeps_hotspot_overlapping_diff_line() {
    let mut findings = vec![ComplexityViolation {
        path: PathBuf::from("/project/src/big.ts"),
        name: "wide_fn".into(),
        line: 10,
        col: 0,
        cyclomatic: 30,
        cognitive: 30,
        line_count: 110,
        param_count: 0,
        react_hook_count: 0,
        react_jsx_max_depth: 0,
        react_prop_count: 0,
        react_hook_profile: None,
        exceeded: ExceededThreshold::Both,
        severity: FindingSeverity::High,
        crap: None,
        coverage_pct: None,
        coverage_tier: None,
        coverage_source: None,
        inherited_from: None,
        component_rollup: None,
        contributions: Vec::new(),
        effective_thresholds: None,
        threshold_source: None,
    }];
    let diff = build_diff(
        "diff --git a/src/big.ts b/src/big.ts\n\
             --- a/src/big.ts\n\
             +++ b/src/big.ts\n\
             @@ -114,1 +114,2 @@\n\
              ctx\n\
             +touched\n",
    );
    filter_complexity_findings_by_diff(&mut findings, &diff, Path::new("/project"));
    assert_eq!(findings.len(), 1);
}

#[test]
fn filter_complexity_findings_by_diff_drops_finding_outside_diff() {
    let mut findings = vec![ComplexityViolation {
        path: PathBuf::from("/project/src/elsewhere.ts"),
        name: "outside".into(),
        line: 10,
        col: 0,
        cyclomatic: 30,
        cognitive: 30,
        line_count: 5,
        param_count: 0,
        react_hook_count: 0,
        react_jsx_max_depth: 0,
        react_prop_count: 0,
        react_hook_profile: None,
        exceeded: ExceededThreshold::Both,
        severity: FindingSeverity::High,
        crap: None,
        coverage_pct: None,
        coverage_tier: None,
        coverage_source: None,
        inherited_from: None,
        component_rollup: None,
        contributions: Vec::new(),
        effective_thresholds: None,
        threshold_source: None,
    }];
    let diff = build_diff(
        "diff --git a/src/big.ts b/src/big.ts\n\
             --- a/src/big.ts\n\
             +++ b/src/big.ts\n\
             @@ -114,1 +114,2 @@\n\
              ctx\n\
             +touched\n",
    );
    filter_complexity_findings_by_diff(&mut findings, &diff, Path::new("/project"));
    assert!(findings.is_empty());
}

#[test]
fn filter_complexity_findings_by_diff_handles_zero_line_count() {
    let mut findings = vec![ComplexityViolation {
        path: PathBuf::from("/project/src/a.ts"),
        name: "zero_extent".into(),
        line: 5,
        col: 0,
        cyclomatic: 30,
        cognitive: 30,
        line_count: 0,
        param_count: 0,
        react_hook_count: 0,
        react_jsx_max_depth: 0,
        react_prop_count: 0,
        react_hook_profile: None,
        exceeded: ExceededThreshold::Both,
        severity: FindingSeverity::High,
        crap: None,
        coverage_pct: None,
        coverage_tier: None,
        coverage_source: None,
        inherited_from: None,
        component_rollup: None,
        contributions: Vec::new(),
        effective_thresholds: None,
        threshold_source: None,
    }];
    let diff = build_diff(
        "diff --git a/src/a.ts b/src/a.ts\n\
             --- a/src/a.ts\n\
             +++ b/src/a.ts\n\
             @@ -4,1 +4,2 @@\n\
              ctx\n\
             +touched\n",
    );
    filter_complexity_findings_by_diff(&mut findings, &diff, Path::new("/project"));
    assert_eq!(findings.len(), 1);
}

#[test]
fn filter_hotspots_by_diff_uses_file_level_membership() {
    use fallow_output::HotspotEntry;
    let mut hotspots = vec![
        HotspotEntry {
            path: PathBuf::from("/project/src/touched.ts"),
            score: 90.0,
            commits: 50,
            weighted_commits: 25.0,
            lines_added: 1000,
            lines_deleted: 500,
            complexity_density: 0.4,
            fan_in: 5,
            trend: crate::ChurnTrend::Stable,
            ownership: None,
            is_test_path: false,
        },
        HotspotEntry {
            path: PathBuf::from("/project/src/untouched.ts"),
            score: 90.0,
            commits: 50,
            weighted_commits: 25.0,
            lines_added: 1000,
            lines_deleted: 500,
            complexity_density: 0.4,
            fan_in: 5,
            trend: crate::ChurnTrend::Stable,
            ownership: None,
            is_test_path: false,
        },
    ];
    let diff = build_diff(
        "diff --git a/src/touched.ts b/src/touched.ts\n\
             --- a/src/touched.ts\n\
             +++ b/src/touched.ts\n\
             @@ -0,0 +1,1 @@\n\
             +new\n",
    );
    filter_hotspots_by_diff(&mut hotspots, &diff, Path::new("/project"));
    assert_eq!(hotspots.len(), 1);
    assert_eq!(hotspots[0].path, PathBuf::from("/project/src/touched.ts"));
}

#[test]
fn filter_large_functions_by_diff_uses_range_overlap() {
    use fallow_output::LargeFunctionEntry;
    let mut entries = vec![
        LargeFunctionEntry {
            path: PathBuf::from("/project/src/a.ts"),
            name: "kept".into(),
            line: 10,
            line_count: 100,
        },
        LargeFunctionEntry {
            path: PathBuf::from("/project/src/a.ts"),
            name: "dropped".into(),
            line: 500,
            line_count: 100,
        },
    ];
    let diff = build_diff(
        "diff --git a/src/a.ts b/src/a.ts\n\
             --- a/src/a.ts\n\
             +++ b/src/a.ts\n\
             @@ -49,1 +49,2 @@\n\
              ctx\n\
             +touched\n",
    );
    filter_large_functions_by_diff(&mut entries, &diff, Path::new("/project"));
    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0].name, "kept");
}

#[test]
fn collect_findings_skips_module_without_path() {
    let modules = vec![make_module(FileId(99), vec![make_fc("orphan", 25, 20, 50)])];
    let file_paths = FxHashMap::default();

    let (findings, files, _) = collect_findings(
        &modules,
        &file_paths,
        Path::new("/project"),
        &globset::GlobSet::empty(),
        None,
        None,
        20,
        15,
        false,
    );
    assert!(findings.is_empty());
    assert_eq!(files, 0);
}

#[test]
fn collect_findings_at_exact_threshold_not_reported() {
    let path = PathBuf::from("/project/src/a.ts");
    let modules = vec![make_module(
        FileId(0),
        vec![make_fc("borderline", 20, 15, 20)],
    )];
    let mut file_paths = FxHashMap::default();
    file_paths.insert(FileId(0), &path);

    let (findings, _, _) = collect_findings(
        &modules,
        &file_paths,
        Path::new("/project"),
        &globset::GlobSet::empty(),
        None,
        None,
        20,
        15,
        false,
    );
    assert!(findings.is_empty());
}

#[test]
fn collect_findings_preserves_function_metadata() {
    let path = PathBuf::from("/project/src/a.ts");
    let modules = vec![make_module(
        FileId(0),
        vec![FunctionComplexity {
            name: "processData".to_string(),
            line: 42,
            col: 8,
            cyclomatic: 25,
            cognitive: 18,
            line_count: 75,
            param_count: 2,
            react_hook_count: 0,
            react_jsx_max_depth: 0,
            react_prop_count: 0,
            source_hash: None,
            contributions: Vec::new(),
        }],
    )];
    let mut file_paths = FxHashMap::default();
    file_paths.insert(FileId(0), &path);

    let (findings, _, _) = collect_findings(
        &modules,
        &file_paths,
        Path::new("/project"),
        &globset::GlobSet::empty(),
        None,
        None,
        20,
        15,
        false,
    );
    assert_eq!(findings.len(), 1);
    let f = &findings[0];
    assert_eq!(f.name, "processData");
    assert_eq!(f.line, 42);
    assert_eq!(f.col, 8);
    assert_eq!(f.cyclomatic, 25);
    assert_eq!(f.cognitive, 18);
    assert_eq!(f.line_count, 75);
    assert_eq!(f.path, PathBuf::from("/project/src/a.ts"));
}

#[test]
fn merge_crap_findings_disambiguates_same_line_functions() {
    let path = PathBuf::from("/project/src/curried.ts");
    let outer = FunctionComplexity {
        name: "handler".to_string(),
        line: 1,
        col: 23,
        cyclomatic: 1,
        cognitive: 0,
        line_count: 11,
        param_count: 1,
        react_hook_count: 0,
        react_jsx_max_depth: 0,
        react_prop_count: 0,
        source_hash: None,
        contributions: Vec::new(),
    };
    let inner = FunctionComplexity {
        name: "<arrow>".to_string(),
        line: 1,
        col: 43,
        cyclomatic: 7,
        cognitive: 0,
        line_count: 10,
        param_count: 1,
        react_hook_count: 0,
        react_jsx_max_depth: 0,
        react_prop_count: 0,
        source_hash: None,
        contributions: Vec::new(),
    };
    let modules = vec![make_module(FileId(0), vec![inner.clone(), outer.clone()])];
    let mut file_paths: FxHashMap<FileId, &PathBuf> = FxHashMap::default();
    file_paths.insert(FileId(0), &path);

    let mut findings: Vec<ComplexityViolation> = Vec::new();

    let mut per_function_crap: FxHashMap<PathBuf, Vec<scoring::PerFunctionCrap>> =
        FxHashMap::default();
    per_function_crap.insert(
        path.clone(),
        vec![
            scoring::PerFunctionCrap {
                line: inner.line,
                col: inner.col,
                crap: 56.0,
                coverage_pct: None,
                coverage_tier: fallow_output::CoverageTier::None,
                coverage_source: fallow_output::CoverageSource::Estimated,
            },
            scoring::PerFunctionCrap {
                line: outer.line,
                col: outer.col,
                crap: 2.0,
                coverage_pct: None,
                coverage_tier: fallow_output::CoverageTier::None,
                coverage_source: fallow_output::CoverageSource::Estimated,
            },
        ],
    );

    let resolver = threshold_resolver(&[]);
    let mut tracker = ThresholdOverrideStateTracker::default();
    let mut input = CrapFindingMergeInput {
        modules: &modules,
        file_paths: &file_paths,
        config_root: Path::new("/project"),
        ignore_set: &globset::GlobSet::empty(),
        changed_files: None,
        ws_roots: None,
        per_function_crap: &per_function_crap,
        template_inherit_provenance: &FxHashMap::default(),
        complexity_breakdown: false,
        threshold_resolver: &resolver,
        threshold_state_tracker: &mut tracker,
    };
    merge_crap_findings(&mut findings, &mut input);

    assert_eq!(
        findings.len(),
        1,
        "expected one CRAP finding for inner arrow"
    );
    let f = &findings[0];
    assert_eq!(f.name, "<arrow>", "name must come from inner arrow");
    assert_eq!(f.line, 1);
    assert_eq!(f.col, 43, "col must disambiguate same-line arrows");
    assert_eq!(f.cyclomatic, 7, "cyclomatic must come from inner arrow");
    assert_eq!(f.cognitive, 0);
    assert_eq!(
        f.crap,
        Some(56.0),
        "CRAP must match the function it's reported against"
    );
    let cc = f64::from(f.cyclomatic);
    #[expect(
        clippy::suboptimal_flops,
        reason = "cc * cc + cc matches the CRAP formula specification"
    )]
    let expected_crap = cc * cc + cc;
    assert!(
        (f.crap.unwrap() - expected_crap).abs() < 0.01,
        "CRAP must be consistent with reported CC: cc={cc}, crap={:?}, expected={expected_crap}",
        f.crap,
    );
}

#[test]
fn merge_crap_findings_picks_outer_when_outer_exceeds() {
    let path = PathBuf::from("/project/src/curried_outer.ts");
    let outer = FunctionComplexity {
        name: "complex".to_string(),
        line: 5,
        col: 10,
        cyclomatic: 8,
        cognitive: 0,
        line_count: 20,
        param_count: 1,
        react_hook_count: 0,
        react_jsx_max_depth: 0,
        react_prop_count: 0,
        source_hash: None,
        contributions: Vec::new(),
    };
    let inner = FunctionComplexity {
        name: "<arrow>".to_string(),
        line: 5,
        col: 30,
        cyclomatic: 1,
        cognitive: 0,
        line_count: 1,
        param_count: 1,
        react_hook_count: 0,
        react_jsx_max_depth: 0,
        react_prop_count: 0,
        source_hash: None,
        contributions: Vec::new(),
    };
    let modules = vec![make_module(FileId(0), vec![inner.clone(), outer.clone()])];
    let mut file_paths: FxHashMap<FileId, &PathBuf> = FxHashMap::default();
    file_paths.insert(FileId(0), &path);

    let mut findings: Vec<ComplexityViolation> = Vec::new();
    let mut per_function_crap: FxHashMap<PathBuf, Vec<scoring::PerFunctionCrap>> =
        FxHashMap::default();
    per_function_crap.insert(
        path.clone(),
        vec![
            scoring::PerFunctionCrap {
                line: inner.line,
                col: inner.col,
                crap: 2.0,
                coverage_pct: None,
                coverage_tier: fallow_output::CoverageTier::None,
                coverage_source: fallow_output::CoverageSource::Estimated,
            },
            scoring::PerFunctionCrap {
                line: outer.line,
                col: outer.col,
                crap: 72.0,
                coverage_pct: None,
                coverage_tier: fallow_output::CoverageTier::None,
                coverage_source: fallow_output::CoverageSource::Estimated,
            },
        ],
    );

    let resolver = threshold_resolver(&[]);
    let mut tracker = ThresholdOverrideStateTracker::default();
    let mut input = CrapFindingMergeInput {
        modules: &modules,
        file_paths: &file_paths,
        config_root: Path::new("/project"),
        ignore_set: &globset::GlobSet::empty(),
        changed_files: None,
        ws_roots: None,
        per_function_crap: &per_function_crap,
        template_inherit_provenance: &FxHashMap::default(),
        complexity_breakdown: false,
        threshold_resolver: &resolver,
        threshold_state_tracker: &mut tracker,
    };
    merge_crap_findings(&mut findings, &mut input);

    assert_eq!(findings.len(), 1);
    let f = &findings[0];
    assert_eq!(f.name, "complex");
    assert_eq!(f.col, 10);
    assert_eq!(f.cyclomatic, 8);
    assert_eq!(f.crap, Some(72.0));
}

fn fx_summary(
    tracked: usize,
    hit: usize,
    unhit: usize,
    untracked: usize,
) -> fallow_output::RuntimeCoverageSummary {
    #[expect(
        clippy::cast_precision_loss,
        reason = "test fixture totals are tiny, f64 precision is fine"
    )]
    let coverage_percent = if tracked == 0 {
        0.0
    } else {
        (hit as f64 / tracked as f64) * 100.0
    };
    fallow_output::RuntimeCoverageSummary {
        data_source: fallow_output::RuntimeCoverageDataSource::Local,
        last_received_at: None,
        functions_tracked: tracked,
        functions_hit: hit,
        functions_unhit: unhit,
        functions_untracked: untracked,
        coverage_percent,
        trace_count: 512,
        period_days: 7,
        deployments_seen: 2,
        capture_quality: None,
    }
}

fn fx_evidence(
    static_status: &str,
    test_coverage: &str,
    v8_tracking: &str,
) -> fallow_output::RuntimeCoverageEvidence {
    fallow_output::RuntimeCoverageEvidence {
        static_status: static_status.to_owned(),
        test_coverage: test_coverage.to_owned(),
        v8_tracking: v8_tracking.to_owned(),
        untracked_reason: None,
        observation_days: 7,
        deployments_observed: 2,
    }
}

#[test]
#[expect(
    clippy::too_many_lines,
    reason = "test fixture; linear setup/assert, length is not a maintainability concern"
)]
fn runtime_coverage_top_applies_after_baseline_filtering() {
    let root = Path::new("/project");
    let baseline = HealthBaselineData {
        findings: vec![],
        finding_counts: std::collections::BTreeMap::new(),
        runtime_coverage_findings: vec![
            "fallow:prod:aaaaaaaa".to_owned(),
            "fallow:prod:bbbbbbbb".to_owned(),
        ],
        runtime_coverage_source_hashes: vec![],
        target_keys: vec![],
    };
    let mut report = fallow_output::RuntimeCoverageReport {
        schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
        verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
        signals: Vec::new(),
        summary: fx_summary(3, 0, 2, 1),
        findings: vec![
            fallow_output::RuntimeCoverageFinding {
                id: "fallow:prod:aaaaaaaa".to_owned(),
                stable_id: None,
                path: PathBuf::from("/project/src/a.ts"),
                function: "alpha".to_owned(),
                line: 10,
                verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
                invocations: Some(0),
                confidence: fallow_output::RuntimeCoverageConfidence::Medium,
                evidence: fx_evidence("used", "not_covered", "tracked"),
                actions: vec![],
                source_hash: None,
                discriminators: None,
            },
            fallow_output::RuntimeCoverageFinding {
                id: "fallow:prod:bbbbbbbb".to_owned(),
                stable_id: None,
                path: PathBuf::from("/project/src/b.ts"),
                function: "beta".to_owned(),
                line: 20,
                verdict: fallow_output::RuntimeCoverageVerdict::CoverageUnavailable,
                invocations: None,
                confidence: fallow_output::RuntimeCoverageConfidence::None,
                evidence: fx_evidence("used", "not_covered", "untracked"),
                actions: vec![],
                source_hash: None,
                discriminators: None,
            },
            fallow_output::RuntimeCoverageFinding {
                id: "fallow:prod:cccccccc".to_owned(),
                stable_id: None,
                path: PathBuf::from("/project/src/c.ts"),
                function: "gamma".to_owned(),
                line: 30,
                verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
                invocations: Some(0),
                confidence: fallow_output::RuntimeCoverageConfidence::Medium,
                evidence: fx_evidence("used", "not_covered", "tracked"),
                actions: vec![],
                source_hash: None,
                discriminators: None,
            },
        ],
        hot_paths: vec![
            fallow_output::RuntimeCoverageHotPath {
                id: "fallow:hot:11111111".to_owned(),
                stable_id: None,
                path: PathBuf::from("/project/src/hot-a.ts"),
                function: "hotAlpha".to_owned(),
                line: 1,
                end_line: 5,
                invocations: 500,
                percentile: 99,
                actions: vec![],
            },
            fallow_output::RuntimeCoverageHotPath {
                id: "fallow:hot:22222222".to_owned(),
                stable_id: None,
                path: PathBuf::from("/project/src/hot-b.ts"),
                function: "hotBeta".to_owned(),
                line: 2,
                end_line: 8,
                invocations: 250,
                percentile: 50,
                actions: vec![],
            },
        ],
        blast_radius: vec![],
        importance: vec![],
        watermark: None,
        warnings: vec![],
        actionable: true,
        actionability_reason: None,
        actionability_verdict: None,
        provenance: fallow_output::RuntimeCoverageProvenance::default(),
    };

    apply_runtime_coverage_filters(
        &mut report,
        &RuntimeCoverageFilterContext::new(root)
            .with_baseline(Some(&baseline))
            .with_top(Some(1)),
    );

    assert_eq!(report.findings.len(), 1);
    assert_eq!(report.findings[0].function, "gamma");
    assert_eq!(
        report.verdict,
        fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected
    );
    assert_eq!(report.summary.functions_tracked, 3);
    assert_eq!(report.summary.functions_hit, 0);
    assert_eq!(report.summary.functions_unhit, 2);
    assert_eq!(report.summary.functions_untracked, 1);
    assert!((report.summary.coverage_percent - 0.0).abs() < 0.05);
    assert_eq!(report.hot_paths.len(), 1);
    assert_eq!(report.hot_paths[0].function, "hotAlpha");
}

#[test]
fn runtime_coverage_baseline_refreshes_to_clean_when_only_baselined_findings_remain() {
    let root = Path::new("/project");
    let baseline = HealthBaselineData {
        findings: vec![],
        finding_counts: std::collections::BTreeMap::new(),
        runtime_coverage_findings: vec!["fallow:prod:aaaaaaaa".to_owned()],
        runtime_coverage_source_hashes: vec![],
        target_keys: vec![],
    };
    let mut report = fallow_output::RuntimeCoverageReport {
        schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
        verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
        signals: Vec::new(),
        summary: fx_summary(2, 1, 1, 0),
        findings: vec![fallow_output::RuntimeCoverageFinding {
            id: "fallow:prod:aaaaaaaa".to_owned(),
            stable_id: None,
            path: PathBuf::from("/project/src/a.ts"),
            function: "alpha".to_owned(),
            line: 10,
            verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
            invocations: Some(0),
            confidence: fallow_output::RuntimeCoverageConfidence::Medium,
            evidence: fx_evidence("used", "not_covered", "tracked"),
            actions: vec![],
            source_hash: None,
            discriminators: None,
        }],
        hot_paths: vec![],
        blast_radius: vec![],
        importance: vec![],
        watermark: None,
        warnings: vec![],
        actionable: true,
        actionability_reason: None,
        actionability_verdict: None,
        provenance: fallow_output::RuntimeCoverageProvenance::default(),
    };

    apply_runtime_coverage_filters(
        &mut report,
        &RuntimeCoverageFilterContext::new(root).with_baseline(Some(&baseline)),
    );

    assert!(report.findings.is_empty());
    assert_eq!(
        report.verdict,
        fallow_output::RuntimeCoverageReportVerdict::Clean
    );
    assert_eq!(report.summary.functions_tracked, 2);
    assert_eq!(report.summary.functions_hit, 1);
    assert_eq!(report.summary.functions_unhit, 1);
    assert_eq!(report.summary.functions_untracked, 0);
    assert!((report.summary.coverage_percent - 50.0).abs() < 0.05);
}

#[test]
fn runtime_coverage_changed_review_uses_hot_path_verdict() {
    let root = Path::new("/project");
    let mut changed_files = FxHashSet::default();
    changed_files.insert(PathBuf::from("/project/src/hot.ts"));
    let mut report = fallow_output::RuntimeCoverageReport {
        schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
        verdict: fallow_output::RuntimeCoverageReportVerdict::Clean,
        signals: Vec::new(),
        summary: fx_summary(2, 2, 0, 0),
        findings: vec![],
        hot_paths: vec![fallow_output::RuntimeCoverageHotPath {
            id: "fallow:hot:33333333".to_owned(),
            stable_id: None,
            path: PathBuf::from("/project/src/hot.ts"),
            function: "renderHotPath".to_owned(),
            line: 7,
            end_line: 24,
            invocations: 9_500,
            percentile: 99,
            actions: vec![],
        }],
        blast_radius: vec![],
        importance: vec![],
        watermark: None,
        warnings: vec![],
        actionable: true,
        actionability_reason: None,
        actionability_verdict: None,
        provenance: fallow_output::RuntimeCoverageProvenance::default(),
    };

    apply_runtime_coverage_filters(
        &mut report,
        &RuntimeCoverageFilterContext::new(root).with_changed_files(Some(&changed_files)),
    );

    assert_eq!(
        report.verdict,
        fallow_output::RuntimeCoverageReportVerdict::HotPathTouched
    );
}

#[test]
fn runtime_coverage_changed_review_ignores_unmodified_hot_paths() {
    let root = Path::new("/project");
    let mut changed_files = FxHashSet::default();
    changed_files.insert(PathBuf::from("/project/src/other.ts"));
    let mut report = fallow_output::RuntimeCoverageReport {
        schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
        verdict: fallow_output::RuntimeCoverageReportVerdict::Clean,
        signals: Vec::new(),
        summary: fx_summary(2, 2, 0, 0),
        findings: vec![],
        hot_paths: vec![fallow_output::RuntimeCoverageHotPath {
            id: "fallow:hot:44444444".to_owned(),
            stable_id: None,
            path: PathBuf::from("/project/src/hot.ts"),
            function: "renderHotPath".to_owned(),
            line: 7,
            end_line: 24,
            invocations: 9_500,
            percentile: 90,
            actions: vec![],
        }],
        blast_radius: vec![],
        importance: vec![],
        watermark: None,
        warnings: vec![],
        actionable: true,
        actionability_reason: None,
        actionability_verdict: None,
        provenance: fallow_output::RuntimeCoverageProvenance::default(),
    };

    apply_runtime_coverage_filters(
        &mut report,
        &RuntimeCoverageFilterContext::new(root).with_changed_files(Some(&changed_files)),
    );

    assert!(report.hot_paths.is_empty());
    assert_eq!(
        report.verdict,
        fallow_output::RuntimeCoverageReportVerdict::Clean
    );
}

fn fx_runtime_coverage_report_with_hot_paths(
    hot_paths: Vec<fallow_output::RuntimeCoverageHotPath>,
) -> fallow_output::RuntimeCoverageReport {
    fallow_output::RuntimeCoverageReport {
        schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
        verdict: fallow_output::RuntimeCoverageReportVerdict::Clean,
        signals: Vec::new(),
        summary: fx_summary(2, 2, 0, 0),
        findings: vec![],
        hot_paths,
        blast_radius: vec![],
        importance: vec![],
        watermark: None,
        warnings: vec![],
        actionable: true,
        actionability_reason: None,
        actionability_verdict: None,
        provenance: fallow_output::RuntimeCoverageProvenance::default(),
    }
}

fn fx_hot_path(
    id: &str,
    path: &str,
    line: u32,
    end_line: u32,
) -> fallow_output::RuntimeCoverageHotPath {
    fallow_output::RuntimeCoverageHotPath {
        id: id.to_owned(),
        stable_id: None,
        path: PathBuf::from(path),
        function: "renderHotPath".to_owned(),
        line,
        end_line,
        invocations: 9_500,
        percentile: 99,
        actions: vec![],
    }
}

#[test]
fn runtime_coverage_diff_index_keeps_hot_paths_with_added_line_in_range() {
    let root = Path::new("/project");
    let diff = "diff --git a/src/hot.ts b/src/hot.ts\n\
                    --- a/src/hot.ts\n\
                    +++ b/src/hot.ts\n\
                    @@ -10,1 +10,2 @@\n\
                    +  // touch the body\n\
                    line 11\n";
    let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
    let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
        "fallow:hot:01010101",
        "src/hot.ts",
        7,
        24,
    )]);

    apply_runtime_coverage_filters(
        &mut report,
        &RuntimeCoverageFilterContext::new(root).with_diff_index(Some(&diff_index)),
    );

    assert_eq!(report.hot_paths.len(), 1);
    assert_eq!(
        report.verdict,
        fallow_output::RuntimeCoverageReportVerdict::HotPathTouched
    );
}

#[test]
fn runtime_coverage_diff_index_drops_hot_paths_when_added_line_outside_range() {
    let root = Path::new("/project");
    let diff = "diff --git a/src/hot.ts b/src/hot.ts\n\
                    --- a/src/hot.ts\n\
                    +++ b/src/hot.ts\n\
                    @@ -50,1 +50,2 @@\n\
                    +  // unrelated change far below the hot function\n\
                    line 51\n";
    let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
    let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
        "fallow:hot:02020202",
        "src/hot.ts",
        7,
        24,
    )]);

    apply_runtime_coverage_filters(
        &mut report,
        &RuntimeCoverageFilterContext::new(root).with_diff_index(Some(&diff_index)),
    );

    assert!(report.hot_paths.is_empty());
    assert_eq!(
        report.verdict,
        fallow_output::RuntimeCoverageReportVerdict::Clean
    );
}

#[test]
fn runtime_coverage_diff_index_falls_back_to_single_line_when_end_line_zero() {
    let root = Path::new("/project");
    let diff = "diff --git a/src/hot.ts b/src/hot.ts\n\
                    --- a/src/hot.ts\n\
                    +++ b/src/hot.ts\n\
                    @@ -7,1 +7,2 @@\n\
                    +  // exactly the function's start line\n\
                    line 8\n";
    let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
    let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
        "fallow:hot:03030303",
        "src/hot.ts",
        7,
        0,
    )]);

    apply_runtime_coverage_filters(
        &mut report,
        &RuntimeCoverageFilterContext::new(root).with_diff_index(Some(&diff_index)),
    );

    assert_eq!(report.hot_paths.len(), 1);
    assert_eq!(
        report.verdict,
        fallow_output::RuntimeCoverageReportVerdict::HotPathTouched
    );
}

#[test]
fn runtime_coverage_diff_index_resolves_absolute_hot_path_against_root() {
    let root = Path::new("/project");
    let diff = "diff --git a/src/hot.ts b/src/hot.ts\n\
                    --- a/src/hot.ts\n\
                    +++ b/src/hot.ts\n\
                    @@ -10,1 +10,2 @@\n\
                    +  // touched\n\
                    line 11\n";
    let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
    let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
        "fallow:hot:04040404",
        "/project/src/hot.ts",
        7,
        24,
    )]);

    apply_runtime_coverage_filters(
        &mut report,
        &RuntimeCoverageFilterContext::new(root).with_diff_index(Some(&diff_index)),
    );

    assert_eq!(report.hot_paths.len(), 1);
}

#[test]
fn runtime_coverage_diff_index_authoritative_for_files_in_diff() {
    let root = Path::new("/project");
    let diff = "diff --git a/src/hot.ts b/src/hot.ts\n\
                    --- a/src/hot.ts\n\
                    +++ b/src/hot.ts\n\
                    @@ -50,1 +50,2 @@\n\
                    +  // outside the hot function\n\
                    line 51\n";
    let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
    let mut changed_files = FxHashSet::default();
    changed_files.insert(PathBuf::from("/project/src/hot.ts"));
    let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
        "fallow:hot:05050505",
        "src/hot.ts",
        7,
        24,
    )]);

    apply_runtime_coverage_filters(
        &mut report,
        &RuntimeCoverageFilterContext::new(root)
            .with_changed_files(Some(&changed_files))
            .with_diff_index(Some(&diff_index)),
    );

    assert!(report.hot_paths.is_empty());
    assert_eq!(
        report.verdict,
        fallow_output::RuntimeCoverageReportVerdict::Clean
    );
}

#[test]
fn runtime_coverage_per_file_fallback_to_changed_files_when_diff_omits_file() {
    let root = Path::new("/project");
    let diff = "diff --git a/src/other.ts b/src/other.ts\n\
                    --- a/src/other.ts\n\
                    +++ b/src/other.ts\n\
                    @@ -1,1 +1,2 @@\n\
                    +  // unrelated\n\
                    line 2\n";
    let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
    let mut changed_files = FxHashSet::default();
    changed_files.insert(PathBuf::from("/project/src/hot.ts"));
    let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
        "fallow:hot:0a0a0a0a",
        "src/hot.ts",
        7,
        24,
    )]);

    apply_runtime_coverage_filters(
        &mut report,
        &RuntimeCoverageFilterContext::new(root)
            .with_changed_files(Some(&changed_files))
            .with_diff_index(Some(&diff_index)),
    );

    assert_eq!(report.hot_paths.len(), 1);
    assert_eq!(
        report.verdict,
        fallow_output::RuntimeCoverageReportVerdict::HotPathTouched
    );
}

#[test]
fn runtime_coverage_pr_context_promotes_hot_path_touched_above_cold_code() {
    let root = Path::new("/project");
    let mut changed_files = FxHashSet::default();
    changed_files.insert(PathBuf::from("/project/src/hot.ts"));
    let mut report = fallow_output::RuntimeCoverageReport {
        schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
        verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
        signals: Vec::new(),
        summary: fx_summary(2, 1, 1, 0),
        findings: vec![fallow_output::RuntimeCoverageFinding {
            id: "fallow:prod:cold0001".to_owned(),
            stable_id: None,
            path: PathBuf::from("/project/src/cold.ts"),
            function: "coldFn".to_owned(),
            line: 4,
            verdict: fallow_output::RuntimeCoverageVerdict::SafeToDelete,
            invocations: Some(0),
            confidence: fallow_output::RuntimeCoverageConfidence::High,
            evidence: fx_evidence("unused", "not_covered", "tracked"),
            actions: vec![],
            source_hash: None,
            discriminators: None,
        }],
        hot_paths: vec![fx_hot_path("fallow:hot:0b0b0b0b", "src/hot.ts", 7, 24)],
        blast_radius: vec![],
        importance: vec![],
        watermark: None,
        warnings: vec![],
        actionable: true,
        actionability_reason: None,
        actionability_verdict: None,
        provenance: fallow_output::RuntimeCoverageProvenance::default(),
    };

    apply_runtime_coverage_filters(
        &mut report,
        &RuntimeCoverageFilterContext::new(root).with_changed_files(Some(&changed_files)),
    );

    assert_eq!(
        report.verdict,
        fallow_output::RuntimeCoverageReportVerdict::HotPathTouched
    );
    assert_eq!(
        report.signals,
        vec![
            fallow_output::RuntimeCoverageSignal::ColdCodeDetected,
            fallow_output::RuntimeCoverageSignal::HotPathTouched,
        ]
    );
}

#[test]
fn runtime_coverage_standalone_keeps_cold_code_primary_above_unchanged_hot_paths() {
    let root = Path::new("/project");
    let mut report = fallow_output::RuntimeCoverageReport {
        schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
        verdict: fallow_output::RuntimeCoverageReportVerdict::Clean,
        signals: Vec::new(),
        summary: fx_summary(2, 1, 1, 0),
        findings: vec![fallow_output::RuntimeCoverageFinding {
            id: "fallow:prod:cold0002".to_owned(),
            stable_id: None,
            path: PathBuf::from("/project/src/cold.ts"),
            function: "coldFn".to_owned(),
            line: 4,
            verdict: fallow_output::RuntimeCoverageVerdict::SafeToDelete,
            invocations: Some(0),
            confidence: fallow_output::RuntimeCoverageConfidence::High,
            evidence: fx_evidence("unused", "not_covered", "tracked"),
            actions: vec![],
            source_hash: None,
            discriminators: None,
        }],
        hot_paths: vec![fx_hot_path("fallow:hot:0c0c0c0c", "src/hot.ts", 7, 24)],
        blast_radius: vec![],
        importance: vec![],
        watermark: None,
        warnings: vec![],
        actionable: true,
        actionability_reason: None,
        actionability_verdict: None,
        provenance: fallow_output::RuntimeCoverageProvenance::default(),
    };

    apply_runtime_coverage_filters(&mut report, &RuntimeCoverageFilterContext::new(root));

    assert_eq!(
        report.verdict,
        fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected
    );
    assert_eq!(
        report.signals,
        vec![fallow_output::RuntimeCoverageSignal::ColdCodeDetected]
    );
    assert_eq!(report.hot_paths.len(), 1);
}

#[test]
fn runtime_coverage_license_grace_outranks_pr_context_signals() {
    let root = Path::new("/project");
    let mut changed_files = FxHashSet::default();
    changed_files.insert(PathBuf::from("/project/src/hot.ts"));
    let mut report = fallow_output::RuntimeCoverageReport {
        schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
        verdict: fallow_output::RuntimeCoverageReportVerdict::LicenseExpiredGrace,
        signals: Vec::new(),
        summary: fx_summary(2, 1, 1, 0),
        findings: vec![],
        hot_paths: vec![fx_hot_path("fallow:hot:0d0d0d0d", "src/hot.ts", 7, 24)],
        blast_radius: vec![],
        importance: vec![],
        watermark: Some(fallow_output::RuntimeCoverageWatermark::LicenseExpiredGrace),
        warnings: vec![],
        actionable: true,
        actionability_reason: None,
        actionability_verdict: None,
        provenance: fallow_output::RuntimeCoverageProvenance::default(),
    };

    apply_runtime_coverage_filters(
        &mut report,
        &RuntimeCoverageFilterContext::new(root).with_changed_files(Some(&changed_files)),
    );

    assert_eq!(
        report.verdict,
        fallow_output::RuntimeCoverageReportVerdict::LicenseExpiredGrace
    );
    assert!(
        report
            .signals
            .contains(&fallow_output::RuntimeCoverageSignal::LicenseExpiredGrace)
    );
    assert!(
        report
            .signals
            .contains(&fallow_output::RuntimeCoverageSignal::HotPathTouched)
    );
}

#[test]
fn retain_hot_paths_drops_when_diff_touches_file_but_no_added_lines() {
    let root = Path::new("/project");
    let diff = fallow_output::DiffIndex::from_unified_diff(
        "diff --git a/src/hot.ts b/src/hot.ts\n\
             --- a/src/hot.ts\n\
             +++ b/src/hot.ts\n\
             @@ -10,3 +10,1 @@\n\
             -one\n\
             -two\n\
             -three\n\
             ctx\n",
    );
    let mut changed_files = FxHashSet::default();
    changed_files.insert(PathBuf::from("/project/src/hot.ts"));
    let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
        "fallow:hot:deletiononly",
        "src/hot.ts",
        10,
        12,
    )]);

    apply_runtime_coverage_filters(
        &mut report,
        &RuntimeCoverageFilterContext::new(root)
            .with_diff_index(Some(&diff))
            .with_changed_files(Some(&changed_files)),
    );

    assert!(
        report.hot_paths.is_empty(),
        "diff touched the file with no added lines: must drop, not fall through to changed_files"
    );
}

#[test]
fn runtime_coverage_changed_files_matches_relative_hot_path_against_absolute_set() {
    let root = Path::new("/project");
    let mut changed_files = FxHashSet::default();
    changed_files.insert(PathBuf::from("/project/src/hot.ts"));
    let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
        "fallow:hot:06060606",
        "src/hot.ts",
        7,
        24,
    )]);

    apply_runtime_coverage_filters(
        &mut report,
        &RuntimeCoverageFilterContext::new(root).with_changed_files(Some(&changed_files)),
    );

    assert_eq!(report.hot_paths.len(), 1);
}

fn make_class_finding(
    path: &str,
    name: &str,
    line: u32,
    cyclomatic: u16,
    cognitive: u16,
) -> ComplexityViolation {
    ComplexityViolation {
        path: PathBuf::from(path),
        name: name.to_string(),
        line,
        col: 0,
        cyclomatic,
        cognitive,
        line_count: 20,
        param_count: 0,
        react_hook_count: 0,
        react_jsx_max_depth: 0,
        react_prop_count: 0,
        react_hook_profile: None,
        exceeded: ExceededThreshold::Both,
        severity: FindingSeverity::Moderate,
        crap: None,
        coverage_pct: None,
        coverage_tier: None,
        coverage_source: None,
        inherited_from: None,
        component_rollup: None,
        contributions: Vec::new(),
        effective_thresholds: None,
        threshold_source: None,
    }
}

fn make_template_finding(
    path: &str,
    line: u32,
    cyclomatic: u16,
    cognitive: u16,
) -> ComplexityViolation {
    ComplexityViolation {
        path: PathBuf::from(path),
        name: "<template>".to_string(),
        line,
        col: 0,
        cyclomatic,
        cognitive,
        line_count: 30,
        param_count: 0,
        react_hook_count: 0,
        react_jsx_max_depth: 0,
        react_prop_count: 0,
        react_hook_profile: None,
        exceeded: ExceededThreshold::Both,
        severity: FindingSeverity::Moderate,
        crap: None,
        coverage_pct: None,
        coverage_tier: None,
        coverage_source: None,
        inherited_from: None,
        component_rollup: None,
        contributions: Vec::new(),
        effective_thresholds: None,
        threshold_source: None,
    }
}

#[test]
fn rollup_external_template_via_provenance_lookup() {
    let component_ts = PathBuf::from("/proj/src/host-game.component.ts");
    let template_html = PathBuf::from("/proj/src/host-game.component.html");
    let mut findings = vec![
        make_class_finding(component_ts.to_str().unwrap(), "handleClick", 42, 3, 4),
        make_template_finding(template_html.to_str().unwrap(), 1, 6, 10),
    ];
    let mut lookup = rustc_hash::FxHashMap::default();
    lookup.insert(template_html.clone(), component_ts.clone());
    append_component_rollup_findings(&mut findings, Some(&lookup), 8, 8);

    assert_eq!(findings.len(), 3, "rollup is strictly additive");
    let rollup = findings
        .iter()
        .find(|f| f.name == "<component>")
        .expect("rollup must be present");
    assert_eq!(rollup.path, component_ts);
    assert_eq!(rollup.cyclomatic, 9, "9 = worst class 3 + template 6");
    assert_eq!(rollup.cognitive, 14, "14 = worst class 4 + template 10");
    assert_eq!(rollup.line, 42, "anchored at worst class function line");
    let breakdown = rollup.component_rollup.as_ref().expect("breakdown present");
    assert_eq!(
        breakdown.component, "host-game.component",
        "component identifier is the .ts owner's file stem"
    );
    assert_eq!(breakdown.class_worst_function, "handleClick");
    assert_eq!(breakdown.class_cyclomatic, 3);
    assert_eq!(breakdown.template_cyclomatic, 6);
    assert_eq!(breakdown.template_path, template_html);
}

#[test]
fn rollup_inline_template_owner_is_same_ts_file() {
    let component_ts = PathBuf::from("/proj/src/inline.component.ts");
    let mut findings = vec![
        make_class_finding(component_ts.to_str().unwrap(), "ngOnInit", 25, 5, 8),
        make_template_finding(component_ts.to_str().unwrap(), 10, 4, 6),
    ];
    append_component_rollup_findings(&mut findings, None, 8, 8);

    let rollup = findings
        .iter()
        .find(|f| f.name == "<component>")
        .expect("rollup must be present for inline-template case without provenance lookup");
    assert_eq!(rollup.cyclomatic, 9);
    assert_eq!(rollup.cognitive, 14);
    let breakdown = rollup.component_rollup.as_ref().unwrap();
    assert_eq!(breakdown.template_path, component_ts);
    assert_eq!(breakdown.component, "inline.component");
}

#[test]
fn rollup_picks_worst_class_function_by_cyclomatic() {
    let component_ts = PathBuf::from("/proj/src/multi.component.ts");
    let template = PathBuf::from("/proj/src/multi.component.html");
    let mut findings = vec![
        make_class_finding(component_ts.to_str().unwrap(), "first", 10, 3, 4),
        make_class_finding(component_ts.to_str().unwrap(), "worst", 20, 8, 9),
        make_class_finding(component_ts.to_str().unwrap(), "middle", 30, 5, 6),
        make_template_finding(template.to_str().unwrap(), 1, 4, 6),
    ];
    let mut lookup = rustc_hash::FxHashMap::default();
    lookup.insert(template, component_ts);
    append_component_rollup_findings(&mut findings, Some(&lookup), 8, 8);

    let rollup = findings.iter().find(|f| f.name == "<component>").unwrap();
    assert_eq!(rollup.cyclomatic, 12, "8 (worst.cyc) + 4 (template.cyc)");
    let breakdown = rollup.component_rollup.as_ref().unwrap();
    assert_eq!(breakdown.class_worst_function, "worst");
    assert_eq!(breakdown.class_cyclomatic, 8);
}

#[test]
fn rollup_skipped_when_no_template_finding() {
    let component_ts = "/proj/src/only-class.component.ts";
    let mut findings = vec![make_class_finding(component_ts, "Foo.method", 10, 5, 7)];
    let before = findings.len();
    append_component_rollup_findings(&mut findings, None, 30, 25);
    assert_eq!(findings.len(), before, "no template means no rollup");
}

#[test]
fn rollup_skipped_when_no_class_findings() {
    let template_html = PathBuf::from("/proj/src/orphan.component.html");
    let component_ts = PathBuf::from("/proj/src/orphan.component.ts");
    let mut findings = vec![make_template_finding(
        template_html.to_str().unwrap(),
        1,
        6,
        10,
    )];
    let mut lookup = rustc_hash::FxHashMap::default();
    lookup.insert(template_html, component_ts);
    let before = findings.len();
    append_component_rollup_findings(&mut findings, Some(&lookup), 8, 8);
    assert_eq!(
        findings.len(),
        before,
        "no class methods above threshold means no rollup"
    );
}

#[test]
fn rollup_skipped_when_multiple_templates_on_one_owner() {
    let component_ts = PathBuf::from("/proj/src/twin.component.ts");
    let mut findings = vec![
        make_class_finding(component_ts.to_str().unwrap(), "TwinA.fn", 10, 5, 7),
        make_template_finding(component_ts.to_str().unwrap(), 5, 3, 4),
        make_template_finding(component_ts.to_str().unwrap(), 50, 4, 5),
    ];
    let before = findings.len();
    append_component_rollup_findings(&mut findings, None, 30, 25);
    assert_eq!(
        findings.len(),
        before,
        "two templates on one owner is defensively skipped"
    );
}

#[test]
fn rollup_external_template_skipped_when_lookup_missing() {
    let template_html = PathBuf::from("/proj/src/no-owner.component.html");
    let component_ts = "/proj/src/no-owner.component.ts";
    let mut findings = vec![
        make_class_finding(component_ts, "NoOwner.fn", 10, 5, 7),
        make_template_finding(template_html.to_str().unwrap(), 1, 6, 10),
    ];
    let before = findings.len();
    append_component_rollup_findings(&mut findings, None, 30, 25);
    assert_eq!(findings.len(), before);
}
