use rustc_hash::{FxHashMap, FxHashSet};

use fallow_config::ResolvedConfig;

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

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

/// Pre-compiled glob matchers for config ignore_exports rules.
type IgnoreMatchers<'a> = Vec<(globset::GlobMatcher, &'a [String])>;

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

/// Compile config ignore_exports rules into glob matchers.
fn compile_ignore_matchers(config: &ResolvedConfig) -> IgnoreMatchers<'_> {
    config
        .ignore_export_rules
        .iter()
        .filter_map(|rule| match globset::Glob::new(&rule.file) {
            Ok(g) => Some((g.compile_matcher(), rule.exports.as_slice())),
            Err(e) => {
                tracing::warn!("invalid ignoreExports pattern '{}': {e}", rule.file);
                None
            }
        })
        .collect()
}

/// 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()
}

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.
///
/// Skips entry points that do not have framework/plugin `used_exports` handling,
/// CJS-only modules, Svelte files (whose `export let` declarations are component
/// props), and fully-unreachable modules where every export has zero references
/// (those are already caught by `find_unused_files`). Unreachable modules with
/// *some* referenced exports are NOT skipped — their individually unused exports
/// would otherwise slip through both detectors.
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() {
        // Completely unreachable with no references at all → caught by find_unused_files
        return module.exports.iter().all(|e| e.references.is_empty());
    }
    // CJS modules with module.exports but no named exports: hard to track individually
    if module.has_cjs_exports() && module.exports.is_empty() {
        return true;
    }
    // Svelte `export let`/`export const` are component props consumed by the runtime;
    // unreachable Svelte files are still caught by `find_unused_files`.
    module.path.extension().is_some_and(|ext| ext == "svelte")
}

/// Check whether an export name is covered by config ignore rules or plugin/framework 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(&export_name))
}

/// Find exports that are never imported by other files.
pub fn find_unused_exports(
    graph: &ModuleGraph,
    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 = compile_ignore_matchers(config);
    let plugin_matchers = compile_plugin_matchers(plugin_result);

    // Precompute reachable FileIds so we can distinguish meaningful references
    // (from reachable modules) from unreachable-to-unreachable references.
    let reachable_files: FxHashSet<u32> = graph
        .modules
        .iter()
        .filter(|m| m.is_reachable())
        .map(|m| m.file_id.0)
        .collect();

    for module in &graph.modules {
        // Compute relative path once per module for glob matching
        let relative_path = module
            .path
            .strip_prefix(&config.root)
            .unwrap_or(&module.path);
        let file_str = relative_path.to_string_lossy();

        // Collect ignore/plugin matchers that apply to this file
        let matching_ignore: Vec<&[String]> = ignore_matchers
            .iter()
            .filter(|(m, _)| m.is_match(file_str.as_ref()))
            .map(|(_, exports)| *exports)
            .collect();

        let matching_plugin: Vec<&[&str]> = plugin_matchers
            .iter()
            .filter(|rule| rule.matches(file_str.as_ref()))
            .map(|rule| rule.exports.as_slice())
            .collect();

        if should_skip_module(
            module,
            !matching_plugin.is_empty(),
            config.include_entry_exports,
        ) {
            continue;
        }

        // Namespace imports are now handled with member-access narrowing in graph.rs:
        // only specific accessed members get references populated. No blanket skip needed.

        // Pre-compute the set of re-exported names for O(1) is_re_export lookups
        // inside the export loop. Barrel files synthesize one ExportSymbol per
        // ReExportEdge, so the naive iter().any() check would be O(N²).
        let re_export_names: FxHashSet<&str> = module
            .re_exports
            .iter()
            .map(|re| re.exported_name.as_str())
            .collect();

        for export in &module.exports {
            // For unreachable modules, only references from reachable files count —
            // references from other unreachable modules don't save an export.
            let is_referenced = if module.is_reachable() {
                !export.references.is_empty()
            } else {
                export
                    .references
                    .iter()
                    .any(|r| reachable_files.contains(&r.from_file.0))
            };
            // Handle @expected-unused: if the export IS used (has references from
            // reachable modules), report as stale. If it's NOT used, suppress it
            // silently (the tag is working as intended). Note: re-exports through
            // barrel files DO count as references here, since the reference list
            // is already filtered to reachable modules above.
            if matches!(
                export.visibility,
                fallow_types::extract::VisibilityTag::ExpectedUnused
            ) {
                if is_referenced {
                    let (line, col) = byte_offset_to_line_col(
                        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(),
                        },
                    });
                }
                continue;
            }

            // Other visibility tags (@public, @internal, @alpha, @beta) permanently suppress
            if export.visibility.suppresses_unused() || is_referenced {
                continue;
            }

            let export_str = export.name.to_string();

            if is_export_ignored(&export_str, &matching_ignore, &matching_plugin) {
                continue;
            }

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

            // Detect re-exports semantically by looking up the export name in the
            // module's re_exports set, rather than relying on a span sentinel.
            // This catches both synthesized re-exports (which still use Span::default()
            // for narrowing/star cases) and real re-exports (which carry the visitor's
            // span for accurate line-number reporting).
            let is_re_export = re_export_names.contains(export_str.as_str());

            // Check inline suppression
            let issue_kind = if export.is_type_only {
                IssueKind::UnusedType
            } else {
                IssueKind::UnusedExport
            };
            if suppressions.is_suppressed(module.file_id, line, issue_kind) {
                continue;
            }

            let unused = UnusedExport {
                path: module.path.clone(),
                export_name: export_str,
                is_type_only: export.is_type_only,
                line,
                col,
                span_start: export.span.start,
                is_re_export,
            };

            if export.is_type_only {
                unused_types.push(unused);
            } else {
                unused_exports.push(unused);
            }
        }
    }

    (unused_exports, unused_types, stale_expected_unused)
}

