use std::sync::LazyLock;

use rayon::prelude::*;
use rustc_hash::{FxHashMap, FxHashSet};

use fallow_config::{CompiledIgnoreExportRule, ResolvedConfig, Severity};
use fallow_types::extract::{ExportInfo, ExportName, ModuleInfo};

use crate::discover::FileId;
use crate::graph::{ExportSymbol, ModuleGraph, ModuleNode};
use crate::results::{
    DuplicateExport, DuplicateLocation, ExportUsage, PrivateTypeLeak, ReferenceLocation,
    StaleSuppression, SuppressionOrigin, UnusedExport,
};
use crate::suppress::{IssueKind, SuppressionContext};

use super::{LineOffsetsMap, byte_offset_to_line_col, read_source};

/// Pre-compiled glob matchers for plugin/framework used_exports rules.
type PluginMatchers<'a> = Vec<CompiledUsedExportRule<'a>>;

const TANSTACK_ROUTER_PLUGIN_NAME: &str = "tanstack-router";

type UnusedExportModuleResult = (Vec<UnusedExport>, Vec<UnusedExport>, Vec<StaleSuppression>);

struct UnusedExportModuleContext<'a> {
    graph: &'a ModuleGraph,
    config: &'a ResolvedConfig,
    ignore_matchers: &'a [CompiledIgnoreExportRule],
    plugin_matchers: &'a PluginMatchers<'a>,
    module_info_by_id: Option<&'a FxHashMap<FileId, &'a ModuleInfo>>,
    suppressions: &'a SuppressionContext<'a>,
    line_offsets_by_file: &'a LineOffsetsMap<'a>,
}

/// Compile plugin-discovered used_exports rules (includes framework preset rules).
fn compile_plugin_matchers(
    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
) -> PluginMatchers<'_> {
    let Some(pr) = plugin_result else {
        return Vec::new();
    };
    pr.used_exports
        .iter()
        .filter_map(compile_used_export_rule)
        .collect()
}

/// Compile TanStack route contract export rules for duplicate-export filtering.
fn compile_tanstack_duplicate_export_matchers(
    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
) -> PluginMatchers<'_> {
    let Some(pr) = plugin_result else {
        return Vec::new();
    };
    pr.used_exports
        .iter()
        .filter(|rule| rule.plugin_name == TANSTACK_ROUTER_PLUGIN_NAME)
        .filter_map(compile_used_export_rule)
        .collect()
}

fn is_tanstack_router_active(
    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
) -> bool {
    plugin_result.is_some_and(|pr| {
        pr.active_plugins
            .iter()
            .any(|plugin| plugin == TANSTACK_ROUTER_PLUGIN_NAME)
    })
}

struct CompiledUsedExportRule<'a> {
    path: crate::plugins::CompiledPathRule,
    exports: Vec<&'a str>,
}

impl CompiledUsedExportRule<'_> {
    fn matches(&self, path: &str) -> bool {
        self.path.matches(path)
    }
}

fn compile_used_export_rule(
    rule: &crate::plugins::PluginUsedExportRule,
) -> Option<CompiledUsedExportRule<'_>> {
    Some(CompiledUsedExportRule {
        path: crate::plugins::CompiledPathRule::for_used_export_rule(
            &rule.rule.path,
            "used_exports pattern",
        )?,
        exports: rule.rule.exports.iter().map(String::as_str).collect(),
    })
}

/// Check whether a module should be skipped for unused-export analysis.
fn should_skip_module(
    module: &ModuleNode,
    has_plugin_used_exports: bool,
    include_entry_exports: bool,
) -> bool {
    if module.is_entry_point() && !has_plugin_used_exports && !include_entry_exports {
        return true;
    }
    if !module.is_reachable() {
        return module.exports.iter().all(|e| e.references.is_empty());
    }
    if module.has_cjs_exports() && module.exports.is_empty() {
        return true;
    }
    module.path.extension().is_some_and(|ext| ext == "svelte")
}

/// Pick up the subset of ignore + plugin matchers whose globs match this module's
/// project-relative path.
fn matchers_for_module<'a>(
    module_path: &std::path::Path,
    config_root: &std::path::Path,
    ignore_matchers: &'a [CompiledIgnoreExportRule],
    plugin_matchers: &'a PluginMatchers<'_>,
) -> (Vec<&'a [String]>, Vec<&'a [&'a str]>) {
    if ignore_matchers.is_empty() && plugin_matchers.is_empty() {
        return (Vec::new(), Vec::new());
    }
    let relative_path = module_path.strip_prefix(config_root).unwrap_or(module_path);
    let file_str = relative_path.to_string_lossy();
    let mi: Vec<&[String]> = ignore_matchers
        .iter()
        .filter(|rule| rule.matcher.is_match(file_str.as_ref()))
        .map(|rule| rule.exports.as_slice())
        .collect();
    let mp: Vec<&[&str]> = plugin_matchers
        .iter()
        .filter(|rule| rule.matches(file_str.as_ref()))
        .map(|rule| rule.exports.as_slice())
        .collect();
    (mi, mp)
}

/// Check whether an export name is covered by config ignore rules or plugin rules.
fn is_export_ignored(
    export_name: &str,
    matching_ignore: &[&[String]],
    matching_plugin: &[&[&str]],
) -> bool {
    matching_ignore
        .iter()
        .any(|exports| exports.iter().any(|e| e == "*" || e == export_name))
        || matching_plugin
            .iter()
            .any(|exports| exports.contains(&"*") || exports.contains(&export_name))
}

fn local_export_binding_name(export: &ExportInfo) -> Option<&str> {
    export.local_name.as_deref().or(match &export.name {
        ExportName::Named(name) => Some(name.as_str()),
        ExportName::Default => None,
    })
}

fn is_js_like_source(path: &std::path::Path) -> bool {
    path.extension()
        .and_then(|ext| ext.to_str())
        .is_some_and(|ext| {
            matches!(
                ext,
                "js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "mts" | "cts"
            )
        })
}

fn has_route_path_segment(module_path: &std::path::Path, config_root: &std::path::Path) -> bool {
    let relative_path = module_path.strip_prefix(config_root).unwrap_or(module_path);
    relative_path
        .to_string_lossy()
        .replace('\\', "/")
        .split('/')
        .any(|segment| segment == "routes")
}

fn is_route_tree_generated_file(path: &std::path::Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.starts_with("routeTree.gen."))
}

fn is_route_referenced_by_tanstack_generated_tree(
    export_name: &str,
    export: &ExportSymbol,
    module: &ModuleNode,
    graph: &ModuleGraph,
    config_root: &std::path::Path,
) -> bool {
    export_name == "Route"
        && has_route_path_segment(&module.path, config_root)
        && export.references.iter().any(|reference| {
            graph
                .modules
                .get(reference.from_file.0 as usize)
                .is_some_and(|from| is_route_tree_generated_file(&from.path))
        })
}

fn collect_exports_used_in_file(module: &ModuleInfo, path: &std::path::Path) -> FxHashSet<String> {
    if module.exports.is_empty() || !is_js_like_source(path) {
        return FxHashSet::default();
    }

    let source = read_source(path);
    if source.is_empty() {
        return FxHashSet::default();
    }

    let source_type = oxc_span::SourceType::from_path(path).unwrap_or_default();
    let allocator = oxc_allocator::Allocator::default();
    let parser_return = oxc_parser::Parser::new(&allocator, &source, source_type).parse();
    let semantic_ret = oxc_semantic::SemanticBuilder::new().build(&parser_return.program);
    let semantic = semantic_ret.semantic;
    let scoping = semantic.scoping();
    let nodes = semantic.nodes();
    let root_scope = scoping.root_scope_id();

    let mut used = FxHashSet::default();
    for export in &module.exports {
        let Some(local_name) = local_export_binding_name(export) else {
            continue;
        };
        let name = oxc_str::Ident::from(local_name);
        let Some(symbol_id) = scoping.get_binding(root_scope, name) else {
            continue;
        };
        let has_real_use = scoping
            .get_resolved_references(symbol_id)
            .any(|reference| !is_inside_export_specifier(nodes, reference.node_id()));
        if has_real_use {
            used.insert(export.name.to_string());
        }
    }

    used
}

/// Walk ancestors of `node_id` and return `true` if the reference originates
/// from inside an `export { foo }` / `export { foo as bar }` specifier or an
/// `export default foo` declaration. Those identifiers are the export site
/// itself, not a same-file *use*, so they must not satisfy
/// `ignoreExportsUsedInFile` (Knip parity).
fn is_inside_export_specifier(
    nodes: &oxc_semantic::AstNodes<'_>,
    node_id: oxc_syntax::node::NodeId,
) -> bool {
    nodes
        .ancestor_kinds(node_id)
        .any(|kind| matches!(kind, oxc_ast::AstKind::ExportSpecifier(_)))
        || matches!(
            nodes.parent_kind(node_id),
            oxc_ast::AstKind::ExportDefaultDeclaration(_)
        )
}

/// Pick up the subset of ignore matchers whose globs match this module's path.
fn ignore_matchers_for_module<'a>(
    module_path: &std::path::Path,
    config_root: &std::path::Path,
    ignore_matchers: &'a [CompiledIgnoreExportRule],
) -> Vec<&'a [String]> {
    if ignore_matchers.is_empty() {
        return Vec::new();
    }
    let relative_path = module_path.strip_prefix(config_root).unwrap_or(module_path);
    let file_str = relative_path.to_string_lossy();
    ignore_matchers
        .iter()
        .filter(|rule| rule.matcher.is_match(file_str.as_ref()))
        .map(|rule| rule.exports.as_slice())
        .collect()
}

fn export_has_reachable_reference(
    graph: &ModuleGraph,
    module: &ModuleNode,
    export: &ExportSymbol,
) -> bool {
    if module.is_reachable() {
        return !export.references.is_empty();
    }
    export.references.iter().any(|r| {
        graph.modules.get(r.from_file.0 as usize).is_some_and(|m| {
            debug_assert_eq!(
                m.file_id, r.from_file,
                "ModuleGraph::modules FileId-as-index invariant broken"
            );
            m.is_reachable()
        })
    })
}

fn stale_expected_unused_suppression(
    module: &ModuleNode,
    export: &ExportSymbol,
    line_offsets_by_file: &LineOffsetsMap<'_>,
) -> StaleSuppression {
    let (line, col) =
        byte_offset_to_line_col(line_offsets_by_file, module.file_id, export.span.start);
    StaleSuppression {
        path: module.path.clone(),
        line,
        col,
        origin: SuppressionOrigin::JsdocTag {
            export_name: export.name.to_string(),
            reason: export.expected_unused_reason.clone(),
        },
        missing_reason: false,
        actions: StaleSuppression::actions_for(false),
    }
}

