use std::{collections::HashMap, fmt};

use turbopath::{
    AbsoluteSystemPath, AnchoredSystemPath, AnchoredSystemPathBuf, RelativeUnixPath,
    RelativeUnixPathBuf,
};

use super::{PackageInfo, PackageName};
use crate::package_manager::pnpm::PnpmCatalogs;

/// Reverse index from package path to package name, built once and shared
/// across all `DependencySplitter` instances.
pub struct WorkspacePathIndex<'a>(HashMap<&'a AnchoredSystemPath, &'a PackageName>);

impl<'a> WorkspacePathIndex<'a> {
    pub fn new(workspaces: &'a HashMap<PackageName, PackageInfo>) -> Self {
        Self(
            workspaces
                .iter()
                .map(|(name, info)| (info.package_path(), name))
                .collect(),
        )
    }
}

pub struct DependencySplitter<'a> {
    repo_root: &'a AbsoluteSystemPath,
    workspace_dir: &'a AbsoluteSystemPath,
    workspaces: &'a HashMap<PackageName, PackageInfo>,
    path_index: &'a WorkspacePathIndex<'a>,
    link_workspace_packages: bool,
    catalogs: Option<&'a PnpmCatalogs>,
}

impl<'a> DependencySplitter<'a> {
    pub fn new(
        repo_root: &'a AbsoluteSystemPath,
        workspace_dir: &'a AbsoluteSystemPath,
        workspaces: &'a HashMap<PackageName, PackageInfo>,
        link_workspace_packages: bool,
        path_index: &'a WorkspacePathIndex<'a>,
        catalogs: Option<&'a PnpmCatalogs>,
    ) -> Self {
        Self {
            repo_root,
            workspace_dir,
            workspaces,
            path_index,
            link_workspace_packages,
            catalogs,
        }
    }

    pub fn is_internal(&self, name: &str, version: &str) -> Option<PackageName> {
        // Resolve catalog: specifiers to their actual version strings
        let resolved;
        let version = if version.starts_with("catalog:") {
            if let Some(catalogs) = self.catalogs {
                resolved = catalogs.resolve(name, version);
                resolved.unwrap_or(version)
            } else {
                version
            }
        } else {
            version
        };

        // If link_workspace_packages isn't set any version without workspace protocol
        // is considered external.
        if !self.link_workspace_packages && !version.starts_with("workspace:") {
            return None;
        }
        let workspace_specifier = WorkspacePackageSpecifier::new(version)
            .unwrap_or(WorkspacePackageSpecifier::Alias(name));
        let (workspace_name, info) = self.find_package(workspace_specifier)?;
        let is_internal = DependencyVersion::new(version).matches_workspace_package(
            // This is the current Go behavior, in the future we might not want to paper over a
            // missing version
            info.package_json.version.as_deref().unwrap_or_default(),
            self.workspace_dir,
            self.repo_root,
        );

        match is_internal {
            true => Some(workspace_name),
            false => None,
        }
    }

    // Find a package in workspace from a specifier
    fn find_package(
        &self,
        specifier: WorkspacePackageSpecifier,
    ) -> Option<(PackageName, &PackageInfo)> {
        match specifier {
            WorkspacePackageSpecifier::Alias(name) => {
                // TODO implement borrowing for workspaces to allow for zero copy queries
                let package_name = PackageName::Other(name.to_string());
                let info = self.workspaces.get(&package_name)?;
                Some((package_name, info))
            }
            WorkspacePackageSpecifier::Path(path) => {
                let package_path = self.workspace_dir.join_unix_path(path);
                // There's a chance that the user provided path could escape the root, in which
                // case we don't support packages outside of the workspace.
                // Pnpm also doesn't support this so we defer to them to provide the error
                // message.
                let package_path = AnchoredSystemPathBuf::new(self.repo_root, package_path).ok()?;
                let (name, info) = self.workspace(&package_path).or_else(|| {
                    // Yarn4 allows for workspace root relative paths
                    let package_path = self.repo_root.join_unix_path(path);
                    let package_path =
                        AnchoredSystemPathBuf::new(self.repo_root, package_path).ok()?;
                    self.workspace(&package_path)
                })?;
                Some((name.clone(), info))
            }
        }
    }

    fn workspace(&self, path: &AnchoredSystemPath) -> Option<(&PackageName, &PackageInfo)> {
        let name = self.path_index.0.get(path)?;
        let info = self.workspaces.get(name)?;
        Some((name, info))
    }
}

