use std::path::{Path, PathBuf};

use rustc_hash::{FxHashMap, FxHashSet};

use fallow_config::{PackageJson, ResolvedConfig};

use crate::discover::FileId;
use crate::graph::ModuleGraph;
use crate::resolve::ResolvedModule;
use crate::results::{
    DependencyLocation, ImportSite, TestOnlyDependency, TypeOnlyDependency, UnlistedDependency,
    UnresolvedImport, UnusedDependency,
};
use crate::suppress::{IssueKind, SuppressionContext};

use super::package_json_utils::{find_dep_line_in_json, read_pkg_json_content};
use super::predicates::{
    is_builtin_module, is_config_file, is_implicit_dependency, is_path_alias, is_virtual_module,
};
use super::{LineOffsetsMap, byte_offset_to_line_col};

/// Return `true` if a workspace `package.json` path is covered by `ignorePatterns`.
///
/// Mirrors the source-walker behavior in `fallow_core::discover::walk`: the glob
/// is matched against the project-root-relative path so relative patterns like
/// `**/dist/**` work as users expect. Without this check, workspace discovery
/// would include build-artifact `package.json` files (e.g., `dist/package.json`
/// generated by ng-packagr / tsc) and report their dependencies as unused.
/// See issue #124.
fn is_package_json_ignored(ws_pkg_path: &Path, config: &ResolvedConfig) -> bool {
    let relative = ws_pkg_path
        .strip_prefix(&config.root)
        .unwrap_or(ws_pkg_path);
    config.ignore_patterns.is_match(relative)
}

/// Per-category configuration for unused dependency detection.
///
/// Each dependency category (prod, dev, optional) has slightly different
/// filter rules. This struct captures those differences so a single helper
/// can handle all three categories.
pub struct DepCategoryConfig {
    /// Which `DependencyLocation` variant to tag results with.
    pub location: DependencyLocation,
    /// Whether to check `is_implicit_dependency` (prod + optional = true, dev = false).
    pub check_implicit: bool,
    /// Whether to check `is_known_tooling_dependency` (dev = true, others = false).
    pub check_known_tooling: bool,
    /// Whether to check `plugin_tooling` set (prod + dev = true, optional = false).
    pub check_plugin_tooling: bool,
}

/// Shared sets used by `collect_unused_for_category` to filter dependencies.
pub struct SharedDepSets<'a> {
    pub plugin_referenced: &'a FxHashSet<&'a str>,
    pub plugin_tooling: &'a FxHashSet<&'a str>,
    pub script_used: &'a FxHashSet<&'a str>,
    pub workspace_names: &'a FxHashSet<&'a str>,
    pub ignore_deps: &'a FxHashSet<&'a str>,
}

/// Collect unused dependencies for a single category (prod, dev, or optional).
///
/// Filters `dep_names` against usage data and category-specific rules, returning
/// `UnusedDependency` entries for deps that are unused.
pub fn collect_unused_for_category(
    dep_names: Vec<String>,
    category: &DepCategoryConfig,
    shared: &SharedDepSets<'_>,
    is_used: impl Fn(&str) -> bool,
    pkg_path: &Path,
    pkg_content: Option<&str>,
) -> Vec<UnusedDependency> {
    dep_names
        .into_iter()
        .filter(|dep| !is_used(dep))
        .filter(|dep| !shared.script_used.contains(dep.as_str()))
        .filter(|dep| !category.check_implicit || !is_implicit_dependency(dep))
        .filter(|dep| {
            !category.check_known_tooling || !crate::plugins::is_known_tooling_dependency(dep)
        })
        .filter(|dep| {
            !category.check_plugin_tooling || !shared.plugin_tooling.contains(dep.as_str())
        })
        .filter(|dep| !shared.plugin_referenced.contains(dep.as_str()))
        .filter(|dep| !shared.ignore_deps.contains(dep.as_str()))
        .filter(|dep| !shared.workspace_names.contains(dep.as_str()))
        .map(|dep| {
            let line = pkg_content.map_or(1, |c| find_dep_line_in_json(c, &dep));
            UnusedDependency {
                package_name: dep,
                location: category.location.clone(),
                path: pkg_path.to_path_buf(),
                line,
            }
        })
        .collect()
}

