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

use fallow_config::{PackageJson, ResolvedConfig};
use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};

use super::parse_scripts::extract_script_file_refs;
use super::walk::SOURCE_EXTENSIONS;

/// Known output directory names from exports maps.
/// When an entry point path is inside one of these directories, we also try
/// the `src/` equivalent to find the tracked source file.
const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];

/// Resolve a path relative to a base directory, with security check and extension fallback.
///
/// Returns `Some(EntryPoint)` if the path resolves to an existing file within `canonical_root`,
/// trying source extensions as fallback when the exact path doesn't exist.
/// Also handles exports map targets in output directories (e.g., `./dist/utils.js`)
/// by trying to map back to the source file (e.g., `./src/utils.ts`).
pub fn resolve_entry_path(
    base: &Path,
    entry: &str,
    canonical_root: &Path,
    source: EntryPointSource,
) -> Option<EntryPoint> {
    let resolved = base.join(entry);
    // Security: ensure resolved path stays within the allowed root
    let canonical_resolved = resolved.canonicalize().unwrap_or_else(|_| resolved.clone());
    if !canonical_resolved.starts_with(canonical_root) {
        tracing::warn!(path = %entry, "Skipping entry point outside project root");
        return None;
    }

    // If the path is in an output directory (dist/, build/, etc.), try mapping to src/ first.
    // This handles exports map targets like `./dist/utils.js` → `./src/utils.ts`.
    // We check this BEFORE the exists() check because even if the dist file exists,
    // fallow ignores dist/ by default, so we need the source file instead.
    if let Some(source_path) = try_output_to_source_path(base, entry) {
        // Security: ensure the mapped source path stays within the project root
        if let Ok(canonical_source) = source_path.canonicalize()
            && canonical_source.starts_with(canonical_root)
        {
            return Some(EntryPoint {
                path: source_path,
                source,
            });
        }
    }

    if resolved.exists() {
        return Some(EntryPoint {
            path: resolved,
            source,
        });
    }
    // Try with source extensions
    for ext in SOURCE_EXTENSIONS {
        let with_ext = resolved.with_extension(ext);
        if with_ext.exists() {
            return Some(EntryPoint {
                path: with_ext,
                source,
            });
        }
    }
    None
}

/// Try to map an entry path from an output directory to its source equivalent.
///
/// Given `base=/project/packages/ui` and `entry=./dist/utils.js`, this tries:
/// - `/project/packages/ui/src/utils.ts`
/// - `/project/packages/ui/src/utils.tsx`
/// - etc. for all source extensions
///
/// Preserves any path prefix between the package root and the output dir,
/// e.g. `./modules/dist/utils.js` → `base/modules/src/utils.ts`.
///
/// Returns `Some(path)` if a source file is found.
fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
    let entry_path = Path::new(entry);
    let components: Vec<_> = entry_path.components().collect();

    // Find the last output directory component in the entry path
    let output_pos = components.iter().rposition(|c| {
        if let std::path::Component::Normal(s) = c
            && let Some(name) = s.to_str()
        {
            return OUTPUT_DIRS.contains(&name);
        }
        false
    })?;

    // Build the relative prefix before the output dir, filtering out CurDir (".")
    let prefix: PathBuf = components[..output_pos]
        .iter()
        .filter(|c| !matches!(c, std::path::Component::CurDir))
        .collect();

    // Build the relative path after the output dir (e.g., "utils.js")
    let suffix: PathBuf = components[output_pos + 1..].iter().collect();

    // Try base + prefix + "src" + suffix-with-source-extension
    for ext in SOURCE_EXTENSIONS {
        let source_candidate = base
            .join(&prefix)
            .join("src")
            .join(suffix.with_extension(ext));
        if source_candidate.exists() {
            return Some(source_candidate);
        }
    }

    None
}

/// Default index patterns used when no other entry points are found.
const DEFAULT_INDEX_PATTERNS: &[&str] = &[
    "src/index.{ts,tsx,js,jsx}",
    "src/main.{ts,tsx,js,jsx}",
    "index.{ts,tsx,js,jsx}",
    "main.{ts,tsx,js,jsx}",
];