/// Record stale-suppression entries for an `@expected-unused` export: a missing
/// reason (when required) and/or the export turning out to be referenced.
fn record_expected_unused_stale(
    module: &ModuleNode,
    export: &ExportSymbol,
    ctx: &UnusedExportModuleContext<'_>,
    is_referenced: bool,
    stale_expected_unused: &mut Vec<StaleSuppression>,
) {
    if ctx.config.rules.require_suppression_reason != Severity::Off
        && export.expected_unused_reason.is_none()
    {
        let (line, col) =
            byte_offset_to_line_col(ctx.line_offsets_by_file, module.file_id, export.span.start);
        stale_expected_unused.push(StaleSuppression {
            path: module.path.clone(),
            line,
            col,
            origin: SuppressionOrigin::JsdocTag {
                export_name: export.name.to_string(),
                reason: None,
            },
            missing_reason: true,
            actions: StaleSuppression::actions_for(true),
        });
    }
    if is_referenced {
        stale_expected_unused.push(stale_expected_unused_suppression(
            module,
            export,
            ctx.line_offsets_by_file,
        ));
    }
}

/// Find exports that are never imported by other files.
#[deprecated(
    since = "2.76.0",
    note = "fallow_core is internal; use fallow_api::run_dead_code for typed output; serialize with fallow_api::serialize_dead_code_programmatic_json for JSON output. See docs/fallow-core-migration.md."
)]
pub fn find_unused_exports(
    graph: &ModuleGraph,
    modules: &[ModuleInfo],
    config: &ResolvedConfig,
    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
    suppressions: &SuppressionContext<'_>,
    line_offsets_by_file: &LineOffsetsMap<'_>,
) -> (Vec<UnusedExport>, Vec<UnusedExport>, Vec<StaleSuppression>) {
    let mut unused_exports = Vec::new();
    let mut unused_types = Vec::new();
    let mut stale_expected_unused = Vec::new();

    let ignore_matchers = config.compiled_ignore_exports.as_slice();
    let plugin_matchers = compile_plugin_matchers(plugin_result);
    let module_info_by_id: Option<FxHashMap<FileId, &ModuleInfo>> =
        if config.ignore_exports_used_in_file.is_enabled() {
            Some(modules.iter().map(|m| (m.file_id, m)).collect())
        } else {
            None
        };

    let module_context = UnusedExportModuleContext {
        graph,
        config,
        ignore_matchers,
        plugin_matchers: &plugin_matchers,
        module_info_by_id: module_info_by_id.as_ref(),
        suppressions,
        line_offsets_by_file,
    };

    let module_results: Vec<UnusedExportModuleResult> = graph
        .modules
        .par_iter()
        .map(|module| find_unused_exports_for_module(module, &module_context))
        .collect();

    for (exports, types, stale_expected) in module_results {
        unused_exports.extend(exports);
        unused_types.extend(types);
        stale_expected_unused.extend(stale_expected);
    }

    (unused_exports, unused_types, stale_expected_unused)
}

fn find_unused_exports_for_module(
    module: &ModuleNode,
    ctx: &UnusedExportModuleContext<'_>,
) -> UnusedExportModuleResult {
    let mut stale_expected_unused = Vec::new();

    if module.exports.is_empty() && !module.has_cjs_exports() {
        return (Vec::new(), Vec::new(), stale_expected_unused);
    }

    let (matching_ignore, matching_plugin) = matchers_for_module(
        &module.path,
        &ctx.config.root,
        ctx.ignore_matchers,
        ctx.plugin_matchers,
    );
    if should_skip_module(
        module,
        !matching_plugin.is_empty(),
        ctx.config.include_entry_exports,
    ) {
        return (Vec::new(), Vec::new(), stale_expected_unused);
    }

    let same_file_used_exports = same_file_used_exports(module, ctx);
    let re_export_names: FxHashSet<&str> = module
        .re_exports
        .iter()
        .map(|re| re.exported_name.as_str())
        .collect();

    let mut match_ctx = UnusedExportMatchContext {
        same_file_used_exports: &same_file_used_exports,
        re_export_names: &re_export_names,
        matching_ignore: &matching_ignore,
        matching_plugin: &matching_plugin,
        stale_expected_unused: &mut stale_expected_unused,
    };
    let (unused_exports, unused_types) =
        collect_module_unused_export_findings(module, ctx, &mut match_ctx);

    (unused_exports, unused_types, stale_expected_unused)
}

fn collect_module_unused_export_findings(
    module: &ModuleNode,
    ctx: &UnusedExportModuleContext<'_>,
    match_ctx: &mut UnusedExportMatchContext<'_>,
) -> (Vec<UnusedExport>, Vec<UnusedExport>) {
    let mut unused_exports = Vec::new();
    let mut unused_types = Vec::new();

    for export in &module.exports {
        if let Some(unused) = unused_export_for_module(
            module,
            export,
            ctx,
            UnusedExportMatchContext {
                same_file_used_exports: match_ctx.same_file_used_exports,
                re_export_names: match_ctx.re_export_names,
                matching_ignore: match_ctx.matching_ignore,
                matching_plugin: match_ctx.matching_plugin,
                stale_expected_unused: &mut *match_ctx.stale_expected_unused,
            },
        ) {
            if export.is_type_only {
                unused_types.push(unused);
            } else {
                unused_exports.push(unused);
            }
        }
    }

    (unused_exports, unused_types)
}

fn same_file_used_exports(
    module: &ModuleNode,
    ctx: &UnusedExportModuleContext<'_>,
) -> FxHashSet<String> {
    ctx.module_info_by_id
        .map_or_else(FxHashSet::default, |by_id| {
            by_id
                .get(&module.file_id)
                .map_or_else(FxHashSet::default, |info| {
                    collect_exports_used_in_file(info, &module.path)
                })
        })
}

struct UnusedExportMatchContext<'a> {
    same_file_used_exports: &'a FxHashSet<String>,
    re_export_names: &'a FxHashSet<&'a str>,
    matching_ignore: &'a [&'a [String]],
    matching_plugin: &'a [&'a [&'a str]],
    stale_expected_unused: &'a mut Vec<StaleSuppression>,
}

fn unused_export_for_module(
    module: &ModuleNode,
    export: &ExportSymbol,
    ctx: &UnusedExportModuleContext<'_>,
    match_ctx: UnusedExportMatchContext<'_>,
) -> Option<UnusedExport> {
    let mut match_ctx = match_ctx;
    let export_str = export.name.to_string();

    if unused_export_candidate_is_skipped(module, export, ctx, &export_str, &mut match_ctx) {
        return None;
    }

    let (line, col) =
        byte_offset_to_line_col(ctx.line_offsets_by_file, module.file_id, export.span.start);
    let issue_kind = if export.is_type_only {
        IssueKind::UnusedType
    } else {
        IssueKind::UnusedExport
    };
    if unused_export_suppressed(ctx, module.file_id, line, issue_kind) {
        return None;
    }

    let is_re_export = match_ctx.re_export_names.contains(export_str.as_str());
    Some(build_unused_export(
        module,
        export,
        export_str,
        line,
        col,
        is_re_export,
    ))
}

fn unused_export_candidate_is_skipped(
    module: &ModuleNode,
    export: &ExportSymbol,
    ctx: &UnusedExportModuleContext<'_>,
    export_str: &str,
    match_ctx: &mut UnusedExportMatchContext<'_>,
) -> bool {
    let has_cross_file_ref = export_has_reachable_reference(ctx.graph, module, export);
    let is_referenced = has_cross_file_ref || (module.is_reachable() && export.is_side_effect_used);
    if matches!(
        export.visibility,
        fallow_types::extract::VisibilityTag::ExpectedUnused
    ) {
        record_expected_unused_stale(
            module,
            export,
            ctx,
            is_referenced,
            match_ctx.stale_expected_unused,
        );
        return true;
    }

    if export.visibility.suppresses_unused() || is_referenced {
        return true;
    }

    if ctx
        .config
        .ignore_exports_used_in_file
        .suppresses(export.is_type_only)
        && match_ctx.same_file_used_exports.contains(export_str)
    {
        return true;
    }

    is_export_ignored(
        export_str,
        match_ctx.matching_ignore,
        match_ctx.matching_plugin,
    )
}

fn unused_export_suppressed(
    ctx: &UnusedExportModuleContext<'_>,
    file_id: FileId,
    line: u32,
    issue_kind: IssueKind,
) -> bool {
    ctx.suppressions.is_suppressed(file_id, line, issue_kind)
}

fn build_unused_export(
    module: &ModuleNode,
    export: &ExportSymbol,
    export_name: String,
    line: u32,
    col: u32,
    is_re_export: bool,
) -> UnusedExport {
    UnusedExport {
        path: module.path.clone(),
        export_name,
        is_type_only: export.is_type_only,
        line,
        col,
        span_start: export.span.start,
        is_re_export,
    }
}

/// Remove exported type findings when the type is only exported to support
/// another public signature in the same module.
pub fn suppress_signature_backing_types(
    unused_types: &mut Vec<UnusedExport>,
    graph: &ModuleGraph,
    modules: &[fallow_types::extract::ModuleInfo],
) {
    let path_by_id: FxHashMap<FileId, &std::path::Path> = graph
        .modules
        .iter()
        .map(|module| (module.file_id, module.path.as_path()))
        .collect();
    let backing_types: FxHashSet<(std::path::PathBuf, String)> = modules
        .iter()
        .filter_map(|module| path_by_id.get(&module.file_id).map(|path| (module, *path)))
        .flat_map(|(module, path)| {
            module
                .public_signature_type_references
                .iter()
                .map(move |reference| (path.to_path_buf(), reference.type_name.clone()))
        })
        .collect();

    unused_types.retain(|unused| {
        !backing_types.contains(&(unused.path.clone(), unused.export_name.clone()))
    });
}

/// File-name suffixes that idiomatically declare local helper types
/// (`type Story = StoryObj<typeof Component>`) used by virtually every export.
/// Skipping these in private-type-leak detection keeps Storybook codebases
/// from drowning in true-but-unhelpful findings.
const STORYBOOK_SUFFIXES: &[&str] = &[
    ".stories.ts",
    ".stories.tsx",
    ".stories.js",
    ".stories.jsx",
    ".stories.mts",
    ".stories.cts",
    ".stories.mjs",
    ".stories.cjs",
    ".story.ts",
    ".story.tsx",
    ".story.js",
    ".story.jsx",
    ".story.mts",
    ".story.cts",
    ".story.mjs",
    ".story.cjs",
];

fn is_storybook_file(path: &std::path::Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| {
            STORYBOOK_SUFFIXES
                .iter()
                .any(|suffix| name.ends_with(suffix))
        })
}

