//! # Bun Lockfile Support
//!
//! This module provides comprehensive support for Bun lockfiles (`bun.lockb`),
//! handling both binary and JSON formats with support for lockfile versions 0
//! and 1.
//!
//! ## Lockfile Version Support
//!
//! ### Version 0 (Legacy)
//! - Basic dependency resolution
//! - Simple workspace support
//! - Limited override functionality
//!
//! ### Version 1 (Current)
//! - Enhanced workspace dependency resolution with optimized lookup strategies
//! - Improved catalog support with multiple catalog types
//! - Advanced override functionality with precedence rules
//! - Optimized subgraph filtering for workspace dependencies
//!
//! ## Key Features
//!
//! ### Catalog Resolution
//! The module supports Bun's catalog system for dependency version management:
//! - **Default catalog**: Available via `catalog` field in lockfile
//! - **Named catalogs**: Available via `catalogs` field with custom catalog
//!   names
//! - **Resolution precedence**: Named catalogs take precedence over default
//!   catalog
//! - **Workspace integration**: Catalogs work seamlessly with workspace
//!   dependencies
//!
//! Example catalog usage:
//! ```json
//! {
//!   "catalog": {
//!     "react": "^18.0.0"
//!   },
//!   "catalogs": {
//!     "frontend": {
//!       "react": "^18.2.0"
//!     }
//!   }
//! }
//! ```
//!
//! ### Override Functionality
//! Provides dependency override capabilities similar to npm/yarn:
//! - **Package overrides**: Replace specific package versions across the
//!   dependency tree
//! - **Scoped overrides**: Apply overrides to specific dependency contexts
//! - **Patch integration**: Overrides work in conjunction with patch
//!   dependencies
//! - **Subgraph filtering**: Overrides are properly handled during dependency
//!   pruning
//!
//! ### V1 Workspace Optimizations
//! Version 1 lockfiles include several workspace-specific optimizations:
//! - **Direct workspace resolution**: Optimized lookup for workspace
//!   dependencies
//! - **Transitive workspace tracking**: Proper handling of
//!   workspace-to-workspace dependencies
//! - **Nested workspace support**: Full support for nested workspace structures
//! - **Dependency deduplication**: Smart deduplication of workspace
//!   dependencies
//!
//! ### Subgraph Filtering Behavior
//! The module provides sophisticated dependency subgraph extraction:
//! - **Workspace-aware filtering**: Preserves workspace dependency
//!   relationships
//! - **Override preservation**: Maintains override behavior in filtered
//!   subgraphs
//! - **Patch dependency handling**: Properly includes patched dependencies and
//!   their patches
//! - **Transitive closure**: Calculates complete transitive dependency closures
//!
//! ## Implementation Details
//!
//! The module handles both binary (`.lockb`) and JSON formats, with automatic
//! format detection and conversion. It provides full round-trip compatibility,
//! ensuring that lockfiles can be read, modified, and written back without data
//! loss.
//!
//! Key data structures:
//! - [`BunLockfile`]: Main lockfile representation with metadata and entry
//!   mappings
//! - [`BunLockfileData`]: Raw lockfile data structure matching Bun's format
//! - [`WorkspaceEntry`]: Workspace package representation
//! - [`PackageEntry`]: External package representation
//! - [`LockfileVersion`]: Version enum for format compatibility

use std::{
    any::Any,
    collections::{BTreeMap, HashMap},
    str::FromStr,
    sync::OnceLock,
};

use biome_json_formatter::context::JsonFormatOptions;
use biome_json_parser::JsonParserOptions;
use id::PossibleKeyIter;
use itertools::Itertools as _;
use semver::{Version, VersionReq};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use turbopath::RelativeUnixPathBuf;
use turborepo_errors::ParseDiagnostic;

use crate::Lockfile;

mod de;
mod id;
mod index;
mod ser;
mod types;

use index::PackageIndex;
pub use types::{PackageIdent, PackageKey, VersionSpec};

type Map<K, V> = std::collections::BTreeMap<K, V>;
type BTreeSet<T> = std::collections::BTreeSet<T>;

/// Check if a package identifier refers to a git or GitHub package.
///
/// Git and GitHub packages have different serialization formats than npm
/// packages:
/// - npm packages: `[ident, registry, info, checksum]` (4 elements)
/// - git/github packages: `[ident, info, checksum]` (3 elements, no registry)
///
/// This function is used in deserialization, serialization, and encoding to
/// ensure consistent handling of these package types.
pub(crate) fn is_git_or_github_package(ident: &str) -> bool {
    ident.contains("@git+") || ident.contains("@github:")
}

pub(crate) fn is_tarball_or_url_package(ident: &str) -> bool {
    ident.contains("@https://") || ident.contains("@http://")
}

/// Represents a platform constraint that can be either inclusive or exclusive.
/// This matches Bun's Negatable type for os/cpu/libc fields.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum Negatable {
    /// No constraint - package works on all platforms
    #[default]
    None,
    /// Single platform constraint
    Single(String),
    /// Multiple platform constraints (all must match)
    Multiple(Vec<String>),
    /// Negated constraints - package works on all platforms except these
    Negated(Vec<String>),
}

impl Serialize for Negatable {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        match self {
            Negatable::None => serializer.serialize_str("none"),
            Negatable::Single(platform) => serializer.serialize_str(platform),
            Negatable::Multiple(platforms) => platforms.serialize(serializer),
            Negatable::Negated(platforms) => {
                let negated_platforms: Vec<String> =
                    platforms.iter().map(|p| format!("!{p}")).collect();
                negated_platforms.serialize(serializer)
            }
        }
    }
}

impl<'de> Deserialize<'de> for Negatable {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let value: Value = Value::deserialize(deserializer)?;

        match value {
            Value::String(s) => {
                // Treating "none" as special breaks wasm packages that use cpu="none"
                if let Some(stripped) = s.strip_prefix('!') {
                    Ok(Negatable::Negated(vec![stripped.to_string()]))
                } else {
                    Ok(Negatable::Single(s))
                }
            }
            Value::Array(arr) => {
                let platforms: Result<Vec<String>, _> = arr
                    .into_iter()
                    .map(|v| match v {
                        Value::String(s) => Ok(s),
                        _ => Err(serde::de::Error::invalid_type(
                            serde::de::Unexpected::Other("non-string in platform array"),
                            &"string",
                        )),
                    })
                    .collect();

                let platforms = platforms?;

                let has_negated = platforms.iter().any(|p| p.starts_with('!'));
                let has_non_negated = platforms.iter().any(|p| !p.starts_with('!'));

                if has_negated && has_non_negated {
                    // npm spec requires explicit allows to take precedence over denies
                    let allowed_platforms: Vec<String> = platforms
                        .into_iter()
                        .filter(|p| !p.starts_with('!'))
                        .collect();
                    Ok(Negatable::Multiple(allowed_platforms))
                } else if has_negated {
                    // Strip '!' prefix to extract the blocked platforms
                    let negated_platforms: Vec<String> = platforms
                        .into_iter()
                        .filter_map(|p| p.strip_prefix('!').map(str::to_string))
                        .collect();
                    Ok(Negatable::Negated(negated_platforms))
                } else {
                    Ok(Negatable::Multiple(platforms))
                }
            }
            _ => Err(serde::de::Error::invalid_type(
                serde::de::Unexpected::Other("non-string/array for platform constraint"),
                &"string or array of strings",
            )),
        }
    }
}

impl Negatable {
    /// Returns true if this constraint allows the given platform
    #[allow(dead_code)]
    pub fn allows(&self, platform: &str) -> bool {
        match self {
            Negatable::None => true,
            Negatable::Single(p) => p == platform,
            Negatable::Multiple(platforms) => platforms.contains(&platform.to_string()),
            Negatable::Negated(platforms) => !platforms.contains(&platform.to_string()),
        }
    }