// A parsed variant of a package dependency that uses the workspace protocol
// The specifier can either be a package name or a relative path
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WorkspacePackageSpecifier<'a> {
    Alias(&'a str),
    Path(&'a RelativeUnixPath),
}

impl<'a> WorkspacePackageSpecifier<'a> {
    fn new(version: &'a str) -> Option<Self> {
        let version = version.strip_prefix("workspace:")?;
        match version.rsplit_once('@') {
            Some((name, "*")) | Some((name, "^")) | Some((name, "~")) => Some(Self::Alias(name)),
            // No indication of different name for the package
            // We want to capture specifiers that have type "directory" by npa which boils down to
            // checking for slashes: https://github.com/pnpm/npm-package-arg/blob/main/npa.js#L79
            Some(_) | None if version.contains('/') => {
                RelativeUnixPath::new(version).ok().map(Self::Path)
            }
            Some(_) | None => None,
        }
    }
}

struct DependencyVersion<'a> {
    protocol: Option<&'a str>,
    version: &'a str,
}

impl<'a> DependencyVersion<'a> {
    fn new(qualified_version: &'a str) -> Self {
        qualified_version.split_once(':').map_or(
            Self {
                protocol: None,
                version: qualified_version,
            },
            |(protocol, version)| Self {
                protocol: Some(protocol),
                version,
            },
        )
    }

    /// Returns true if this dependency uses an npm alias (`npm:pkg@version`).
    /// This is distinct from a plain npm range (`npm:^1.0.0`). When a package
    /// name is present in the specifier, the user is explicitly requesting an
    /// npm registry package, which should never resolve to a workspace.
    fn is_npm_alias(&self) -> bool {
        if self.protocol != Some("npm") {
            return false;
        }
        // npm alias format: `npm:<package-name>@<version>`
        // A scoped package looks like `npm:@scope/pkg@version`, so we need to
        // handle the leading `@` carefully.
        let name_and_version = if let Some(rest) = self.version.strip_prefix('@') {
            // Scoped: skip past scope to find the `@version` separator
            rest.find('@').map(|i| i + 1)
        } else {
            self.version.find('@')
        };
        // If there's an `@` separating a package name from a version, and the
        // part before it isn't empty, this is an alias.
        name_and_version.is_some_and(|i| i > 0)
    }

    fn is_external(&self) -> bool {
        // The npm protocol for yarn by default still uses the workspace package if the
        // workspace version is in a compatible semver range. See https://github.com/yarnpkg/berry/discussions/4015
        // For now, we will just assume if the npm protocol is being used and the
        // version matches its an internal dependency which matches the existing
        // behavior before this additional logic was added.

        // TODO: extend this to support the `enableTransparentWorkspaces` yarn option
        self.protocol.is_some_and(|p| p != "npm")
    }

    fn matches_workspace_package(
        &self,
        package_version: &str,
        cwd: &AbsoluteSystemPath,
        root: &AbsoluteSystemPath,
    ) -> bool {
        match self.protocol {
            Some("workspace") => {
                // TODO: Since support at the moment is non-existent for workspaces that contain
                // multiple versions of the same package name, just assume its a
                // match and don't check the range for an exact match.
                true
            }
            Some("file") | Some("link") => {
                // Default to internal if we have the package but somehow cannot get the path
                RelativeUnixPathBuf::new(self.version)
                    .map(|file_path| cwd.join_unix_path(file_path))
                    .map_or(true, |dep_path| root.contains(&dep_path))
            }
            Some(_) if self.is_external() => {
                // Other protocols are assumed to be external references ("github:", etc)
                false
            }
            // npm: alias syntax (e.g. `npm:buffer@6.0.3` or `npm:@scope/pkg@^1.0.0`)
            // means the user explicitly wants the npm registry package, not a workspace.
            Some("npm") if self.is_npm_alias() => false,
            _ if self.version == "*" => true,
            _ if package_version.is_empty() => {
                // The workspace version of this package does not contain a version, no version
                // based constraints will match it so it must be external.
                false
            }
            _ => {
                // If we got this far, then we need to check the workspace package version to
                // see it satisfies the dependencies range to determine whether
                // or not it's an internal or external dependency.
                let constraint = node_semver::Range::parse(self.version);
                let version = node_semver::Version::parse(package_version);

                // For backwards compatibility with existing behavior, if we can't parse the
                // version then we treat the dependency as an internal package
                // reference and swallow the error.

                // TODO: some package managers also support tags like "latest". Does extra
                // handling need to be added for this corner-case
                constraint
                    .ok()
                    .zip(version.ok())
                    .is_none_or(|(constraint, version)| constraint.satisfies(&version))
            }
        }
    }
}