/// File-path globs for framework routing conventions where per-file private types are shared across exports.
const ROUTE_CONVENTION_PATTERNS: &[&str] = &[
    "**/app/**/page.{ts,tsx,js,jsx}",
    "**/app/**/layout.{ts,tsx,js,jsx}",
    "**/app/**/template.{ts,tsx,js,jsx}",
    "**/app/**/loading.{ts,tsx,js,jsx}",
    "**/app/**/error.{ts,tsx,js,jsx}",
    "**/app/**/not-found.{ts,tsx,js,jsx}",
    "**/app/**/route.{ts,tsx,js,jsx}",
    "**/app/**/default.{ts,tsx,js,jsx}",
    "**/app/**/global-error.{ts,tsx,js,jsx}",
    "**/app/**/forbidden.{ts,tsx,js,jsx}",
    "**/app/**/unauthorized.{ts,tsx,js,jsx}",
    "**/app/global-not-found.{ts,tsx,js,jsx}",
    "**/app/page.{ts,tsx,js,jsx}",
    "**/app/layout.{ts,tsx,js,jsx}",
    "**/app/template.{ts,tsx,js,jsx}",
    "**/app/loading.{ts,tsx,js,jsx}",
    "**/app/error.{ts,tsx,js,jsx}",
    "**/app/not-found.{ts,tsx,js,jsx}",
    "**/app/route.{ts,tsx,js,jsx}",
    "**/app/default.{ts,tsx,js,jsx}",
    "**/app/global-error.{ts,tsx,js,jsx}",
    "**/app/**/opengraph-image.{ts,tsx,js,jsx}",
    "**/app/**/twitter-image.{ts,tsx,js,jsx}",
    "**/app/**/icon.{ts,tsx,js,jsx}",
    "**/app/**/apple-icon.{ts,tsx,js,jsx}",
    "**/app/**/manifest.{ts,tsx,js,jsx}",
    "**/app/**/sitemap.{ts,tsx,js,jsx}",
    "**/app/**/robots.{ts,tsx,js,jsx}",
    "**/pages/**/*.{ts,tsx,js,jsx}",
    "**/src/templates/**/*.{ts,tsx,js,jsx}",
    "**/routes/*.{ts,tsx,js,jsx}",
    "**/routes/**/route.{ts,tsx,js,jsx}",
    "**/routes/**/_layout.{ts,tsx,js,jsx}",
    "**/routes/**/_index.{ts,tsx,js,jsx}",
    "**/routes/**/index.{ts,tsx,js,jsx}",
    "**/routes/**/__root.{ts,tsx,js,jsx}",
    "**/app/**/_layout.{ts,tsx,js,jsx}",
    "**/app/**/+*.{ts,tsx,js,jsx}",
];

#[expect(
    clippy::expect_used,
    reason = "route convention globs are hard-coded and covered by entry point tests"
)]
fn build_route_convention_globset() -> globset::GlobSet {
    let mut builder = globset::GlobSetBuilder::new();
    for pattern in ROUTE_CONVENTION_PATTERNS {
        let glob = globset::GlobBuilder::new(pattern)
            .literal_separator(true)
            .build()
            .expect("static route-convention glob pattern must be valid");
        builder.add(glob);
    }
    builder
        .build()
        .expect("static route-convention globset must build")
}

fn is_route_convention_file(absolute_path: &std::path::Path, root: &std::path::Path) -> bool {
    let relative = absolute_path.strip_prefix(root).unwrap_or(absolute_path);
    let normalized = relative.to_string_lossy().replace('\\', "/");
    ROUTE_CONVENTION_GLOBSET.is_match(normalized.as_str())
}

static ROUTE_CONVENTION_GLOBSET: LazyLock<globset::GlobSet> =
    LazyLock::new(build_route_convention_globset);

/// Find exported signatures that reference same-file type declarations that
/// are not exported by that same name.
pub fn find_private_type_leaks(
    graph: &ModuleGraph,
    modules: &[fallow_types::extract::ModuleInfo],
    config: &ResolvedConfig,
    suppressions: &SuppressionContext<'_>,
    line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<PrivateTypeLeak> {
    let mut leaks = Vec::new();
    for module_info in modules {
        if module_info.public_signature_type_references.is_empty()
            || module_info.local_type_declarations.is_empty()
        {
            continue;
        }
        let Some(module) = graph.modules.get(module_info.file_id.0 as usize) else {
            continue;
        };
        debug_assert_eq!(
            module.file_id, module_info.file_id,
            "ModuleGraph::modules FileId-as-index invariant broken"
        );
        if is_storybook_file(&module.path) || is_route_convention_file(&module.path, &config.root) {
            continue;
        }
        collect_module_private_type_leaks(
            module,
            module_info,
            suppressions,
            line_offsets_by_file,
            &mut leaks,
        );
    }

    leaks
}

/// Collect private-type-leak findings for a single module: every public
/// signature reference to a same-file type declaration not exported by name.
fn collect_module_private_type_leaks(
    module: &ModuleNode,
    module_info: &fallow_types::extract::ModuleInfo,
    suppressions: &SuppressionContext<'_>,
    line_offsets_by_file: &LineOffsetsMap<'_>,
    leaks: &mut Vec<PrivateTypeLeak>,
) {
    let local_types: FxHashSet<&str> = module_info
        .local_type_declarations
        .iter()
        .map(|decl| decl.name.as_str())
        .collect();
    let exported_names: FxHashSet<String> = module
        .exports
        .iter()
        .map(|export| export.name.to_string())
        .collect();

    let mut seen: FxHashSet<(String, String)> = FxHashSet::default();
    for reference in &module_info.public_signature_type_references {
        if !local_types.contains(reference.type_name.as_str())
            || exported_names.contains(&reference.type_name)
        {
            continue;
        }
        if !seen.insert((reference.export_name.clone(), reference.type_name.clone())) {
            continue;
        }
        let (line, col) = byte_offset_to_line_col(
            line_offsets_by_file,
            module_info.file_id,
            reference.span.start,
        );
        if suppressions.is_suppressed(module_info.file_id, line, IssueKind::PrivateTypeLeak) {
            continue;
        }
        leaks.push(PrivateTypeLeak {
            path: module.path.clone(),
            export_name: reference.export_name.clone(),
            type_name: reference.type_name.clone(),
            line,
            col,
            span_start: reference.span.start,
        });
    }
}

/// Add dynamic-import edges that act as re-exports to the existing
/// `re_export_sources` map. Caller has already populated it from static
/// `re_exports`. A dynamic import counts as a re-export only when the wrapper
/// module also exports the same name, mirroring the static `export { X } from`
/// shape.
fn collect_dynamic_reexport_sources(
    resolved_modules: &[crate::resolve::ResolvedModule],
    graph: &ModuleGraph,
    re_export_sources: &mut FxHashMap<usize, FxHashSet<usize>>,
) {
    use crate::extract::ExportName;
    use fallow_types::extract::ImportedName;

    for resolved in resolved_modules {
        let wrapper_idx = resolved.file_id.0 as usize;
        let Some(wrapper) = graph.modules.get(wrapper_idx) else {
            continue;
        };
        debug_assert_eq!(
            wrapper.file_id, resolved.file_id,
            "ModuleGraph::modules FileId-as-index invariant broken"
        );
        let wrapper_exports = &wrapper.exports;

        for dynamic_import in &resolved.resolved_dynamic_imports {
            let Some(source_file_id) = dynamic_import.target.internal_file_id() else {
                continue;
            };

            let matches_export = match &dynamic_import.info.imported_name {
                ImportedName::Named(name) => wrapper_exports
                    .iter()
                    .any(|e| matches!(&e.name, ExportName::Named(n) if n == name)),
                ImportedName::Default => wrapper_exports
                    .iter()
                    .any(|e| matches!(&e.name, ExportName::Default)),
                ImportedName::Namespace | ImportedName::SideEffect => false,
            };
            if !matches_export {
                continue;
            }

            let source_idx = source_file_id.0 as usize;
            if source_idx >= graph.modules.len() {
                continue;
            }
            re_export_sources
                .entry(wrapper_idx)
                .or_default()
                .insert(source_idx);
        }
    }
}

/// An export name occurrence in a single module, collected before deduplication.
struct ExportEntry {
    module_idx: usize,
    path: std::path::PathBuf,
    file_id: FileId,
    span_start: u32,
    is_type_only: bool,
}

/// Partition `entries` into connected components where two entries are connected
/// when they share at least one common importer in `graph`, or one directly
/// imports the other.
///
/// Returns index sets into `entries`; each inner vec is one component.
/// Singleton components (no shared importer with any sibling) are included so
/// the caller can drop them as unrelated leaf modules.
fn partition_by_common_importer(entries: &[ExportEntry], graph: &ModuleGraph) -> Vec<Vec<usize>> {
    let n = entries.len();
    let mut union_find = UnionFind::new(n);

    // Build importer -> [entry index] map so we can union all entries that share
    // a common importer.
    let mut importer_to_entries: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
    for (i, entry) in entries.iter().enumerate() {
        let idx = entry.file_id.0 as usize;
        if idx >= graph.reverse_deps.len() {
            continue;
        }
        for &importer in &graph.reverse_deps[idx] {
            importer_to_entries.entry(importer).or_default().push(i);
        }
    }

    // Union all entries that share a common importer.
    for members in importer_to_entries.values() {
        if let Some((&first, rest)) = members.split_first() {
            for &other in rest {
                union_find.union(first, other);
            }
        }
    }

    // Also union entries where one directly imports the other (i.e. the importer
    // itself is a member of the duplicate set).
    let entry_file_ids: FxHashSet<FileId> = entries.iter().map(|e| e.file_id).collect();
    for (i, entry) in entries.iter().enumerate() {
        let idx = entry.file_id.0 as usize;
        if idx >= graph.reverse_deps.len() {
            continue;
        }
        for &importer in &graph.reverse_deps[idx] {
            if entry_file_ids.contains(&importer)
                && let Some(j) = entries.iter().position(|e| e.file_id == importer)
            {
                union_find.union(i, j);
            }
        }
    }

    union_find.components()
}

/// Union-Find with path compression for duplicate-export component grouping.
struct UnionFind {
    parent: Vec<usize>,
}

impl UnionFind {
    fn new(len: usize) -> Self {
        Self {
            parent: (0..len).collect(),
        }
    }

    fn find(&mut self, mut x: usize) -> usize {
        while self.parent[x] != x {
            self.parent[x] = self.parent[self.parent[x]];
            x = self.parent[x];
        }
        x
    }

    fn union(&mut self, a: usize, b: usize) {
        let root_a = self.find(a);
        let root_b = self.find(b);
        if root_a != root_b {
            self.parent[root_a] = root_b;
        }
    }

    fn components(mut self) -> Vec<Vec<usize>> {
        // Group entry indices by their union-find root.
        let mut components: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
        for i in 0..self.parent.len() {
            let root = self.find(i);
            components.entry(root).or_default().push(i);
        }

        components.into_values().collect()
    }
}

/// Find exports that appear with the same name in multiple files (potential duplicates).
///
/// Barrel re-exports (files that only re-export from other modules via `export { X } from './source'`)
/// are excluded. Having an index.ts re-export the same name as the source module is the normal
/// barrel file pattern, not a true duplicate.
///
/// `resolved_modules` is the set of modules with their resolved dynamic imports.
/// Pass `&[]` to opt out of dynamic-import re-export detection (existing static
/// `export { X } from '...'` re-exports are still recognized).
#[deprecated(
    since = "2.76.0",
    note = "fallow_core is internal; use fallow_api::run_dead_code for typed output; serialize with fallow_api::serialize_dead_code_programmatic_json for JSON output. See docs/fallow-core-migration.md."
)]
pub fn find_duplicate_exports(
    graph: &ModuleGraph,
    config: &ResolvedConfig,
    suppressions: &SuppressionContext<'_>,
    line_offsets_by_file: &LineOffsetsMap<'_>,
    resolved_modules: &[crate::resolve::ResolvedModule],
) -> Vec<DuplicateExport> {
    find_duplicate_exports_with_plugins(
        graph,
        config,
        suppressions,
        line_offsets_by_file,
        None,
        resolved_modules,
    )
}