    /// Returns true if this is an empty/none constraint
    pub fn is_none(&self) -> bool {
        matches!(self, Negatable::None)
    }
}

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("Failed to strip commas: {0}")]
    Utf8(#[from] std::str::Utf8Error),
    #[error("Failed to strip commas: {0}")]
    Format(#[from] biome_formatter::FormatError),
    #[error("Failed to strip commas: {0}")]
    Print(#[from] biome_formatter::PrintError),
    #[error("{ident} had two entries with differing checksums: {sha1}, {sha2}")]
    MismatchedShas {
        ident: String,
        sha1: String,
        sha2: String,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockfileVersion {
    V0 = 0,
    V1 = 1,
}

impl LockfileVersion {
    #[allow(dead_code)]
    fn from_i32(value: i32) -> Option<Self> {
        match value {
            0 => Some(Self::V0),
            1 => Some(Self::V1),
            _ => None,
        }
    }

    #[allow(dead_code)]
    fn as_i32(self) -> i32 {
        self as i32
    }
}

#[derive(Debug)]
pub struct BunLockfile {
    data: BunLockfileData,
    key_to_entry: HashMap<String, String>,
    index: PackageIndex,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BunLockfileData {
    lockfile_version: i32,
    #[serde(default)]
    config_version: Option<i32>,
    workspaces: Map<String, WorkspaceEntry>,
    #[serde(default)]
    trusted_dependencies: Vec<String>,
    #[serde(default)]
    overrides: Map<String, String>,
    #[serde(default)]
    catalog: Map<String, String>,
    #[serde(default)]
    catalogs: Map<String, Map<String, String>>,
    packages: Map<String, PackageEntry>,
    #[serde(default)]
    patched_dependencies: Map<String, String>,
}

#[derive(Debug, Deserialize, PartialEq, Default, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct WorkspaceEntry {
    name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    version: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    dependencies: Option<Map<String, String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    dev_dependencies: Option<Map<String, String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    optional_dependencies: Option<Map<String, String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    peer_dependencies: Option<Map<String, String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    optional_peers: Option<Vec<String>>,
}

#[derive(Debug, PartialEq, Clone)]
struct PackageEntry {
    ident: String,
    registry: Option<String>,
    // Present for all package types except root deps
    info: Option<PackageInfo>,
    // Present on registry
    checksum: Option<String>,
    root: Option<RootInfo>,
}

#[derive(Debug, Deserialize, Default, PartialEq, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PackageInfo {
    #[serde(default, skip_serializing_if = "Map::is_empty")]
    dependencies: Map<String, String>,
    #[serde(default, skip_serializing_if = "Map::is_empty")]
    dev_dependencies: Map<String, String>,
    #[serde(default, skip_serializing_if = "Map::is_empty")]
    optional_dependencies: Map<String, String>,
    #[serde(default, skip_serializing_if = "Map::is_empty")]
    peer_dependencies: Map<String, String>,
    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
    optional_peers: BTreeSet<String>,
    /// Operating system constraint for this package
    #[serde(default, skip_serializing_if = "Negatable::is_none")]
    os: Negatable,
    /// CPU architecture constraint for this package
    #[serde(default, skip_serializing_if = "Negatable::is_none")]
    cpu: Negatable,
    // We do not care about the rest here
    // the values here should be generic
    #[serde(flatten)]
    other: Map<String, Value>,
}
#[derive(Debug, Deserialize, PartialEq, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct RootInfo {
    bin: Option<String>,
    bin_dir: Option<String>,
}

impl BunLockfile {
    /// Process a package entry, handling workspace filtering, overrides, and
    /// patches
    fn process_package_entry(
        &self,
        entry: &PackageEntry,
        name: &str,
        override_version: &str,
        resolved_version: &str,
    ) -> Result<Option<crate::Package>, crate::Error> {
        let ident = PackageIdent::parse(&entry.ident);

        // Filter out workspace mapping entries
        if ident.is_workspace() {
            return Ok(None);
        }

        // Check for overrides
        if override_version != resolved_version {
            let override_ident = format!("{name}@{override_version}");
            if let Some((_override_key, override_entry)) = self.index.get_by_ident(&override_ident)
            {
                let mut pkg_version = override_entry.version().to_string();
                if let Some(patch) = self.data.patched_dependencies.get(&override_entry.ident) {
                    pkg_version.push('+');
                    pkg_version.push_str(patch);
                }
                return Ok(Some(crate::Package {
                    key: override_entry.ident.to_string(),
                    version: pkg_version,
                }));
            }
        }

        // Return the package with its version (and patch if applicable)
        let mut version = entry.version().to_string();
        if let Some(patch) = self.data.patched_dependencies.get(&entry.ident) {
            version.push('+');
            version.push_str(patch);
        }
        Ok(Some(crate::Package {
            key: entry.ident.to_string(),
            version,
        }))
    }

    /// Check if a package version satisfies a version specification.
    ///
    /// Returns true if the version satisfies the spec, false otherwise.
    /// For non-semver specs (tags, catalogs, workspaces), returns true.
    fn version_satisfies_spec(&self, version: &str, version_spec: &str) -> bool {
        let spec = VersionSpec::parse(version_spec);

        match spec {
            VersionSpec::Semver(spec_str) => {
                // Parse both the requirement and the version
                let Ok(req) = VersionReq::parse(&spec_str) else {
                    // If we can't parse the requirement, be lenient and accept it
                    return true;
                };

                let Ok(ver) = Version::parse(version) else {
                    // If we can't parse the version, be lenient and accept it
                    return true;
                };

                req.matches(&ver)
            }
            // For non-semver specs (tags, catalogs, workspace), accept any version
            // since validation happens elsewhere
            _ => true,
        }
    }

    fn nested_dependency_entry(
        &self,
        entry_key: &str,
        dependency: &str,
        version: &str,
    ) -> Option<(String, &PackageEntry)> {
        let direct_key = format!("{entry_key}/{dependency}");
        if let Some(entry) = self.data.packages.get(&direct_key) {
            return Some((direct_key, entry));
        }

        let mut search_key = entry_key;
        while let Some(slash_pos) = search_key.rfind('/') {
            search_key = &search_key[..slash_pos];
            let ancestor_key = format!("{search_key}/{dependency}");
            if let Some(entry) = self.data.packages.get(&ancestor_key) {
                if self.version_satisfies_spec(entry.version(), version) {
                    return Some((ancestor_key, entry));
                }
                break;
            }
        }

        None
    }

    /// Find a package version that satisfies the given version spec.
    ///
    /// Searches in order:
    /// 1. Workspace-scoped entries
    /// 2. Top-level entries
    /// 3. Nested/aliased entries (by searching all idents)
    fn find_matching_version(
        &self,
        workspace_name: &str,
        name: &str,
        version_spec: &str,
        override_version: &str,
        resolved_version: &str,
    ) -> Result<Option<crate::Package>, crate::Error> {
        // When an override is active, the overridden version is authoritative
        // and should always be accepted regardless of the original version spec.
        // For example, if a package declares `"lightningcss": "1.30.2"` but the
        // root package.json overrides it to `"1.30.1"`, the resolved version
        // 1.30.1 must be accepted even though it doesn't satisfy `^1.30.2`.
        let has_override = override_version != resolved_version;

        // Try workspace-scoped first
        if let Some(entry) = self.index.get_workspace_scoped(workspace_name, name)
            && let Some(pkg) =
                self.process_package_entry(entry, name, override_version, resolved_version)?
            && (has_override || self.version_satisfies_spec(&pkg.version, version_spec))
        {
            return Ok(Some(pkg));
        }

        // Try hoisted/top-level
        if let Some((_key, entry)) = self.index.find_package(Some(workspace_name), name)
            && let Some(pkg) =
                self.process_package_entry(entry, name, override_version, resolved_version)?
            && (has_override || self.version_satisfies_spec(&pkg.version, version_spec))
        {
            return Ok(Some(pkg));
        }

        // Search for nested/aliased versions that match
        // Only search explicitly nested entries (with '/' in key), not bundled deps
        for (lockfile_key, entry) in &self.data.packages {
            // Only consider explicitly nested entries (not bundled)
            if !lockfile_key.contains('/') {
                continue;
            }

            // Skip bundled dependencies
            if let Some(info) = &entry.info
                && info
                    .other
                    .get("bundled")
                    .and_then(|v| v.as_bool())
                    .unwrap_or(false)
            {
                continue;
            }

            let ident = PackageIdent::parse(&entry.ident);

            // Skip if the name doesn't match
            if ident.name() != name {
                continue;
            }

            // Skip workspace mappings
            if ident.is_workspace() {
                continue;
            }

            if let Some(pkg) =
                self.process_package_entry(entry, name, override_version, resolved_version)?
                && (has_override || self.version_satisfies_spec(&pkg.version, version_spec))
            {
                tracing::debug!(
                    "Found matching version {} for {} (spec: {}) in nested entry {}",
                    pkg.version,
                    name,
                    version_spec,
                    lockfile_key
                );
                return Ok(Some(pkg));
            }
        }

        Ok(None)
    }
}

impl Lockfile for BunLockfile {
    #[tracing::instrument(skip(self, workspace_path))]
    fn resolve_package(
        &self,
        workspace_path: &str,
        name: &str,
        version: &str,
    ) -> Result<Option<crate::Package>, crate::Error> {
        let workspace_entry = self
            .data
            .workspaces
            .get(workspace_path)
            .ok_or_else(|| crate::Error::MissingWorkspace(workspace_path.into()))?;
        let workspace_name = &workspace_entry.name;

        // Parse version spec using structured type
        let version_spec = VersionSpec::parse(version);

        // Handle catalog references
        let resolved_version = if version_spec.is_catalog() {
            // Try to resolve catalog reference
            if let Some(catalog_version) = self.resolve_catalog_version(name, version) {
                catalog_version
            } else {
                // Catalog reference couldn't be resolved, return None
                return Ok(None);
            }
        } else {
            version
        };

        // Apply overrides to the resolved version if any exist for this package
        let override_version = self.apply_overrides(name, resolved_version);

        // V1 optimization: Check if this is a workspace dependency that can be resolved
        // directly from the workspaces section without requiring a packages entry
        if self.data.lockfile_version >= 1 {
            let override_spec = VersionSpec::parse(override_version);
            if let Some(workspace_target_path) = override_spec.workspace_path()
                && let Some(target_workspace) = self.data.workspaces.get(workspace_target_path)
            {
                // This is a workspace dependency, create a synthetic package entry
                let workspace_version = target_workspace.version.as_deref().unwrap_or("0.0.0");
                return Ok(Some(crate::Package {
                    key: format!("{name}@{workspace_version}"),
                    version: workspace_version.to_string(),
                }));
            }
        }

        // Find a package version that satisfies the version spec
        // This searches workspace-scoped, hoisted, and nested entries
        if let Some(pkg) = self.find_matching_version(
            workspace_name,
            name,
            version,
            override_version,
            resolved_version,
        )? {
            return Ok(Some(pkg));
        }

        Ok(None)
    }

    #[tracing::instrument(skip(self))]
    fn all_dependencies(
        &self,
        key: &str,
    ) -> Result<
        Option<std::borrow::Cow<'_, std::collections::BTreeMap<String, String>>>,
        crate::Error,
    > {
        let entry_key = self
            .key_to_entry
            .get(key)
            .ok_or_else(|| crate::Error::MissingPackage(key.into()))?;
        let entry = self
            .data
            .packages
            .get(entry_key)
            .ok_or_else(|| crate::Error::MissingPackage(key.into()))?;

        let mut deps = std::collections::BTreeMap::new();

        let Some(info) = &entry.info else {
            return Ok(Some(std::borrow::Cow::Owned(deps)));
        };

        for (dependency, version) in info.all_dependencies() {
            // Bun resolves nested dependencies by walking up the parent chain
            // from the current package.
            let nested_entry = self
                .nested_dependency_entry(entry_key, dependency, version)
                .map(|(_, entry)| entry);

            let is_optional = info.optional_dependencies.contains_key(dependency)
                || info.optional_peers.contains(dependency);

            if is_optional {
                let has_nested = nested_entry.is_some();

                if !has_nested {
                    let is_optional_peer_only =
                        !info.optional_dependencies.contains_key(dependency);
                    let has_hoisted = self.data.packages.contains_key(dependency);

                    if is_optional_peer_only || !has_hoisted {
                        continue;
                    }
                }
            }

            // When a nested entry exists for this dependency (e.g.,
            // "chalk/ansi-styles/color-convert/color-name"), return an exact
            // version constraint so resolve_package matches the correct
            // nested version rather than a different nested entry that
            // happens to also satisfy the semver range.
            let resolved_version = if let Some(nested) = nested_entry {
                format!("={}", nested.version())
            } else {
                version.to_string()
            };

            deps.insert(dependency.to_string(), resolved_version);
        }

        Ok(Some(std::borrow::Cow::Owned(deps)))
    }

    fn subgraph(
        &self,
        workspace_packages: &[String],
        packages: &[String],
    ) -> Result<Box<dyn Lockfile>, crate::Error> {
        // Workspace mappings must be included in the packages list to ensure they're
        // found during pruning
        let mut packages_with_workspaces: std::collections::HashSet<String> =
            packages.iter().cloned().collect();

        if let Some(root_workspace) = self.data.workspaces.get("") {
            let mut root_deps = BTreeMap::new();
            if let Some(deps) = &root_workspace.dependencies {
                root_deps.extend(deps.clone());
            }
            if let Some(dev_deps) = &root_workspace.dev_dependencies {
                root_deps.extend(dev_deps.clone());
            }
            if let Some(optional_deps) = &root_workspace.optional_dependencies {
                root_deps.extend(optional_deps.clone());
            }
            if let Some(peer_deps) = &root_workspace.peer_dependencies {
                root_deps.extend(peer_deps.clone());
            }

            if !root_deps.is_empty() {
                let root_closures = crate::all_transitive_closures(
                    self,
                    Some(("".to_string(), root_deps)).into_iter().collect(),
                    true,
                )?;
                if let Some(root_closure) = root_closures.get("") {
                    packages_with_workspaces
                        .extend(root_closure.iter().map(|package| package.key.clone()));
                }
            }
        }

        let workspace_deps: HashMap<String, BTreeMap<String, String>> = workspace_packages
            .iter()
            .filter(|ws_path| !ws_path.is_empty())
            .filter_map(|ws_path| {
                let workspace_entry = self.data.workspaces.get(ws_path.as_str())?;
                let mut deps = BTreeMap::new();
                if let Some(d) = &workspace_entry.dependencies {
                    deps.extend(d.clone());
                }
                if let Some(dd) = &workspace_entry.dev_dependencies {
                    deps.extend(dd.clone());
                }
                if let Some(od) = &workspace_entry.optional_dependencies {
                    deps.extend(od.clone());
                }
                if let Some(pd) = &workspace_entry.peer_dependencies {
                    deps.extend(pd.clone());
                }
                (!deps.is_empty()).then(|| (ws_path.clone(), deps))
            })
            .collect();

        if !workspace_deps.is_empty() {
            let workspace_closures = crate::all_transitive_closures(self, workspace_deps, true)?;
            packages_with_workspaces.extend(
                workspace_closures
                    .values()
                    .flat_map(|closure| closure.iter().map(|package| package.key.clone())),
            );
        }

        for ws_path in workspace_packages {
            if ws_path.is_empty() {
                continue;
            }
            if let Some(workspace_entry) = self.data.workspaces.get(ws_path.as_str()) {
                packages_with_workspaces.insert(workspace_entry.name.clone());
            }
        }

        // Add workspace peer dependencies that are actually installed
        // Peer dependencies declared at workspace level are requirements, not automatic
        // dependencies, but if they're installed (exist in packages section), they
        // should be included in the pruned lockfile
        for ws_path in workspace_packages {
            if let Some(workspace_entry) = self.data.workspaces.get(ws_path.as_str())
                && let Some(peer_deps) = &workspace_entry.peer_dependencies
            {
                for peer_name in peer_deps.keys() {
                    // Check if this peer dependency exists as an installed package
                    if self.data.packages.contains_key(peer_name) {
                        packages_with_workspaces.insert(peer_name.clone());
                    }
                }
            }
        }

        // Also check root workspace peer dependencies
        if let Some(root_workspace) = self.data.workspaces.get("")
            && let Some(peer_deps) = &root_workspace.peer_dependencies
        {
            for peer_name in peer_deps.keys() {
                if self.data.packages.contains_key(peer_name) {
                    packages_with_workspaces.insert(peer_name.clone());
                }
            }
        }

        let packages_vec: Vec<String> = packages_with_workspaces.into_iter().collect();

        let subgraph = self.subgraph(workspace_packages, &packages_vec)?;
        Ok(Box::new(subgraph))
    }

    fn encode(&self) -> Result<Vec<u8>, crate::Error> {
        let mut output = String::new();
        self.write_header(&mut output);
        self.write_workspaces(&mut output)?;
        self.write_trusted_dependencies(&mut output)?;
        self.write_overrides(&mut output)?;
        self.write_catalogs(&mut output)?;
        self.write_packages(&mut output)?;
        self.write_patched_dependencies(&mut output)?;
        output.push_str("}\n");
        Ok(output.into_bytes())
    }

    fn patches(&self) -> Result<Vec<RelativeUnixPathBuf>, crate::Error> {
        let mut patches = self
            .data
            .patched_dependencies
            .values()
            .map(RelativeUnixPathBuf::new)
            .collect::<Result<Vec<_>, turbopath::PathError>>()?;
        patches.sort();
        Ok(patches)
    }

    fn global_change(&self, other: &dyn Lockfile) -> bool {
        let any_other = other as &dyn Any;
        let Some(other_bun) = any_other.downcast_ref::<Self>() else {
            return true;
        };

        self.data.lockfile_version != other_bun.data.lockfile_version
    }

    fn turbo_version(&self) -> Option<String> {
        let (_, entry) = self.package_entry("turbo")?;
        let version = entry.version();
        Version::parse(version).ok()?;
        Some(version.to_owned())
    }

    fn human_name(&self, package: &crate::Package) -> Option<String> {
        let entry = self.data.packages.get(&package.key)?;
        Some(entry.ident.clone())
    }
}

impl BunLockfile {
    pub fn from_bytes(input: &[u8]) -> Result<Self, super::Error> {
        let s = std::str::from_utf8(input).map_err(Error::from)?;
        Self::from_str(s)
    }

    fn write_header(&self, output: &mut String) {
        output.push_str("{\n");
        output.push_str(&format!(
            "  \"lockfileVersion\": {},\n",
            self.data.lockfile_version
        ));
        // Write configVersion if present
        if let Some(config_version) = self.data.config_version {
            output.push_str(&format!("  \"configVersion\": {},\n", config_version));
        }
    }

    fn write_workspaces(&self, output: &mut String) -> Result<(), crate::Error> {
        // serde_json uses 2-space indentation, but Bun uses 4-space
        output.push_str("  \"workspaces\": ");
        let workspaces_json = serde_json::to_string_pretty(&self.data.workspaces)?;

        let lines: Vec<&str> = workspaces_json.lines().collect();
        let mut adjusted_json = String::new();
        for (i, line) in lines.iter().enumerate() {
            if i == 0 {
                adjusted_json.push_str(line);
            } else {
                let spaces = line.len() - line.trim_start().len();
                let indent = " ".repeat(spaces + 2);
                adjusted_json.push_str(&format!("\n{}{}", indent, line.trim_start()));
            }
        }

        // Use the helper function to add trailing commas
        let workspaces_with_commas = Self::add_trailing_commas(&adjusted_json);

        output.push_str(&workspaces_with_commas);
        output.push_str(",\n");

        Ok(())
    }

    fn write_trusted_dependencies(&self, output: &mut String) -> Result<(), crate::Error> {
        if self.data.trusted_dependencies.is_empty() {
            return Ok(());
        }
        let json = serde_json::to_string_pretty(&self.data.trusted_dependencies)?;
        output.push_str("  \"trustedDependencies\": ");
        output.push_str(&Self::format_json_field(&json));
        output.push_str(",\n");
        Ok(())
    }

    /// Add trailing commas to JSON values before closing brackets/braces
    /// Handles strings, numbers, booleans, nulls, and nested structures
    fn add_trailing_commas(json: &str) -> String {
        static TRAILING_COMMA_RE: OnceLock<Option<regex::Regex>> = OnceLock::new();
        // Match: any JSON value (string, number, boolean, null, ] or }) followed by
        // newline+whitespace and then a closing bracket/brace
        // Pattern covers:
        // - Strings ending with "
        // - Numbers ending with digits
        // - Booleans: true, false
        // - Null: null
        // - Nested closings: ] or }
        let Some(re) = TRAILING_COMMA_RE
            .get_or_init(|| regex::Regex::new(r#"("|true|false|null|\d|[\]}])\n(\s*)([\]}])"#).ok())
            .as_ref()
        else {
            return json.to_string();
        };
        // Run multiple passes until no more changes (handles deeply nested structures)
        let mut result = json.to_string();
        loop {
            let new_result = re.replace_all(&result, "$1,\n$2$3").to_string();
            if new_result == result {
                break;
            }
            result = new_result;
        }
        result
    }

    /// Format a JSON value for Bun lockfile output with proper indentation and
    /// trailing commas.
    ///
    /// This helper consolidates the common formatting logic used by multiple
    /// write_* methods:
    /// 1. Adjusts indentation (adds 2 extra spaces to all lines after the
    ///    first)
    /// 2. Adds trailing commas (required by Bun's lockfile format)
    fn format_json_field(json: &str) -> String {
        let lines: Vec<&str> = json.lines().collect();
        let mut adjusted = String::new();
        for (i, line) in lines.iter().enumerate() {
            if i == 0 {
                adjusted.push_str(line);
            } else {
                let spaces = line.len() - line.trim_start().len();
                let indent = " ".repeat(spaces + 2);
                adjusted.push_str(&format!("\n{}{}", indent, line.trim_start()));
            }
        }
        Self::add_trailing_commas(&adjusted)
    }

    fn write_overrides(&self, output: &mut String) -> Result<(), crate::Error> {
        if self.data.overrides.is_empty() {
            return Ok(());
        }
        let json = serde_json::to_string_pretty(&self.data.overrides)?;
        output.push_str("  \"overrides\": ");
        output.push_str(&Self::format_json_field(&json));
        output.push_str(",\n");
        Ok(())
    }

    fn write_catalogs(&self, output: &mut String) -> Result<(), crate::Error> {
        // Write default catalog if present
        if !self.data.catalog.is_empty() {
            let json = serde_json::to_string_pretty(&self.data.catalog)?;
            output.push_str("  \"catalog\": ");
            output.push_str(&Self::format_json_field(&json));
            output.push_str(",\n");
        }

        // Write named catalogs if present
        if self.data.catalogs.is_empty() {
            return Ok(());
        }
        let json = serde_json::to_string_pretty(&self.data.catalogs)?;
        output.push_str("  \"catalogs\": ");
        output.push_str(&Self::format_json_field(&json));
        output.push_str(",\n");
        Ok(())
    }

    fn write_packages(&self, output: &mut String) -> Result<(), crate::Error> {
        output.push_str("  \"packages\": {\n");

        let package_keys = self.sort_package_keys();
        for (i, key) in package_keys.iter().enumerate() {
            let entry = &self.data.packages[*key];

            let ident = PackageIdent::parse(&entry.ident);
            if ident.is_workspace() {
                // Workspace entries: [ident] when no info, [ident, info]
                // when there are dependencies. Bun omits the info object
                // entirely for workspace mappings with no dependencies.
                let ident_json = serde_json::to_string(&entry.ident)?;
                if let Some(info) = entry.info.as_ref().filter(|info| !info.is_empty()) {
                    let info_json = serde_json::to_string(info)?;
                    let info_json_spaced = self.format_info_json(&info_json);
                    output.push_str(&format!(
                        "    \"{key}\": [{ident_json}, {info_json_spaced}],"
                    ));
                } else {
                    output.push_str(&format!("    \"{key}\": [{ident_json}],"));
                }
            } else if ident.is_local_package() || is_tarball_or_url_package(&entry.ident) {
                let ident_json = serde_json::to_string(&entry.ident)?;
                let info_json =
                    serde_json::to_string(&entry.info.as_ref().unwrap_or(&PackageInfo::default()))?;
                let info_json_spaced = self.format_info_json(&info_json);
                if let Some(checksum) = &entry.checksum {
                    if !checksum.is_empty() {
                        let checksum_json = serde_json::to_string(checksum)?;
                        output.push_str(&format!(
                            "    \"{key}\": [{ident_json}, {info_json_spaced}, {checksum_json}],",
                        ));
                    } else {
                        output.push_str(&format!(
                            "    \"{key}\": [{ident_json}, {info_json_spaced}],",
                        ));
                    }
                } else {
                    output.push_str(&format!(
                        "    \"{key}\": [{ident_json}, {info_json_spaced}],",
                    ));
                }
            } else {
                let ident_json = serde_json::to_string(&entry.ident)?;
                let info_json =
                    serde_json::to_string(&entry.info.as_ref().unwrap_or(&PackageInfo::default()))?;
                let checksum_json = serde_json::to_string(entry.checksum.as_deref().unwrap_or(""))?;

                // Bun's format differs from serde_json: objects need padding spaces,
                // 3-element arrays get expanded with trailing commas, others stay compact
                let info_json_spaced = self.format_info_json(&info_json);

                // GitHub and git packages have 3 elements (no registry)
                // npm packages have 4 elements (with registry)
                if is_git_or_github_package(&entry.ident) {
                    // GitHub/git packages: [ident, info, checksum] - 3 elements
                    output.push_str(&format!(
                        "    \"{key}\": [{ident_json}, {info_json_spaced}, {checksum_json}],",
                    ));
                } else {
                    // npm packages: [ident, registry, info, checksum] - 4 elements
                    let registry_json =
                        serde_json::to_string(entry.registry.as_deref().unwrap_or(""))?;
                    output.push_str(&format!(
                        "    \"{key}\": [{ident_json}, {registry_json}, {info_json_spaced}, \
                         {checksum_json}],",
                    ));
                }
            }

            if i < package_keys.len() - 1 {
                output.push_str("\n\n");
            } else {
                output.push('\n');
            }
        }
        // Add comma if there are patched dependencies to follow
        if !self.data.patched_dependencies.is_empty() {
            output.push_str("  },\n");
        } else {
            output.push_str("  }\n");
        }

        Ok(())
    }

    fn write_patched_dependencies(&self, output: &mut String) -> Result<(), crate::Error> {
        if self.data.patched_dependencies.is_empty() {
            return Ok(());
        }
        let json = serde_json::to_string_pretty(&self.data.patched_dependencies)?;
        output.push_str("  \"patchedDependencies\": ");
        output.push_str(&Self::format_json_field(&json));
        // No trailing comma - this is the last section before closing brace
        output.push('\n');
        Ok(())
    }

    /// Bun sorts packages by structure: regular packages, then scoped hoisted,
    /// then non-scoped hoisted, then deeply nested
    fn sort_package_keys(&self) -> Vec<&String> {
        // Sort priorities for package keys
        const SORT_PRIORITY_TOP_LEVEL: u8 = 1;
        const SORT_PRIORITY_SHALLOW_NESTED: u8 = 2;
        const SORT_PRIORITY_DEEP_NESTED: u8 = 3;
        const SORT_PRIORITY_VERY_DEEP_NESTED: u8 = 4;

        let mut package_keys: Vec<_> = self.data.packages.keys().collect();
        package_keys.sort_by(|a, b| {
            let category = |key_str: &str| -> u8 {
                let key = PackageKey::parse(key_str);
                match key {
                    PackageKey::Simple(_) => SORT_PRIORITY_TOP_LEVEL,
                    PackageKey::Scoped { .. } => SORT_PRIORITY_TOP_LEVEL,
                    PackageKey::Nested { .. } => SORT_PRIORITY_DEEP_NESTED,
                    PackageKey::ScopedNested { .. } => {
                        // Count slashes to determine nesting depth
                        let slash_count = key_str.matches('/').count();
                        if slash_count == 2 {
                            SORT_PRIORITY_SHALLOW_NESTED // @scope/parent/dep
                        } else {
                            SORT_PRIORITY_VERY_DEEP_NESTED // deeper nesting
                        }
                    }
                }
            };

            let a_cat = category(a);
            let b_cat = category(b);

            if a_cat != b_cat {
                a_cat.cmp(&b_cat)
            } else {
                a.cmp(b)
            }
        });
        package_keys
    }

    /// Formats JSON to match Bun's specific formatting requirements:
    /// - Objects need padding spaces: `{ "key": "value" }`
    /// - 3-element arrays get expanded with trailing commas: `[ item1, item2,
    ///   item3, ]`
    /// - Other arrays stay compact: `[item1, item2]`
    fn format_info_json(&self, info_json: &str) -> String {
        if info_json == "{}" {
            return info_json.to_string();
        }

        let mut result = String::with_capacity(info_json.len() + 100);
        let chars: Vec<char> = info_json.chars().collect();
        let mut i = 0;
        let mut in_string = false;
        let mut escape_next = false;

        while i < chars.len() {
            let c = chars[i];

            if !escape_next {
                if c == '"' {
                    in_string = !in_string;
                } else if c == '\\' && in_string {
                    escape_next = true;
                }
            } else {
                escape_next = false;
            }

            if !in_string {
                match c {
                    '{' => {
                        result.push_str("{ ");
                        i += 1;
                        continue;
                    }
                    '}' => {
                        result.push_str(" }");
                        i += 1;
                        continue;
                    }
                    ':' => {
                        result.push_str(": ");
                        i += 1;
                        continue;
                    }
                    '[' => {
                        let array_result = self.format_array(&chars, &mut i);
                        result.push_str(&array_result);
                        i += 1; // skip closing ]
                        continue;
                    }
                    ',' => {
                        result.push_str(", ");
                        i += 1;
                        continue;
                    }
                    _ => {}
                }
            }

            result.push(c);
            i += 1;
        }

        result
    }

    /// Formats arrays according to Bun's requirements.
    /// Returns the formatted array string and updates the index.
    fn format_array(&self, chars: &[char], i: &mut usize) -> String {
        let mut array_depth = 1;
        let mut array_content = String::new();
        let mut in_array_string = false;
        let mut array_escape_next = false;
        *i += 1;

        while *i < chars.len() && array_depth > 0 {
            let array_char = chars[*i];

            if !array_escape_next {
                if array_char == '"' {
                    in_array_string = !in_array_string;
                } else if array_char == '\\' && in_array_string {
                    array_escape_next = true;
                } else if !in_array_string {
                    if array_char == '[' {
                        array_depth += 1;
                    } else if array_char == ']' {
                        array_depth -= 1;
                        if array_depth == 0 {
                            break;
                        }
                    }
                }
            } else {
                array_escape_next = false;
            }

            array_content.push(array_char);
            *i += 1;
        }

        let trimmed_content = array_content.trim_matches(|c: char| c == ',' || c.is_whitespace());

        // Bun uses compact arrays without trailing commas inside package entries
        format!("[{}]", self.format_array_content(trimmed_content))
    }

    /// Formats the content inside an array by adding proper spacing after
    /// commas.
    fn format_array_content(&self, content: &str) -> String {
        let mut formatted = String::with_capacity(content.len() + 20);
        let mut depth = 0;
        let mut in_str = false;
        let mut esc = false;

        for ch in content.chars() {
            if !esc {
                if ch == '"' {
                    in_str = !in_str;
                } else if ch == '\\' && in_str {
                    esc = true;
                } else if !in_str {
                    if ch == '[' || ch == '{' {
                        depth += 1;
                    } else if ch == ']' || ch == '}' {
                        depth -= 1;
                    } else if ch == ',' && depth == 0 {
                        formatted.push_str(", ");
                        continue;
                    }
                }
            } else {
                esc = false;
            }
            formatted.push(ch);
        }

        formatted
    }

    /// Returns the version override if there's an override for a package name
    fn apply_overrides<'a>(&'a self, name: &str, version: &'a str) -> &'a str {
        self.data
            .overrides
            .get(name)
            .map(|s| s.as_str())
            .unwrap_or(version)
    }

    /// Resolves a catalog reference to the actual version
    /// Supports both default catalog ("catalog:") and named catalogs
    /// ("catalog:group:")
    fn resolve_catalog_version(&self, name: &str, catalog_ref: &str) -> Option<&str> {
        if let Some(stripped) = catalog_ref.strip_prefix("catalog:") {
            if stripped.is_empty() {
                // Default catalog reference: "catalog:"
                self.data.catalog.get(name).map(|s| s.as_str())
            } else {
                // Named catalog reference: "catalog:group:"
                self.data
                    .catalogs
                    .get(stripped)
                    .and_then(|catalog| catalog.get(name).map(|s| s.as_str()))
            }
        } else {
            None
        }
    }

    // Given a specific key for a package, return the most specific key that is
    // present in the lockfile
    fn package_entry(&self, key: &str) -> Option<(&str, &PackageEntry)> {
        let (key, entry) =
            PossibleKeyIter::new(key).find_map(|k| self.data.packages.get_key_value(k))?;
        Some((key, entry))
    }

    fn include_duplicate_alias_children(&self, pruned_data: &mut BunLockfileData) {
        loop {
            let mut keys_by_ident: HashMap<String, Vec<String>> = HashMap::new();
            for (key, entry) in &pruned_data.packages {
                if PackageIdent::parse(&entry.ident).is_workspace() {
                    continue;
                }
                keys_by_ident
                    .entry(entry.ident.clone())
                    .or_default()
                    .push(key.clone());
            }

            let current_keys: std::collections::HashSet<String> =
                pruned_data.packages.keys().cloned().collect();
            let mut additions = BTreeMap::new();

            for keys in keys_by_ident.values().filter(|keys| keys.len() > 1) {
                let mut child_suffixes = BTreeSet::new();
                for key in keys {
                    let prefix = format!("{key}/");
                    child_suffixes.extend(
                        current_keys
                            .iter()
                            .filter_map(|current_key| current_key.strip_prefix(&prefix))
                            .map(ToString::to_string),
                    );
                }

                for key in keys {
                    for suffix in &child_suffixes {
                        let child_key = format!("{key}/{suffix}");
                        if current_keys.contains(&child_key) || additions.contains_key(&child_key) {
                            continue;
                        }
                        if let Some(entry) = self.data.packages.get(&child_key) {
                            additions.insert(child_key, entry.clone());
                        }
                    }
                }
            }

            if additions.is_empty() {
                break;
            }

            pruned_data.packages.extend(additions);
        }
    }

    pub fn lockfile(self) -> Result<BunLockfileData, Error> {
        Ok(self.data)
    }

    fn subgraph(
        &self,
        workspace_packages: &[String],
        packages: &[String],
    ) -> Result<BunLockfile, Error> {
        use std::collections::HashSet;

        // Create pruned lockfile structure
        let mut pruned_data = BunLockfileData {
            lockfile_version: self.data.lockfile_version,
            config_version: self.data.config_version,
            workspaces: Map::new(),
            // trustedDependencies are intentionally left empty. turbo prune
            // copies the root package.json which is the source of truth for
            // trusted scripts; bun re-derives the set at install time.
            trusted_dependencies: Vec::new(),
            overrides: Map::new(),
            catalog: self.data.catalog.clone(),
            catalogs: self.data.catalogs.clone(),
            packages: Map::new(),
            patched_dependencies: Map::new(),
        };

        if let Some(root) = self.data.workspaces.get("") {
            pruned_data.workspaces.insert("".to_string(), root.clone());
        }

        for ws_path in workspace_packages {
            if let Some(entry) = self.data.workspaces.get(ws_path) {
                pruned_data
                    .workspaces
                    .insert(ws_path.clone(), entry.clone());
            }
        }

        let mut keys_to_include = HashSet::new();

        let target_workspace_names: HashSet<String> = workspace_packages
            .iter()
            .filter_map(|ws_path| self.data.workspaces.get(ws_path).map(|ws| ws.name.clone()))
            .collect();

        // When idents map to multiple lockfile keys, only include workspace-specific
        // entries for target workspaces to avoid pulling in unrelated workspace
        // versions
        for pkg in packages {
            if self.data.packages.contains_key(pkg)
                || pruned_data.workspaces.values().any(|ws| &ws.name == pkg)
            {
                keys_to_include.insert(pkg.clone());
            } else if let Some(at_pos) = pkg.rfind('@') {
                let name = &pkg[..at_pos];

                if let Some(entry) = self.data.packages.get(name)
                    && entry.ident.contains("@workspace:")
                {
                    keys_to_include.insert(name.to_string());
                    // Continue to also find package entries with this ident
                    // (e.g., both "storybook" workspace mapping and
                    // "storybook/storybook")
                }

                for (lockfile_key, entry) in &self.data.packages {
                    if &entry.ident != pkg {
                        continue;
                    }

                    if let Some(slash_pos) = lockfile_key.find('/') {
                        let prefix = &lockfile_key[..slash_pos];

                        let is_workspace_prefix =
                            self.data.workspaces.values().any(|ws| ws.name == prefix);

                        if is_workspace_prefix {
                            if target_workspace_names.contains(prefix) {
                                keys_to_include.insert(lockfile_key.clone());
                            }
                        } else {
                            keys_to_include.insert(lockfile_key.clone());
                        }
                    } else {
                        keys_to_include.insert(lockfile_key.clone());
                    }
                }
            } else {
                for (lockfile_key, entry) in &self.data.packages {
                    if &entry.ident != pkg {
                        continue;
                    }

                    if let Some(slash_pos) = lockfile_key.find('/') {
                        let prefix = &lockfile_key[..slash_pos];

                        let is_workspace_prefix =
                            self.data.workspaces.values().any(|ws| ws.name == prefix);

                        if is_workspace_prefix {
                            if target_workspace_names.contains(prefix) {
                                keys_to_include.insert(lockfile_key.clone());
                            }
                        } else {
                            keys_to_include.insert(lockfile_key.clone());
                        }
                    } else {
                        keys_to_include.insert(lockfile_key.clone());
                    }
                }
            }
        }

        // De-alias workspace-specific keys (e.g., "blog/@types/react" ->
        // "@types/react") so peer dependencies resolve correctly in pruned
        // lockfiles
        let should_dealias = !workspace_packages.is_empty();

        let mut dealias_set: std::collections::HashSet<String> = std::collections::HashSet::new();
        if should_dealias {
            for key in &keys_to_include {
                let parsed_key = PackageKey::parse(key);

                // Only nested keys can be dealiased
                if let Some(parent) = parsed_key.parent() {
                    // Check if this is nested under a target workspace
                    if target_workspace_names.contains(&parent) {
                        // Get the dealiased version
                        if let Some(dealiased_key) = parsed_key.dealias() {
                            let dealiased_str = dealiased_key.to_string();

                            // Check if dealiasing would conflict with an existing package.
                            // If the top-level key points at a different ident, keep the
                            // nested key so both versions remain addressable.
                            let would_conflict = if let Some(existing_entry) =
                                self.data.packages.get(&dealiased_str)
                            {
                                let ident = PackageIdent::parse(&existing_entry.ident);
                                let current_entry = self.data.packages.get(key);
                                ident.is_workspace()
                                    || current_entry
                                        .map(|entry| entry.ident != existing_entry.ident)
                                        .unwrap_or(false)
                            } else {
                                false
                            };

                            if !would_conflict {
                                dealias_set.insert(dealiased_str);
                            }
                        }
                    }
                }
            }
        }

        let mut sorted_keys: Vec<_> = keys_to_include.iter().collect();
        sorted_keys.sort();

        let mut renamed_prefixes: Vec<(String, String)> = Vec::new();

        for key in sorted_keys {
            if let Some(entry) = self.data.packages.get(key) {
                let mut pruned_key = if should_dealias {
                    let parsed_key = PackageKey::parse(key);

                    // Check if this is a nested key that could be dealiased
                    if let Some(parent) = parsed_key.parent() {
                        let is_target_workspace_prefix = target_workspace_names.contains(&parent);

                        if is_target_workspace_prefix {
                            // Try to dealias
                            if let Some(dealiased_key) = parsed_key.dealias() {
                                let dealiased_str = dealiased_key.to_string();

                                // Check if dealiasing would conflict with an existing package.
                                // Different idents must keep distinct keys.
                                if let Some(existing_entry) = self.data.packages.get(&dealiased_str)
                                {
                                    let ident = PackageIdent::parse(&existing_entry.ident);
                                    if ident.is_workspace() || entry.ident != existing_entry.ident {
                                        // This would conflict with another package - keep full key.
                                        key.clone()
                                    } else {
                                        // No conflict - safe to dealias
                                        dealiased_str
                                    }
                                } else {
                                    // No existing entry - safe to dealias
                                    dealiased_str
                                }
                            } else {
                                // Cannot dealias
                                key.clone()
                            }
                        } else {
                            // Keep the key as-is (it's nested under a package, not a workspace)
                            key.clone()
                        }
                    } else {
                        // No slash - this is a top-level entry
                        // Check if a workspace-scoped version will be de-aliased to this same key
                        if dealias_set.contains(key) {
                            // Skip this top-level entry - it conflicts with a workspace-scoped
                            // version
                            continue;
                        }
                        key.clone()
                    }
                } else {
                    // Not dealiasing - keep key as-is
                    key.clone()
                };

                if let Some((old_prefix, new_prefix)) = renamed_prefixes
                    .iter()
                    .filter(|(old_prefix, _)| key.starts_with(old_prefix))
                    .max_by_key(|(old_prefix, _)| old_prefix.len())
                {
                    pruned_key = format!("{}{}", new_prefix, &key[old_prefix.len()..]);
                }

                if pruned_key != *key {
                    renamed_prefixes.push((format!("{key}/"), format!("{pruned_key}/")));
                }

                // Check if this is a workspace mapping entry (e.g., "storybook":
                // ["storybook@workspace:apps/storybook"])
                let ident = PackageIdent::parse(&entry.ident);
                let is_workspace_mapping = ident.is_workspace() && ident.name() == key;

                // Handle workspace mapping entries
                if is_workspace_mapping {
                    // Extract the workspace path from the mapping
                    // Format: "storybook@workspace:apps/storybook" -> workspace path is
                    // "apps/storybook"
                    if let Some(workspace_path) = ident.workspace_path() {
                        // Ensure transitive workspace dependencies are in the
                        // pruned set. The initial pruned_data.workspaces only
                        // contains the root and target workspaces, but
                        // workspaces depended on transitively must also be
                        // included.
                        if !pruned_data.workspaces.contains_key(workspace_path)
                            && let Some(ws_entry) = self.data.workspaces.get(workspace_path)
                        {
                            pruned_data
                                .workspaces
                                .insert(workspace_path.to_string(), ws_entry.clone());
                        }

                        // Check if this workspace is in the pruned set
                        if pruned_data.workspaces.contains_key(workspace_path) {
                            // This workspace IS in the pruned set - keep the mapping as-is
                            pruned_data
                                .packages
                                .insert(pruned_key.clone(), entry.clone());
                            continue;
                        }

                        // This workspace is NOT in the pruned set (doesn't
                        // exist in the original data either). Try to find the
                        // actual npm package entry instead.
                        // Get the workspace name (last component of path)
                        let workspace_name = workspace_path
                            .split('/')
                            .next_back()
                            .unwrap_or(workspace_path);

                        // Look for the actual package entry stored with workspace-scoped key
                        // e.g., "storybook/storybook" for workspace "storybook"
                        let scoped_key = format!("{workspace_name}/{key}");

                        if let Some(actual_package) = self.data.packages.get(&scoped_key) {
                            // Include the actual package entry with the unscoped key
                            pruned_data
                                .packages
                                .insert(pruned_key.clone(), actual_package.clone());
                        }
                    }

                    // Skip the workspace mapping entry itself
                    continue;
                }

                pruned_data
                    .packages
                    .insert(pruned_key.clone(), entry.clone());

                if pruned_key != *key {
                    let old_prefix = format!("{key}/");
                    let new_prefix = format!("{pruned_key}/");
                    for (descendant_key, descendant_entry) in &self.data.packages {
                        if descendant_key.starts_with(&old_prefix) {
                            let renamed_key =
                                format!("{}{}", new_prefix, &descendant_key[old_prefix.len()..]);
                            pruned_data
                                .packages
                                .insert(renamed_key, descendant_entry.clone());
                        }
                    }
                }

                // Check if this package references a workspace (e.g., via @workspace: in ident)
                // and ensure that workspace is included
                let package_ident = PackageIdent::parse(&entry.ident);
                if let Some(workspace_path) = package_ident.workspace_path() {
                    // Add this workspace if not already included
                    if !pruned_data.workspaces.contains_key(workspace_path)
                        && let Some(ws_entry) = self.data.workspaces.get(workspace_path)
                    {
                        pruned_data
                            .workspaces
                            .insert(workspace_path.to_string(), ws_entry.clone());
                    }
                }

                // Include bundled dependencies
                // Bundled dependencies are stored with nested keys like "parent/dep"
                // and have "bundled": true in their info
                // Note: We search using the original key from the source lockfile
                let bundled_prefix = format!("{key}/");
                for (lockfile_key, bundled_entry) in &self.data.packages {
                    if lockfile_key.starts_with(&bundled_prefix)
                        && let Some(bundled_info) = &bundled_entry.info
                        && bundled_info
                            .other
                            .get("bundled")
                            .and_then(|v| v.as_bool())
                            .unwrap_or(false)
                    {
                        // Check if this is a bundled dependency
                        // In Bun's format, bundled is indicated by the "bundled" field
                        // Bundled deps are always nested under their parent,
                        // so we need to adjust the key if we dealiased the parent
                        let bundled_pruned_key =
                            if should_dealias && lockfile_key.starts_with(&bundled_prefix) {
                                // Replace the parent prefix with the dealiased version
                                format!("{}{}", pruned_key, &lockfile_key[key.len()..])
                            } else {
                                lockfile_key.clone()
                            };
                        pruned_data
                            .packages
                            .insert(bundled_pruned_key, bundled_entry.clone());
                    }
                }
            } else {
                // Key doesn't exist in original lockfile - check if it's a workspace name
                // and create a workspace mapping entry for it
                if let Some((ws_path, ws_entry)) = pruned_data
                    .workspaces
                    .iter()
                    .find(|(_, ws)| &ws.name == key)
                {
                    // Skip root workspace
                    if !ws_path.is_empty() {
                        let ident = format!("{key}@workspace:{ws_path}");
                        let info = PackageInfo {
                            dependencies: ws_entry.dependencies.clone().unwrap_or_default(),
                            dev_dependencies: ws_entry.dev_dependencies.clone().unwrap_or_default(),
                            optional_dependencies: ws_entry
                                .optional_dependencies
                                .clone()
                                .unwrap_or_default(),
                            peer_dependencies: ws_entry
                                .peer_dependencies
                                .clone()
                                .unwrap_or_default(),
                            optional_peers: ws_entry
                                .optional_peers
                                .as_ref()
                                .map(|v| v.iter().cloned().collect())
                                .unwrap_or_default(),
                            ..Default::default()
                        };
                        let entry = PackageEntry {
                            ident,
                            registry: None,
                            info: Some(info),
                            checksum: None,
                            root: None,
                        };
                        pruned_data.packages.insert(key.clone(), entry);
                    }
                }
            }
        }

        // Collect idents of all packages in the subgraph for filtering purposes
        let included_idents: HashSet<&str> = pruned_data
            .packages
            .values()
            .map(|entry| entry.ident.as_str())
            .collect();

        // Preserve ALL overrides from the original lockfile. turbo prune copies
        // the root package.json with all overrides intact, so the lockfile must
        // keep them in sync. If overrides are selectively stripped here, bun
        // detects the mismatch with package.json and re-resolves, which breaks
        // `bun install --frozen-lockfile` when a range has drifted (e.g. "latest").
        pruned_data.overrides = self.data.overrides.clone();

        // Filter patched_dependencies to only include those for packages in the
        // subgraph
        for (pkg_ident, patch_path) in &self.data.patched_dependencies {
            if included_idents.contains(pkg_ident.as_str()) {
                pruned_data
                    .patched_dependencies
                    .insert(pkg_ident.clone(), patch_path.clone());
            }
        }

        // ORPHAN REMOVAL: After de-aliasing, some packages may be orphaned
        // (only depended on by packages that were skipped due to de-aliasing
        // conflicts). We need to remove these orphaned packages.
        //
        // Strategy: Recompute which packages are reachable from workspace dependencies
        // using the pruned packages. Packages not reachable are orphans.

        // Build key_to_entry for closure computation
        let mut temp_key_to_entry: HashMap<String, String> = HashMap::new();
        for (path, entry) in &pruned_data.packages {
            // Take first occurrence for duplicate idents (shouldn't happen after
            // de-aliasing)
            temp_key_to_entry
                .entry(entry.ident.clone())
                .or_insert(path.clone());
        }

        // Create temporary lockfile for recomputation
        let temp_data = BunLockfileData {
            lockfile_version: pruned_data.lockfile_version,
            config_version: pruned_data.config_version,
            workspaces: pruned_data.workspaces.clone(),
            trusted_dependencies: pruned_data.trusted_dependencies.clone(),
            overrides: pruned_data.overrides.clone(),
            catalog: self.data.catalog.clone(),
            catalogs: self.data.catalogs.clone(),
            packages: pruned_data.packages.clone(),
            patched_dependencies: pruned_data.patched_dependencies.clone(),
        };
        let temp_index = PackageIndex::new(&temp_data.packages);
        let temp_lockfile = BunLockfile {
            data: temp_data,
            key_to_entry: temp_key_to_entry,
            index: temp_index,
        };

        // Collect workspace dependencies
        let workspace_deps: HashMap<String, BTreeMap<String, String>> = pruned_data
            .workspaces
            .iter()
            .map(|(ws_path, ws_entry)| {
                let mut deps = BTreeMap::new();
                if let Some(d) = &ws_entry.dependencies {
                    deps.extend(d.clone());
                }
                if let Some(dd) = &ws_entry.dev_dependencies {
                    deps.extend(dd.clone());
                }
                if let Some(od) = &ws_entry.optional_dependencies {
                    deps.extend(od.clone());
                }
                // Include peer dependencies for orphan removal computation
                // Peer dependencies that are actually installed should not be considered
                // orphans
                if let Some(pd) = &ws_entry.peer_dependencies {
                    deps.extend(pd.clone());
                }
                (ws_path.clone(), deps)
            })
            .collect();

        // Recompute transitive closure
        match crate::all_transitive_closures(&temp_lockfile, workspace_deps, true) {
            Ok(recomputed_closures) => {
                let reachable_idents: HashSet<String> = recomputed_closures
                    .values()
                    .flat_map(|closure| closure.iter().map(|p| p.key.clone()))
                    .collect();

                // Also keep track of reachable lockfile keys for nested package detection
                let reachable_lockfile_keys: HashSet<String> = recomputed_closures
                    .values()
                    .flat_map(|closure| {
                        closure
                            .iter()
                            .filter_map(|p| temp_lockfile.key_to_entry.get(&p.key).cloned())
                    })
                    .collect();

                let pruned_workspace_names: HashSet<String> = pruned_data
                    .workspaces
                    .values()
                    .map(|workspace| workspace.name.clone())
                    .collect();

                pruned_data.packages.retain(|key, entry| {
                    if entry.ident.contains("@workspace:") {
                        return true;
                    }

                    let parsed = PackageKey::parse(key);
                    if let Some(parent) = parsed.parent() {
                        reachable_idents.contains(&entry.ident)
                            && (reachable_lockfile_keys.contains(&parent)
                                || pruned_workspace_names.contains(&parent))
                    } else {
                        reachable_idents.contains(&entry.ident)
                    }
                });

                loop {
                    let current_keys: HashSet<String> =
                        pruned_data.packages.keys().cloned().collect();
                    let before = current_keys.len();
                    pruned_data.packages.retain(|key, _entry| {
                        let parsed = PackageKey::parse(key);
                        match parsed.parent() {
                            Some(parent) => current_keys.contains(&parent),
                            None => true,
                        }
                    });
                    if pruned_data.packages.len() == before {
                        break;
                    }
                }
            }
            Err(e) => {
                tracing::warn!("Failed to recompute transitive closures for orphan removal: {e}");
            }
        }

        // NESTED KEY PROMOTION: In bun lockfiles, nested entries like
        // "accepts/negotiator" only exist as overrides of a hoisted top-level
        // entry ("negotiator"). When pruning removes the hoisted version but
        // keeps a nested version, we must promote the nested entry to top-level
        // to maintain a valid lockfile structure that bun's --frozen-lockfile
        // accepts.
        loop {
            let top_level_pkg_names: HashSet<String> = pruned_data
                .packages
                .iter()
                .filter(|(key, _)| PackageKey::parse(key).parent().is_none())
                .filter_map(|(_, entry)| {
                    let ident = PackageIdent::parse(&entry.ident);
                    if !ident.is_workspace() {
                        Some(ident.name().to_string())
                    } else {
                        None
                    }
                })
                .collect();

            // Find the first nested entry (by sorted key order) whose package
            // name has no top-level entry. We promote one package per iteration
            // because promotion renames children, which may expose further
            // entries that need promotion.
            let mut sorted_pkg_keys: Vec<_> = pruned_data.packages.keys().cloned().collect();
            sorted_pkg_keys.sort();

            let mut promote_target: Option<(String, String)> = None; // (pkg_name, old_key)
            for key in &sorted_pkg_keys {
                if let Some(parent) = PackageKey::parse(key).parent() {
                    if (parent == "npm" || parent.starts_with("npm/"))
                        && pruned_data.packages.contains_key(&parent)
                    {
                        continue;
                    }
                } else {
                    continue;
                }

                let entry = &pruned_data.packages[key];
                let ident = PackageIdent::parse(&entry.ident);
                if ident.is_workspace() {
                    continue;
                }

                let pkg_name = ident.name().to_string();
                if !top_level_pkg_names.contains(&pkg_name) {
                    promote_target = Some((pkg_name, key.clone()));
                    break;
                }
            }

            let Some((pkg_name, old_key)) = promote_target else {
                break;
            };

            // Rename the entry and all its children
            let old_prefix = format!("{old_key}/");
            let new_prefix = format!("{pkg_name}/");
            let mut renames: Vec<(String, String)> = vec![(old_key, pkg_name)];
            for key in &sorted_pkg_keys {
                if key.starts_with(&old_prefix) {
                    let new_key = format!("{new_prefix}{}", &key[old_prefix.len()..]);
                    renames.push((key.clone(), new_key));
                }
            }

            for (old, new) in renames {
                if let Some(entry) = pruned_data.packages.remove(&old) {
                    pruned_data.packages.insert(new, entry);
                }
            }
        }

        // After pruning, some packages may have required peer dependencies that
        // are no longer present in the pruned set (e.g. expo-network is provided
        // by mobile workspaces that were pruned away). Bun refuses to parse a
        // lockfile with unresolvable required peers, so we move them to
        // optionalPeers.
        let pruned_package_names: HashSet<String> = pruned_data.packages.keys().cloned().collect();
        for entry in pruned_data.packages.values_mut() {
            if let Some(info) = &mut entry.info {
                if info.peer_dependencies.is_empty() {
                    continue;
                }
                let missing_peers: Vec<String> = info
                    .peer_dependencies
                    .keys()
                    .filter(|peer_name| {
                        !info.optional_peers.contains(*peer_name)
                            && !pruned_package_names.contains(*peer_name)
                    })
                    .cloned()
                    .collect();
                for peer_name in missing_peers {
                    info.optional_peers.insert(peer_name);
                }
            }
        }

        // SUFFIXED KEY RENAME: Bun appends suffixes like `--for-generate-function-map`
        // to distinguish multiple versions of the same package. When the primary
        // (unsuffixed) entry is pruned and only the suffixed one remains, rename
        // the key to the base package name so bun can resolve it.
        {
            let top_level_pkg_names: HashSet<String> = pruned_data
                .packages
                .iter()
                .filter(|(key, _)| !key.contains("--") && PackageKey::parse(key).parent().is_none())
                .map(|(_, entry)| PackageIdent::parse(&entry.ident).name().to_string())
                .collect();

            let mut renames: Vec<(String, String)> = Vec::new();
            for (key, entry) in &pruned_data.packages {
                if !key.contains("--") {
                    continue;
                }
                let parsed = PackageKey::parse(key);
                if parsed.parent().is_some() {
                    continue;
                }
                let ident = PackageIdent::parse(&entry.ident);
                let pkg_name = ident.name();
                if key != pkg_name && !top_level_pkg_names.contains(pkg_name) {
                    renames.push((key.clone(), pkg_name.to_string()));
                }
            }
            for (old_key, new_key) in renames {
                if let Some(entry) = pruned_data.packages.remove(&old_key) {
                    pruned_data.packages.insert(new_key, entry);
                }
            }
        }

        // NESTED ENTRY DEDUPLICATION: In bun lockfiles, nested entries like
        // `@babel/core/@babel/traverse` only need to exist when they resolve to
        // a DIFFERENT version than the hoisted top-level `@babel/traverse`.
        // After pruning removes some versions, nested entries that now match
        // the hoisted entry are redundant and must be removed — bun's
        // --frozen-lockfile rejects them.
        {
            let hoisted_idents: HashMap<String, String> = pruned_data
                .packages
                .iter()
                .filter(|(key, entry)| {
                    let parsed = PackageKey::parse(key);
                    if parsed.parent().is_some() {
                        return false;
                    }
                    let ident = PackageIdent::parse(&entry.ident);
                    let pkg_name = ident.name();
                    *key == pkg_name
                })
                .map(|(_, entry)| {
                    let ident = PackageIdent::parse(&entry.ident);
                    (ident.name().to_string(), entry.ident.clone())
                })
                .collect();

            pruned_data.packages.retain(|key, entry| {
                if PackageKey::parse(key).parent().is_none() {
                    return true;
                }
                let ident = PackageIdent::parse(&entry.ident);
                if ident.is_workspace() {
                    return true;
                }
                match hoisted_idents.get(ident.name()) {
                    Some(hoisted_ident) => &entry.ident != hoisted_ident,
                    None => true,
                }
            });
        }

        // De-aliasing can turn a workspace-scoped package key like
        // `@repo/app/pkg` into `pkg`. Its nested resolutions need to remain
        // addressable from the new key, otherwise bun treats required deps as
        // missing during frozen installs.
        loop {
            let package_snapshot: Vec<(String, PackageEntry)> = pruned_data
                .packages
                .iter()
                .map(|(key, entry)| (key.clone(), entry.clone()))
                .collect();
            let mut additions = Vec::new();

            for (key, entry) in package_snapshot {
                let Some(info) = &entry.info else {
                    continue;
                };

                for (dep_name, dep_version) in info
                    .dependencies
                    .iter()
                    .chain(info.optional_dependencies.iter())
                {
                    let nested_key = format!("{key}/{dep_name}");
                    if pruned_data.packages.contains_key(dep_name)
                        || pruned_data.packages.contains_key(&nested_key)
                    {
                        continue;
                    }

                    let exact_source = self
                        .data
                        .packages
                        .get_key_value(&key)
                        .filter(|(_, source_entry)| source_entry.ident == entry.ident)
                        .and_then(|(source_key, _)| {
                            self.nested_dependency_entry(source_key, dep_name, dep_version)
                                .map(|(dep_key, dep_entry)| (dep_key, dep_entry, source_key))
                        });

                    let ident_source = || {
                        self.data
                            .packages
                            .iter()
                            .filter(|(source_key, source_entry)| {
                                source_key.as_str() != key && source_entry.ident == entry.ident
                            })
                            .find_map(|(source_key, _)| {
                                self.nested_dependency_entry(source_key, dep_name, dep_version)
                                    .map(|(dep_key, dep_entry)| (dep_key, dep_entry, source_key))
                            })
                    };

                    let Some((source_dep_key, source_dep_entry, source_parent_key)) =
                        exact_source.or_else(ident_source)
                    else {
                        continue;
                    };

                    let source_prefix = format!("{source_parent_key}/");
                    let pruned_dep_key =
                        if let Some(suffix) = source_dep_key.strip_prefix(&source_prefix) {
                            format!("{key}/{suffix}")
                        } else {
                            source_dep_key
                        };

                    if pruned_data.packages.contains_key(&pruned_dep_key) {
                        continue;
                    }

                    additions.push((pruned_dep_key, source_dep_entry.clone()));
                }
            }

            if additions.is_empty() {
                break;
            }

            for (key, entry) in additions {
                pruned_data.packages.insert(key, entry);
            }
        }

        // Alias keys can share the same package ident, e.g. `string-width` and
        // `string-width-cjs`. Bun expects their nested dependency sets to stay in
        // sync; keeping only one sibling makes `bun install --frozen-lockfile`
        // rewrite the pruned lockfile.
        self.include_duplicate_alias_children(&mut pruned_data);

        // Rebuild key_to_entry HashMap for the pruned lockfile
        let mut key_to_entry: HashMap<String, String> =
            HashMap::with_capacity(pruned_data.packages.len());
        for (path, entry) in pruned_data.packages.iter() {
            if let Some(prev_path) = key_to_entry.insert(entry.ident.clone(), path.clone()) {
                let Some(prev_entry) = pruned_data.packages.get(&prev_path) else {
                    continue;
                };

                // Verify checksums match for duplicate idents
                if prev_entry.checksum != entry.checksum {
                    return Err(Error::MismatchedShas {
                        ident: entry.ident.clone(),
                        sha1: prev_entry.checksum.clone().unwrap_or_default(),
                        sha2: entry.checksum.clone().unwrap_or_default(),
                    });
                }
            }
        }

        // Build package index for pruned data
        let index = PackageIndex::new(&pruned_data.packages);

        Ok(BunLockfile {
            data: pruned_data,
            key_to_entry,
            index,
        })
    }
}

impl FromStr for BunLockfile {
    type Err = super::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let parsed_json = biome_json_parser::parse_json(
            s,
            JsonParserOptions::default().with_allow_trailing_commas(),
        );
        if parsed_json.has_errors() {
            let diags = parsed_json
                .into_diagnostics()
                .into_iter()
                .map(|diagnostic| ParseDiagnostic::from(&diagnostic).to_string())
                .join("\n");
            return Err(super::Error::BiomeJsonError(diags));
        }
        let syntax_tree = parsed_json.syntax();
        let format = biome_json_formatter::format_node(
            JsonFormatOptions::default()
                .with_trailing_commas(biome_json_formatter::context::TrailingCommas::None),
            &syntax_tree,
        )
        .map_err(Error::from)?;
        let strict_json = format.print().map_err(Error::from)?;
        let data: BunLockfileData = serde_json::from_str(strict_json.as_code())?;

        // Validate that we support this lockfile version
        let _version = LockfileVersion::from_i32(data.lockfile_version)
            .ok_or(super::Error::UnsupportedBunVersion(data.lockfile_version))?;

        // Build key_to_entry map
        // When there are multiple lockfile keys with the same ident (e.g., nested
        // versions), we pick the FIRST one in sorted order for determinism.
        // Sort keys to ensure deterministic selection: workspace-specific entries (with
        // /) come before hoisted entries (without /) in the sort order.
        let mut sorted_keys: Vec<_> = data.packages.keys().collect();
        sorted_keys.sort();

        let mut key_to_entry: HashMap<String, String> = HashMap::with_capacity(data.packages.len());
        for path in sorted_keys {
            let Some(info) = data.packages.get(path) else {
                continue;
            };

            if let Some(prev_path) = key_to_entry.get(&info.ident) {
                let Some(prev_info) = data.packages.get(prev_path) else {
                    continue;
                };

                // Verify checksums match for duplicate idents
                if prev_info.checksum != info.checksum {
                    return Err(Error::MismatchedShas {
                        ident: info.ident.clone(),
                        sha1: prev_info.checksum.clone().unwrap_or_default(),
                        sha2: info.checksum.clone().unwrap_or_default(),
                    }
                    .into());
                }
                // Skip this entry - we already have one for this ident
            } else {
                // First time seeing this ident
                key_to_entry.insert(info.ident.clone(), path.clone());
            }
        }
        // Build package index
        let index = PackageIndex::new(&data.packages);

        Ok(Self {
            data,
            key_to_entry,
            index,
        })
    }
}

impl PackageEntry {
    // Extracts version from key
    fn version(&self) -> &str {
        self.ident
            .rsplit_once('@')
            .map(|(_, version)| version)
            .unwrap_or(&self.ident)
    }
}

impl PackageInfo {
    pub fn is_empty(&self) -> bool {
        self.dependencies.is_empty()
            && self.dev_dependencies.is_empty()
            && self.optional_dependencies.is_empty()
            && self.peer_dependencies.is_empty()
            && self.optional_peers.is_empty()
            && self.other.is_empty()
    }

    pub fn all_dependencies(&self) -> impl Iterator<Item = (&str, &str)> {
        [
            self.dependencies.iter(),
            self.dev_dependencies.iter(),
            self.optional_dependencies.iter(),
            self.peer_dependencies.iter(),
        ]
        .into_iter()
        .flatten()
        .map(|(k, v)| (k.as_str(), v.as_str()))
    }
}

/// Check if there are any global changes between two bun lockfiles
pub fn bun_global_change(prev_contents: &[u8], curr_contents: &[u8]) -> Result<bool, super::Error> {
    let prev = BunLockfile::from_bytes(prev_contents)?;
    let curr = BunLockfile::from_bytes(curr_contents)?;

    Ok(prev.global_change(&curr as &dyn Lockfile))
}

#[cfg(test)]
mod test {
    use pretty_assertions::assert_eq;
    use serde_json::json;

    use super::*;

    #[test]
    fn test_global_change_version_mismatch() {
        let v0_contents = serde_json::to_string(&json!({
            "lockfileVersion": 0,
            "workspaces": {
                "": {
                    "name": "test",
                }
            },
            "packages": {}
        }))
        .unwrap();

        let v1_contents = serde_json::to_string(&json!({
            "lockfileVersion": 1,
            "workspaces": {
                "": {
                    "name": "test",
                }
            },
            "packages": {}
        }))
        .unwrap();

        let v0_lockfile = BunLockfile::from_str(&v0_contents).unwrap();
        let v1_lockfile = BunLockfile::from_str(&v1_contents).unwrap();

        // Version change should be detected
        assert!(v0_lockfile.global_change(&v1_lockfile));

        // Same version should not be a global change
        assert!(!v0_lockfile.global_change(&v0_lockfile));
        assert!(!v1_lockfile.global_change(&v1_lockfile));
    }

    #[test]
    fn test_bun_global_change_function() {
        let v0_contents = serde_json::to_string(&json!({
            "lockfileVersion": 0,
            "workspaces": {
                "": {
                    "name": "test",
                }
            },
            "packages": {}
        }))
        .unwrap();

        let v1_contents = serde_json::to_string(&json!({
            "lockfileVersion": 1,
            "workspaces": {
                "": {
                    "name": "test",
                }
            },
            "packages": {}
        }))
        .unwrap();

        // Test the standalone function
        assert!(bun_global_change(v0_contents.as_bytes(), v1_contents.as_bytes()).unwrap());
        assert!(!bun_global_change(v0_contents.as_bytes(), v0_contents.as_bytes()).unwrap());
        assert!(!bun_global_change(v1_contents.as_bytes(), v1_contents.as_bytes()).unwrap());
    }

    #[test]
    fn test_new_fields_parsing() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 1,
            "workspaces": {
                "": {
                    "name": "test",
                    "dependencies": {
                        "foo": "^1.0.0"
                    }
                }
            },
            "packages": {
                "foo": ["foo@1.0.0", {}, "sha512-hello"]
            },
            "overrides": {
                "foo": "1.0.0"
            },
            "catalog": {
                "react": "^18.0.0"
            },
            "catalogs": {
                "frontend": {
                    "react": "^18.0.0",
                    "next": "^14.0.0"
                }
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();

        // Check that new fields are parsed
        assert_eq!(lockfile.data.overrides.len(), 1);
        assert_eq!(
            lockfile.data.overrides.get("foo"),
            Some(&"1.0.0".to_string())
        );

        assert_eq!(lockfile.data.catalog.len(), 1);
        assert_eq!(
            lockfile.data.catalog.get("react"),
            Some(&"^18.0.0".to_string())
        );

        assert_eq!(lockfile.data.catalogs.len(), 1);
        let frontend_catalog = lockfile.data.catalogs.get("frontend").unwrap();
        assert_eq!(frontend_catalog.len(), 2);
        assert_eq!(frontend_catalog.get("react"), Some(&"^18.0.0".to_string()));
        assert_eq!(frontend_catalog.get("next"), Some(&"^14.0.0".to_string()));
    }

    #[test]
    fn test_failure_if_mismatched_keys() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 0,
            "workspaces": {
                "": {
                    "name": "test",
                    "dependencies": {
                        "foo": "^1.0.0",
                        "bar": "^1.0.0",
                    }
                }
            },
            "packages": {
                "bar": ["bar@1.0.0", { "dependencies": { "shared": "^1.0.0" } }, "sha512-goodbye"],
                "bar/shared": ["shared@1.0.0", {}, "sha512-bar"],
                "foo": ["foo@1.0.0", { "dependencies": { "shared": "^1.0.0" } }, "sha512-hello"],
                "foo/shared": ["shared@1.0.0", { }, "sha512-foo"],
            }
        }))
        .unwrap();
        let lockfile = BunLockfile::from_str(&contents);
        assert!(lockfile.is_err(), "matching packages have differing shas");
    }

    #[test]
    fn test_override_functionality_no_override() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 0,
            "workspaces": {
                "": {
                    "name": "test",
                    "dependencies": {
                        "bar": "^1.0.0"
                    }
                }
            },
            "packages": {
                "bar": ["bar@1.0.0", {}, "sha512-original"]
            },
            "overrides": {
                "foo": "2.0.0"
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();

        // Resolve bar - should get original version since no override exists for bar
        let result = lockfile
            .resolve_package("", "bar", "^1.0.0")
            .unwrap()
            .unwrap();

        // Should resolve to original version (no override)
        assert_eq!(result.key, "bar@1.0.0");
        assert_eq!(result.version, "1.0.0");
    }

    #[test]
    fn test_override_resolves_lower_version() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 0,
            "workspaces": {
                "": {
                    "name": "test",
                    "dependencies": {
                        "parent": "^1.0.0"
                    }
                }
            },
            "packages": {
                "parent": ["parent@1.0.0", "", {
                    "dependencies": {
                        "dep": "1.5.0"
                    }
                }, "sha512-parent"],
                "dep": ["dep@1.4.0", "", {}, "sha512-dep"]
            },
            "overrides": {
                "dep": "1.4.0"
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();

        let result = lockfile.resolve_package("", "dep", "1.5.0").unwrap();

        assert!(
            result.is_some(),
            "Override to lower version (1.4.0) must still resolve even though 1.4.0 does not \
             satisfy ^1.5.0"
        );
        let pkg = result.unwrap();
        assert_eq!(pkg.key, "dep@1.4.0");
        assert_eq!(pkg.version, "1.4.0");
    }

    #[test]
    fn test_override_lower_version_in_transitive_closure() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 0,
            "workspaces": {
                "": {
                    "name": "test",
                    "dependencies": {
                        "parent": "^1.0.0"
                    }
                }
            },
            "packages": {
                "parent": ["parent@1.0.0", "", {
                    "dependencies": {
                        "dep": "1.5.0"
                    }
                }, "sha512-parent"],
                "dep": ["dep@1.4.0", "", {}, "sha512-dep"]
            },
            "overrides": {
                "dep": "1.4.0"
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();

        let unresolved_deps: std::collections::BTreeMap<String, String> =
            [("parent".to_string(), "^1.0.0".to_string())]
                .into_iter()
                .collect();
        let closure = crate::transitive_closure(&lockfile, "", unresolved_deps, false).unwrap();

        let dep_keys: Vec<String> = closure.iter().map(|p| p.key.clone()).collect();
        assert!(
            dep_keys.contains(&"dep@1.4.0".to_string()),
            "Overridden dep@1.4.0 must be in transitive closure even though parent declares \
             dep@1.5.0. Got: {:?}",
            dep_keys
        );
    }

    #[test]
    fn test_subgraph_filters_overrides() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 0,
            "workspaces": {
                "": {
                    "name": "test",
                    "dependencies": {
                        "foo": "^1.0.0",
                        "bar": "^1.0.0"
                    }
                },
                "apps/web": {
                    "name": "web",
                    "dependencies": {
                        "foo": "^1.0.0"
                    }
                }
            },
            "packages": {
                "foo": ["foo@1.0.0", {}, "sha512-foo"],
                "bar": ["bar@1.0.0", {}, "sha512-bar"]
            },
            "overrides": {
                "foo": "2.0.0",
                "bar": "2.0.0",
                "unused": "1.0.0"
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();

        // Create subgraph with only foo package
        let subgraph = lockfile
            .subgraph(&["apps/web".into()], &["foo@1.0.0".into()])
            .unwrap();
        let subgraph_data = subgraph.lockfile().unwrap();

        // All overrides are preserved to stay in sync with the root
        // package.json that turbo prune copies as-is
        assert_eq!(subgraph_data.overrides.len(), 3);
        assert!(subgraph_data.overrides.contains_key("foo"));
        assert!(subgraph_data.overrides.contains_key("bar"));
        assert!(subgraph_data.overrides.contains_key("unused"));

        // Check that workspaces are correct
        assert_eq!(subgraph_data.workspaces.len(), 2);
        assert!(subgraph_data.workspaces.contains_key(""));
        assert!(subgraph_data.workspaces.contains_key("apps/web"));

        // Check that packages are correct
        assert_eq!(subgraph_data.packages.len(), 1);
        assert!(subgraph_data.packages.contains_key("foo"));
        assert!(!subgraph_data.packages.contains_key("bar"));
    }

    #[test]
    fn test_override_with_patches() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 0,
            "workspaces": {
                "": {
                    "name": "test",
                    "dependencies": {
                        "lodash": "^4.17.20"
                    }
                }
            },
            "packages": {
                "lodash": ["lodash@4.17.20", {}, "sha512-original"],
                "lodash-override": ["lodash@4.17.21", {}, "sha512-override"]
            },
            "overrides": {
                "lodash": "4.17.21"
            },
            "patchedDependencies": {
                "lodash@4.17.21": "patches/lodash@4.17.21.patch"
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();

        // Resolve lodash - should get override version with patch
        let result = lockfile
            .resolve_package("", "lodash", "^4.17.20")
            .unwrap()
            .unwrap();

        // Should resolve to overridden version with patch
        assert_eq!(result.key, "lodash@4.17.21");
        assert_eq!(result.version, "4.17.21+patches/lodash@4.17.21.patch");
    }

    #[test]
    fn test_catalog_with_overrides() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 0,
            "workspaces": {
                "": {
                    "name": "test",
                    "dependencies": {
                        "react": "catalog:"
                    }
                }
            },
            "packages": {
                "react": ["react@18.2.0", {}, "sha512-react18"],
                "react-override": ["react@19.0.0", {}, "sha512-react19"]
            },
            "catalog": {
                "react": "^18.2.0"
            },
            "overrides": {
                "react": "19.0.0"
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();

        // Resolve react - should get override version instead of catalog version
        let result = lockfile
            .resolve_package("", "react", "catalog:")
            .unwrap()
            .unwrap();

        // Should resolve to overridden version
        assert_eq!(result.key, "react@19.0.0");
        assert_eq!(result.version, "19.0.0");
    }

    #[test]
    fn test_optional_dependencies_not_in_lockfile() {
        // Test that optional dependencies that are not present in the lockfile
        // don't cause errors when calculating transitive closures
        let lockfile_content = r#"{
            "lockfileVersion": 1,
            "workspaces": {
                "": {
                    "name": "test-app",
                    "dependencies": {
                        "@emnapi/runtime": "^1.0.0"
                    }
                }
            },
            "packages": {
                "@emnapi/runtime": [
                    "@emnapi/runtime@1.5.0",
                    "",
                    {
                        "dependencies": {
                            "tslib": "^2.4.0"
                        },
                        "optionalDependencies": {
                            "@emnapi/wasi-threads": "^1.0.0"
                        }
                    },
                    "sha512"
                ],
                "tslib": [
                    "tslib@2.8.1",
                    "",
                    {},
                    "sha512"
                ]
            }
        }"#;

        let lockfile = BunLockfile::from_str(lockfile_content).unwrap();

        // This should not error even though @emnapi/wasi-threads is not in the packages
        let deps = lockfile
            .all_dependencies("@emnapi/runtime@1.5.0")
            .unwrap()
            .unwrap();

        // Should only contain tslib, not @emnapi/wasi-threads
        assert_eq!(deps.len(), 1);
        let keys: Vec<_> = deps.keys().collect();
        assert_eq!(keys.len(), 1);
        assert!(keys[0].contains("tslib"));
        assert!(!deps.values().any(|v| v.contains("@emnapi/wasi-threads")));
    }

    #[test]
    fn test_integration_v1_catalog_override_patch_combined() {
        // Test combining V1 format, catalogs, overrides, and patches
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 1,
            "workspaces": {
                "": {
                    "name": "integration-test",
                    "dependencies": {
                        "react": "catalog:ui",
                        "lodash": "catalog:"
                    }
                },
                "packages/ui": {
                    "name": "@repo/ui",
                    "version": "1.0.0",
                    "dependencies": {
                        "@repo/utils": "packages/utils",
                        "react": "catalog:ui"
                    }
                },
                "packages/utils": {
                    "name": "@repo/utils",
                    "version": "2.0.0",
                    "dependencies": {
                        "lodash": "catalog:"
                    }
                }
            },
            "packages": {
                "react": ["react@18.0.0", {}, "sha512-react18"],
                "react-19": ["react@19.0.0", {}, "sha512-react19"],
                "lodash": ["lodash@4.17.20", {}, "sha512-lodash420"],
                "lodash-patched": ["lodash@4.17.21", {}, "sha512-lodash421"]
            },
            "catalog": {
                "lodash": "^4.17.20"
            },
            "catalogs": {
                "ui": {
                    "react": "^18.0.0"
                }
            },
            "overrides": {
                "lodash": "4.17.21"
            },
            "patchedDependencies": {
                "lodash@4.17.21": "patches/lodash-security.patch"
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();

        // Test catalog resolution with override
        let lodash_result = lockfile
            .resolve_package("", "lodash", "catalog:")
            .unwrap()
            .unwrap();
        // Should resolve catalog to 4.17.20, then override to 4.17.21, then apply patch
        assert_eq!(lodash_result.key, "lodash@4.17.21");
        assert_eq!(
            lodash_result.version,
            "4.17.21+patches/lodash-security.patch"
        );

        // Test V1 workspace dependency from packages/ui to packages/utils
        let utils_result = lockfile
            .resolve_package("packages/ui", "@repo/utils", "packages/utils")
            .unwrap()
            .unwrap();
        assert_eq!(utils_result.key, "@repo/utils@2.0.0");
        assert_eq!(utils_result.version, "2.0.0");

        // Test catalog resolution from named catalog
        let react_result = lockfile
            .resolve_package("packages/ui", "react", "catalog:ui")
            .unwrap()
            .unwrap();
        assert_eq!(react_result.key, "react@18.0.0");
        assert_eq!(react_result.version, "18.0.0");

        // Verify all fields are preserved
        assert_eq!(lockfile.data.lockfile_version, 1);
        assert_eq!(lockfile.data.overrides.len(), 1);
        assert_eq!(lockfile.data.catalog.len(), 1);
        assert_eq!(lockfile.data.catalogs.len(), 1);
        assert_eq!(lockfile.data.patched_dependencies.len(), 1);
    }

    #[test]
    fn test_integration_complex_subgraph_filtering() {
        // Test subgraph filtering with all features enabled
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 1,
            "workspaces": {
                "": {
                    "name": "complex-monorepo"
                },
                "apps/web": {
                    "name": "web",
                    "version": "1.0.0",
                    "dependencies": {
                        "@repo/ui": "packages/ui",
                        "react": "catalog:frontend"
                    }
                },
                "apps/api": {
                    "name": "api",
                    "version": "1.0.0",
                    "dependencies": {
                        "@repo/shared": "packages/shared",
                        "express": "^4.18.0"
                    }
                },
                "packages/ui": {
                    "name": "@repo/ui",
                    "version": "0.1.0",
                    "dependencies": {
                        "@repo/shared": "packages/shared",
                        "react": "catalog:frontend"
                    }
                },
                "packages/shared": {
                    "name": "@repo/shared",
                    "version": "0.2.0",
                    "dependencies": {
                        "lodash": "catalog:"
                    }
                }
            },
            "packages": {
                "react": ["react@18.0.0", {}, "sha512-react"],
                "react-19": ["react@19.0.0", {}, "sha512-react19"],
                "lodash": ["lodash@4.17.20", {}, "sha512-lodash"],
                "lodash-override": ["lodash@4.17.21", {}, "sha512-lodash21"],
                "express": ["express@4.18.0", {}, "sha512-express"]
            },
            "catalog": {
                "lodash": "^4.17.20"
            },
            "catalogs": {
                "frontend": {
                    "react": "^18.0.0"
                }
            },
            "overrides": {
                "lodash": "4.17.21",
                "react": "19.0.0",
                "express": "4.18.0"
            },
            "patchedDependencies": {
                "lodash@4.17.21": "patches/lodash.patch",
                "express@4.18.0": "patches/express.patch"
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();

        // Create subgraph for web app only
        let subgraph = lockfile
            .subgraph(
                &[
                    "apps/web".into(),
                    "packages/ui".into(),
                    "packages/shared".into(),
                ],
                &["react@19.0.0".into(), "lodash@4.17.21".into()],
            )
            .unwrap();
        let subgraph_data = subgraph.lockfile().unwrap();

        // Verify workspace filtering
        assert_eq!(subgraph_data.workspaces.len(), 4); // root + 3 specified
        assert!(subgraph_data.workspaces.contains_key(""));
        assert!(subgraph_data.workspaces.contains_key("apps/web"));
        assert!(subgraph_data.workspaces.contains_key("packages/ui"));
        assert!(subgraph_data.workspaces.contains_key("packages/shared"));
        assert!(!subgraph_data.workspaces.contains_key("apps/api"));

        // Verify package filtering
        assert_eq!(subgraph_data.packages.len(), 2);
        assert!(subgraph_data.packages.contains_key("react-19"));
        assert!(subgraph_data.packages.contains_key("lodash-override"));
        assert!(!subgraph_data.packages.contains_key("express"));

        // All overrides are preserved to stay in sync with root package.json
        assert_eq!(subgraph_data.overrides.len(), 3);
        assert!(subgraph_data.overrides.contains_key("react"));
        assert!(subgraph_data.overrides.contains_key("lodash"));
        assert!(subgraph_data.overrides.contains_key("express"));

        // Verify patches filtering
        assert_eq!(subgraph_data.patched_dependencies.len(), 1);
        assert!(
            subgraph_data
                .patched_dependencies
                .contains_key("lodash@4.17.21")
        );
        assert!(
            !subgraph_data
                .patched_dependencies
                .contains_key("express@4.18.0")
        );

        // Verify catalogs are preserved (they're kept for potential references)
        assert_eq!(subgraph_data.catalog.len(), 1);
        assert_eq!(subgraph_data.catalogs.len(), 1);
    }

    #[test]
    fn test_subgraph_includes_transitive_dependencies() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 1,
            "workspaces": {
                "": {
                    "name": "test-root"
                },
                "apps/acme-client": {
                    "name": "acme-client",
                    "version": "0.0.1",
                    "dependencies": {
                        "@hookform/resolvers": "^5.0.1"
                    }
                }
            },
            "packages": {
                "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", {
                    "dependencies": {
                        "@standard-schema/utils": "^0.3.0"
                    },
                    "peerDependencies": {
                        "react-hook-form": "^7.55.0"
                    }
                }, "sha512-test"],
                "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-test2"],
                "react-hook-form": ["react-hook-form@7.62.0", "", {}, "sha512-test3"]
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();

        // Simulate what turbo prune would call: Get transitive closure first
        let unresolved_deps: std::collections::BTreeMap<String, String> =
            [("@hookform/resolvers".to_string(), "^5.0.1".to_string())]
                .into_iter()
                .collect();
        let closure =
            crate::transitive_closure(&lockfile, "apps/acme-client", unresolved_deps, false)
                .unwrap();

        // Convert closure to idents
        let package_idents: Vec<String> = closure.iter().map(|pkg| pkg.key.clone()).collect();

        // Create subgraph with the transitive closure
        let subgraph = lockfile
            .subgraph(&["apps/acme-client".into()], &package_idents)
            .unwrap();
        let subgraph_data = subgraph.lockfile().unwrap();

        // Verify @hookform/resolvers is included
        assert!(
            subgraph_data
                .packages
                .values()
                .any(|entry| entry.ident == "@hookform/resolvers@5.2.2"),
            "@hookform/resolvers should be in subgraph"
        );

        // Verify @standard-schema/utils is included (transitive dependency)
        assert!(
            subgraph_data
                .packages
                .values()
                .any(|entry| entry.ident == "@standard-schema/utils@0.3.0"),
            "@standard-schema/utils should be in subgraph as transitive dependency"
        );

        // Verify peer dependency is also included
        assert!(
            subgraph_data
                .packages
                .values()
                .any(|entry| entry.ident == "react-hook-form@7.62.0"),
            "react-hook-form should be in subgraph as peer dependency"
        );
    }

    /// Test that pruning a lockfile with GitHub dependencies doesn't corrupt
    /// the format. GitHub packages should have 3 elements: [ident, info,
    /// checksum] NOT 4 elements with an empty registry: [ident, "", info,
    /// checksum]
    #[test]
    fn test_prune_github_package_format() {
        let lockfile_json = json!({
            "lockfileVersion": 1,
            "workspaces": {
                "": {
                    "name": "root",
                },
                "packages/a": {
                    "name": "pkg-a",
                    "dependencies": {
                        "some-lib": "github:user/repo#abc123",
                    },
                },
            },
            "packages": {
                "some-lib": [
                    "some-lib@github:user/repo#abc123",
                    { "dependencies": {} },
                    "abc123"
                ],
            },
        });

        let lockfile = BunLockfile::from_str(&lockfile_json.to_string()).unwrap();
        let subgraph = lockfile
            .subgraph(
                &["packages/a".into()],
                &["some-lib@github:user/repo#abc123".into()],
            )
            .unwrap();

        let encoded = String::from_utf8(subgraph.encode().unwrap()).unwrap();

        // Verify the GitHub package has exactly 3 elements (no empty string registry)
        // The output should contain the ident followed directly by the info object
        assert!(
            !encoded.contains(r#"["some-lib@github:user/repo#abc123", "", {"#),
            "GitHub package should NOT have empty string registry field"
        );
        assert!(
            encoded.contains(r#""some-lib": ["some-lib@github:user/repo#abc123", {"#),
            "GitHub package should have ident followed directly by info object"
        );
    }

    /// Test that metadata sections are preserved through encode round-trip.
    /// Bun expects configVersion, trustedDependencies, overrides, and catalogs.
    #[test]
    fn test_encode_preserves_metadata_sections() {
        let lockfile_json = json!({
            "lockfileVersion": 1,
            "configVersion": 1,
            "workspaces": {
                "": {
                    "name": "test",
                },
            },
            "trustedDependencies": ["esbuild", "sharp"],
            "overrides": {
                "lodash": "4.17.21",
            },
            "catalog": {
                "react": "^18.0.0",
            },
            "packages": {},
        });

        let lockfile = BunLockfile::from_str(&lockfile_json.to_string()).unwrap();
        let encoded = String::from_utf8(lockfile.encode().unwrap()).unwrap();

        // Verify configVersion is present
        assert!(
            encoded.contains(r#""configVersion": 1"#),
            "configVersion should be preserved in encoded output"
        );

        // Verify trustedDependencies section is present with its contents
        assert!(
            encoded.contains(r#""trustedDependencies""#),
            "trustedDependencies section should be present"
        );
        assert!(
            encoded.contains(r#""esbuild""#),
            "trustedDependencies should contain esbuild"
        );
        assert!(
            encoded.contains(r#""sharp""#),
            "trustedDependencies should contain sharp"
        );

        // Verify overrides section is present with its contents
        assert!(
            encoded.contains(r#""overrides""#),
            "overrides section should be present"
        );
        assert!(
            encoded.contains(r#""lodash""#),
            "overrides should contain lodash"
        );

        // Verify catalog section is present with its contents
        assert!(
            encoded.contains(r#""catalog""#),
            "catalog section should be present"
        );
        assert!(
            encoded.contains(r#""react""#),
            "catalog should contain react"
        );
    }

    /// Test that optionalPeers arrays use compact format without trailing
    /// commas. Bun expects: ["react", "vue"] NOT [ "react", "vue", ]
    #[test]
    fn test_optional_peers_compact_format() {
        let lockfile_json = json!({
            "lockfileVersion": 1,
            "workspaces": {
                "": {
                    "name": "test",
                },
            },
            "packages": {
                "some-pkg": [
                    "some-pkg@1.0.0",
                    "",
                    {
                        "peerDependencies": {
                            "react": "^18.0.0",
                            "vue": "^3.0.0",
                        },
                        "optionalPeers": ["react", "vue"],
                    },
                    "sha512-abc"
                ],
            },
        });

        let lockfile = BunLockfile::from_str(&lockfile_json.to_string()).unwrap();
        let encoded = String::from_utf8(lockfile.encode().unwrap()).unwrap();

        // Verify optionalPeers uses compact format without leading/trailing spaces or
        // commas The array should be formatted as ["react", "vue"] or ["vue",
        // "react"] NOT as [ "react", "vue", ] or similar
        assert!(
            !encoded.contains(r#"[ ""#),
            "optionalPeers array should NOT have leading space after opening bracket"
        );
        assert!(
            !encoded.contains(r#", ]"#),
            "optionalPeers array should NOT have trailing comma before closing bracket"
        );

        // Verify the optionalPeers field exists and has content
        assert!(
            encoded.contains(r#""optionalPeers""#),
            "optionalPeers field should be present"
        );
    }

    /// Test that named catalogs (catalogs field) are preserved through encode.
    /// This tests the plural "catalogs" field, not the singular "catalog"
    /// field.
    #[test]
    fn test_encode_preserves_named_catalogs() {
        let lockfile_json = json!({
            "lockfileVersion": 1,
            "workspaces": {
                "": {
                    "name": "test",
                },
            },
            "catalog": {
                "lodash": "^4.17.0",
            },
            "catalogs": {
                "frontend": {
                    "react": "^18.0.0",
                    "vue": "^3.0.0",
                },
                "backend": {
                    "express": "^4.18.0",
                },
            },
            "packages": {},
        });

        let lockfile = BunLockfile::from_str(&lockfile_json.to_string()).unwrap();
        let encoded = String::from_utf8(lockfile.encode().unwrap()).unwrap();

        // Verify default catalog is present
        assert!(
            encoded.contains(r#""catalog""#),
            "default catalog section should be present"
        );
        assert!(
            encoded.contains(r#""lodash""#),
            "default catalog should contain lodash"
        );

        // Verify named catalogs section is present
        assert!(
            encoded.contains(r#""catalogs""#),
            "named catalogs section should be present"
        );

        // Verify frontend catalog entries
        assert!(
            encoded.contains(r#""frontend""#),
            "frontend catalog should be present"
        );
        assert!(
            encoded.contains(r#""react""#),
            "frontend catalog should contain react"
        );
        assert!(
            encoded.contains(r#""vue""#),
            "frontend catalog should contain vue"
        );

        // Verify backend catalog entries
        assert!(
            encoded.contains(r#""backend""#),
            "backend catalog should be present"
        );
        assert!(
            encoded.contains(r#""express""#),
            "backend catalog should contain express"
        );
    }

    /// Test that patched_dependencies are preserved through encode.
    #[test]
    fn test_encode_preserves_patched_dependencies() {
        let lockfile_json = json!({
            "lockfileVersion": 1,
            "workspaces": {
                "": {
                    "name": "test",
                    "dependencies": {
                        "lodash": "^4.17.21",
                    },
                },
            },
            "packages": {
                "lodash": [
                    "lodash@4.17.21",
                    "",
                    {},
                    "sha512-abc"
                ],
            },
            "patchedDependencies": {
                "lodash@4.17.21": "patches/lodash+4.17.21.patch",
            },
        });

        let lockfile = BunLockfile::from_str(&lockfile_json.to_string()).unwrap();
        let encoded = String::from_utf8(lockfile.encode().unwrap()).unwrap();

        // Verify patchedDependencies section is present
        assert!(
            encoded.contains(r#""patchedDependencies""#),
            "patchedDependencies section should be present"
        );
        assert!(
            encoded.contains(r#""lodash@4.17.21""#),
            "patchedDependencies should contain lodash entry"
        );
        assert!(
            encoded.contains(r#"patches/lodash+4.17.21.patch"#),
            "patchedDependencies should contain patch path"
        );
    }

    /// Test that packages section is correctly encoded with proper format.
    /// This verifies the packages field ordering and structure.
    #[test]
    fn test_encode_packages_structure() {
        let lockfile_json = json!({
            "lockfileVersion": 1,
            "workspaces": {
                "": {
                    "name": "test",
                    "dependencies": {
                        "is-odd": "^3.0.0",
                    },
                },
            },
            "packages": {
                "is-odd": [
                    "is-odd@3.0.1",
                    "",
                    {
                        "dependencies": {
                            "is-number": "^6.0.0",
                        },
                    },
                    "sha512-def"
                ],
                "is-number": [
                    "is-number@6.0.0",
                    "",
                    {},
                    "sha512-ghi"
                ],
            },
        });

        let lockfile = BunLockfile::from_str(&lockfile_json.to_string()).unwrap();
        let encoded = String::from_utf8(lockfile.encode().unwrap()).unwrap();

        // Verify packages section exists
        assert!(
            encoded.contains(r#""packages""#),
            "packages section should be present"
        );

        // Verify package entries are present with correct identifiers
        assert!(
            encoded.contains(r#""is-odd": ["is-odd@3.0.1""#),
            "is-odd package should be present with correct format"
        );
        assert!(
            encoded.contains(r#""is-number": ["is-number@6.0.0""#),
            "is-number package should be present with correct format"
        );

        // Verify packages have registry field (empty string for npm packages)
        assert!(
            encoded.contains(r#"["is-odd@3.0.1", "","#),
            "npm packages should have empty string registry field"
        );
    }

    /// Comprehensive test that all metadata sections are written in correct
    /// order. Bun expects a specific ordering of top-level keys.
    #[test]
    fn test_encode_section_ordering() {
        let lockfile_json = json!({
            "lockfileVersion": 1,
            "configVersion": 1,
            "workspaces": {
                "": { "name": "test" },
            },
            "trustedDependencies": ["esbuild"],
            "overrides": { "lodash": "4.17.21" },
            "catalog": { "react": "^18.0.0" },
            "catalogs": {
                "frontend": { "vue": "^3.0.0" },
            },
            "packages": {
                "lodash": ["lodash@4.17.21", "", {}, "sha512-abc"],
            },
            "patchedDependencies": {
                "lodash@4.17.21": "patches/lodash.patch",
            },
        });

        let lockfile = BunLockfile::from_str(&lockfile_json.to_string()).unwrap();
        let encoded = String::from_utf8(lockfile.encode().unwrap()).unwrap();

        // Find positions of each section to verify ordering
        let lockfile_version_pos = encoded.find(r#""lockfileVersion""#).unwrap();
        let config_version_pos = encoded.find(r#""configVersion""#).unwrap();
        let workspaces_pos = encoded.find(r#""workspaces""#).unwrap();
        let trusted_deps_pos = encoded.find(r#""trustedDependencies""#).unwrap();
        let overrides_pos = encoded.find(r#""overrides""#).unwrap();
        let catalog_pos = encoded.find(r#""catalog""#).unwrap();
        let catalogs_pos = encoded.find(r#""catalogs""#).unwrap();
        let packages_pos = encoded.find(r#""packages""#).unwrap();
        let patched_deps_pos = encoded.find(r#""patchedDependencies""#).unwrap();

        // Verify ordering: lockfileVersion < configVersion < workspaces <
        // trustedDependencies < overrides < catalog < catalogs < packages <
        // patchedDependencies
        assert!(
            lockfile_version_pos < config_version_pos,
            "lockfileVersion should come before configVersion"
        );
        assert!(
            config_version_pos < workspaces_pos,
            "configVersion should come before workspaces"
        );
        assert!(
            workspaces_pos < trusted_deps_pos,
            "workspaces should come before trustedDependencies"
        );
        assert!(
            trusted_deps_pos < overrides_pos,
            "trustedDependencies should come before overrides"
        );
        assert!(
            overrides_pos < catalog_pos,
            "overrides should come before catalog"
        );
        assert!(
            catalog_pos < catalogs_pos,
            "catalog should come before catalogs"
        );
        assert!(
            catalogs_pos < packages_pos,
            "catalogs should come before packages"
        );
        assert!(
            packages_pos < patched_deps_pos,
            "packages should come before patchedDependencies"
        );
    }

    #[test]
    fn test_turbo_version_rejects_non_semver() {
        // Malicious version strings that could be used for RCE via npx should be
        // rejected
        let malicious_versions = [
            "file:./malicious.tgz",
            "https://evil.com/malicious.tgz",
            "git+https://github.com/evil/repo.git",
            "../../../etc/passwd",
            "1.0.0 && curl evil.com",
        ];

        for malicious_version in malicious_versions {
            let json = format!(
                r#"{{
  "lockfileVersion": 0,
  "workspaces": {{
    "": {{
      "name": "test"
    }}
  }},
  "packages": {{
    "turbo": ["turbo@{malicious_version}", "", {{}}, ""]
  }}
}}"#
            );
            let lockfile = BunLockfile::from_str(&json).unwrap();
            assert_eq!(
                lockfile.turbo_version(),
                None,
                "should reject malicious version: {}",
                malicious_version
            );
        }
    }

    // Regression test for https://github.com/vercel/turborepo/issues/11923
    // When multiple workspaces depend on different versions of the same package
    // (e.g., color-convert@3.x, color-convert@2.x, color-convert@1.x), pruning
    // for a single workspace must preserve the nested key hierarchy so bun
    // resolves the correct version for each parent.
    #[test]
    fn test_subgraph_multiple_versions_same_package() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 1,
            "configVersion": 1,
            "workspaces": {
                "": {
                    "name": "bug-repro",
                    "dependencies": {
                        "typescript": "^5.9.3"
                    }
                },
                "apps/app1": {
                    "name": "app1",
                    "dependencies": {
                        "color": "^5.0.3",
                        "express-winston": "4.2.0"
                    },
                    "peerDependencies": {
                        "typescript": "^5"
                    }
                },
                "apps/app2": {
                    "name": "app2",
                    "dependencies": {
                        "ansi-styles": "4.3.0"
                    },
                    "peerDependencies": {
                        "typescript": "^5"
                    }
                }
            },
            "packages": {
                "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-ansi"],
                "app1": ["app1@workspace:apps/app1"],
                "app2": ["app2@workspace:apps/app2"],
                "chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-chalk"],
                "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-color"],
                "color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-cc3"],
                "color-name": ["color-name@2.1.0", "", {}, "sha512-cn2"],
                "color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-cs"],
                "escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-esr"],
                "express-winston": ["express-winston@4.2.0", "", { "dependencies": { "chalk": "^2.4.2", "lodash": "^4.17.21" } }, "sha512-ew"],
                "has-flag": ["has-flag@3.0.0", "", {}, "sha512-hf"],
                "lodash": ["lodash@4.17.23", "", {}, "sha512-lo"],
                "supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-sc"],
                "typescript": ["typescript@5.9.3", "", {}, "sha512-ts"],
                "ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-cc2"],
                "ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-cn114"],
                "chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-as3"],
                "chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-cc1"],
                "chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-cn113"]
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();

        // Compute transitive closure for app1 (simulating turbo prune --scope=app1)
        let mut app1_deps = std::collections::BTreeMap::new();
        app1_deps.insert("color".to_string(), "^5.0.3".to_string());
        app1_deps.insert("express-winston".to_string(), "4.2.0".to_string());

        let closure = crate::transitive_closure(&lockfile, "apps/app1", app1_deps, false).unwrap();

        let package_idents: Vec<String> = closure.iter().map(|pkg| pkg.key.clone()).collect();

        // Create subgraph
        let subgraph = lockfile
            .subgraph(&["apps/app1".into()], &package_idents)
            .unwrap();

        // Verify the pruned lockfile round-trips correctly
        let encoded = subgraph.encode().unwrap();
        let encoded_str = String::from_utf8(encoded).unwrap();

        let reparsed =
            BunLockfile::from_str(&encoded_str).expect("pruned lockfile should be parseable");

        // app1 uses:
        //   color -> color-convert@3.1.3 -> color-name@2.1.0
        //   express-winston -> chalk -> chalk/ansi-styles@3.2.1
        //     -> chalk/ansi-styles/color-convert@1.9.3
        //     -> chalk/ansi-styles/color-convert/color-name@1.1.3

        let has_ident = |ident: &str| reparsed.data.packages.values().any(|e| e.ident == ident);

        assert!(
            has_ident("color-convert@3.1.3"),
            "color-convert@3.1.3 (for color@5) should be in subgraph"
        );

        assert!(
            has_ident("color-convert@1.9.3"),
            "color-convert@1.9.3 (for chalk/ansi-styles) should be in subgraph"
        );

        // color-convert@2.0.1 (used by ansi-styles@4.3.0, app2-only) should NOT be
        // present
        assert!(
            !has_ident("color-convert@2.0.1"),
            "color-convert@2.0.1 (app2 only) should NOT be in subgraph"
        );

        // ansi-styles@4.3.0 (app2-only) should NOT be present
        assert!(
            !has_ident("ansi-styles@4.3.0"),
            "ansi-styles@4.3.0 (app2 only) should NOT be in subgraph"
        );

        assert!(
            has_ident("color-name@1.1.3"),
            "color-name@1.1.3 (for chalk chain) should be in subgraph"
        );

        // color should resolve to 5.0.3
        let color_dep = reparsed
            .resolve_package("apps/app1", "color", "^5.0.3")
            .unwrap()
            .expect("should resolve color");
        assert_eq!(color_dep.key, "color@5.0.3");

        // chalk should resolve to 2.4.2
        let chalk_dep = reparsed
            .resolve_package("apps/app1", "chalk", "^2.4.2")
            .unwrap()
            .expect("should resolve chalk");
        assert_eq!(chalk_dep.key, "chalk@2.4.2");
    }

    // Regression test for https://github.com/vercel/turborepo/issues/12744
    // Bun resolves nested package dependencies from ancestor scopes too. If
    // `npm/@npmcli/arborist` depends on `@npmcli/metavuln-calculator`, the
    // matching entry is `npm/@npmcli/metavuln-calculator`, not
    // `npm/@npmcli/arborist/@npmcli/metavuln-calculator`.
    #[test]
    fn test_subgraph_preserves_ancestor_scoped_nested_dependencies() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 1,
            "configVersion": 1,
            "workspaces": {
                "": {
                    "name": "test-monorepo"
                },
                "apps/app": {
                    "name": "app",
                    "dependencies": {
                        "npm": "^11.0.0"
                    }
                }
            },
            "packages": {
                "app": ["app@workspace:apps/app"],
                "npm": ["npm@11.0.0", "", { "dependencies": { "@npmcli/arborist": "^8.0.0", "postcss-selector-parser": "^7.0.0" } }, "sha512-npm"],
                "npm/@npmcli/arborist": ["@npmcli/arborist@8.0.5", "", { "dependencies": { "@npmcli/metavuln-calculator": "^8.0.0" }, "bundled": true }, "sha512-arborist"],
                "npm/@npmcli/metavuln-calculator": ["@npmcli/metavuln-calculator@8.0.1", "", {}, "sha512-metavuln"],
                "npm/cssesc": ["cssesc@3.0.0", "", {}, "sha512-cssesc"],
                "npm/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0" } }, "sha512-postcss"],
                "@oclif/plugin-plugins/npm/cssesc": ["cssesc@3.0.0", "", {}, "sha512-cssesc"],
                "@oclif/plugin-plugins/npm/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0" } }, "sha512-postcss"]
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();
        let mut app_deps = std::collections::BTreeMap::new();
        app_deps.insert("npm".to_string(), "^11.0.0".to_string());

        let closure = crate::transitive_closure(&lockfile, "apps/app", app_deps, false).unwrap();
        let package_idents: Vec<String> = closure.iter().map(|pkg| pkg.key.clone()).collect();
        let subgraph = lockfile
            .subgraph(&["apps/app".into()], &package_idents)
            .unwrap();
        let subgraph_data = subgraph.lockfile().unwrap();

        assert!(subgraph_data.packages.contains_key("npm/@npmcli/arborist"));
        assert!(
            subgraph_data
                .packages
                .contains_key("npm/@npmcli/metavuln-calculator"),
            "ancestor-scoped nested dependency should remain in pruned lockfile"
        );
        assert!(
            subgraph_data.packages.contains_key("npm/cssesc"),
            "exact source key should win when duplicate idents exist in other nested scopes"
        );
    }

    // Regression test for https://github.com/vercel/turborepo/issues/12156
    // When pruning removes the hoisted version of a package but keeps a nested
    // version, the nested entry must be promoted to top-level so that bun's
    // --frozen-lockfile doesn't reject the structural change.
    #[test]
    fn test_nested_entries_promoted_when_hoisted_version_pruned() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 1,
            "configVersion": 1,
            "workspaces": {
                "": {
                    "name": "test-monorepo",
                    "dependencies": {
                        "typescript": "^5.0.0"
                    }
                },
                "apps/web": {
                    "name": "web",
                    "dependencies": {
                        "express": "^4.18.0"
                    },
                    "peerDependencies": {
                        "typescript": "^5"
                    }
                },
                "apps/admin": {
                    "name": "admin",
                    "dependencies": {
                        "compression": "^1.8.0"
                    },
                    "peerDependencies": {
                        "typescript": "^5"
                    }
                }
            },
            "packages": {
                "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-accepts"],
                "admin": ["admin@workspace:apps/admin"],
                "body-parser": ["body-parser@1.20.3", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0" } }, "sha512-bp"],
                "bytes": ["bytes@3.1.2", "", {}, "sha512-bytes"],
                "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": "~1.52.0" } }, "sha512-compress"],
                "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-compression"],
                "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-debug"],
                "depd": ["depd@2.0.0", "", {}, "sha512-depd"],
                "express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "body-parser": "1.20.3" } }, "sha512-express"],
                "mime-db": ["mime-db@1.52.0", "", {}, "sha512-mimedb"],
                "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-mimetypes"],
                "ms": ["ms@2.0.0", "", {}, "sha512-ms"],
                "negotiator": ["negotiator@0.6.4", "", {}, "sha512-neg064"],
                "on-headers": ["on-headers@1.1.0", "", {}, "sha512-onh"],
                "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-sb"],
                "typescript": ["typescript@5.9.3", "", {}, "sha512-ts"],
                "vary": ["vary@1.1.2", "", {}, "sha512-vary"],
                "web": ["web@workspace:apps/web"],
                "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-neg063"]
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();

        // Prune for apps/web only. The transitive closure includes
        // negotiator@0.6.3 (via accepts) but NOT negotiator@0.6.4 (only
        // needed by compression, which is in apps/admin).
        let mut web_deps = std::collections::BTreeMap::new();
        web_deps.insert("express".to_string(), "^4.18.0".to_string());
        let closure = crate::transitive_closure(&lockfile, "apps/web", web_deps, false).unwrap();
        let package_idents: Vec<String> = closure.iter().map(|pkg| pkg.key.clone()).collect();

        let subgraph = lockfile
            .subgraph(&["apps/web".into()], &package_idents)
            .unwrap();

        let encoded = subgraph.encode().unwrap();
        let encoded_str = String::from_utf8(encoded).unwrap();
        let pruned =
            BunLockfile::from_str(&encoded_str).expect("pruned lockfile should be parseable");

        // The nested entry "accepts/negotiator" should be promoted to
        // "negotiator" since the hoisted negotiator@0.6.4 was pruned.
        assert!(
            pruned.data.packages.contains_key("negotiator"),
            "negotiator should exist as a top-level entry after promotion"
        );
        assert!(
            !pruned.data.packages.contains_key("accepts/negotiator"),
            "nested accepts/negotiator should have been promoted to top-level"
        );
        assert_eq!(
            pruned.data.packages["negotiator"].ident, "negotiator@0.6.3",
            "promoted entry should have the correct ident"
        );
    }

    // Test nested key promotion with deeply nested chains
    #[test]
    fn test_nested_promotion_deep_chain() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 1,
            "configVersion": 1,
            "workspaces": {
                "": {
                    "name": "deep-chain-test",
                    "dependencies": {}
                },
                "apps/web": {
                    "name": "web",
                    "dependencies": {
                        "express-winston": "4.2.0"
                    }
                },
                "apps/other": {
                    "name": "other",
                    "dependencies": {
                        "ansi-styles": "4.3.0",
                        "color-convert": "2.0.1"
                    }
                }
            },
            "packages": {
                "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-as4"],
                "chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1" } }, "sha512-chalk"],
                "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-cc2"],
                "color-name": ["color-name@2.1.0", "", {}, "sha512-cn2"],
                "express-winston": ["express-winston@4.2.0", "", { "dependencies": { "chalk": "^2.4.2" } }, "sha512-ew"],
                "other": ["other@workspace:apps/other"],
                "web": ["web@workspace:apps/web"],
                "chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-as3"],
                "chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-cc1"],
                "chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-cn113"],
                "color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-cn114"]
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();

        // Prune for apps/web. The transitive closure includes chalk ->
        // chalk/ansi-styles -> chalk/ansi-styles/color-convert ->
        // chalk/ansi-styles/color-convert/color-name but NOT the hoisted
        // ansi-styles@4.3.0, color-convert@2.0.1, or color-name@2.1.0.
        let mut web_deps = std::collections::BTreeMap::new();
        web_deps.insert("express-winston".to_string(), "4.2.0".to_string());
        let closure = crate::transitive_closure(&lockfile, "apps/web", web_deps, false).unwrap();
        let package_idents: Vec<String> = closure.iter().map(|pkg| pkg.key.clone()).collect();

        let subgraph = lockfile
            .subgraph(&["apps/web".into()], &package_idents)
            .unwrap();

        let encoded = subgraph.encode().unwrap();
        let encoded_str = String::from_utf8(encoded).unwrap();
        let pruned =
            BunLockfile::from_str(&encoded_str).expect("pruned lockfile should be parseable");

        // After promotion, deeply nested entries should be flattened.
        // "chalk/ansi-styles" → "ansi-styles" (promoted, children renamed)
        // "ansi-styles/color-convert" → "color-convert" (promoted, children renamed)
        // "color-convert/color-name" → "color-name" (promoted)
        assert!(
            pruned.data.packages.contains_key("ansi-styles"),
            "ansi-styles should be promoted to top-level"
        );
        assert!(
            pruned.data.packages.contains_key("color-convert"),
            "color-convert should be promoted to top-level"
        );
        assert!(
            pruned.data.packages.contains_key("color-name"),
            "color-name should be promoted to top-level"
        );

        // No nested keys should remain for these packages
        assert!(
            !pruned.data.packages.contains_key("chalk/ansi-styles"),
            "chalk/ansi-styles should have been promoted"
        );

        // Verify idents
        assert_eq!(
            pruned.data.packages["ansi-styles"].ident,
            "ansi-styles@3.2.1"
        );
        assert_eq!(
            pruned.data.packages["color-convert"].ident,
            "color-convert@1.9.3"
        );
        assert_eq!(pruned.data.packages["color-name"].ident, "color-name@1.1.3");
    }

    // Regression test for https://github.com/vercel/turborepo/issues/11701
    // file: protocol dependencies should be serialized as 2-element arrays
    // [ident, info], not corrupted into 4-element arrays [ident, "", info, ""]
    #[test]
    fn test_file_protocol_roundtrip() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 1,
            "workspaces": {
                "": {
                    "name": "file-dep-fixture",
                },
                "apps/api": {
                    "name": "api",
                    "dependencies": {
                        "@api/sdk": "file:apps/api/.api/apis/sdk",
                        "lodash": "^4.17.21"
                    }
                },
                "packages/ui": {
                    "name": "@repo/ui",
                    "dependencies": {
                        "lodash": "^4.17.21"
                    }
                }
            },
            "packages": {
                "@api/sdk": ["@api/sdk@file:apps/api/.api/apis/sdk", { "dependencies": { "cross-fetch": "^3.1.5" } }],
                "@repo/ui": ["@repo/ui@workspace:packages/ui"],
                "api": ["api@workspace:apps/api"],
                "cross-fetch": ["cross-fetch@3.1.8", "", { "dependencies": { "node-fetch": "^2.6.12" } }, "sha512-crossfetch"],
                "lodash": ["lodash@4.17.23", "", {}, "sha512-lodash"],
                "node-fetch": ["node-fetch@2.7.0", "", {}, "sha512-nodefetch"]
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();

        // Verify the file: entry was parsed correctly
        let sdk_entry = &lockfile.data.packages["@api/sdk"];
        assert_eq!(sdk_entry.ident, "@api/sdk@file:apps/api/.api/apis/sdk");
        assert!(
            sdk_entry.registry.is_none(),
            "file: packages should not have registry"
        );
        assert!(
            sdk_entry.checksum.is_none(),
            "file: packages should not have checksum"
        );

        // Prune to just the api workspace
        let mut api_deps = std::collections::BTreeMap::new();
        api_deps.insert(
            "@api/sdk".to_string(),
            "file:apps/api/.api/apis/sdk".to_string(),
        );
        api_deps.insert("lodash".to_string(), "^4.17.21".to_string());

        let closure = crate::transitive_closure(&lockfile, "apps/api", api_deps, false).unwrap();
        let package_idents: Vec<String> = closure.iter().map(|pkg| pkg.key.clone()).collect();

        let subgraph = lockfile
            .subgraph(&["apps/api".into()], &package_idents)
            .unwrap();

        let encoded = subgraph.encode().unwrap();
        let encoded_str = String::from_utf8(encoded).unwrap();

        // The encoded lockfile must be reparseable
        let reparsed = BunLockfile::from_str(&encoded_str)
            .expect("pruned lockfile with file: deps should be parseable");

        // Verify the file: entry survived the roundtrip as a 2-element array
        let sdk_reparsed = &reparsed.data.packages["@api/sdk"];
        assert_eq!(sdk_reparsed.ident, "@api/sdk@file:apps/api/.api/apis/sdk");
        assert!(
            sdk_reparsed.registry.is_none(),
            "file: packages should not gain a registry after roundtrip"
        );
        assert!(
            sdk_reparsed.checksum.is_none(),
            "file: packages should not gain a checksum after roundtrip"
        );

        // Verify the encoded string does NOT contain empty strings for the file: entry
        // A correct entry looks like: ["@api/sdk@file:apps/api/.api/apis/sdk", { ... }]
        // A corrupted entry looks like: ["@api/sdk@file:apps/api/.api/apis/sdk", "", {
        // ... }, ""]
        assert!(
            !encoded_str.contains(r#""@api/sdk@file:apps/api/.api/apis/sdk", """#),
            "file: entry should not have empty registry string inserted"
        );
    }

    // Regression test: link: protocol dependencies should also be 2-element arrays
    #[test]
    fn test_link_protocol_roundtrip() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 1,
            "workspaces": {
                "": {
                    "name": "link-dep-fixture",
                },
                "apps/web": {
                    "name": "web",
                    "dependencies": {
                        "my-local-pkg": "link:../../local-pkg"
                    }
                }
            },
            "packages": {
                "my-local-pkg": ["my-local-pkg@link:../../local-pkg", {}],
                "web": ["web@workspace:apps/web"]
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();
        let subgraph = lockfile
            .subgraph(
                &["apps/web".into()],
                &["my-local-pkg@link:../../local-pkg".into()],
            )
            .unwrap();

        let encoded = subgraph.encode().unwrap();
        let encoded_str = String::from_utf8(encoded).unwrap();

        BunLockfile::from_str(&encoded_str)
            .expect("pruned lockfile with link: deps should be parseable");

        assert!(
            !encoded_str.contains(r#""my-local-pkg@link:../../local-pkg", """#),
            "link: entry should not have empty registry string inserted"
        );
    }

    // Regression test for https://github.com/vercel/turborepo/issues/12252
    // When two workspaces have workspace-scoped entries for the same package
    // at different versions, and a shared transitive dependency references that
    // package with a wide semver range, the transitive closure must resolve
    // to the correct workspace-scoped version for each workspace — not whichever
    // version happened to be cached first during parallel processing.
    #[test]
    fn test_all_transitive_closures_deterministic_with_workspace_scoped_packages() {
        use std::collections::{BTreeMap, HashMap, HashSet};

        let lockfile_content = r#"{
            "lockfileVersion": 1,
            "workspaces": {
                "": { "name": "test-monorepo" },
                "apps/app-a": {
                    "name": "app-a",
                    "dependencies": { "shared": "^1.0.0" }
                },
                "apps/app-b": {
                    "name": "app-b",
                    "dependencies": { "shared": "^1.0.0" }
                }
            },
            "packages": {
                "app-a": ["app-a@workspace:apps/app-a"],
                "app-b": ["app-b@workspace:apps/app-b"],
                "shared": ["shared@1.0.0", "", {
                    "dependencies": { "lib": ">=1.0.0" }
                }, "sha512-shared"],
                "lib": ["lib@2.0.0", "", {}, "sha512-lib2"],
                "app-a/lib": ["lib@2.0.0", "", {}, "sha512-lib2"],
                "app-b/lib": ["lib@1.0.0", "", {}, "sha512-lib1"]
            }
        }"#;

        let lockfile = BunLockfile::from_str(lockfile_content).unwrap();

        let mut workspaces = HashMap::new();
        workspaces.insert(
            "apps/app-a".to_string(),
            BTreeMap::from([("shared".to_string(), "^1.0.0".to_string())]),
        );
        workspaces.insert(
            "apps/app-b".to_string(),
            BTreeMap::from([("shared".to_string(), "^1.0.0".to_string())]),
        );

        // Run multiple times to catch race-condition-driven non-determinism.
        // Before the fix, the result would vary depending on which workspace
        // populated the shared resolve cache first.
        let mut prev_app_a: Option<HashSet<crate::Package>> = None;
        let mut prev_app_b: Option<HashSet<crate::Package>> = None;

        for _ in 0..50 {
            let result =
                crate::all_transitive_closures(&lockfile, workspaces.clone(), false).unwrap();

            let app_a_pkgs = result.get("apps/app-a").unwrap();
            let app_b_pkgs = result.get("apps/app-b").unwrap();

            assert!(
                app_a_pkgs.iter().any(|p| p.key == "lib@2.0.0"),
                "app-a should have lib@2.0.0 via workspace-scoped resolution"
            );
            assert!(
                app_b_pkgs.iter().any(|p| p.key == "lib@1.0.0"),
                "app-b should have lib@1.0.0 via workspace-scoped resolution"
            );

            if let Some(ref prev) = prev_app_a {
                assert_eq!(app_a_pkgs, prev, "app-a closure changed between runs");
            }
            if let Some(ref prev) = prev_app_b {
                assert_eq!(app_b_pkgs, prev, "app-b closure changed between runs");
            }

            prev_app_a = Some(app_a_pkgs.clone());
            prev_app_b = Some(app_b_pkgs.clone());
        }
    }

    // Regression test for https://github.com/vercel/turborepo/issues/12816
    // Duplicate alias keys that point at the same package need matching nested
    // children. If pruning keeps `string-width` and `string-width-cjs` but only
    // one `*/emoji-regex` child, Bun rewrites the pruned lockfile.
    #[test]
    fn test_subgraph_preserves_duplicate_alias_nested_children() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 1,
            "configVersion": 1,
            "workspaces": {
                "": {
                    "name": "monorepo",
                    "devDependencies": {
                        "root-tool": "1.0.0"
                    }
                },
                "apps/api": {
                    "name": "api",
                    "dependencies": {
                        "api-tool": "1.0.0"
                    }
                },
                "apps/front": {
                    "name": "front",
                    "dependencies": {
                        "front-tool": "1.0.0"
                    }
                }
            },
            "packages": {
                "api": ["api@workspace:apps/api"],
                "api-tool": ["api-tool@1.0.0", "", { "dependencies": { "string-width": "^4.2.0" } }, "sha512-api"],
                "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-emoji9"],
                "front": ["front@workspace:apps/front"],
                "front-tool": ["front-tool@1.0.0", "", {}, "sha512-front"],
                "root-tool": ["root-tool@1.0.0", "", { "dependencies": { "emoji-regex": "^9.2.2", "string-width-cjs": "npm:string-width@^4.2.0" } }, "sha512-root"],
                "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0" } }, "sha512-string-width"],
                "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0" } }, "sha512-string-width"],
                "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-emoji8"],
                "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-emoji8"]
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();
        let mut api_deps = std::collections::BTreeMap::new();
        api_deps.insert("api-tool".to_string(), "1.0.0".to_string());

        let closure = crate::transitive_closure(&lockfile, "apps/api", api_deps, false).unwrap();
        let package_idents: Vec<String> = closure.iter().map(|pkg| pkg.key.clone()).collect();
        let subgraph = <BunLockfile as crate::Lockfile>::subgraph(
            &lockfile,
            &["apps/api".into()],
            &package_idents,
        )
        .unwrap();

        let encoded = subgraph.encode().unwrap();
        let encoded_str = String::from_utf8(encoded).unwrap();
        let pruned = BunLockfile::from_str(&encoded_str).unwrap();

        assert!(pruned.data.packages.contains_key("string-width"));
        assert!(pruned.data.packages.contains_key("string-width-cjs"));
        assert!(
            pruned
                .data
                .packages
                .contains_key("string-width/emoji-regex")
        );
        assert!(
            pruned
                .data
                .packages
                .contains_key("string-width-cjs/emoji-regex"),
            "alias-specific nested child should be restored when its sibling remains"
        );
    }

    #[test]
    fn test_subgraph_preserves_nested_workspace_dependency_version() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 1,
            "configVersion": 1,
            "workspaces": {
                "": {
                    "name": "root"
                },
                "packages/a": {
                    "name": "a",
                    "version": "0.0.1",
                    "dependencies": {
                        "b": "*"
                    },
                    "devDependencies": {
                        "b": "workspace:*",
                        "is-number": "^7.0.0"
                    }
                },
                "packages/b": {
                    "name": "b",
                    "version": "0.0.1",
                    "devDependencies": {
                        "is-number": "6.0.0"
                    },
                    "peerDependencies": {
                        "is-number": "6.0.0"
                    }
                }
            },
            "packages": {
                "a": ["a@workspace:packages/a"],
                "b": ["b@workspace:packages/b"],
                "is-number": ["is-number@7.0.0", "", {}, "sha512-7"],
                "b/is-number": ["is-number@6.0.0", "", {}, "sha512-6"]
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();
        let subgraph = <BunLockfile as crate::Lockfile>::subgraph(
            &lockfile,
            &["packages/a".into(), "packages/b".into()],
            &["is-number@7.0.0".into()],
        )
        .unwrap();

        let encoded = subgraph.encode().unwrap();
        let encoded_str = String::from_utf8(encoded).unwrap();
        let pruned = BunLockfile::from_str(&encoded_str).unwrap();

        assert_eq!(pruned.data.packages["is-number"].ident, "is-number@7.0.0");
        assert_eq!(pruned.data.packages["b/is-number"].ident, "is-number@6.0.0");
    }

    // Regression test for https://github.com/vercel/turborepo/issues/12962
    #[test]
    fn test_subgraph_preserves_hoisted_transitive_version_with_nested_mismatch() {
        let contents = serde_json::to_string(&json!({
            "lockfileVersion": 1,
            "configVersion": 1,
            "workspaces": {
                "": {
                    "name": "turbo-prune-bun-nested-major-mismatch",
                    "dependencies": {
                        "bs58": "5.0.0"
                    }
                },
                "apps/app": {
                    "name": "@repro/app",
                    "version": "0.0.0",
                    "dependencies": {
                        "@repro/lib": "workspace:*",
                        "bs58": "6.0.0"
                    }
                },
                "packages/lib": {
                    "name": "@repro/lib",
                    "version": "0.0.0",
                    "dependencies": {
                        "bs58": "5.0.0"
                    }
                }
            },
            "packages": {
                "@repro/app": ["@repro/app@workspace:apps/app"],
                "@repro/lib": ["@repro/lib@workspace:packages/lib"],
                "base-x": ["base-x@4.0.1", "", {}, "sha512-base-x-4"],
                "bs58": ["bs58@5.0.0", "", { "dependencies": { "base-x": "^4.0.0" } }, "sha512-bs58-5"],
                "@repro/app/bs58": ["bs58@6.0.0", "", { "dependencies": { "base-x": "^5.0.0" } }, "sha512-bs58-6"],
                "@repro/app/bs58/base-x": ["base-x@5.0.1", "", {}, "sha512-base-x-5"]
            }
        }))
        .unwrap();

        let lockfile = BunLockfile::from_str(&contents).unwrap();
        let subgraph = <BunLockfile as crate::Lockfile>::subgraph(
            &lockfile,
            &["apps/app".into(), "packages/lib".into()],
            &[],
        )
        .unwrap();

        let encoded = subgraph.encode().unwrap();
        let encoded_str = String::from_utf8(encoded).unwrap();
        let pruned = BunLockfile::from_str(&encoded_str).unwrap();

        assert!(
            pruned.data.packages.contains_key("base-x"),
            "hoisted base-x@4 used by bs58@5 must remain in the pruned lockfile"
        );
        assert!(
            pruned.data.packages.contains_key("@repro/app/bs58/base-x"),
            "nested base-x@5 used by bs58@6 must remain in the pruned lockfile"
        );

        let lib_base_x = pruned
            .resolve_package("packages/lib", "base-x", "^4.0.0")
            .unwrap()
            .expect("bs58@5 should resolve base-x@4");
        assert_eq!(lib_base_x.key, "base-x@4.0.1");

        let app_base_x = pruned
            .resolve_package("apps/app", "base-x", "=5.0.1")
            .unwrap()
            .expect("bs58@6 should resolve base-x@5");
        assert_eq!(app_base_x.key, "base-x@5.0.1");
    }
}