impl fmt::Display for DependencyVersion<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self.protocol {
            Some(protocol) => f.write_fmt(format_args!("{}:{}", protocol, self.version)),
            None => f.write_str(self.version),
        }
    }
}

#[cfg(test)]
mod test {
    use test_case::test_case;
    use turbopath::AbsoluteSystemPathBuf;

    use super::*;
    use crate::package_json::PackageJson;

    #[test_case("1.2.3", None, "1.2.3", Some("@scope/foo"), true ; "handles exact match")]
    #[test_case("1.2.3", None, "^1.0.0", Some("@scope/foo"), true ; "handles semver range satisfied")]
    #[test_case("2.3.4", None, "^1.0.0", None, true ; "handles semver range not satisfied")]
    #[test_case("1.2.3", None, "workspace:1.2.3", Some("@scope/foo"), true ; "handles workspace protocol with version")]
    #[test_case("1.2.3", None, "workspace:*", Some("@scope/foo"), true ; "handles workspace protocol with no version")]
    #[test_case("1.2.3", None, "workspace:../@scope/foo", Some("@scope/foo"), true ; "handles workspace protocol with scoped relative path")]
    #[test_case("1.2.3", None, "workspace:packages/@scope/foo", Some("@scope/foo"), true ; "handles workspace protocol with root relative path")]
    #[test_case("1.2.3", Some("bar"), "workspace:../baz", Some("baz"), true ; "handles workspace protocol with path to differing package")]
    #[test_case("1.2.3", None, "npm:^1.2.3", Some("@scope/foo"), true ; "handles npm protocol with satisfied semver range")]
    #[test_case("2.3.4", None, "npm:^1.2.3", None, true ; "handles npm protocol with not satisfied semver range")]
    #[test_case("1.2.3", None, "1.2.2-alpha-123abcd.0", None, true ; "handles pre-release versions")]
    // for backwards compatibility with the code before versions were verified
    #[test_case("sometag", None, "1.2.3", Some("@scope/foo"), true ; "handles non-semver package version")]
    // for backwards compatibility with the code before versions were verified
    #[test_case("1.2.3", None, "sometag", Some("@scope/foo"), true ; "handles non-semver dependency version")]
    #[test_case("1.2.3", None, "file:../libB", Some("@scope/foo"), true ; "handles file:.. inside repo")]
    #[test_case("1.2.3", None, "file:../../../otherproject", None, true ; "handles file:.. outside repo")]
    #[test_case("1.2.3", None, "link:../libB", Some("@scope/foo"), true ; "handles link:.. inside repo")]
    #[test_case("1.2.3", None, "link:../../../otherproject", None, true ; "handles link:.. outside repo")]
    #[test_case("0.0.0-development", None, "*", Some("@scope/foo"), true ; "handles development versions")]
    #[test_case("1.2.3", Some("foo"), "workspace:@scope/foo@*", Some("@scope/foo"), true ; "handles pnpm alias star")]
    #[test_case("1.2.3", Some("foo"), "workspace:@scope/foo@~", Some("@scope/foo"), true ; "handles pnpm alias tilde")]
    #[test_case("1.2.3", Some("foo"), "workspace:@scope/foo@^", Some("@scope/foo"), true ; "handles pnpm alias caret")]
    #[test_case("6.0.3", Some("buffer"), "npm:buffer@6.0.3", None, true ; "handles npm alias with matching workspace name")]
    #[test_case("1.2.3", None, "npm:other-pkg@^1.0.0", None, true ; "handles npm alias with different package name")]
    #[test_case("1.2.3", None, "npm:@scope/foo@^1.0.0", None, true ; "handles npm alias with scoped package")]
    #[test_case("1.2.3", None, "1.2.3", None, false ; "no workspace linking")]
    #[test_case("1.2.3", None, "workspace:1.2.3", Some("@scope/foo"), false ; "no workspace linking with protocol")]
    #[test_case("", None, "1.2.3", None, true ; "no workspace package version")]
    fn test_matches_workspace_package(
        package_version: &str,
        dependency_name: Option<&str>,
        range: &str,
        expected: Option<&str>,
        link_workspace_packages: bool,
    ) {
        let root = AbsoluteSystemPathBuf::new(if cfg!(windows) {
            "C:\\some\\repo"
        } else {
            "/some/repo"
        })
        .unwrap();
        let pkg_dir = root.join_components(&["packages", "libA"]);
        let workspaces = {
            let mut map = HashMap::new();
            map.insert(
                PackageName::Other("@scope/foo".to_string()),
                PackageInfo {
                    package_json: PackageJson {
                        version: Some(package_version.to_string()),
                        ..Default::default()
                    },
                    package_json_path: AnchoredSystemPathBuf::from_raw(
                        ["packages", "@scope", "foo", "package.json"]
                            .join(std::path::MAIN_SEPARATOR_STR),
                    )
                    .unwrap(),
                    unresolved_external_dependencies: None,
                    transitive_dependencies: None,
                },
            );
            map.insert(
                PackageName::Other("bar".to_string()),
                PackageInfo {
                    package_json: PackageJson {
                        version: Some("1.0.0".to_string()),
                        ..Default::default()
                    },
                    package_json_path: AnchoredSystemPathBuf::from_raw(
                        ["packages", "bar", "package.json"].join(std::path::MAIN_SEPARATOR_STR),
                    )
                    .unwrap(),
                    unresolved_external_dependencies: None,
                    transitive_dependencies: None,
                },
            );
            map.insert(
                PackageName::Other("baz".to_string()),
                PackageInfo {
                    package_json: PackageJson {
                        version: Some("1.0.0".to_string()),
                        ..Default::default()
                    },
                    package_json_path: AnchoredSystemPathBuf::from_raw(
                        ["packages", "baz", "package.json"].join(std::path::MAIN_SEPARATOR_STR),
                    )
                    .unwrap(),
                    unresolved_external_dependencies: None,
                    transitive_dependencies: None,
                },
            );
            map.insert(
                PackageName::Other("buffer".to_string()),
                PackageInfo {
                    package_json: PackageJson {
                        version: Some("6.0.3".to_string()),
                        ..Default::default()
                    },
                    package_json_path: AnchoredSystemPathBuf::from_raw(
                        ["packages", "buffer", "package.json"].join(std::path::MAIN_SEPARATOR_STR),
                    )
                    .unwrap(),
                    unresolved_external_dependencies: None,
                    transitive_dependencies: None,
                },
            );
            map
        };

        let path_index = WorkspacePathIndex::new(&workspaces);
        let splitter = DependencySplitter {
            repo_root: &root,
            workspace_dir: &pkg_dir,
            workspaces: &workspaces,
            path_index: &path_index,
            link_workspace_packages,
            catalogs: None,
        };

        assert_eq!(
            splitter.is_internal(dependency_name.unwrap_or("@scope/foo"), range),
            expected.map(PackageName::from)
        );
    }