/// Category configs for the three dependency types.
const fn prod_category() -> DepCategoryConfig {
    DepCategoryConfig {
        location: DependencyLocation::Dependencies,
        check_implicit: true,
        check_known_tooling: false,
        check_plugin_tooling: true,
    }
}

const fn dev_category() -> DepCategoryConfig {
    DepCategoryConfig {
        location: DependencyLocation::DevDependencies,
        check_implicit: false,
        check_known_tooling: true,
        check_plugin_tooling: true,
    }
}

const fn optional_category() -> DepCategoryConfig {
    DepCategoryConfig {
        location: DependencyLocation::OptionalDependencies,
        check_implicit: true,
        check_known_tooling: false,
        check_plugin_tooling: false,
    }
}

/// Find dependencies in package.json that are never imported.
///
/// Checks both the root package.json and each workspace's package.json.
/// For workspace deps, only files within that workspace are considered when
/// determining whether a dependency is used (mirroring `find_unlisted_dependencies`).
pub fn find_unused_dependencies(
    graph: &ModuleGraph,
    pkg: &PackageJson,
    config: &ResolvedConfig,
    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
    workspaces: &[fallow_config::WorkspaceInfo],
) -> (
    Vec<UnusedDependency>,
    Vec<UnusedDependency>,
    Vec<UnusedDependency>,
) {
    // Collect deps referenced in config files (discovered by plugins)
    let plugin_referenced: FxHashSet<&str> = plugin_result
        .map(|pr| {
            pr.referenced_dependencies
                .iter()
                .map(String::as_str)
                .collect()
        })
        .unwrap_or_default();

    // Collect tooling deps from plugins
    let plugin_tooling: FxHashSet<&str> = plugin_result
        .map(|pr| pr.tooling_dependencies.iter().map(String::as_str).collect())
        .unwrap_or_default();

    // Collect packages used as binaries in package.json scripts
    let script_used: FxHashSet<&str> = plugin_result
        .map(|pr| pr.script_used_packages.iter().map(String::as_str).collect())
        .unwrap_or_default();

    // Collect workspace package names — these are internal deps, not npm packages
    let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();

    // Pre-compute ignore deps as FxHashSet for O(1) lookups instead of O(n) linear scan
    let ignore_deps: FxHashSet<&str> = config
        .ignore_dependencies
        .iter()
        .map(String::as_str)
        .collect();

    // Build per-package set of files that use it (globally)
    let used_packages: FxHashSet<&str> = graph.package_usage.keys().map(String::as_str).collect();

    let root_pkg_path = config.root.join("package.json");
    let root_pkg_content = read_pkg_json_content(&root_pkg_path);

    let shared = SharedDepSets {
        plugin_referenced: &plugin_referenced,
        plugin_tooling: &plugin_tooling,
        script_used: &script_used,
        workspace_names: &workspace_names,
        ignore_deps: &ignore_deps,
    };

    let is_used_globally = |dep: &str| used_packages.contains(dep);

    // --- Root package.json check (existing behavior: any file can satisfy usage) ---
    let mut unused_deps = collect_unused_for_category(
        pkg.production_dependency_names(),
        &prod_category(),
        &shared,
        is_used_globally,
        &root_pkg_path,
        root_pkg_content.as_deref(),
    );

    let mut unused_dev_deps = collect_unused_for_category(
        pkg.dev_dependency_names(),
        &dev_category(),
        &shared,
        is_used_globally,
        &root_pkg_path,
        root_pkg_content.as_deref(),
    );

    let mut unused_optional_deps = collect_unused_for_category(
        pkg.optional_dependency_names(),
        &optional_category(),
        &shared,
        is_used_globally,
        &root_pkg_path,
        root_pkg_content.as_deref(),
    );

    // --- Workspace package.json checks: scope usage to files within each workspace ---
    // Track which deps are already flagged from root to avoid double-reporting
    let root_flagged: FxHashSet<String> = unused_deps
        .iter()
        .chain(unused_dev_deps.iter())
        .chain(unused_optional_deps.iter())
        .map(|d| d.package_name.clone())
        .collect();

    for ws in workspaces {
        let ws_pkg_path = ws.root.join("package.json");
        if is_package_json_ignored(&ws_pkg_path, config) {
            continue;
        }
        let Ok(ws_pkg) = PackageJson::load(&ws_pkg_path) else {
            continue;
        };
        let ws_pkg_content = read_pkg_json_content(&ws_pkg_path);

        // Helper: check if a dependency is used by any file within this workspace.
        // Uses raw path comparison (module paths are absolute, workspace root is absolute)
        // to avoid per-file canonicalize() syscalls.
        let ws_root = &ws.root;
        let is_used_in_workspace = |dep: &str| -> bool {
            root_flagged.contains(dep)
                || graph.package_usage.get(dep).is_some_and(|file_ids| {
                    file_ids.iter().any(|id| {
                        graph
                            .modules
                            .get(id.0 as usize)
                            .is_some_and(|module| module.path.starts_with(ws_root))
                    })
                })
        };

        unused_deps.extend(collect_unused_for_category(
            ws_pkg.production_dependency_names(),
            &prod_category(),
            &shared,
            is_used_in_workspace,
            &ws_pkg_path,
            ws_pkg_content.as_deref(),
        ));

        unused_dev_deps.extend(collect_unused_for_category(
            ws_pkg.dev_dependency_names(),
            &dev_category(),
            &shared,
            is_used_in_workspace,
            &ws_pkg_path,
            ws_pkg_content.as_deref(),
        ));

        unused_optional_deps.extend(collect_unused_for_category(
            ws_pkg.optional_dependency_names(),
            &optional_category(),
            &shared,
            is_used_in_workspace,
            &ws_pkg_path,
            ws_pkg_content.as_deref(),
        ));
    }

    (unused_deps, unused_dev_deps, unused_optional_deps)
}