/// Fall back to default index patterns if no entries were found.
///
/// When `ws_filter` is `Some`, only files whose path starts with the given
/// workspace root are considered (used for workspace-scoped discovery).
fn apply_default_fallback(
    files: &[DiscoveredFile],
    root: &Path,
    ws_filter: Option<&Path>,
) -> Vec<EntryPoint> {
    let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
        .iter()
        .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
        .collect();

    let mut entries = Vec::new();
    for file in files {
        // Use strip_prefix instead of canonicalize for workspace filtering
        if let Some(ws_root) = ws_filter
            && file.path.strip_prefix(ws_root).is_err()
        {
            continue;
        }
        let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
        let relative_str = relative.to_string_lossy();
        if default_matchers
            .iter()
            .any(|m| m.is_match(relative_str.as_ref()))
        {
            entries.push(EntryPoint {
                path: file.path.clone(),
                source: EntryPointSource::DefaultIndex,
            });
        }
    }
    entries
}

/// Discover entry points from package.json, framework rules, and defaults.
pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
    let _span = tracing::info_span!("discover_entry_points").entered();
    let mut entries = Vec::new();

    // Pre-compute relative paths for all files (once, not per pattern)
    let relative_paths: Vec<String> = files
        .iter()
        .map(|f| {
            f.path
                .strip_prefix(&config.root)
                .unwrap_or(&f.path)
                .to_string_lossy()
                .into_owned()
        })
        .collect();

    // 1. Manual entries from config — batch all patterns into a single GlobSet
    // for O(files) matching instead of O(patterns × files).
    {
        let mut builder = globset::GlobSetBuilder::new();
        for pattern in &config.entry_patterns {
            if let Ok(glob) = globset::Glob::new(pattern) {
                builder.add(glob);
            }
        }
        if let Ok(glob_set) = builder.build()
            && !glob_set.is_empty()
        {
            for (idx, rel) in relative_paths.iter().enumerate() {
                if glob_set.is_match(rel) {
                    entries.push(EntryPoint {
                        path: files[idx].path.clone(),
                        source: EntryPointSource::ManualEntry,
                    });
                }
            }
        }
    }

    // 2. Package.json entries
    // Pre-compute canonical root once for all resolve_entry_path calls
    let canonical_root = config
        .root
        .canonicalize()
        .unwrap_or_else(|_| config.root.clone());
    let pkg_path = config.root.join("package.json");
    if let Ok(pkg) = PackageJson::load(&pkg_path) {
        for entry_path in pkg.entry_points() {
            if let Some(ep) = resolve_entry_path(
                &config.root,
                &entry_path,
                &canonical_root,
                EntryPointSource::PackageJsonMain,
            ) {
                entries.push(ep);
            }
        }

        // 2b. Package.json scripts — extract file references as entry points
        if let Some(scripts) = &pkg.scripts {
            for script_value in scripts.values() {
                for file_ref in extract_script_file_refs(script_value) {
                    if let Some(ep) = resolve_entry_path(
                        &config.root,
                        &file_ref,
                        &canonical_root,
                        EntryPointSource::PackageJsonScript,
                    ) {
                        entries.push(ep);
                    }
                }
            }
        }

        // Framework rules now flow through PluginRegistry via external_plugins.
    }

    // 4. Auto-discover nested package.json entry points
    // For monorepo-like structures without explicit workspace config, scan for
    // package.json files in subdirectories and use their main/exports as entries.
    discover_nested_package_entries(&config.root, files, &mut entries, &canonical_root);

    // 5. Default index files (if no other entries found)
    if entries.is_empty() {
        entries = apply_default_fallback(files, &config.root, None);
    }

    // Deduplicate by path
    entries.sort_by(|a, b| a.path.cmp(&b.path));
    entries.dedup_by(|a, b| a.path == b.path);

    entries
}