pub(super) fn find_duplicate_exports_with_plugins(
    graph: &ModuleGraph,
    config: &ResolvedConfig,
    suppressions: &SuppressionContext<'_>,
    line_offsets_by_file: &LineOffsetsMap<'_>,
    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
    resolved_modules: &[crate::resolve::ResolvedModule],
) -> Vec<DuplicateExport> {
    let re_export_sources = build_re_export_source_map(graph, resolved_modules);
    let export_locations =
        collect_duplicate_export_locations(graph, config, suppressions, plugin_result);

    let mut sorted_locations: Vec<_> = export_locations.into_iter().collect();
    sorted_locations.sort_by(|a, b| a.0.cmp(&b.0));

    sorted_locations
        .into_iter()
        .filter_map(|(name, locations)| {
            evaluate_duplicate_export_group(
                name,
                locations,
                &re_export_sources,
                graph,
                line_offsets_by_file,
            )
        })
        .collect()
}

/// Map each module index to the set of module indices it re-exports from
/// (static `export ... from` edges plus dynamically-resolved re-exports). Used
/// to strip re-export chain members from a duplicate-export group: a module that
/// re-exports from another group member is not an independent origin.
fn build_re_export_source_map(
    graph: &ModuleGraph,
    resolved_modules: &[crate::resolve::ResolvedModule],
) -> FxHashMap<usize, FxHashSet<usize>> {
    let mut re_export_sources: FxHashMap<usize, FxHashSet<usize>> = FxHashMap::default();
    for (idx, module) in graph.modules.iter().enumerate() {
        debug_assert_eq!(
            module.file_id.0 as usize, idx,
            "ModuleGraph::modules FileId-as-index invariant broken"
        );
        for re in &module.re_exports {
            re_export_sources
                .entry(idx)
                .or_default()
                .insert(re.source_file.0 as usize);
        }
    }

    collect_dynamic_reexport_sources(resolved_modules, graph, &mut re_export_sources);
    re_export_sources
}

/// Collect every non-default, non-ignored named export across reachable,
/// non-entry-point modules, keyed by export name. The resulting groups feed
/// duplicate detection.
fn collect_duplicate_export_locations(
    graph: &ModuleGraph,
    config: &ResolvedConfig,
    suppressions: &SuppressionContext<'_>,
    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
) -> FxHashMap<String, Vec<ExportEntry>> {
    let ignore_matchers = config.compiled_ignore_exports.as_slice();
    let tanstack_duplicate_matchers = compile_tanstack_duplicate_export_matchers(plugin_result);
    let has_tanstack_router = is_tanstack_router_active(plugin_result);

    let mut export_locations: FxHashMap<String, Vec<ExportEntry>> = FxHashMap::default();

    for (idx, module) in graph.modules.iter().enumerate() {
        collect_module_duplicate_export_locations(
            &mut export_locations,
            DuplicateExportModuleInput {
                graph,
                config,
                suppressions,
                ignore_matchers,
                tanstack_duplicate_matchers: &tanstack_duplicate_matchers,
                has_tanstack_router,
                module_idx: idx,
                module,
            },
        );
    }

    export_locations
}

#[derive(Clone, Copy)]
struct DuplicateExportModuleInput<'a> {
    graph: &'a ModuleGraph,
    config: &'a ResolvedConfig,
    suppressions: &'a SuppressionContext<'a>,
    ignore_matchers: &'a [CompiledIgnoreExportRule],
    tanstack_duplicate_matchers: &'a PluginMatchers<'a>,
    has_tanstack_router: bool,
    module_idx: usize,
    module: &'a ModuleNode,
}

fn collect_module_duplicate_export_locations(
    export_locations: &mut FxHashMap<String, Vec<ExportEntry>>,
    input: DuplicateExportModuleInput<'_>,
) {
    if !input.module.is_reachable() || input.module.is_entry_point() {
        return;
    }
    if input
        .suppressions
        .is_file_suppressed(input.module.file_id, IssueKind::DuplicateExport)
    {
        return;
    }

    let matching_ignore = ignore_matchers_for_module(
        &input.module.path,
        &input.config.root,
        input.ignore_matchers,
    );
    let (_, matching_tanstack_contracts) = matchers_for_module(
        &input.module.path,
        &input.config.root,
        &[],
        input.tanstack_duplicate_matchers,
    );

    for export in &input.module.exports {
        let Some((name, entry)) = duplicate_export_entry(
            export,
            &input,
            &matching_ignore,
            &matching_tanstack_contracts,
        ) else {
            continue;
        };
        export_locations.entry(name).or_default().push(entry);
    }
}

fn duplicate_export_entry(
    export: &ExportSymbol,
    input: &DuplicateExportModuleInput<'_>,
    matching_ignore: &[&[String]],
    matching_tanstack_contracts: &[&[&str]],
) -> Option<(String, ExportEntry)> {
    if matches!(export.name, crate::extract::ExportName::Default) {
        return None;
    }
    if export.span.start == 0 && export.span.end == 0 {
        return None;
    }
    let name = export.name.to_string();
    if is_export_ignored(&name, matching_ignore, matching_tanstack_contracts) {
        return None;
    }
    if input.has_tanstack_router
        && is_route_referenced_by_tanstack_generated_tree(
            &name,
            export,
            input.module,
            input.graph,
            &input.config.root,
        )
    {
        return None;
    }

    Some((
        name,
        ExportEntry {
            module_idx: input.module_idx,
            path: input.module.path.clone(),
            file_id: input.module.file_id,
            span_start: export.span.start,
            is_type_only: export.is_type_only,
        },
    ))
}