    #[test_case("npm:buffer@6.0.3", true ; "npm alias unscoped")]
    #[test_case("npm:@scope/pkg@^1.0.0", true ; "npm alias scoped")]
    #[test_case("npm:^1.2.3", false ; "npm range")]
    #[test_case("npm:*", false ; "npm wildcard")]
    #[test_case("workspace:*", false ; "workspace protocol")]
    #[test_case("^1.0.0", false ; "bare range")]
    fn test_is_npm_alias(version: &str, expected: bool) {
        let dep = DependencyVersion::new(version);
        assert_eq!(dep.is_npm_alias(), expected, "is_npm_alias({version})");
    }

    #[test_case("1.2.3", None ; "non-workspace")]
    #[test_case("workspace:1.2.3", None ; "workspace version")]
    #[test_case("workspace:*", None ; "workspace any")]
    #[test_case("workspace:foo@*", Some(WorkspacePackageSpecifier::Alias("foo")) ; "star")]
    #[test_case("workspace:foo@~", Some(WorkspacePackageSpecifier::Alias("foo")) ; "tilde")]
    #[test_case("workspace:foo@^", Some(WorkspacePackageSpecifier::Alias("foo")) ; "caret")]
    #[test_case("workspace:@scope/foo@*", Some(WorkspacePackageSpecifier::Alias("@scope/foo")) ; "package with scope")]
    #[test_case("workspace:../bar", Some(WorkspacePackageSpecifier::Path(RelativeUnixPath::new("../bar").unwrap())) ; "package with path")]
    #[test_case("workspace:notpath", None ; "package with not a path")]
    #[test_case("workspace:../@scope/foo", Some(WorkspacePackageSpecifier::Path(RelativeUnixPath::new("../@scope/foo").unwrap())) ; "scope in path")]
    fn test_workspace_specifier(input: &str, expected: Option<WorkspacePackageSpecifier>) {
        assert_eq!(WorkspacePackageSpecifier::new(input), expected);
    }