/// 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.
pub fn find_duplicate_exports(
    graph: &ModuleGraph,
    suppressions: &SuppressionContext<'_>,
    line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<DuplicateExport> {
    // Build a set of re-export relationships: (re-exporting module idx) -> set of (source module idx)
    let mut re_export_sources: FxHashMap<usize, FxHashSet<usize>> = FxHashMap::default();
    for (idx, module) in graph.modules.iter().enumerate() {
        for re in &module.re_exports {
            re_export_sources
                .entry(idx)
                .or_default()
                .insert(re.source_file.0 as usize);
        }
    }

    struct ExportEntry {
        module_idx: usize,
        path: std::path::PathBuf,
        file_id: FileId,
        span_start: u32,
        is_type_only: bool,
    }

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

    for (idx, module) in graph.modules.iter().enumerate() {
        if !module.is_reachable() || module.is_entry_point() {
            continue;
        }

        // Skip files with file-wide duplicate-export suppression
        if suppressions.is_file_suppressed(module.file_id, IssueKind::DuplicateExport) {
            continue;
        }

        for export in &module.exports {
            if matches!(export.name, crate::extract::ExportName::Default) {
                continue; // Skip default exports
            }
            // Skip synthetic re-export entries (span 0..0) — these are generated by
            // graph construction for re-exports, not real local declarations
            if export.span.start == 0 && export.span.end == 0 {
                continue;
            }
            let name = export.name.to_string();
            export_locations.entry(name).or_default().push(ExportEntry {
                module_idx: idx,
                path: module.path.clone(),
                file_id: module.file_id,
                span_start: export.span.start,
                is_type_only: export.is_type_only,
            });
        }
    }

    // Filter: only keep truly independent duplicates (not re-export chains)
    // Sort by export name for deterministic output order
    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)| {
            if locations.len() <= 1 {
                return None;
            }

            // TypeScript declaration merging: a value export (`export const X`) and
            // a type export (`export type X`) sharing the same name are distinct in
            // TS's value/type namespace split. This is idiomatic with Zod, Prisma,
            // class+interface merging, etc. Skip groups that mix value and type exports.
            let has_value = locations.iter().any(|e| !e.is_type_only);
            let has_type = locations.iter().any(|e| e.is_type_only);
            if has_value && has_type {
                // Deduplicate within each namespace: keep only value-only or type-only
                // entries and check if either namespace alone has duplicates.
                let value_modules: FxHashSet<usize> = locations
                    .iter()
                    .filter(|e| !e.is_type_only)
                    .map(|e| e.module_idx)
                    .collect();
                let type_modules: FxHashSet<usize> = locations
                    .iter()
                    .filter(|e| e.is_type_only)
                    .map(|e| e.module_idx)
                    .collect();
                // If neither namespace alone has cross-file duplicates, skip entirely
                if value_modules.len() <= 1 && type_modules.len() <= 1 {
                    return None;
                }
            }

            // Remove entries where one module re-exports from another in the set.
            // For each pair (A, B), if A re-exports from B or B re-exports from A,
            // they are part of the same export chain, not true duplicates.
            let module_indices: FxHashSet<usize> = locations.iter().map(|e| e.module_idx).collect();
            let independent: Vec<DuplicateLocation> = 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
                })
                .map(|e| {
                    let (line, col) =
                        byte_offset_to_line_col(line_offsets_by_file, e.file_id, e.span_start);
                    DuplicateLocation {
                        path: e.path,
                        line,
                        col,
                    }
                })
                .collect();

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

            // Filter: only report duplicates where at least two files share a common
            // importer in the module graph. Unrelated leaf files (e.g., SvelteKit route
            // modules in different directories) that happen to export the same name
            // are not actionable duplicates since they can never be confused at an
            // import site.
            let has_shared_importer = has_common_importer(&independent, graph);
            if has_shared_importer {
                Some(DuplicateExport {
                    export_name: name,
                    locations: independent,
                })
            } else {
                None
            }
        })
        .collect()
}