/// Evaluate one same-name export group into an optional duplicate finding:
/// strip re-export chain members, partition the remaining origins into
/// importer-connected components, and collect the surviving locations.
fn evaluate_duplicate_export_group(
    name: String,
    locations: Vec<ExportEntry>,
    re_export_sources: &FxHashMap<usize, FxHashSet<usize>>,
    graph: &ModuleGraph,
    line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Option<DuplicateExport> {
    if locations.len() <= 1 {
        return None;
    }

    // Strip re-export chain members: a module that re-exports from another
    // group member is not an independent origin.
    let module_indices: FxHashSet<usize> = locations.iter().map(|e| e.module_idx).collect();
    let independent_entries: Vec<ExportEntry> = locations
        .into_iter()
        .filter(|e| {
            let sources = re_export_sources.get(&e.module_idx);
            let has_source_in_set =
                sources.is_some_and(|s| s.iter().any(|src| module_indices.contains(src)));
            !has_source_in_set
        })
        .collect();

    if independent_entries.len() <= 1 {
        return None;
    }

    // Partition independent entries into connected components where two
    // entries are connected when they share a common importer. This prevents
    // a cross-package member (e.g. a backend `class Label` that no frontend
    // module imports) from inflating `value_modules` and defeating the
    // value+type self-suppression check that should apply only to the
    // frontend component.
    let components = partition_by_common_importer(&independent_entries, graph);

    let mut surviving_locations: Vec<DuplicateLocation> = Vec::new();
    for component_indices in components {
        surviving_locations.extend(component_duplicate_locations(
            &component_indices,
            &independent_entries,
            line_offsets_by_file,
        ));
    }

    if surviving_locations.len() <= 1 {
        return None;
    }

    Some(DuplicateExport {
        export_name: name,
        locations: surviving_locations,
    })
}

/// Resolve one importer-connected component into the duplicate locations it
/// contributes, dropping unrelated singletons and the TypeScript value+type
/// self-pair (a runtime value re-declared alongside its type alias).
fn component_duplicate_locations(
    component_indices: &[usize],
    independent_entries: &[ExportEntry],
    line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<DuplicateLocation> {
    if component_indices.len() <= 1 {
        // A singleton component shares no importer with any sibling; it is an
        // unrelated leaf and must be dropped.
        return Vec::new();
    }

    // Value+type self-suppression: a single value export paired with a single
    // type export in the same importer-connected component is the TypeScript
    // pattern of re-declaring a runtime value alongside its type alias, not a
    // genuine duplicate.
    let value_count = component_indices
        .iter()
        .filter(|&&i| !independent_entries[i].is_type_only)
        .count();
    let type_count = component_indices
        .iter()
        .filter(|&&i| independent_entries[i].is_type_only)
        .count();
    if value_count == 1 && type_count == 1 {
        return Vec::new();
    }

    component_indices
        .iter()
        .map(|&i| {
            let e = &independent_entries[i];
            let (line, col) =
                byte_offset_to_line_col(line_offsets_by_file, e.file_id, e.span_start);
            DuplicateLocation {
                path: e.path.clone(),
                line,
                col,
            }
        })
        .collect()
}

/// Collect usage counts for all exports in the module graph.
///
/// Iterates every module and every export, producing an `ExportUsage` entry with the
/// reference count and reference locations. This data is used by the LSP server to show
/// Code Lens annotations (e.g., "3 references") above export declarations, with
/// click-to-navigate support via `editor.action.showReferences`.
pub fn collect_export_usages(
    graph: &ModuleGraph,
    line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<ExportUsage> {
    let mut usages = Vec::new();

    let file_paths: FxHashMap<FileId, &std::path::Path> = graph
        .modules
        .iter()
        .map(|m| (m.file_id, m.path.as_path()))
        .collect();

    let mut source_cache: FxHashMap<FileId, (String, Vec<u32>)> = FxHashMap::default();

    for module in &graph.modules {
        if !module.is_reachable() {
            continue;
        }

        for export in &module.exports {
            if export.span.start == 0 && export.span.end == 0 {
                continue;
            }

            let (line, col) =
                byte_offset_to_line_col(line_offsets_by_file, module.file_id, export.span.start);

            let reference_locations = export_reference_locations(
                export,
                &file_paths,
                line_offsets_by_file,
                &mut source_cache,
            );

            usages.push(ExportUsage {
                path: module.path.clone(),
                export_name: export.name.to_string(),
                line,
                col,
                reference_count: export.references.len(),
                reference_locations,
            });
        }
    }

    usages
}

/// Resolve each reference on `export` to a `(path, line, col)` location,
/// falling back to an on-disk source read (cached) for files without
/// precomputed line offsets.
fn export_reference_locations(
    export: &ExportSymbol,
    file_paths: &FxHashMap<FileId, &std::path::Path>,
    line_offsets_by_file: &LineOffsetsMap<'_>,
    source_cache: &mut FxHashMap<FileId, (String, Vec<u32>)>,
) -> Vec<ReferenceLocation> {
    export
        .references
        .iter()
        .filter_map(|r| {
            if r.import_span.start == 0 && r.import_span.end == 0 {
                return None;
            }
            let ref_path = file_paths.get(&r.from_file)?;
            let (ref_line, ref_col) = if line_offsets_by_file.contains_key(&r.from_file) {
                byte_offset_to_line_col(line_offsets_by_file, r.from_file, r.import_span.start)
            } else {
                let (_, offsets) = source_cache.entry(r.from_file).or_insert_with(|| {
                    let src = read_source(ref_path);
                    let ofs = fallow_types::extract::compute_line_offsets(&src);
                    (src, ofs)
                });
                fallow_types::extract::byte_offset_to_line_col(offsets, r.import_span.start)
            };
            Some(ReferenceLocation {
                path: ref_path.to_path_buf(),
                line: ref_line,
                col: ref_col,
            })
        })
        .collect()
}

#[cfg(test)]
#[expect(
    deprecated,
    reason = "Core-internal policy keeps direct detector unit tests while the public warning targets external callers"
)]
mod tests {
    use super::*;
    use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
    use crate::extract::{ExportName, VisibilityTag};
    use crate::graph::{ExportSymbol, ModuleGraph, ReExportEdge, SymbolReference};
    use crate::resolve::ResolvedModule;
    use crate::suppress::Suppression;
    use oxc_span::Span;
    use std::path::PathBuf;

    /// Build a minimal ModuleGraph via the build() constructor.
    #[expect(
        clippy::cast_possible_truncation,
        reason = "test file counts are trivially small"
    )]
    fn build_graph(file_specs: &[(&str, bool)]) -> ModuleGraph {
        let files: Vec<DiscoveredFile> = file_specs
            .iter()
            .enumerate()
            .map(|(i, (path, _))| DiscoveredFile {
                id: FileId(i as u32),
                path: PathBuf::from(path),
                size_bytes: 0,
            })
            .collect();

        let entry_points: Vec<EntryPoint> = file_specs
            .iter()
            .filter(|(_, is_entry)| *is_entry)
            .map(|(path, _)| EntryPoint {
                path: PathBuf::from(path),
                source: EntryPointSource::ManualEntry,
            })
            .collect();

        let resolved_modules: Vec<ResolvedModule> = files
            .iter()
            .map(|f| ResolvedModule {
                file_id: f.id,
                path: f.path.clone(),
                exports: vec![],
                re_exports: vec![],
                resolved_imports: vec![],
                resolved_dynamic_imports: vec![],
                resolved_dynamic_patterns: vec![],
                member_accesses: vec![],
                semantic_facts: Box::default(),
                whole_object_uses: Box::default(),
                has_cjs_exports: false,
                has_angular_component_template_url: false,
                unused_import_bindings: FxHashSet::default(),
                type_referenced_import_bindings: vec![],
                value_referenced_import_bindings: vec![],
                namespace_object_aliases: vec![],
                exported_factory_returns: Box::default(),
                type_member_types: Box::default(),
            })
            .collect();

        ModuleGraph::build(&resolved_modules, &entry_points, &files)
    }

    /// Build a default ResolvedConfig for tests.
    fn test_config() -> ResolvedConfig {
        fallow_config::FallowConfig::default().resolve(
            PathBuf::from("/tmp/test"),
            fallow_config::OutputFormat::Human,
            1,
            true,
            true,
            None,
        )
    }

    fn make_export(name: &str, span_start: u32, span_end: u32) -> ExportSymbol {
        ExportSymbol {
            name: ExportName::Named(name.to_string()),
            is_type_only: false,
            is_side_effect_used: false,
            visibility: VisibilityTag::None,
            expected_unused_reason: None,
            span: Span::new(span_start, span_end),
            references: vec![],
            members: vec![],
        }
    }

    fn make_referenced_export(
        name: &str,
        span_start: u32,
        span_end: u32,
        from: u32,
    ) -> ExportSymbol {
        ExportSymbol {
            name: ExportName::Named(name.to_string()),
            is_type_only: false,
            is_side_effect_used: false,
            visibility: VisibilityTag::None,
            expected_unused_reason: None,
            span: Span::new(span_start, span_end),
            references: vec![SymbolReference {
                from_file: FileId(from),
                kind: crate::graph::ReferenceKind::NamedImport,
                import_span: Span::new(0, 10),
            }],
            members: vec![],
        }
    }

    #[test]
    fn duplicate_exports_empty_graph() {
        let graph = build_graph(&[]);
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert!(result.is_empty());
    }

    #[test]
    fn duplicate_exports_no_duplicates_single_module() {
        let mut graph = build_graph(&[("/src/entry.ts", true), ("/src/utils.ts", false)]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("foo", 10, 20), make_export("bar", 30, 40)];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert!(result.is_empty());
    }

    #[test]
    fn duplicate_exports_detects_same_name_in_two_modules() {
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/a.ts", false),
            ("/src/b.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("helper", 10, 20)];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_export("helper", 10, 20)];
        graph.reverse_deps[1] = vec![FileId(0)];
        graph.reverse_deps[2] = vec![FileId(0)];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].export_name, "helper");
        assert_eq!(result[0].locations.len(), 2);
    }

    #[test]
    fn duplicate_exports_skips_default_exports() {
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/a.ts", false),
            ("/src/b.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![ExportSymbol {
            name: ExportName::Default,
            is_type_only: false,
            is_side_effect_used: false,
            visibility: VisibilityTag::None,
            expected_unused_reason: None,
            span: Span::new(10, 20),
            references: vec![],
            members: vec![],
        }];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![ExportSymbol {
            name: ExportName::Default,
            is_type_only: false,
            is_side_effect_used: false,
            visibility: VisibilityTag::None,
            expected_unused_reason: None,
            span: Span::new(10, 20),
            references: vec![],
            members: vec![],
        }];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert!(result.is_empty());
    }

    #[test]
    fn duplicate_exports_skips_synthetic_re_export_entries() {
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/a.ts", false),
            ("/src/b.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("helper", 0, 0)]; // synthetic
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_export("helper", 10, 20)]; // real
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert!(result.is_empty());
    }

    #[test]
    fn duplicate_exports_skips_unreachable_modules() {
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/a.ts", false),
            ("/src/b.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("helper", 10, 20)];
        graph.modules[2].exports = vec![make_export("helper", 10, 20)];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert!(result.is_empty());
    }

    #[test]
    fn duplicate_exports_skips_entry_points() {
        let mut graph = build_graph(&[("/src/entry.ts", true), ("/src/b.ts", false)]);
        graph.modules[0].exports = vec![make_export("helper", 10, 20)];
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("helper", 10, 20)];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert!(result.is_empty());
    }

    #[test]
    fn duplicate_exports_filters_re_export_chains() {
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/index.ts", false),
            ("/src/helper.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("helper", 10, 20)];
        graph.modules[1].re_exports = vec![ReExportEdge {
            source_file: FileId(2),
            imported_name: "helper".to_string(),
            exported_name: "helper".to_string(),
            is_type_only: false,
            span: oxc_span::Span::default(),
        }];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_export("helper", 5, 15)];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert!(result.is_empty());
    }

    #[test]
    fn duplicate_exports_suppressed_file_wide() {
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/a.ts", false),
            ("/src/b.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("helper", 10, 20)];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_export("helper", 10, 20)];

        let supp = vec![Suppression::issue(0, 1, IssueKind::DuplicateExport)];
        let mut supp_map: FxHashMap<FileId, &[Suppression]> = FxHashMap::default();
        supp_map.insert(FileId(2), &supp);
        let suppressions = SuppressionContext::from_map(supp_map);

        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert!(result.is_empty());
    }

    #[test]
    fn duplicate_exports_three_modules_same_name() {
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/a.ts", false),
            ("/src/b.ts", false),
            ("/src/c.ts", false),
        ]);
        for i in 1..=3 {
            graph.modules[i].set_reachable(true);
            graph.modules[i].exports = vec![make_export("sharedFn", 10, 20)];
        }
        graph.reverse_deps[1] = vec![FileId(0)];
        graph.reverse_deps[2] = vec![FileId(0)];
        graph.reverse_deps[3] = vec![FileId(0)];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].export_name, "sharedFn");
        assert_eq!(result[0].locations.len(), 3);
    }

    #[test]
    fn duplicate_exports_unrelated_leaf_files_not_flagged() {
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/routes/foo/page.ts", false),
            ("/src/routes/bar/page.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("Area", 10, 20)];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_export("Area", 10, 20)];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert!(
            result.is_empty(),
            "unrelated leaf files should not be flagged as duplicates"
        );
    }

    #[test]
    fn duplicate_exports_direct_import_still_flagged() {
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/a.ts", false),
            ("/src/b.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("helper", 10, 20)];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_export("helper", 10, 20)];
        graph.reverse_deps[2] = vec![FileId(1)];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert_eq!(
            result.len(),
            1,
            "directly connected files should still be flagged"
        );
    }

    #[test]
    fn duplicate_exports_different_names_not_duplicated() {
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/a.ts", false),
            ("/src/b.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("foo", 10, 20)];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_export("bar", 10, 20)];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert!(result.is_empty());
    }

    #[test]
    fn duplicate_exports_value_type_merging_not_flagged() {
        let mut graph = build_graph(&[("/src/entry.ts", true), ("/src/schema.ts", false)]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![
            make_export("Status", 10, 20),
            make_type_export("Status", 50, 60),
        ];
        graph.reverse_deps[1] = vec![FileId(0)];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert!(
            result.is_empty(),
            "value+type merging should not be flagged as duplicate"
        );
    }

    #[test]
    fn duplicate_exports_value_type_cross_file_not_flagged() {
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/a.ts", false),
            ("/src/b.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("Status", 10, 20)];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_type_export("Status", 10, 20)];
        graph.reverse_deps[1] = vec![FileId(0)];
        graph.reverse_deps[2] = vec![FileId(0)];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert!(
            result.is_empty(),
            "cross-file value+type should not be flagged"
        );
    }

    #[test]
    fn duplicate_exports_same_namespace_still_flagged() {
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/a.ts", false),
            ("/src/b.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("helper", 10, 20)];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_export("helper", 10, 20)];
        graph.reverse_deps[1] = vec![FileId(0)];
        graph.reverse_deps[2] = vec![FileId(0)];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert_eq!(
            result.len(),
            1,
            "same-namespace duplicates should still be flagged"
        );
    }

    /// Regression test for issue #848.
    ///
    /// Setup:
    ///   module 1 (backend): `class Label` (value) -- no shared importer with frontend
    ///   module 2 (frontend value): `function Label` (value)
    ///   module 3 (frontend type): `export type Label` (type)
    ///   module 4 (frontend consumer): imports from both module 2 and module 3
    ///
    /// Before the fix, `value_modules` contained both module 1 and module 2, so
    /// `.len() > 1` defeated the self-suppression check and a false duplicate was
    /// reported. After the fix the group is partitioned: module 1 is a singleton
    /// (dropped), and the {module 2, module 3} pair self-suppresses via value+type.
    #[test]
    fn duplicate_exports_cross_package_backend_does_not_defeat_value_type_suppression() {
        // Files: 0=backend-entry, 1=frontend-entry, 2=backend Label, 3=frontend Label value,
        // 4=frontend Label type, 5=frontend consumer
        let mut graph = build_graph(&[
            ("/src/backend/entry.ts", true),
            ("/src/frontend/entry.ts", true),
            ("/src/backend/label.ts", false),
            ("/src/frontend/label.ts", false),
            ("/src/frontend/label.types.ts", false),
            ("/src/frontend/consumer.ts", false),
        ]);
        // Reachable non-entry modules
        graph.modules[2].set_reachable(true);
        graph.modules[3].set_reachable(true);
        graph.modules[4].set_reachable(true);
        graph.modules[5].set_reachable(true);

        // Backend: value export `Label`
        graph.modules[2].exports = vec![make_export("Label", 10, 20)];
        // Frontend value: `function Label`
        graph.modules[3].exports = vec![make_export("Label", 10, 20)];
        // Frontend type: `export type Label`
        graph.modules[4].exports = vec![make_type_export("Label", 10, 20)];

        // Backend label is imported only by the backend entry (FileId 0).
        graph.reverse_deps[2] = vec![FileId(0)];
        // Frontend consumer (FileId 5) imports from both the frontend value and type.
        graph.reverse_deps[3] = vec![FileId(5)];
        graph.reverse_deps[4] = vec![FileId(5)];
        // Frontend consumer is imported by the frontend entry.
        graph.reverse_deps[5] = vec![FileId(1)];

        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert!(
            result.is_empty(),
            "backend `class Label` shares no importer with the frontend value+type pair; \
            the frontend pair must self-suppress, so no duplicate should be reported, \
            but got: {result:?}",
        );
    }

    /// Companion to the regression test: two value modules that DO share a common
    /// importer must still be reported as genuine duplicates even when an unrelated
    /// third module with the same name (but no shared importer) exists.
    #[test]
    fn duplicate_exports_genuine_value_duplicate_still_flagged_despite_unrelated_backend() {
        // Files: 0=entry, 1=backend Label (no shared importer), 2=frontend-a Label,
        // 3=frontend-b Label, 4=shared consumer of frontend-a and frontend-b
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/backend/label.ts", false),
            ("/src/frontend/a.ts", false),
            ("/src/frontend/b.ts", false),
            ("/src/frontend/consumer.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[2].set_reachable(true);
        graph.modules[3].set_reachable(true);
        graph.modules[4].set_reachable(true);

        // All three export a value `Label`.
        graph.modules[1].exports = vec![make_export("Label", 10, 20)];
        graph.modules[2].exports = vec![make_export("Label", 10, 20)];
        graph.modules[3].exports = vec![make_export("Label", 10, 20)];

        // Backend is only imported by the entry (no connection to frontend).
        graph.reverse_deps[1] = vec![FileId(0)];
        // Frontend consumer imports from both frontend-a and frontend-b.
        graph.reverse_deps[2] = vec![FileId(4)];
        graph.reverse_deps[3] = vec![FileId(4)];
        // Frontend consumer is imported by entry.
        graph.reverse_deps[4] = vec![FileId(0)];

        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert_eq!(
            result.len(),
            1,
            "frontend-a and frontend-b share a common importer and both export a value \
            `Label`, so a genuine duplicate must be reported even though an unrelated \
            backend module also exports `Label`"
        );
        assert_eq!(result[0].export_name, "Label");
        // The finding must include only the two connected frontend locations, not backend.
        assert_eq!(
            result[0].locations.len(),
            2,
            "only the two connected frontend locations should be in the finding"
        );
    }

    fn make_resolved_module(file_id: u32, path: &str) -> crate::resolve::ResolvedModule {
        crate::resolve::ResolvedModule {
            file_id: FileId(file_id),
            path: std::path::PathBuf::from(path),
            exports: vec![],
            re_exports: vec![],
            resolved_imports: vec![],
            resolved_dynamic_imports: vec![],
            resolved_dynamic_patterns: vec![],
            member_accesses: vec![],
            semantic_facts: Box::default(),
            whole_object_uses: Box::default(),
            has_cjs_exports: false,
            has_angular_component_template_url: false,
            unused_import_bindings: FxHashSet::default(),
            type_referenced_import_bindings: vec![],
            value_referenced_import_bindings: vec![],
            namespace_object_aliases: vec![],
            exported_factory_returns: Box::default(),
            type_member_types: Box::default(),
        }
    }

    fn dynamic_import_to(
        target_id: u32,
        imported_name: crate::extract::ImportedName,
    ) -> crate::resolve::ResolvedImport {
        crate::resolve::ResolvedImport {
            info: fallow_types::extract::ImportInfo {
                source: "./target".to_string(),
                imported_name,
                local_name: "_local".to_string(),
                is_type_only: false,
                from_style: false,
                span: oxc_span::Span::new(0, 1),
                source_span: oxc_span::Span::default(),
            },
            target: crate::resolve::ResolveResult::InternalModule(FileId(target_id)),
        }
    }

    #[test]
    fn dynamic_import_then_member_not_flagged_as_duplicate() {
        use crate::extract::ImportedName;

        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/Foo.tsx", false),
            ("/src/Foo-lazy.tsx", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("Foo", 10, 30)];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_export("Foo", 10, 30)];
        graph.reverse_deps[1] = vec![FileId(0)];
        graph.reverse_deps[2] = vec![FileId(0)];

        let mut foo_lazy = make_resolved_module(2, "/src/Foo-lazy.tsx");
        foo_lazy.resolved_dynamic_imports =
            vec![dynamic_import_to(1, ImportedName::Named("Foo".to_string()))];

        let resolved_modules = vec![make_resolved_module(1, "/src/Foo.tsx"), foo_lazy];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result = find_duplicate_exports(
            &graph,
            &config,
            &suppressions,
            &FxHashMap::default(),
            &resolved_modules,
        );
        assert!(
            result.is_empty(),
            "dynamic(import().then(m=>m.Foo)) wrapper must not be flagged as duplicate-export"
        );
    }

    #[test]
    fn dynamic_import_without_then_default_not_flagged_as_duplicate() {
        use crate::extract::{ExportName, ImportedName, VisibilityTag};
        use crate::graph::ExportSymbol;

        let make_default_export = || ExportSymbol {
            name: ExportName::Default,
            is_type_only: false,
            is_side_effect_used: false,
            visibility: VisibilityTag::None,
            expected_unused_reason: None,
            span: oxc_span::Span::new(0, 10),
            references: vec![],
            members: vec![],
        };

        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/Foo.tsx", false),
            ("/src/Foo-lazy.tsx", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_default_export()];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_default_export()];
        graph.reverse_deps[1] = vec![FileId(0)];
        graph.reverse_deps[2] = vec![FileId(0)];

        let mut foo_lazy = make_resolved_module(2, "/src/Foo-lazy.tsx");
        foo_lazy.resolved_dynamic_imports = vec![dynamic_import_to(1, ImportedName::Default)];

        let resolved_modules = vec![make_resolved_module(1, "/src/Foo.tsx"), foo_lazy];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result = find_duplicate_exports(
            &graph,
            &config,
            &suppressions,
            &FxHashMap::default(),
            &resolved_modules,
        );
        assert!(
            result.is_empty(),
            "dynamic(import('./Foo')) wrapper with matching default export must not be flagged"
        );
    }

    #[test]
    fn dynamic_import_named_without_matching_export_still_flagged() {
        use crate::extract::ImportedName;

        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/Foo.tsx", false),
            ("/src/other-foo.tsx", false),
            ("/src/wrapper.tsx", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("Foo", 10, 30)];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_export("Foo", 10, 30)];
        graph.modules[3].set_reachable(true);
        graph.modules[3].exports = vec![make_export("Bar", 10, 30)];
        graph.reverse_deps[1] = vec![FileId(0)];
        graph.reverse_deps[2] = vec![FileId(0)];
        graph.reverse_deps[3] = vec![FileId(0)];

        let mut wrapper = make_resolved_module(3, "/src/wrapper.tsx");
        wrapper.resolved_dynamic_imports =
            vec![dynamic_import_to(1, ImportedName::Named("Foo".to_string()))];

        let resolved_modules = vec![
            make_resolved_module(1, "/src/Foo.tsx"),
            make_resolved_module(2, "/src/other-foo.tsx"),
            wrapper,
        ];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result = find_duplicate_exports(
            &graph,
            &config,
            &suppressions,
            &FxHashMap::default(),
            &resolved_modules,
        );
        assert_eq!(
            result.len(),
            1,
            "Foo duplication between unrelated modules must be flagged"
        );
        assert_eq!(result[0].export_name, "Foo");
    }

    #[test]
    fn dynamic_import_default_without_default_export_still_flagged() {
        use crate::extract::ImportedName;

        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/source.ts", false),
            ("/src/wrapper.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("helper", 10, 20)];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_export("helper", 10, 20)];
        graph.reverse_deps[1] = vec![FileId(0)];
        graph.reverse_deps[2] = vec![FileId(0)];

        let mut wrapper = make_resolved_module(2, "/src/wrapper.ts");
        wrapper.resolved_dynamic_imports = vec![dynamic_import_to(1, ImportedName::Default)];

        let resolved_modules = vec![make_resolved_module(1, "/src/source.ts"), wrapper];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result = find_duplicate_exports(
            &graph,
            &config,
            &suppressions,
            &FxHashMap::default(),
            &resolved_modules,
        );
        assert_eq!(
            result.len(),
            1,
            "Default dynamic import without matching Default export must not suppress duplicate-export"
        );
        assert_eq!(result[0].export_name, "helper");
    }

    #[test]
    fn duplicate_without_dynamic_link_still_flagged() {
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/a.ts", false),
            ("/src/b.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("helper", 10, 20)];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_export("helper", 10, 20)];
        graph.reverse_deps[1] = vec![FileId(0)];
        graph.reverse_deps[2] = vec![FileId(0)];

        let resolved_modules = vec![
            make_resolved_module(1, "/src/a.ts"),
            make_resolved_module(2, "/src/b.ts"),
        ];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result = find_duplicate_exports(
            &graph,
            &config,
            &suppressions,
            &FxHashMap::default(),
            &resolved_modules,
        );
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].export_name, "helper");
    }

    #[test]
    fn dynamic_import_named_mismatched_with_wrapper_export_still_flagged() {
        use crate::extract::ImportedName;

        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/source.ts", false),
            ("/src/wrapper.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("Foo", 10, 30)];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_export("Foo", 10, 30)];
        graph.reverse_deps[1] = vec![FileId(0)];
        graph.reverse_deps[2] = vec![FileId(0)];

        let mut wrapper = make_resolved_module(2, "/src/wrapper.ts");
        wrapper.resolved_dynamic_imports =
            vec![dynamic_import_to(1, ImportedName::Named("Bar".to_string()))];

        let resolved_modules = vec![make_resolved_module(1, "/src/source.ts"), wrapper];
        let suppressions = SuppressionContext::empty();
        let config = test_config();
        let result = find_duplicate_exports(
            &graph,
            &config,
            &suppressions,
            &FxHashMap::default(),
            &resolved_modules,
        );
        assert_eq!(
            result.len(),
            1,
            "Named dynamic import whose name does not match the wrapper's own export must not suppress the duplicate"
        );
        assert_eq!(result[0].export_name, "Foo");
    }

    #[test]
    fn duplicate_exports_skipped_when_ignore_exports_matches_with_wildcard() {
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/ui/dialog/index.ts", false),
            ("/src/ui/card/index.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports =
            vec![make_export("Root", 10, 30), make_export("Content", 40, 60)];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports =
            vec![make_export("Root", 10, 30), make_export("Content", 40, 60)];
        graph.reverse_deps[1] = vec![FileId(0)];
        graph.reverse_deps[2] = vec![FileId(0)];

        let suppressions = SuppressionContext::empty();
        let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
            file: "**/ui/**".to_owned(),
            exports: vec!["*".to_owned()],
        }]);
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        assert!(
            result.is_empty(),
            "wildcard ignoreExports on a glob matching both barrels must clear all duplicate-export groups, got: {result:?}"
        );
    }

    #[test]
    fn duplicate_exports_skipped_when_ignore_exports_lists_specific_names() {
        let mut graph = build_graph(&[
            ("/src/entry.ts", true),
            ("/src/ui/dialog/index.ts", false),
            ("/src/ui/card/index.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("Root", 10, 30), make_export("Helper", 40, 60)];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_export("Root", 10, 30), make_export("Helper", 40, 60)];
        graph.reverse_deps[1] = vec![FileId(0)];
        graph.reverse_deps[2] = vec![FileId(0)];

        let suppressions = SuppressionContext::empty();
        let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
            file: "**/ui/**".to_owned(),
            exports: vec!["Root".to_owned()],
        }]);
        let result =
            find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
        let names: Vec<_> = result.iter().map(|d| d.export_name.as_str()).collect();
        assert!(
            !names.contains(&"Root"),
            "Root listed in ignoreExports must not surface, got: {names:?}"
        );
        assert!(
            names.contains(&"Helper"),
            "Helper not in the ignore list must still surface, got: {names:?}"
        );
    }

    fn test_config_with_ignore_exports(
        rules: Vec<fallow_config::IgnoreExportRule>,
    ) -> ResolvedConfig {
        fallow_config::FallowConfig {
            ignore_exports: rules,
            ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
            ..Default::default()
        }
        .resolve(
            PathBuf::from("/tmp/test"),
            fallow_config::OutputFormat::Human,
            1,
            true,
            true,
            None,
        )
    }

    fn make_plugin_result(
        used_exports: Vec<(String, Vec<String>)>,
    ) -> crate::plugins::AggregatedPluginResult {
        crate::plugins::AggregatedPluginResult {
            entry_patterns: vec![],
            config_patterns: vec![],
            always_used: vec![],
            used_exports: used_exports
                .into_iter()
                .map(|(pattern, exports)| {
                    crate::plugins::PluginUsedExportRule::new(
                        "test-plugin",
                        crate::plugins::UsedExportRule::new(pattern, exports),
                    )
                })
                .collect(),
            used_class_members: vec![],
            scss_include_paths: vec![],
            entry_point_roles: FxHashMap::default(),
            referenced_dependencies: vec![],
            package_referenced_dependencies: vec![],
            discovered_always_used: vec![],
            setup_files: vec![],
            tooling_dependencies: vec![],
            script_used_packages: FxHashSet::default(),
            virtual_module_prefixes: vec![],
            virtual_package_suffixes: vec![],
            generated_import_patterns: vec![],
            generated_type_import_prefixes: vec![],
            path_aliases: vec![],
            auto_imports: vec![],
            active_plugins: vec![],
            fixture_patterns: vec![],
            static_dir_mappings: vec![],
            provided_dependencies: vec![],
        }
    }

    fn make_type_export(name: &str, span_start: u32, span_end: u32) -> ExportSymbol {
        ExportSymbol {
            name: ExportName::Named(name.to_string()),
            is_type_only: true,
            is_side_effect_used: false,
            visibility: VisibilityTag::None,
            expected_unused_reason: None,
            span: Span::new(span_start, span_end),
            references: vec![],
            members: vec![],
        }
    }

    #[test]
    fn unused_exports_empty_graph() {
        let graph = build_graph(&[]);
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, types, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert!(exports.is_empty());
        assert!(types.is_empty());
    }

    #[test]
    fn unused_exports_detects_unreferenced_export() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/utils.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("helper", 10, 20)];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, types, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert_eq!(exports.len(), 1);
        assert_eq!(exports[0].export_name, "helper");
        assert!(types.is_empty());
    }

    #[test]
    fn unused_exports_skips_referenced_export() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/utils.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_referenced_export("helper", 10, 20, 0)];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, types, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert!(exports.is_empty());
        assert!(types.is_empty());
    }

    #[test]
    fn unused_exports_skips_public_export() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/utils.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![ExportSymbol {
            name: ExportName::Named("publicFn".to_string()),
            is_type_only: false,
            is_side_effect_used: false,
            visibility: VisibilityTag::Public,
            expected_unused_reason: None,
            span: Span::new(10, 20),
            references: vec![],
            members: vec![],
        }];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, types, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert!(exports.is_empty());
        assert!(types.is_empty());
    }

    #[test]
    fn unused_exports_separates_types_from_values() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/utils.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![
            make_export("valueFn", 10, 20),
            make_type_export("MyType", 30, 40),
        ];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, types, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert_eq!(exports.len(), 1);
        assert_eq!(exports[0].export_name, "valueFn");
        assert_eq!(types.len(), 1);
        assert_eq!(types[0].export_name, "MyType");
    }

    #[test]
    fn unused_exports_skips_unreachable_module() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/dead.ts", false),
        ]);
        graph.modules[1].exports = vec![make_export("orphan", 10, 20)];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, types, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert!(exports.is_empty());
        assert!(types.is_empty());
    }

    #[test]
    fn unused_exports_skips_entry_point() {
        let mut graph = build_graph(&[("/tmp/test/src/entry.ts", true)]);
        graph.modules[0].exports = vec![make_export("main", 10, 20)];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, types, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert!(exports.is_empty());
        assert!(types.is_empty());
    }

    #[test]
    fn unused_exports_reports_non_framework_exports_in_entry_point_with_plugin_rules() {
        let mut graph = build_graph(&[("/tmp/test/src/app/page.tsx", true)]);
        graph.modules[0].set_reachable(true);
        graph.modules[0].exports = vec![
            make_export("default", 10, 20),
            make_export("generateMetadata", 30, 40),
            make_export("helper", 50, 60),
        ];

        let plugin = make_plugin_result(vec![(
            "src/app/**/page.{ts,tsx,js,jsx}".to_string(),
            vec!["default".to_string(), "generateMetadata".to_string()],
        )]);
        let config = test_config();
        let suppressions = SuppressionContext::empty();

        let (exports, types, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            Some(&plugin),
            &suppressions,
            &FxHashMap::default(),
        );

        assert_eq!(exports.len(), 1);
        assert_eq!(exports[0].export_name, "helper");
        assert!(types.is_empty());
    }

    #[test]
    fn unused_exports_skips_cjs_only_module() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/legacy.js", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].set_cjs_exports(true);
        graph.modules[1].exports = vec![];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, types, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert!(exports.is_empty());
        assert!(types.is_empty());
    }

    #[test]
    fn unused_exports_does_not_skip_cjs_module_with_named_exports() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/mixed.js", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].set_cjs_exports(true);
        graph.modules[1].exports = vec![make_export("namedFn", 10, 20)];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, _, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert_eq!(exports.len(), 1);
        assert_eq!(exports[0].export_name, "namedFn");
    }

    #[test]
    fn unused_exports_skips_svelte_files() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/Component.svelte", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("count", 10, 20)];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, types, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert!(exports.is_empty());
        assert!(types.is_empty());
    }

    #[test]
    fn unused_exports_reports_reachable_non_entry_non_cjs_non_svelte() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/utils.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].set_cjs_exports(false);
        graph.modules[1].exports = vec![make_export("helper", 10, 20)];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, _, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert_eq!(exports.len(), 1);
        assert_eq!(exports[0].export_name, "helper");
    }

    #[test]
    fn unused_exports_empty_ignore_config() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/utils.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("foo", 10, 20)];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, _, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert_eq!(
            exports.len(),
            1,
            "no ignore rules, export should be reported"
        );
    }

    #[test]
    fn unused_exports_ignore_multiple_patterns() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/types.ts", false),
            ("/tmp/test/src/constants.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("MyType", 10, 20)];
        graph.modules[2].set_reachable(true);
        graph.modules[2].exports = vec![make_export("MY_CONST", 10, 20)];

        let config = test_config_with_ignore_exports(vec![
            fallow_config::IgnoreExportRule {
                file: "src/types.ts".to_string(),
                exports: vec!["*".to_string()],
            },
            fallow_config::IgnoreExportRule {
                file: "src/constants.ts".to_string(),
                exports: vec!["MY_CONST".to_string()],
            },
        ]);
        let suppressions = SuppressionContext::empty();
        let (exports, _, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert!(
            exports.is_empty(),
            "both exports should be ignored by config rules"
        );
    }

    #[test]
    #[should_panic(expected = "validated at config load time")]
    fn unused_exports_panics_on_unvalidated_invalid_ignore_glob() {
        let _ = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
            file: "[invalid".to_string(),
            exports: vec!["*".to_string()],
        }]);
    }

    #[test]
    fn unused_exports_ignore_wildcard_matches_all() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/types.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("TypeA", 10, 20), make_export("TypeB", 30, 40)];

        let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
            file: "src/types.ts".to_string(),
            exports: vec!["*".to_string()],
        }]);
        let suppressions = SuppressionContext::empty();
        let (exports, _, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert!(
            exports.is_empty(),
            "wildcard * should ignore all exports in matching file"
        );
    }

    #[test]
    fn unused_exports_ignore_specific_name_only() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/utils.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![
            make_export("ignored", 10, 20),
            make_export("reported", 30, 40),
        ];

        let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
            file: "src/utils.ts".to_string(),
            exports: vec!["ignored".to_string()],
        }]);
        let suppressions = SuppressionContext::empty();
        let (exports, _, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert_eq!(exports.len(), 1);
        assert_eq!(exports[0].export_name, "reported");
    }

    #[test]
    fn unused_exports_ignore_rule_wrong_file_no_effect() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/utils.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("foo", 10, 20)];

        let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
            file: "src/other.ts".to_string(),
            exports: vec!["*".to_string()],
        }]);
        let suppressions = SuppressionContext::empty();
        let (exports, _, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert_eq!(
            exports.len(),
            1,
            "ignore rule for different file should not suppress"
        );
    }

    #[test]
    fn unused_exports_no_plugin_result() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/utils.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("foo", 10, 20)];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, _, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert_eq!(
            exports.len(),
            1,
            "None plugin_result means no plugin matchers"
        );
    }

    #[test]
    fn unused_exports_plugin_no_used_exports() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/utils.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("foo", 10, 20)];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let pr = make_plugin_result(vec![]);
        let (exports, _, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            Some(&pr),
            &suppressions,
            &FxHashMap::default(),
        );
        assert_eq!(
            exports.len(),
            1,
            "plugin with no used_exports should not suppress"
        );
    }

    #[test]
    fn unused_exports_plugin_used_exports_suppresses() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/pages/index.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![
            make_export("getStaticProps", 10, 20),
            make_export("unusedHelper", 30, 40),
        ];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let pr = make_plugin_result(vec![(
            "src/pages/**".to_string(),
            vec!["getStaticProps".to_string()],
        )]);
        let (exports, _, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            Some(&pr),
            &suppressions,
            &FxHashMap::default(),
        );
        assert_eq!(exports.len(), 1);
        assert_eq!(exports[0].export_name, "unusedHelper");
    }

    #[test]
    fn unused_exports_both_config_and_plugin_ignore() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/api/handler.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("handler", 10, 20)];

        let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
            file: "src/api/*.ts".to_string(),
            exports: vec!["handler".to_string()],
        }]);
        let suppressions = SuppressionContext::empty();
        let pr = make_plugin_result(vec![(
            "src/api/**".to_string(),
            vec!["handler".to_string()],
        )]);
        let (exports, _, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            Some(&pr),
            &suppressions,
            &FxHashMap::default(),
        );
        assert!(
            exports.is_empty(),
            "export matching both config and plugin should be ignored"
        );
    }

    #[test]
    fn unused_exports_invalid_plugin_glob_skipped() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/utils.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("foo", 10, 20)];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let pr = make_plugin_result(vec![("[invalid".to_string(), vec!["foo".to_string()])]);
        let (exports, _, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            Some(&pr),
            &suppressions,
            &FxHashMap::default(),
        );
        assert_eq!(exports.len(), 1, "invalid plugin glob should be skipped");
    }

    #[test]
    fn unused_exports_marks_re_export_semantically() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/barrel.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![make_export("reexported", 100, 120)];
        graph.modules[1].re_exports = vec![ReExportEdge {
            source_file: FileId(0),
            imported_name: "reexported".to_string(),
            exported_name: "reexported".to_string(),
            is_type_only: false,
            span: oxc_span::Span::default(),
        }];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, _, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert_eq!(exports.len(), 1);
        assert!(
            exports[0].is_re_export,
            "export with matching ReExportEdge should be flagged as re-export"
        );
        assert_eq!(exports[0].span_start, 100);
    }

    #[test]
    fn collect_usages_empty_graph() {
        let graph = build_graph(&[]);
        let result = collect_export_usages(&graph, &FxHashMap::default());
        assert!(result.is_empty());
    }

    #[test]
    fn collect_usages_skips_unreachable_modules() {
        let mut graph = build_graph(&[("/src/dead.ts", false)]);
        graph.modules[0].exports = vec![make_export("unused", 10, 20)];
        let result = collect_export_usages(&graph, &FxHashMap::default());
        assert!(result.is_empty());
    }

    #[test]
    fn collect_usages_skips_synthetic_exports() {
        let mut graph = build_graph(&[("/src/barrel.ts", true)]);
        graph.modules[0].exports = vec![make_export("reexported", 0, 0)];
        let result = collect_export_usages(&graph, &FxHashMap::default());
        assert!(result.is_empty());
    }

    #[test]
    fn collect_usages_counts_references() {
        let mut graph = build_graph(&[("/src/utils.ts", true), ("/src/app.ts", false)]);
        graph.modules[0].exports = vec![make_referenced_export("helper", 10, 20, 1)];
        let result = collect_export_usages(&graph, &FxHashMap::default());
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].export_name, "helper");
        assert_eq!(result[0].reference_count, 1);
    }

    #[test]
    fn collect_usages_zero_references_still_reported() {
        let mut graph = build_graph(&[("/src/utils.ts", true)]);
        graph.modules[0].exports = vec![make_export("unused", 10, 20)];
        let result = collect_export_usages(&graph, &FxHashMap::default());
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].export_name, "unused");
        assert_eq!(result[0].reference_count, 0);
        assert!(result[0].reference_locations.is_empty());
    }

    #[test]
    fn collect_usages_multiple_exports_same_module() {
        let mut graph = build_graph(&[("/src/utils.ts", true)]);
        graph.modules[0].exports = vec![make_export("alpha", 10, 20), make_export("beta", 30, 40)];
        let result = collect_export_usages(&graph, &FxHashMap::default());
        assert_eq!(result.len(), 2);
        let names: FxHashSet<&str> = result.iter().map(|u| u.export_name.as_str()).collect();
        assert!(names.contains("alpha"));
        assert!(names.contains("beta"));
    }

    #[test]
    fn unused_exports_checks_unreachable_module_with_mixed_references() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/helpers.ts", false),
            ("/tmp/test/src/setup.ts", false),
        ]);
        graph.modules[1].exports = vec![
            ExportSymbol {
                name: ExportName::Named("usedByUnreachable".to_string()),
                is_type_only: false,
                is_side_effect_used: false,
                visibility: VisibilityTag::None,
                expected_unused_reason: None,
                span: Span::new(10, 30),
                references: vec![SymbolReference {
                    from_file: FileId(2),
                    kind: crate::graph::ReferenceKind::NamedImport,
                    import_span: Span::new(0, 10),
                }],
                members: vec![],
            },
            make_export("totallyUnused", 40, 55),
        ];
        graph.modules[2].exports = vec![];

        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, types, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        let names: FxHashSet<&str> = exports.iter().map(|e| e.export_name.as_str()).collect();
        assert!(
            names.contains("usedByUnreachable"),
            "reference from unreachable module should not save an export"
        );
        assert!(
            names.contains("totallyUnused"),
            "completely unreferenced export should be flagged"
        );
        assert_eq!(exports.len(), 2);
        assert!(types.is_empty());
    }

    #[test]
    fn unused_exports_skips_export_referenced_by_reachable() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/helpers.ts", false),
        ]);
        graph.modules[1].exports = vec![ExportSymbol {
            name: ExportName::Named("usedByReachable".to_string()),
            is_type_only: false,
            is_side_effect_used: false,
            visibility: VisibilityTag::None,
            expected_unused_reason: None,
            span: Span::new(10, 28),
            references: vec![SymbolReference {
                from_file: FileId(0),
                kind: crate::graph::ReferenceKind::NamedImport,
                import_span: Span::new(0, 10),
            }],
            members: vec![],
        }];

        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, types, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert!(
            exports.is_empty(),
            "export referenced by reachable module should not be flagged"
        );
        assert!(types.is_empty());
    }

    #[test]
    fn unused_exports_skips_internal_visibility() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/utils.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![ExportSymbol {
            name: ExportName::Named("internalHelper".to_string()),
            is_type_only: false,
            is_side_effect_used: false,
            visibility: VisibilityTag::Internal,
            expected_unused_reason: None,
            span: Span::new(10, 30),
            references: vec![],
            members: vec![],
        }];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, types, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert!(
            exports.is_empty(),
            "@internal export should not be flagged as unused"
        );
        assert!(types.is_empty());
    }

    #[test]
    fn unused_exports_skips_beta_visibility() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/utils.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![ExportSymbol {
            name: ExportName::Named("betaFeature".to_string()),
            is_type_only: false,
            is_side_effect_used: false,
            visibility: VisibilityTag::Beta,
            expected_unused_reason: None,
            span: Span::new(10, 30),
            references: vec![],
            members: vec![],
        }];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, types, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert!(
            exports.is_empty(),
            "@beta export should not be flagged as unused"
        );
        assert!(types.is_empty());
    }

    #[test]
    fn unused_exports_skips_alpha_visibility() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/utils.ts", false),
        ]);
        graph.modules[1].set_reachable(true);
        graph.modules[1].exports = vec![ExportSymbol {
            name: ExportName::Named("alphaFeature".to_string()),
            is_type_only: false,
            is_side_effect_used: false,
            visibility: VisibilityTag::Alpha,
            expected_unused_reason: None,
            span: Span::new(10, 30),
            references: vec![],
            members: vec![],
        }];
        let config = test_config();
        let suppressions = SuppressionContext::empty();
        let (exports, types, _stale) = find_unused_exports(
            &graph,
            &[],
            &config,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert!(
            exports.is_empty(),
            "@alpha export should not be flagged as unused"
        );
        assert!(types.is_empty());
    }

    #[test]
    fn unused_exports_include_entry_exports_flag() {
        let mut graph = build_graph(&[("/tmp/test/src/entry.ts", true)]);
        graph.modules[0].exports = vec![make_export("main", 10, 20)];

        let config_off = test_config();
        assert!(!config_off.include_entry_exports);
        let suppressions = SuppressionContext::empty();
        let (exports_off, _, _stale) = find_unused_exports(
            &graph,
            &[],
            &config_off,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert!(
            exports_off.is_empty(),
            "entry export should be skipped when include_entry_exports is false"
        );

        let mut config_on = test_config();
        config_on.include_entry_exports = true;
        let (exports_on, _, _stale) = find_unused_exports(
            &graph,
            &[],
            &config_on,
            None,
            &suppressions,
            &FxHashMap::default(),
        );
        assert_eq!(
            exports_on.len(),
            1,
            "entry export should be flagged when include_entry_exports is true"
        );
        assert_eq!(exports_on[0].export_name, "main");
    }
}