/// Discover entry points from nested package.json files in subdirectories.
///
/// When a project has subdirectories with their own package.json (e.g., `packages/foo/package.json`),
/// the `main`, `module`, `exports`, and `bin` fields of those package.json files should be treated
/// as entry points. This handles monorepos without explicit workspace configuration.
fn discover_nested_package_entries(
    root: &Path,
    _files: &[DiscoveredFile],
    entries: &mut Vec<EntryPoint>,
    canonical_root: &Path,
) {
    // Walk common monorepo patterns to find nested package.json files
    let search_dirs = [
        "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
    ];
    for dir_name in &search_dirs {
        let search_dir = root.join(dir_name);
        if !search_dir.is_dir() {
            continue;
        }
        let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
            continue;
        };
        for entry in read_dir.flatten() {
            let pkg_path = entry.path().join("package.json");
            if !pkg_path.exists() {
                continue;
            }
            let Ok(pkg) = PackageJson::load(&pkg_path) else {
                continue;
            };
            let pkg_dir = entry.path();
            for entry_path in pkg.entry_points() {
                if let Some(ep) = resolve_entry_path(
                    &pkg_dir,
                    &entry_path,
                    canonical_root,
                    EntryPointSource::PackageJsonExports,
                ) {
                    entries.push(ep);
                }
            }
            // Also check scripts in nested package.json
            if let Some(scripts) = &pkg.scripts {
                for script_value in scripts.values() {
                    for file_ref in extract_script_file_refs(script_value) {
                        if let Some(ep) = resolve_entry_path(
                            &pkg_dir,
                            &file_ref,
                            canonical_root,
                            EntryPointSource::PackageJsonScript,
                        ) {
                            entries.push(ep);
                        }
                    }
                }
            }
        }
    }
}

/// Discover entry points for a workspace package.
#[must_use]
pub fn discover_workspace_entry_points(
    ws_root: &Path,
    _config: &ResolvedConfig,
    all_files: &[DiscoveredFile],
) -> Vec<EntryPoint> {
    let mut entries = Vec::new();

    let pkg_path = ws_root.join("package.json");
    if let Ok(pkg) = PackageJson::load(&pkg_path) {
        let canonical_ws_root = ws_root
            .canonicalize()
            .unwrap_or_else(|_| ws_root.to_path_buf());
        for entry_path in pkg.entry_points() {
            if let Some(ep) = resolve_entry_path(
                ws_root,
                &entry_path,
                &canonical_ws_root,
                EntryPointSource::PackageJsonMain,
            ) {
                entries.push(ep);
            }
        }

        // Scripts field — extract file references as entry points
        if let Some(scripts) = &pkg.scripts {
            for script_value in scripts.values() {
                for file_ref in extract_script_file_refs(script_value) {
                    if let Some(ep) = resolve_entry_path(
                        ws_root,
                        &file_ref,
                        &canonical_ws_root,
                        EntryPointSource::PackageJsonScript,
                    ) {
                        entries.push(ep);
                    }
                }
            }
        }

        // Framework rules now flow through PluginRegistry via external_plugins.
    }

    // Fall back to default index files if no entry points found for this workspace
    if entries.is_empty() {
        entries = apply_default_fallback(all_files, ws_root, None);
    }

    entries.sort_by(|a, b| a.path.cmp(&b.path));
    entries.dedup_by(|a, b| a.path == b.path);
    entries
}

/// Discover entry points from plugin results (dynamic config parsing).
///
/// Converts plugin-discovered patterns and setup files into concrete entry points
/// by matching them against the discovered file list.
#[must_use]
pub fn discover_plugin_entry_points(
    plugin_result: &crate::plugins::AggregatedPluginResult,
    config: &ResolvedConfig,
    files: &[DiscoveredFile],
) -> Vec<EntryPoint> {
    let mut entries = Vec::new();

    // Pre-compute relative paths
    let relative_paths: Vec<String> = files
        .iter()
        .map(|f| {
            f.path
                .strip_prefix(&config.root)
                .unwrap_or(&f.path)
                .to_string_lossy()
                .into_owned()
        })
        .collect();

    // Match plugin entry patterns against files using a single GlobSet
    // for O(files) matching instead of O(patterns × files).
    // Track which plugin name corresponds to each glob index.
    let mut builder = globset::GlobSetBuilder::new();
    let mut glob_plugin_names: Vec<&str> = Vec::new();
    for (pattern, pname) in plugin_result
        .entry_patterns
        .iter()
        .chain(plugin_result.discovered_always_used.iter())
        .chain(plugin_result.always_used.iter())
        .chain(plugin_result.fixture_patterns.iter())
    {
        if let Ok(glob) = globset::Glob::new(pattern) {
            builder.add(glob);
            glob_plugin_names.push(pname);
        }
    }
    if let Ok(glob_set) = builder.build()
        && !glob_set.is_empty()
    {
        for (idx, rel) in relative_paths.iter().enumerate() {
            let matches = glob_set.matches(rel);
            if !matches.is_empty() {
                // Use the plugin name from the first matching pattern
                let name = glob_plugin_names[matches[0]].to_string();
                entries.push(EntryPoint {
                    path: files[idx].path.clone(),
                    source: EntryPointSource::Plugin { name },
                });
            }
        }
    }

    // Add setup files (absolute paths from plugin config parsing)
    for (setup_file, pname) in &plugin_result.setup_files {
        let resolved = if setup_file.is_absolute() {
            setup_file.clone()
        } else {
            config.root.join(setup_file)
        };
        if resolved.exists() {
            entries.push(EntryPoint {
                path: resolved,
                source: EntryPointSource::Plugin {
                    name: pname.clone(),
                },
            });
        } else {
            // Try with extensions
            for ext in SOURCE_EXTENSIONS {
                let with_ext = resolved.with_extension(ext);
                if with_ext.exists() {
                    entries.push(EntryPoint {
                        path: with_ext,
                        source: EntryPointSource::Plugin {
                            name: pname.clone(),
                        },
                    });
                    break;
                }
            }
        }
    }

    // Deduplicate
    entries.sort_by(|a, b| a.path.cmp(&b.path));
    entries.dedup_by(|a, b| a.path == b.path);
    entries
}