/// Check if any two files in the duplicate set share a common importer.
///
/// Two files "share a common importer" if there exists a third file that imports
/// from both. This filters out false positives from unrelated leaf modules (e.g.,
/// SvelteKit route files in different directories) that coincidentally export the
/// same name but are never imported together.
fn has_common_importer(locations: &[DuplicateLocation], graph: &ModuleGraph) -> bool {
    if locations.len() <= 1 {
        return false;
    }

    // Collect FileIds for the duplicate locations by matching paths
    let file_ids: Vec<FileId> = locations
        .iter()
        .filter_map(|loc| {
            graph
                .modules
                .iter()
                .find(|m| m.path == loc.path)
                .map(|m| m.file_id)
        })
        .collect();

    if file_ids.len() <= 1 {
        return false;
    }

    // For each pair, check if they share a common importer via reverse_deps
    for i in 0..file_ids.len() {
        let idx_i = file_ids[i].0 as usize;
        if idx_i >= graph.reverse_deps.len() {
            continue;
        }
        let importers_i: FxHashSet<FileId> = graph.reverse_deps[idx_i].iter().copied().collect();
        for j in (i + 1)..file_ids.len() {
            let idx_j = file_ids[j].0 as usize;
            if idx_j >= graph.reverse_deps.len() {
                continue;
            }
            // Check if any importer of file j also imports file i
            if graph.reverse_deps[idx_j]
                .iter()
                .any(|imp| importers_i.contains(imp))
            {
                return true;
            }
            // Also check if one directly imports the other
            if importers_i.contains(&file_ids[j])
                || graph.reverse_deps[idx_j].contains(&file_ids[i])
            {
                return true;
            }
        }
    }
    false
}