/// Check if a dependency should be skipped during unused dependency analysis.
///
/// Shared guard conditions for both production and dev dependency loops:
/// already flagged from root, used in scripts, referenced by plugins, in ignore list,
/// is a workspace package, or used by files in the workspace.
///
/// Retained for test coverage of the individual guard logic.
#[cfg(test)]
fn should_skip_dependency(
    dep: &str,
    root_flagged: &FxHashSet<String>,
    script_used: &FxHashSet<&str>,
    plugin_referenced: &FxHashSet<&str>,
    ignore_deps: &FxHashSet<&str>,
    workspace_names: &FxHashSet<&str>,
    is_used_in_workspace: impl Fn(&str) -> bool,
) -> bool {
    root_flagged.contains(dep)
        || script_used.contains(dep)
        || plugin_referenced.contains(dep)
        || ignore_deps.contains(dep)
        || workspace_names.contains(dep)
        || is_used_in_workspace(dep)
}

/// Find production dependencies that are only imported via type-only imports.
///
/// In production mode, `import type { Foo } from 'pkg'` is erased at compile time,
/// meaning the dependency is not needed at runtime. Such dependencies should be
/// moved to devDependencies.
pub fn find_type_only_dependencies(
    graph: &ModuleGraph,
    pkg: &PackageJson,
    config: &ResolvedConfig,
    workspaces: &[fallow_config::WorkspaceInfo],
) -> Vec<TypeOnlyDependency> {
    let root_pkg_path = config.root.join("package.json");
    let root_pkg_content = read_pkg_json_content(&root_pkg_path);
    let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();

    let mut type_only_deps = Vec::new();

    // Check root production dependencies
    for dep in pkg.production_dependency_names() {
        // Skip internal workspace packages
        if workspace_names.contains(dep.as_str()) {
            continue;
        }
        // Skip ignored dependencies
        if config.ignore_dependencies.iter().any(|d| d == &dep) {
            continue;
        }

        let has_any_usage = graph.package_usage.contains_key(dep.as_str());
        let has_type_only_usage = graph.type_only_package_usage.contains_key(dep.as_str());

        if !has_any_usage {
            // Not used at all — this will be caught by unused_dependencies
            continue;
        }

        // Check if ALL usages are type-only: the number of type-only usages must equal
        // the total number of usages for this package
        let total_count = graph.package_usage.get(dep.as_str()).map_or(0, Vec::len);
        let type_only_count = graph
            .type_only_package_usage
            .get(dep.as_str())
            .map_or(0, Vec::len);

        if has_type_only_usage && type_only_count == total_count {
            let line = root_pkg_content
                .as_deref()
                .map_or(1, |c| find_dep_line_in_json(c, &dep));
            type_only_deps.push(TypeOnlyDependency {
                package_name: dep,
                path: root_pkg_path.clone(),
                line,
            });
        }
    }

    type_only_deps
}