/// Discover entry points from `dynamicallyLoaded` config patterns.
///
/// Matches the configured glob patterns against the discovered file list and
/// marks matching files as entry points so they are never flagged as unused.
#[must_use]
pub fn discover_dynamically_loaded_entry_points(
    config: &ResolvedConfig,
    files: &[DiscoveredFile],
) -> Vec<EntryPoint> {
    if config.dynamically_loaded.is_empty() {
        return Vec::new();
    }

    let mut builder = globset::GlobSetBuilder::new();
    for pattern in &config.dynamically_loaded {
        if let Ok(glob) = globset::Glob::new(pattern) {
            builder.add(glob);
        }
    }
    let Ok(glob_set) = builder.build() else {
        return Vec::new();
    };
    if glob_set.is_empty() {
        return Vec::new();
    }

    let mut entries = Vec::new();
    for file in files {
        let rel = file
            .path
            .strip_prefix(&config.root)
            .unwrap_or(&file.path)
            .to_string_lossy();
        if glob_set.is_match(rel.as_ref()) {
            entries.push(EntryPoint {
                path: file.path.clone(),
                source: EntryPointSource::DynamicallyLoaded,
            });
        }
    }
    entries
}

/// Pre-compile a set of glob patterns for efficient matching against many paths.
#[must_use]
pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
    if patterns.is_empty() {
        return None;
    }
    let mut builder = globset::GlobSetBuilder::new();
    for pattern in patterns {
        if let Ok(glob) = globset::Glob::new(pattern) {
            builder.add(glob);
        }
    }
    builder.build().ok()
}

#[cfg(test)]
mod tests {
    use super::*;
    use fallow_types::discover::FileId;
    use proptest::prelude::*;

    proptest! {
        /// Valid glob patterns should never panic when compiled via globset.
        #[test]
        fn glob_patterns_never_panic_on_compile(
            prefix in "[a-zA-Z0-9_]{1,20}",
            ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
        ) {
            let pattern = format!("**/{prefix}*.{ext}");
            // Should not panic — either compiles or returns Err gracefully
            let result = globset::Glob::new(&pattern);
            prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
        }

        /// Non-source extensions should NOT be in the SOURCE_EXTENSIONS list.
        #[test]
        fn non_source_extensions_not_in_list(
            ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "html", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
        ) {
            prop_assert!(
                !SOURCE_EXTENSIONS.contains(&ext),
                "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
            );
        }

        /// compile_glob_set should never panic on arbitrary well-formed glob patterns.
        #[test]
        fn compile_glob_set_no_panic(
            patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
        ) {
            // Should not panic regardless of input
            let _ = compile_glob_set(&patterns);
        }
    }

    // compile_glob_set unit tests
    #[test]
    fn compile_glob_set_empty_input() {
        assert!(
            compile_glob_set(&[]).is_none(),
            "empty patterns should return None"
        );
    }