/// 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();

    // Build FileId -> path index for resolving reference locations
    let file_paths: FxHashMap<FileId, &std::path::Path> = graph
        .modules
        .iter()
        .map(|m| (m.file_id, m.path.as_path()))
        .collect();

    // Fallback source + line-offset cache for reference locations not in the line offsets map.
    // Only populated when a referencing file's line offsets are unavailable.
    // Caches both source and computed offsets to avoid redundant recomputation.
    let mut source_cache: FxHashMap<FileId, (String, Vec<u32>)> = FxHashMap::default();

    for module in &graph.modules {
        // Skip unreachable modules — no point showing Code Lens for files
        // that aren't reachable from any entry point
        if !module.is_reachable() {
            continue;
        }

        for export in &module.exports {
            // Skip synthetic re-export entries (span 0..0) — these are generated
            // by graph construction, not real local declarations in the source
            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);

            // Resolve reference locations for Code Lens navigation
            let reference_locations: Vec<ReferenceLocation> = export
                .references
                .iter()
                .filter_map(|r| {
                    // Skip references with no span (e.g. from dynamic import patterns)
                    if r.import_span.start == 0 && r.import_span.end == 0 {
                        return None;
                    }
                    let ref_path = file_paths.get(&r.from_file)?;
                    // Use pre-computed line offsets when available, fall back to disk read
                    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();

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

    usages
}

#[cfg(test)]
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![],
                whole_object_uses: vec![],
                has_cjs_exports: false,
                unused_import_bindings: FxHashSet::default(),
                type_referenced_import_bindings: vec![],
                value_referenced_import_bindings: vec![],
            })
            .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,
        )
    }

    fn make_export(name: &str, span_start: u32, span_end: u32) -> ExportSymbol {
        ExportSymbol {
            name: ExportName::Named(name.to_string()),
            is_type_only: false,
            visibility: VisibilityTag::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,
            visibility: VisibilityTag::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![],
        }
    }

    // ---- find_duplicate_exports tests ----

    #[test]
    fn duplicate_exports_empty_graph() {
        let graph = build_graph(&[]);
        let suppressions = SuppressionContext::empty();
        let result = find_duplicate_exports(&graph, &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 result = find_duplicate_exports(&graph, &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)];
        // entry.ts imports both a.ts and b.ts — they share a common importer
        graph.reverse_deps[1] = vec![FileId(0)];
        graph.reverse_deps[2] = vec![FileId(0)];
        let suppressions = SuppressionContext::empty();
        let result = find_duplicate_exports(&graph, &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,
            visibility: VisibilityTag::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,
            visibility: VisibilityTag::None,
            span: Span::new(10, 20),
            references: vec![],
            members: vec![],
        }];
        let suppressions = SuppressionContext::empty();
        let result = find_duplicate_exports(&graph, &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 result = find_duplicate_exports(&graph, &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)];
        // Module 2 stays unreachable
        graph.modules[2].exports = vec![make_export("helper", 10, 20)];
        let suppressions = SuppressionContext::empty();
        let result = find_duplicate_exports(&graph, &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 result = find_duplicate_exports(&graph, &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 result = find_duplicate_exports(&graph, &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 {
            line: 0,
            comment_line: 1,
            kind: Some(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 result = find_duplicate_exports(&graph, &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)];
        }
        // entry.ts imports all three — they share a common importer
        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 result = find_duplicate_exports(&graph, &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() {
        // Two route files exporting the same name but with no common importer
        // (e.g., SvelteKit routes in different directories)
        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)];
        // No shared importer: each is imported by a different parent
        // (or not imported at all — just reachable via framework routing)
        let suppressions = SuppressionContext::empty();
        let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
        assert!(
            result.is_empty(),
            "unrelated leaf files should not be flagged as duplicates"
        );
    }

    #[test]
    fn duplicate_exports_direct_import_still_flagged() {
        // Two files where one imports the other — they are connected
        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)];
        // a.ts imports b.ts directly
        graph.reverse_deps[2] = vec![FileId(1)];
        let suppressions = SuppressionContext::empty();
        let result = find_duplicate_exports(&graph, &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 result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
        assert!(result.is_empty());
    }

    #[test]
    fn duplicate_exports_value_type_merging_not_flagged() {
        // `export const Status = z.enum([...])` + `export type Status = z.infer<typeof Status>`
        // in the same file is TypeScript declaration merging, not a duplicate.
        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),      // value export
            make_type_export("Status", 50, 60), // type export
        ];
        graph.reverse_deps[1] = vec![FileId(0)];
        let suppressions = SuppressionContext::empty();
        let result = find_duplicate_exports(&graph, &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() {
        // File A exports `const Status` and file B exports `type Status`.
        // These are in different TS namespaces and should not be 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 result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
        assert!(
            result.is_empty(),
            "cross-file value+type should not be flagged"
        );
    }

    #[test]
    fn duplicate_exports_same_namespace_still_flagged() {
        // Two files both export `const helper` (both value exports) — this IS a duplicate.
        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 result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
        assert_eq!(
            result.len(),
            1,
            "same-namespace duplicates should still be flagged"
        );
    }

    // ---- find_unused_exports tests (exercises compile_ignore_matchers, compile_plugin_matchers,
    //       should_skip_module, is_export_ignored) ----

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

    /// Helper: build a minimal AggregatedPluginResult with used_exports.
    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![],
            discovered_always_used: vec![],
            setup_files: vec![],
            tooling_dependencies: vec![],
            script_used_packages: FxHashSet::default(),
            virtual_module_prefixes: vec![],
            generated_import_patterns: vec![],
            path_aliases: vec![],
            active_plugins: vec![],
            fixture_patterns: 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,
            visibility: VisibilityTag::None,
            span: Span::new(span_start, span_end),
            references: vec![],
            members: vec![],
        }
    }

    // -- find_unused_exports: basic behavior --

    #[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,
            visibility: VisibilityTag::Public,
            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");
    }

    // -- should_skip_module: unreachable --

    #[test]
    fn unused_exports_skips_unreachable_module() {
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/dead.ts", false),
        ]);
        // Module stays unreachable (default)
        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());
    }

    // -- should_skip_module: entry point --

    #[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());
    }

    // -- should_skip_module: CJS-only --

    #[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);
        // No named exports, only module.exports
        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");
    }

    // -- should_skip_module: Svelte files --

    #[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());
    }

    // -- should_skip_module: module passes all checks --

    #[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");
    }

    // -- compile_ignore_matchers: empty config --

    #[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(); // no ignore_exports rules
        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"
        );
    }

    // -- compile_ignore_matchers: multiple patterns --

    #[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"
        );
    }

    // -- compile_ignore_matchers: invalid glob handled gracefully --

    #[test]
    fn unused_exports_invalid_ignore_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)];

        // Invalid glob pattern with unclosed bracket
        let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
            file: "[invalid".to_string(),
            exports: vec!["*".to_string()],
        }]);
        let suppressions = SuppressionContext::empty();
        // Should not panic — invalid globs are silently skipped
        let (exports, _, _stale) =
            find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
        assert_eq!(
            exports.len(),
            1,
            "invalid glob should be skipped, export still reported"
        );
    }

    // -- is_export_ignored: config wildcard match --

    #[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"
        );
    }

    // -- is_export_ignored: config specific name match --

    #[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");
    }

    // -- is_export_ignored: no match --

    #[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"
        );
    }

    // -- compile_plugin_matchers: no plugin result --

    #[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"
        );
    }

    // -- compile_plugin_matchers: plugin with empty used_exports --

    #[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"
        );
    }

    // -- compile_plugin_matchers / is_export_ignored: plugin used_exports match --

    #[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");
    }

    // -- is_export_ignored: matching both config and plugin --

    #[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"
        );
    }

    // -- compile_plugin_matchers: invalid plugin glob handled gracefully --

    #[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()])]);
        // Should not panic
        let (exports, _, _stale) = find_unused_exports(
            &graph,
            &config,
            Some(&pr),
            &suppressions,
            &FxHashMap::default(),
        );
        assert_eq!(exports.len(), 1, "invalid plugin glob should be skipped");
    }

    // -- find_unused_exports: re-export semantic detection --

    #[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)];
        // The export must have a matching ReExportEdge for the unused-export
        // detector to classify it as a re-export. This mirrors how the graph
        // builder synthesizes ExportSymbol entries from ReExportInfo.
        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"
        );
        // span_start carries the original byte offset (100), not the (0,0) sentinel
        // — confirms that the re-export reporting uses the visitor's real span.
        assert_eq!(exports[0].span_start, 100);
    }

    // ---- collect_export_usages tests ----

    #[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"));
    }

    // -- unreachable module with mixed references (blindspot fix) --

    #[test]
    fn unused_exports_checks_unreachable_module_with_mixed_references() {
        // Unreachable module with 2 exports:
        // - "usedByUnreachable" referenced by another unreachable module (should still be flagged)
        // - "totallyUnused" referenced by nobody (should be flagged)
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/helpers.ts", false),
            ("/tmp/test/src/setup.ts", false),
        ]);
        // helpers.ts is unreachable, has one export referenced by setup.ts (also unreachable)
        graph.modules[1].exports = vec![
            ExportSymbol {
                name: ExportName::Named("usedByUnreachable".to_string()),
                is_type_only: false,
                visibility: VisibilityTag::None,
                span: Span::new(10, 30),
                references: vec![SymbolReference {
                    from_file: FileId(2), // setup.ts — also unreachable
                    kind: crate::graph::ReferenceKind::NamedImport,
                    import_span: Span::new(0, 10),
                }],
                members: vec![],
            },
            make_export("totallyUnused", 40, 55),
        ];
        // setup.ts is also unreachable
        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());
        // Both exports should be flagged: the unreachable-to-unreachable reference doesn't count
        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() {
        // Unreachable module with 1 export referenced by a REACHABLE module.
        // The export should NOT be flagged as unused.
        let mut graph = build_graph(&[
            ("/tmp/test/src/entry.ts", true),
            ("/tmp/test/src/helpers.ts", false),
        ]);
        // helpers.ts is unreachable but has an export referenced by entry.ts (reachable)
        graph.modules[1].exports = vec![ExportSymbol {
            name: ExportName::Named("usedByReachable".to_string()),
            is_type_only: false,
            visibility: VisibilityTag::None,
            span: Span::new(10, 28),
            references: vec![SymbolReference {
                from_file: FileId(0), // entry.ts — reachable (entry point)
                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());
    }

    // -- VisibilityTag suppression --

    #[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,
            visibility: VisibilityTag::Internal,
            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,
            visibility: VisibilityTag::Beta,
            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,
            visibility: VisibilityTag::Alpha,
            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());
    }

    // -- include_entry_exports --

    #[test]
    fn unused_exports_include_entry_exports_flag() {
        // With include_entry_exports = false (default), entry point exports are skipped
        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"
        );

        // With include_entry_exports = true, entry point exports ARE checked
        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");
    }
}