    fn make_catalogs(default: &[(&str, &str)], named: &[(&str, &[(&str, &str)])]) -> PnpmCatalogs {
        PnpmCatalogs {
            default: default
                .iter()
                .map(|(k, v)| (k.to_string(), v.to_string()))
                .collect(),
            named: named
                .iter()
                .map(|(name, entries)| {
                    (
                        name.to_string(),
                        entries
                            .iter()
                            .map(|(k, v)| (k.to_string(), v.to_string()))
                            .collect(),
                    )
                })
                .collect(),
        }
    }

    #[test]
    fn catalog_default_resolves_to_workspace() {
        let root = AbsoluteSystemPathBuf::new(if cfg!(windows) {
            "C:\\some\\repo"
        } else {
            "/some/repo"
        })
        .unwrap();
        let pkg_dir = root.join_components(&["packages", "app-a"]);
        let workspaces = {
            let mut map = HashMap::new();
            map.insert(
                PackageName::Other("pkg-b".to_string()),
                PackageInfo {
                    package_json: PackageJson {
                        version: Some("1.0.0".to_string()),
                        ..Default::default()
                    },
                    package_json_path: AnchoredSystemPathBuf::from_raw(
                        ["packages", "pkg-b", "package.json"].join(std::path::MAIN_SEPARATOR_STR),
                    )
                    .unwrap(),
                    unresolved_external_dependencies: None,
                    transitive_dependencies: None,
                },
            );
            map
        };
        let catalogs = make_catalogs(&[("pkg-b", "workspace:*")], &[]);
        let path_index = WorkspacePathIndex::new(&workspaces);
        let splitter = DependencySplitter {
            repo_root: &root,
            workspace_dir: &pkg_dir,
            workspaces: &workspaces,
            path_index: &path_index,
            link_workspace_packages: false,
            catalogs: Some(&catalogs),
        };
        assert_eq!(
            splitter.is_internal("pkg-b", "catalog:"),
            Some(PackageName::Other("pkg-b".to_string()))
        );
    }

    #[test]
    fn catalog_named_resolves_to_workspace() {
        let root = AbsoluteSystemPathBuf::new(if cfg!(windows) {
            "C:\\some\\repo"
        } else {
            "/some/repo"
        })
        .unwrap();
        let pkg_dir = root.join_components(&["packages", "app-a"]);
        let workspaces = {
            let mut map = HashMap::new();
            map.insert(
                PackageName::Other("pkg-b".to_string()),
                PackageInfo {
                    package_json: PackageJson {
                        version: Some("1.0.0".to_string()),
                        ..Default::default()
                    },
                    package_json_path: AnchoredSystemPathBuf::from_raw(
                        ["packages", "pkg-b", "package.json"].join(std::path::MAIN_SEPARATOR_STR),
                    )
                    .unwrap(),
                    unresolved_external_dependencies: None,
                    transitive_dependencies: None,
                },
            );
            map
        };
        let catalogs = make_catalogs(&[], &[("internal", &[("pkg-b", "workspace:*")])]);
        let path_index = WorkspacePathIndex::new(&workspaces);
        let splitter = DependencySplitter {
            repo_root: &root,
            workspace_dir: &pkg_dir,
            workspaces: &workspaces,
            path_index: &path_index,
            link_workspace_packages: false,
            catalogs: Some(&catalogs),
        };
        assert_eq!(
            splitter.is_internal("pkg-b", "catalog:internal"),
            Some(PackageName::Other("pkg-b".to_string()))
        );
    }

    #[test]
    fn catalog_resolves_to_matching_semver() {
        let root = AbsoluteSystemPathBuf::new(if cfg!(windows) {
            "C:\\some\\repo"
        } else {
            "/some/repo"
        })
        .unwrap();
        let pkg_dir = root.join_components(&["packages", "app-a"]);
        let workspaces = {
            let mut map = HashMap::new();
            map.insert(
                PackageName::Other("pkg-b".to_string()),
                PackageInfo {
                    package_json: PackageJson {
                        version: Some("1.2.3".to_string()),
                        ..Default::default()
                    },
                    package_json_path: AnchoredSystemPathBuf::from_raw(
                        ["packages", "pkg-b", "package.json"].join(std::path::MAIN_SEPARATOR_STR),
                    )
                    .unwrap(),
                    unresolved_external_dependencies: None,
                    transitive_dependencies: None,
                },
            );
            map
        };
        // catalog resolves to a semver range that matches the workspace package version
        let catalogs = make_catalogs(&[("pkg-b", "^1.0.0")], &[]);
        let path_index = WorkspacePathIndex::new(&workspaces);
        let splitter = DependencySplitter {
            repo_root: &root,
            workspace_dir: &pkg_dir,
            workspaces: &workspaces,
            path_index: &path_index,
            link_workspace_packages: true,
            catalogs: Some(&catalogs),
        };
        assert_eq!(
            splitter.is_internal("pkg-b", "catalog:"),
            Some(PackageName::Other("pkg-b".to_string()))
        );
    }