    #[test]
    fn compile_glob_set_valid_patterns() {
        let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
        let set = compile_glob_set(&patterns);
        assert!(set.is_some(), "valid patterns should compile");
        let set = set.unwrap();
        assert!(set.is_match("src/foo.ts"));
        assert!(set.is_match("src/bar.js"));
        assert!(!set.is_match("src/bar.py"));
    }

    // resolve_entry_path unit tests
    mod resolve_entry_path_tests {
        use super::*;

        #[test]
        fn resolves_existing_file() {
            let dir = tempfile::tempdir().expect("create temp dir");
            let src = dir.path().join("src");
            std::fs::create_dir_all(&src).unwrap();
            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();

            let canonical = dir.path().canonicalize().unwrap();
            let result = resolve_entry_path(
                dir.path(),
                "src/index.ts",
                &canonical,
                EntryPointSource::PackageJsonMain,
            );
            assert!(result.is_some(), "should resolve an existing file");
            assert!(result.unwrap().path.ends_with("src/index.ts"));
        }

        #[test]
        fn resolves_with_extension_fallback() {
            let dir = tempfile::tempdir().expect("create temp dir");
            // Use canonical base to avoid macOS /var → /private/var symlink mismatch
            let canonical = dir.path().canonicalize().unwrap();
            let src = canonical.join("src");
            std::fs::create_dir_all(&src).unwrap();
            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();

            // Provide path without extension — should try adding .ts, .tsx, etc.
            let result = resolve_entry_path(
                &canonical,
                "src/index",
                &canonical,
                EntryPointSource::PackageJsonMain,
            );
            assert!(
                result.is_some(),
                "should resolve via extension fallback when exact path doesn't exist"
            );
            let ep = result.unwrap();
            assert!(
                ep.path.to_string_lossy().contains("index.ts"),
                "should find index.ts via extension fallback"
            );
        }

        #[test]
        fn returns_none_for_nonexistent_file() {
            let dir = tempfile::tempdir().expect("create temp dir");
            let canonical = dir.path().canonicalize().unwrap();
            let result = resolve_entry_path(
                dir.path(),
                "does/not/exist.ts",
                &canonical,
                EntryPointSource::PackageJsonMain,
            );
            assert!(result.is_none(), "should return None for nonexistent files");
        }

        #[test]
        fn maps_dist_output_to_src() {
            let dir = tempfile::tempdir().expect("create temp dir");
            let src = dir.path().join("src");
            std::fs::create_dir_all(&src).unwrap();
            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();

            // Also create the dist/ file to make sure it prefers src/
            let dist = dir.path().join("dist");
            std::fs::create_dir_all(&dist).unwrap();
            std::fs::write(dist.join("utils.js"), "// compiled").unwrap();

            let canonical = dir.path().canonicalize().unwrap();
            let result = resolve_entry_path(
                dir.path(),
                "./dist/utils.js",
                &canonical,
                EntryPointSource::PackageJsonExports,
            );
            assert!(result.is_some(), "should resolve dist/ path to src/");
            let ep = result.unwrap();
            assert!(
                ep.path
                    .to_string_lossy()
                    .replace('\\', "/")
                    .contains("src/utils.ts"),
                "should map ./dist/utils.js to src/utils.ts"
            );
        }

        #[test]
        fn maps_build_output_to_src() {
            let dir = tempfile::tempdir().expect("create temp dir");
            // Use canonical base to avoid macOS /var → /private/var symlink mismatch
            let canonical = dir.path().canonicalize().unwrap();
            let src = canonical.join("src");
            std::fs::create_dir_all(&src).unwrap();
            std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();

            let result = resolve_entry_path(
                &canonical,
                "./build/index.js",
                &canonical,
                EntryPointSource::PackageJsonExports,
            );
            assert!(result.is_some(), "should map build/ output to src/");
            let ep = result.unwrap();
            assert!(
                ep.path
                    .to_string_lossy()
                    .replace('\\', "/")
                    .contains("src/index.tsx"),
                "should map ./build/index.js to src/index.tsx"
            );
        }

        #[test]
        fn preserves_entry_point_source() {
            let dir = tempfile::tempdir().expect("create temp dir");
            std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();

            let canonical = dir.path().canonicalize().unwrap();
            let result = resolve_entry_path(
                dir.path(),
                "index.ts",
                &canonical,
                EntryPointSource::PackageJsonScript,
            );
            assert!(result.is_some());
            assert!(
                matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
                "should preserve the source kind"
            );
        }
    }