/// Find production dependencies that are only imported by test/dev files.
///
/// When NOT in production mode (where test files are still discovered), a dep
/// that appears exclusively in test/story/config files should be a devDependency.
pub fn find_test_only_dependencies(
    graph: &ModuleGraph,
    pkg: &PackageJson,
    config: &ResolvedConfig,
    workspaces: &[fallow_config::WorkspaceInfo],
) -> Vec<TestOnlyDependency> {
    // Build a GlobSet from the production exclude patterns (test/dev/story files)
    let test_globs = {
        let mut builder = globset::GlobSetBuilder::new();
        for pattern in crate::discover::PRODUCTION_EXCLUDE_PATTERNS {
            if let Ok(glob) = globset::GlobBuilder::new(pattern)
                .literal_separator(true)
                .build()
            {
                builder.add(glob);
            }
        }
        match builder.build() {
            Ok(set) => set,
            Err(_) => return Vec::new(),
        }
    };

    let root_pkg_path = config.root.join("package.json");
    let root_pkg_content = read_pkg_json_content(&root_pkg_path);
    let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
    let ignore_deps: FxHashSet<&str> = config
        .ignore_dependencies
        .iter()
        .map(String::as_str)
        .collect();

    let mut test_only_deps = Vec::new();

    for dep in pkg.production_dependency_names() {
        if workspace_names.contains(dep.as_str()) {
            continue;
        }
        if ignore_deps.contains(dep.as_str()) {
            continue;
        }

        let Some(file_ids) = graph.package_usage.get(dep.as_str()) else {
            // Not used at all — caught by unused_dependencies
            continue;
        };

        // Skip if already caught as type-only (all usages are type-only imports)
        let total_count = file_ids.len();
        let type_only_count = graph
            .type_only_package_usage
            .get(dep.as_str())
            .map_or(0, Vec::len);
        if type_only_count == total_count {
            continue;
        }

        // Check if ALL importing files are test/dev/config files.
        // Test/story files are matched by glob patterns from PRODUCTION_EXCLUDE_PATTERNS.
        // Config files use the curated `is_config_file` predicate which avoids false
        // matches on application config files like `app.config.ts` (see #111, #112).
        let all_test_only = file_ids.iter().all(|id| {
            graph.modules.get(id.0 as usize).is_some_and(|module| {
                let relative = module
                    .path
                    .strip_prefix(&config.root)
                    .unwrap_or(&module.path);
                test_globs.is_match(relative) || is_config_file(&module.path)
            })
        });

        if all_test_only {
            let line = root_pkg_content
                .as_deref()
                .map_or(1, |c| find_dep_line_in_json(c, &dep));
            test_only_deps.push(TestOnlyDependency {
                package_name: dep,
                path: root_pkg_path.clone(),
                line,
            });
        }
    }

    test_only_deps
}

/// Check whether a package is listed in root deps or in the workspace that owns `file_path`.
pub fn is_package_listed_for_file(
    file_path: &Path,
    package_name: &str,
    root_deps: &FxHashSet<String>,
    ws_dep_map: &[(PathBuf, FxHashSet<String>)],
) -> bool {
    if root_deps.contains(package_name) {
        return true;
    }
    ws_dep_map
        .iter()
        .any(|(ws_root, ws_deps)| file_path.starts_with(ws_root) && ws_deps.contains(package_name))
}

/// Check if a corresponding `@types/<package>` is listed in dependencies.
///
/// When `@types/X` is installed but `X` itself is not, the dependency is used for types
/// only (e.g., `@types/geojson` for `import { Feature } from 'geojson'`). TypeScript
/// resolves types from `@types/X` automatically, and the import is erased at compile time
/// regardless of whether it uses the `import type` syntax.
///
/// For scoped packages like `@scope/pkg`, the DefinitelyTyped convention is `@types/scope__pkg`.
fn has_types_package(package_name: &str, all_workspace_deps: &FxHashSet<String>) -> bool {
    let types_name = package_name.strip_prefix('@').map_or_else(
        || format!("@types/{package_name}"),
        // @scope/pkg -> @types/scope__pkg
        |scoped| format!("@types/{}", scoped.replacen('/', "__", 1)),
    );
    all_workspace_deps.contains(&types_name)
}