    #[test]
    fn catalog_resolves_to_external_package() {
        let root = AbsoluteSystemPathBuf::new(if cfg!(windows) {
            "C:\\some\\repo"
        } else {
            "/some/repo"
        })
        .unwrap();
        let pkg_dir = root.join_components(&["packages", "app-a"]);
        let workspaces = HashMap::new();
        // "react" is not a workspace package
        let catalogs = make_catalogs(&[("react", "^18.2.0")], &[]);
        let path_index = WorkspacePathIndex::new(&workspaces);
        let splitter = DependencySplitter {
            repo_root: &root,
            workspace_dir: &pkg_dir,
            workspaces: &workspaces,
            path_index: &path_index,
            link_workspace_packages: false,
            catalogs: Some(&catalogs),
        };
        assert_eq!(splitter.is_internal("react", "catalog:"), None);
    }

    #[test]
    fn catalog_without_catalogs_configured() {
        let root = AbsoluteSystemPathBuf::new(if cfg!(windows) {
            "C:\\some\\repo"
        } else {
            "/some/repo"
        })
        .unwrap();
        let pkg_dir = root.join_components(&["packages", "app-a"]);
        let workspaces = {
            let mut map = HashMap::new();
            map.insert(
                PackageName::Other("pkg-b".to_string()),
                PackageInfo {
                    package_json: PackageJson {
                        version: Some("1.0.0".to_string()),
                        ..Default::default()
                    },
                    package_json_path: AnchoredSystemPathBuf::from_raw(
                        ["packages", "pkg-b", "package.json"].join(std::path::MAIN_SEPARATOR_STR),
                    )
                    .unwrap(),
                    unresolved_external_dependencies: None,
                    transitive_dependencies: None,
                },
            );
            map
        };
        let path_index = WorkspacePathIndex::new(&workspaces);
        // No catalogs - catalog: specifier can't be resolved, treated as external
        let splitter = DependencySplitter {
            repo_root: &root,
            workspace_dir: &pkg_dir,
            workspaces: &workspaces,
            path_index: &path_index,
            link_workspace_packages: false,
            catalogs: None,
        };
        assert_eq!(splitter.is_internal("pkg-b", "catalog:internal"), None);
    }

    #[test]
    fn catalog_default_keyword() {
        let root = AbsoluteSystemPathBuf::new(if cfg!(windows) {
            "C:\\some\\repo"
        } else {
            "/some/repo"
        })
        .unwrap();
        let pkg_dir = root.join_components(&["packages", "app-a"]);
        let workspaces = {
            let mut map = HashMap::new();
            map.insert(
                PackageName::Other("pkg-b".to_string()),
                PackageInfo {
                    package_json: PackageJson {
                        version: Some("1.0.0".to_string()),
                        ..Default::default()
                    },
                    package_json_path: AnchoredSystemPathBuf::from_raw(
                        ["packages", "pkg-b", "package.json"].join(std::path::MAIN_SEPARATOR_STR),
                    )
                    .unwrap(),
                    unresolved_external_dependencies: None,
                    transitive_dependencies: None,
                },
            );
            map
        };
        // "catalog:default" should resolve to the default catalog
        let catalogs = make_catalogs(&[("pkg-b", "workspace:*")], &[]);
        let path_index = WorkspacePathIndex::new(&workspaces);
        let splitter = DependencySplitter {
            repo_root: &root,
            workspace_dir: &pkg_dir,
            workspaces: &workspaces,
            path_index: &path_index,
            link_workspace_packages: false,
            catalogs: Some(&catalogs),
        };
        assert_eq!(
            splitter.is_internal("pkg-b", "catalog:default"),
            Some(PackageName::Other("pkg-b".to_string()))
        );
    }
}