    // try_output_to_source_path unit tests
    mod output_to_source_tests {
        use super::*;

        #[test]
        fn maps_dist_to_src_with_ts_extension() {
            let dir = tempfile::tempdir().expect("create temp dir");
            let src = dir.path().join("src");
            std::fs::create_dir_all(&src).unwrap();
            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();

            let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
            assert!(result.is_some());
            assert!(
                result
                    .unwrap()
                    .to_string_lossy()
                    .replace('\\', "/")
                    .contains("src/utils.ts")
            );
        }

        #[test]
        fn returns_none_when_no_source_file_exists() {
            let dir = tempfile::tempdir().expect("create temp dir");
            // No src/ directory at all
            let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
            assert!(result.is_none());
        }

        #[test]
        fn ignores_non_output_directories() {
            let dir = tempfile::tempdir().expect("create temp dir");
            let src = dir.path().join("src");
            std::fs::create_dir_all(&src).unwrap();
            std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();

            // "lib" is not in OUTPUT_DIRS, so no mapping should occur
            let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
            assert!(result.is_none());
        }

        #[test]
        fn maps_nested_output_path_preserving_prefix() {
            let dir = tempfile::tempdir().expect("create temp dir");
            let modules_src = dir.path().join("modules").join("src");
            std::fs::create_dir_all(&modules_src).unwrap();
            std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();

            let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
            assert!(result.is_some());
            assert!(
                result
                    .unwrap()
                    .to_string_lossy()
                    .replace('\\', "/")
                    .contains("modules/src/helper.ts")
            );
        }
    }

    // apply_default_fallback unit tests
    mod default_fallback_tests {
        use super::*;

        #[test]
        fn finds_src_index_ts_as_fallback() {
            let dir = tempfile::tempdir().expect("create temp dir");
            let src = dir.path().join("src");
            std::fs::create_dir_all(&src).unwrap();
            let index_path = src.join("index.ts");
            std::fs::write(&index_path, "export const a = 1;").unwrap();

            let files = vec![DiscoveredFile {
                id: FileId(0),
                path: index_path.clone(),
                size_bytes: 20,
            }];

            let entries = apply_default_fallback(&files, dir.path(), None);
            assert_eq!(entries.len(), 1);
            assert_eq!(entries[0].path, index_path);
            assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
        }

        #[test]
        fn finds_root_index_js_as_fallback() {
            let dir = tempfile::tempdir().expect("create temp dir");
            let index_path = dir.path().join("index.js");
            std::fs::write(&index_path, "module.exports = {};").unwrap();

            let files = vec![DiscoveredFile {
                id: FileId(0),
                path: index_path.clone(),
                size_bytes: 21,
            }];

            let entries = apply_default_fallback(&files, dir.path(), None);
            assert_eq!(entries.len(), 1);
            assert_eq!(entries[0].path, index_path);
        }

        #[test]
        fn returns_empty_when_no_index_file() {
            let dir = tempfile::tempdir().expect("create temp dir");
            let other_path = dir.path().join("src").join("utils.ts");

            let files = vec![DiscoveredFile {
                id: FileId(0),
                path: other_path,
                size_bytes: 10,
            }];

            let entries = apply_default_fallback(&files, dir.path(), None);
            assert!(
                entries.is_empty(),
                "non-index files should not match default fallback"
            );
        }

        #[test]
        fn workspace_filter_restricts_scope() {
            let dir = tempfile::tempdir().expect("create temp dir");
            let ws_a = dir.path().join("packages").join("a").join("src");
            std::fs::create_dir_all(&ws_a).unwrap();
            let ws_b = dir.path().join("packages").join("b").join("src");
            std::fs::create_dir_all(&ws_b).unwrap();

            let index_a = ws_a.join("index.ts");
            let index_b = ws_b.join("index.ts");

            let files = vec![
                DiscoveredFile {
                    id: FileId(0),
                    path: index_a.clone(),
                    size_bytes: 10,
                },
                DiscoveredFile {
                    id: FileId(1),
                    path: index_b,
                    size_bytes: 10,
                },
            ];

            // Filter to workspace A only
            let ws_root = dir.path().join("packages").join("a");
            let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
            assert_eq!(entries.len(), 1);
            assert_eq!(entries[0].path, index_a);
        }
    }
}