/// Look up the import location (line, col) for a given package in a given file.
///
/// Falls back to `(1, 0)` when no span is found (e.g. re-export-only usages).
pub fn find_import_location(
    import_spans_by_file: &FxHashMap<FileId, Vec<(&str, u32)>>,
    line_offsets_by_file: &LineOffsetsMap<'_>,
    file_id: FileId,
    package_name: &str,
) -> (u32, u32) {
    import_spans_by_file
        .get(&file_id)
        .and_then(|spans| {
            spans
                .iter()
                .find(|(name, _)| *name == package_name)
                .map(|(_, span_start)| {
                    byte_offset_to_line_col(line_offsets_by_file, file_id, *span_start)
                })
        })
        .unwrap_or((1, 0))
}

/// Find dependencies used in imports but not listed in package.json.
pub fn find_unlisted_dependencies(
    graph: &ModuleGraph,
    pkg: &PackageJson,
    config: &ResolvedConfig,
    workspaces: &[fallow_config::WorkspaceInfo],
    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
    resolved_modules: &[ResolvedModule],
    line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<UnlistedDependency> {
    let all_deps: FxHashSet<String> = pkg.all_dependency_names().into_iter().collect();

    // Build a set of all deps across all workspace package.json files.
    // In monorepos, imports in workspace files reference deps from that workspace's package.json.
    let mut all_workspace_deps: FxHashSet<String> = all_deps.clone();
    // Also collect workspace package names — internal workspace deps should not be flagged
    let mut workspace_names: FxHashSet<String> = FxHashSet::default();
    // Map: canonical workspace root -> set of dep names (for per-file checks)
    let mut ws_dep_map: Vec<(PathBuf, FxHashSet<String>)> = Vec::new();

    for ws in workspaces {
        workspace_names.insert(ws.name.clone());
        let ws_pkg_path = ws.root.join("package.json");
        if is_package_json_ignored(&ws_pkg_path, config) {
            continue;
        }
        if let Ok(ws_pkg) = PackageJson::load(&ws_pkg_path) {
            let ws_deps: FxHashSet<String> = ws_pkg.all_dependency_names().into_iter().collect();
            all_workspace_deps.extend(ws_deps.iter().cloned());
            // Use raw workspace root path for starts_with checks (avoids per-file canonicalize)
            ws_dep_map.push((ws.root.clone(), ws_deps));
        }
    }

    // Collect virtual module prefixes from active plugins (e.g., Docusaurus @theme/, @site/)
    let virtual_prefixes: Vec<&str> = plugin_result
        .map(|pr| {
            pr.virtual_module_prefixes
                .iter()
                .map(String::as_str)
                .collect()
        })
        .unwrap_or_default();

    // Collect tooling dependencies from active plugins — these are framework-provided
    // packages (e.g., Nuxt provides `ofetch`, `h3`, `vue-router` at runtime) that may
    // be imported in user code without being listed in package.json.
    let plugin_tooling: FxHashSet<&str> = plugin_result
        .map(|pr| pr.tooling_dependencies.iter().map(String::as_str).collect())
        .unwrap_or_default();

    // Build a lookup: FileId -> Vec<(package_name, span_start)> from resolved modules,
    // so we can recover the import location when building UnlistedDependency results.
    let mut import_spans_by_file: FxHashMap<FileId, Vec<(&str, u32)>> = FxHashMap::default();
    for rm in resolved_modules {
        for import in &rm.resolved_imports {
            if let crate::resolve::ResolveResult::NpmPackage(name) = &import.target {
                import_spans_by_file
                    .entry(rm.file_id)
                    .or_default()
                    .push((name.as_str(), import.info.span.start));
            }
        }
        // Re-exports don't have span info on ReExportInfo, so skip them here.
        // The import span lookup will fall back to (1, 0) for re-export-only usages.
    }

    let ignore_deps: FxHashSet<&str> = config
        .ignore_dependencies
        .iter()
        .map(String::as_str)
        .collect();

    let mut unlisted: FxHashMap<String, Vec<ImportSite>> = FxHashMap::default();

    for (package_name, file_ids) in &graph.package_usage {
        if is_builtin_module(package_name) || is_path_alias(package_name) {
            continue;
        }
        if is_virtual_module(package_name) {
            continue;
        }
        if workspace_names.contains(package_name) {
            continue;
        }
        if ignore_deps.contains(package_name.as_str()) {
            continue;
        }
        if plugin_tooling.contains(package_name.as_str()) {
            continue;
        }
        if virtual_prefixes.iter().any(|prefix| {
            package_name.starts_with(prefix)
                || prefix
                    .strip_suffix('/')
                    .is_some_and(|base| package_name == base)
        }) {
            continue;
        }
        // Quick check: if listed in any root or workspace deps, skip
        if all_workspace_deps.contains(package_name) {
            continue;
        }
        // When @types/<package> is listed, the bare package is used for types only —
        // TypeScript resolves types from @types/ and erases the import at compile time.
        if has_types_package(package_name, &all_workspace_deps) {
            continue;
        }

        // Slower fallback: check if each importing file belongs to a workspace that lists this dep.
        // Uses raw path comparison (module paths are absolute) to avoid per-file canonicalize().
        let mut unlisted_sites: Vec<ImportSite> = Vec::new();
        for id in file_ids {
            let Some(module) = graph.modules.get(id.0 as usize) else {
                continue;
            };
            if is_package_listed_for_file(&module.path, package_name, &all_deps, &ws_dep_map) {
                continue;
            }
            let (line, col) = find_import_location(
                &import_spans_by_file,
                line_offsets_by_file,
                *id,
                package_name,
            );
            unlisted_sites.push(ImportSite {
                path: module.path.clone(),
                line,
                col,
            });
        }

        if !unlisted_sites.is_empty() {
            unlisted_sites.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
            unlisted_sites.dedup_by(|a, b| a.path == b.path);
            unlisted.insert(package_name.clone(), unlisted_sites);
        }
    }

    unlisted
        .into_iter()
        .map(|(name, sites)| UnlistedDependency {
            package_name: name,
            imported_from: sites,
        })
        .collect()
}

/// Find imports that could not be resolved.
pub fn find_unresolved_imports(
    resolved_modules: &[ResolvedModule],
    _config: &ResolvedConfig,
    suppressions: &SuppressionContext<'_>,
    virtual_prefixes: &[&str],
    generated_patterns: &[&str],
    line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<UnresolvedImport> {
    let mut unresolved = Vec::new();

    for module in resolved_modules {
        for import in &module.resolved_imports {
            if let crate::resolve::ResolveResult::Unresolvable(spec) = &import.target {
                // Skip virtual module imports using the `virtual:` convention
                // (e.g., `virtual:pwa-register`, `virtual:uno.css`)
                if is_virtual_module(spec) {
                    continue;
                }
                // Skip virtual module imports provided by active framework plugins
                // (e.g., Nuxt's #imports, #app, #components, #build).
                // Note: `spec` is the full import specifier (e.g., `$app/navigation`),
                // not the extracted package name, so trailing-slash prefixes like `$app/`
                // always match when the import has a subpath.
                if virtual_prefixes
                    .iter()
                    .any(|prefix| spec.starts_with(prefix))
                {
                    continue;
                }
                // Skip build-time generated relative imports from framework plugins
                // (e.g., SvelteKit's `./$types` / `./$types.js` route type imports).
                // Strip `.js` or `.ts` extension before matching so `/$types.js` and
                // `/$types.ts` both match the `/$types` pattern.
                if !generated_patterns.is_empty() {
                    let bare = spec
                        .strip_suffix(".js")
                        .or_else(|| spec.strip_suffix(".ts"))
                        .unwrap_or(spec);
                    if generated_patterns.iter().any(|pat| bare.ends_with(pat)) {
                        continue;
                    }
                }

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

                // Compute the column of the source string literal for precise LSP highlighting.
                // Falls back to the import statement column when source_span is not available
                // (e.g., synthetic CSS/SFC imports that use Span::default()).
                let specifier_col = if import.info.source_span.end > import.info.source_span.start {
                    let (_, sc) = byte_offset_to_line_col(
                        line_offsets_by_file,
                        module.file_id,
                        import.info.source_span.start,
                    );
                    sc
                } else {
                    col
                };

                // Check inline suppression
                if suppressions.is_suppressed(module.file_id, line, IssueKind::UnresolvedImport) {
                    continue;
                }

                unresolved.push(UnresolvedImport {
                    path: module.path.clone(),
                    specifier: spec.clone(),
                    line,
                    col,
                    specifier_col,
                });
            }
        }
    }

    unresolved
}

#[cfg(test)]
#[path = "unused_deps_tests/mod.rs"]
mod tests;
