// Copyright 2018-2026 the Deno authors. MIT license.

#![deny(clippy::print_stderr)]
#![deny(clippy::print_stdout)]

mod error;
mod graphs;

use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::btree_map::Entry as BTreeMapEntry;
use std::collections::hash_map::Entry as HashMapEntry;
use std::path::PathBuf;

use deno_semver::SmallStackString;
use deno_semver::StackString;
use deno_semver::Version;
use deno_semver::jsr::JsrDepPackageReq;
use deno_semver::package::PackageKind;
use deno_semver::package::PackageNv;
use serde::Deserialize;
use serde::Serialize;
use serde::de::DeserializeOwned;

mod printer;
mod transforms;

pub use error::DeserializationError;
pub use error::LockfileError;
pub use error::LockfileErrorReason;
pub use transforms::Lockfile5NpmInfo;
pub use transforms::NpmPackageInfoProvider;

use crate::graphs::LockfilePackageGraph;

pub struct SetWorkspaceConfigOptions {
  pub config: WorkspaceConfig,
  /// Maintains deno.json dependencies and workspace config
  /// regardless of the `config` options provided.
  ///
  /// Ex. the CLI sets this to `true` when someone runs a
  /// one-off script with `--no-config`.
  pub no_config: bool,
  /// Maintains package.json dependencies regardless of the
  /// `config` options provided.
  ///
  /// Ex. the CLI sets this to `true` when someone runs a
  /// one-off script with `--no-npm`.
  pub no_npm: bool,
}

#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceConfig {
  pub root: WorkspaceMemberConfig,
  pub members: HashMap<String, WorkspaceMemberConfig>,
  pub links: HashMap<String, LockfileLinkContent>,
  /// npm overrides from the root package.json
  pub npm_overrides: Option<serde_json::Value>,
}

#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceMemberConfig {
  pub dependencies: HashSet<JsrDepPackageReq>,
  pub package_json_deps: HashSet<JsrDepPackageReq>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NpmPackageLockfileInfo {
  pub serialized_id: StackString,
  /// Will be `None` for patch packages.
  pub integrity: Option<String>,
  pub dependencies: Vec<NpmPackageDependencyLockfileInfo>,
  pub optional_dependencies: Vec<NpmPackageDependencyLockfileInfo>,
  pub optional_peers: Vec<NpmPackageDependencyLockfileInfo>,
  pub os: Vec<SmallStackString>,
  pub cpu: Vec<SmallStackString>,
  pub tarball: Option<StackString>,
  pub deprecated: bool,
  pub scripts: bool,
  pub bin: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NpmPackageDependencyLockfileInfo {
  pub name: StackString,
  pub id: StackString,
}

#[derive(Debug, Clone, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct NpmPackageInfo {
  /// Will be `None` for patch packages.
  pub integrity: Option<String>,
  #[serde(default)]
  pub dependencies: BTreeMap<StackString, StackString>,
  #[serde(default)]
  pub optional_dependencies: BTreeMap<StackString, StackString>,
  #[serde(default)]
  pub optional_peers: BTreeMap<StackString, StackString>,
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
  pub os: Vec<SmallStackString>,
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
  pub cpu: Vec<SmallStackString>,
  #[serde(skip_serializing_if = "Option::is_none")]
  pub tarball: Option<StackString>,
  #[serde(default, skip_serializing_if = "is_false")]
  pub deprecated: bool,
  #[serde(default, skip_serializing_if = "is_false")]
  pub scripts: bool,
  #[serde(default, skip_serializing_if = "is_false")]
  pub bin: bool,
}

impl NpmPackageInfo {
  pub fn matches_link(&self, link: &LockfileLinkContent) -> bool {
    fn parse_nv(v: &StackString) -> Option<PackageNv> {
      let v = v.split_once('_').map(|(l, _)| l).unwrap_or(v);
      PackageNv::from_str(v).ok()
    }

    fn matches(
      link_deps: &HashSet<JsrDepPackageReq>,
      self_deps: &HashSet<PackageNv>,
    ) -> bool {
      if link_deps.len() != self_deps.len() {
        return false;
      }
      for req in link_deps {
        if !self_deps.iter().any(|nv| {
          nv.name == req.req.name && req.req.version_req.matches(&nv.version)
        }) {
          return false;
        }
      }
      true
    }

    {
      let optional_dep_nvs = self
        .optional_dependencies
        .values()
        .filter_map(parse_nv)
        .collect::<HashSet<_>>();
      if !matches(&link.optional_dependencies, &optional_dep_nvs) {
        return false;
      }
    }
    {
      let dep_nvs = self
        .dependencies
        .values()
        .filter_map(parse_nv)
        .collect::<HashSet<_>>();
      let link_deps = link
        .dependencies
        .iter()
        .chain(link.peer_dependencies.iter())
        .cloned()
        .collect::<HashSet<_>>();
      if !matches(&link_deps, &dep_nvs) {
        return false;
      }
    }
    {
      let optional_peer_nvs = self
        .optional_peers
        .values()
        .filter_map(parse_nv)
        .collect::<HashSet<_>>();
      let link_optional_peers = link
        .peer_dependencies_meta
        .iter()
        .filter(|(_, value)| {
          value
            .as_object()
            .and_then(|o| o.get("optional"))
            .and_then(|v| v.as_bool())
            .unwrap_or(false)
        })
        .filter_map(|(k, _)| JsrDepPackageReq::from_str(k).ok())
        .collect::<HashSet<_>>();
      if !matches(&link_optional_peers, &optional_peer_nvs) {
        return false;
      }
    }
    true
  }
}

fn is_false(value: &bool) -> bool {
  !value
}

#[derive(Debug, Clone, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct NpmPackageDist {
  pub shasum: String,
  pub integrity: Option<String>,
}

#[derive(Debug, Clone)]
pub struct JsrPackageInfo {
  pub integrity: String,
  /// List of package requirements found in the dependency.
  ///
  /// This is used to tell when a package can be removed from the lockfile.
  pub dependencies: HashSet<JsrDepPackageReq>,
}

impl JsrPackageInfo {
  pub fn matches_link(&self, link: &LockfileLinkContent) -> bool {
    self.dependencies == link.dependencies
  }
}

#[derive(Clone, Debug, Default)]
pub struct PackagesContent {
  /// Mapping between requests for jsr specifiers and resolved packages, eg.
  /// {
  ///   "jsr:@foo/bar@^2.1": "2.1.3",
  ///   "npm:@ts-morph/common@^11": "11.0.0",
  ///   "npm:@ts-morph/common@^12": "12.0.0__some-peer-dep@1.0.0",
  /// }
  pub specifiers: HashMap<JsrDepPackageReq, SmallStackString>,

  /// Mapping between resolved jsr specifiers and their associated info, eg.
  /// {
  ///   "@oak/oak@12.6.3": {
  ///     "dependencies": [
  ///       "jsr:@std/bytes@0.210",
  ///       // ...etc...
  ///       "npm:path-to-regexpr@^6.2"
  ///     ]
  ///   }
  /// }
  pub jsr: BTreeMap<PackageNv, JsrPackageInfo>,

  /// Mapping between resolved npm specifiers and their associated info, eg.
  /// {
  ///   "chalk@5.0.0_peer-dep@1": {
  ///     "integrity": "sha512-...",
  ///     "dependencies": {
  ///       "ansi-styles": "ansi-styles@4.1.0",
  ///     }
  ///   }
  /// }
  pub npm: BTreeMap<StackString, NpmPackageInfo>,
}

impl PackagesContent {
  fn is_empty(&self) -> bool {
    self.specifiers.is_empty() && self.npm.is_empty() && self.jsr.is_empty()
  }
}

#[derive(Debug, Default, Clone, Deserialize)]
pub(crate) struct LockfilePackageJsonContent {
  #[serde(default)]
  pub dependencies: HashSet<JsrDepPackageReq>,
  /// npm overrides (only present in root package.json section)
  #[serde(default)]
  pub overrides: Option<serde_json::Value>,
}

impl LockfilePackageJsonContent {
  pub fn is_empty(&self) -> bool {
    self.dependencies.is_empty() && self.overrides.is_none()
  }
}

#[derive(Debug, Default, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WorkspaceMemberConfigContent {
  #[serde(default)]
  pub dependencies: HashSet<JsrDepPackageReq>,
  #[serde(default)]
  pub package_json: LockfilePackageJsonContent,
}

impl WorkspaceMemberConfigContent {
  pub fn is_empty(&self) -> bool {
    self.dependencies.is_empty() && self.package_json.is_empty()
  }

  pub fn dep_reqs(&self) -> impl Iterator<Item = &JsrDepPackageReq> {
    self
      .package_json
      .dependencies
      .iter()
      .chain(self.dependencies.iter())
  }
}

#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct LockfileLinkContent {
  #[serde(default)]
  #[serde(skip_serializing_if = "Vec::is_empty")]
  pub dependencies: HashSet<JsrDepPackageReq>,
  #[serde(default)]
  #[serde(skip_serializing_if = "HashSet::is_empty")]
  pub optional_dependencies: HashSet<JsrDepPackageReq>,
  #[serde(default)]
  #[serde(skip_serializing_if = "Vec::is_empty")]
  pub peer_dependencies: HashSet<JsrDepPackageReq>,
  #[serde(default)]
  #[serde(skip_serializing_if = "HashMap::is_empty")]
  pub peer_dependencies_meta: HashMap<String, serde_json::Value>,
}

impl LockfileLinkContent {
  pub fn dep_reqs(&self) -> impl Iterator<Item = &JsrDepPackageReq> {
    self
      .dependencies
      .iter()
      .chain(self.peer_dependencies.iter())
      .chain(self.optional_dependencies.iter())
  }
}

#[derive(Debug, Default, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WorkspaceConfigContent {
  #[serde(default, flatten)]
  pub root: WorkspaceMemberConfigContent,
  #[serde(default)]
  pub members: HashMap<String, WorkspaceMemberConfigContent>,
  // todo(dsherret): patches is deprecated, remove in Deno 3.0
  #[serde(default, alias = "patches")]
  pub links: HashMap<String, LockfileLinkContent>,
  /// npm overrides from the root package.json
  #[serde(default)]
  pub npm_overrides: Option<serde_json::Value>,
}

impl WorkspaceConfigContent {
  pub fn is_empty(&self) -> bool {
    self.root.is_empty()
      && self.members.is_empty()
      && self.links.is_empty()
      && self.npm_overrides.is_none()
  }

  fn get_all_dep_reqs(&self) -> impl Iterator<Item = &JsrDepPackageReq> {
    self
      .root
      .dep_reqs()
      .chain(self.members.values().flat_map(|m| m.dep_reqs()))
  }
}

#[derive(Debug, Default, Clone)]
pub struct LockfileContent {
  pub packages: PackagesContent,
  pub redirects: BTreeMap<String, String>,
  /// Mapping between URLs and their checksums for "http:" and "https:" deps
  pub(crate) remote: BTreeMap<String, String>,
  pub(crate) workspace: WorkspaceConfigContent,
}

impl LockfileContent {
  pub fn from_json(
    json: serde_json::Value,
  ) -> Result<Self, DeserializationError> {
    fn extract_nv_from_id(value: &str) -> Option<(&str, &str)> {
      if value.is_empty() {
        return None;
      }
      let at_index = value[1..].find('@').map(|i| i + 1)?;
      let name = &value[..at_index];
      let version = &value[at_index + 1..];
      Some((name, version))
    }

    fn handle_dep(
      dep: StackString,
      version_by_dep_name: &HashMap<StackString, StackString>,
      dependencies: &mut BTreeMap<StackString, StackString>,
    ) -> Result<(), DeserializationError> {
      let (left, right) = match extract_nv_from_id(&dep) {
        Some((name, version)) => (name, version),
        None => match version_by_dep_name.get(&dep) {
          Some(version) => (dep.as_str(), version.as_str()),
          None => return Err(DeserializationError::MissingPackage(dep)),
        },
      };
      let (key, package_name, version) = match right.strip_prefix("npm:") {
        Some(right) => {
          // ex. key@npm:package-a@version
          match extract_nv_from_id(right) {
            Some((package_name, version)) => (left, package_name, version),
            None => {
              return Err(DeserializationError::InvalidNpmPackageDependency(
                dep,
              ));
            }
          }
        }
        None => (left, left, right),
      };
      dependencies.insert(key.into(), {
        let mut text =
          StackString::with_capacity(package_name.len() + 1 + version.len());
        text.push_str(package_name);
        text.push('@');
        text.push_str(version);
        text
      });
      Ok(())
    }

    #[derive(Debug, Deserialize)]
    #[serde(rename_all = "camelCase")]
    struct RawNpmPackageInfo {
      pub integrity: Option<String>,
      #[serde(default)]
      pub dependencies: Vec<StackString>,
      #[serde(default)]
      pub optional_dependencies: Vec<StackString>,
      #[serde(default, skip_serializing_if = "Vec::is_empty")]
      pub optional_peers: Vec<StackString>,
      #[serde(default)]
      pub os: Vec<SmallStackString>,
      #[serde(default)]
      pub cpu: Vec<SmallStackString>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub tarball: Option<StackString>,
      #[serde(default, skip_serializing_if = "is_false")]
      pub deprecated: bool,
      #[serde(default, skip_serializing_if = "is_false")]
      pub scripts: bool,
      #[serde(default, skip_serializing_if = "is_false")]
      pub bin: bool,
    }

    #[derive(Debug, Deserialize)]
    struct RawJsrPackageInfo {
      pub integrity: String,
      #[serde(default)]
      pub dependencies: Vec<StackString>,
    }

    fn deserialize_section<T: DeserializeOwned + Default>(
      json: &mut serde_json::Map<String, serde_json::Value>,
      key: &'static str,
    ) -> Result<T, DeserializationError> {
      match json.remove(key) {
        Some(value) => serde_json::from_value(value)
          .map_err(|err| DeserializationError::FailedDeserializing(key, err)),
        None => Ok(Default::default()),
      }
    }

    use serde_json::Value;

    let Value::Object(mut json) = json else {
      return Ok(Self::default());
    };

    Ok(LockfileContent {
      packages: {
        let deserialized_specifiers: BTreeMap<StackString, SmallStackString> =
          deserialize_section(&mut json, "specifiers")?;
        let mut specifiers =
          HashMap::with_capacity(deserialized_specifiers.len());
        for (key, value) in deserialized_specifiers {
          let dep = JsrDepPackageReq::from_str_loose(&key)?;
          specifiers.insert(dep, value);
        }

        let mut npm: BTreeMap<StackString, NpmPackageInfo> = Default::default();
        let raw_npm: BTreeMap<StackString, RawNpmPackageInfo> =
          deserialize_section(&mut json, "npm")?;
        if !raw_npm.is_empty() {
          // collect the versions
          let mut version_by_dep_name: HashMap<StackString, StackString> =
            HashMap::with_capacity(raw_npm.len());
          for id in raw_npm.keys() {
            let Some((name, version)) = extract_nv_from_id(id) else {
              return Err(DeserializationError::InvalidNpmPackageId(
                id.clone(),
              ));
            };
            version_by_dep_name.insert(name.into(), version.into());
          }

          // now go through and create the resolved npm package information
          for (key, value) in raw_npm {
            let mut dependencies: BTreeMap<StackString, StackString> =
              BTreeMap::new();
            let mut optional_dependencies =
              BTreeMap::<StackString, StackString>::new();
            let mut optional_peers =
              BTreeMap::<StackString, StackString>::new();

            for dep in value.dependencies.into_iter() {
              handle_dep(dep, &version_by_dep_name, &mut dependencies)?;
            }
            for dep in value.optional_dependencies.into_iter() {
              handle_dep(
                dep,
                &version_by_dep_name,
                &mut optional_dependencies,
              )?;
            }
            for dep in value.optional_peers.into_iter() {
              handle_dep(dep, &version_by_dep_name, &mut optional_peers)?;
            }

            npm.insert(
              key,
              NpmPackageInfo {
                integrity: value.integrity,
                dependencies,
                cpu: value.cpu,
                os: value.os,
                tarball: value.tarball,
                optional_dependencies,
                optional_peers,
                deprecated: value.deprecated,
                scripts: value.scripts,
                bin: value.bin,
              },
            );
          }
        }
        let mut jsr: BTreeMap<PackageNv, JsrPackageInfo> = Default::default();
        {
          let raw_jsr: BTreeMap<PackageNv, RawJsrPackageInfo> =
            deserialize_section(&mut json, "jsr")?;
          if !raw_jsr.is_empty() {
            // collect the specifier information
            let mut to_resolved_specifiers: HashMap<
              Cow<JsrDepPackageReq>,
              &JsrDepPackageReq,
            > = HashMap::with_capacity(specifiers.len() * 2);
            // first insert the specifiers with the version reqs
            for dep in specifiers.keys() {
              to_resolved_specifiers.insert(Cow::Borrowed(dep), dep);
            }
            // then insert the specifiers without version reqs
            for dep in specifiers.keys() {
              let Ok(dep_no_version_req) = JsrDepPackageReq::from_str(
                &format!("{}{}", dep.kind.scheme_with_colon(), dep.req.name),
              ) else {
                continue; // should never happen
              };
              let entry =
                to_resolved_specifiers.entry(Cow::Owned(dep_no_version_req));
              // if an entry is occupied that means there's multiple specifiers
              // for the same name, such as one without a req, so ignore inserting
              // here
              if let HashMapEntry::Vacant(entry) = entry {
                entry.insert(dep);
              }
            }

            // now go through the dependencies mapping to the new ones
            for (key, value) in raw_jsr {
              let mut dependencies =
                HashSet::with_capacity(value.dependencies.len());
              for dep in value.dependencies {
                let raw_dep = dep;
                let Ok(dep) = JsrDepPackageReq::from_str(&raw_dep) else {
                  continue; // should never happen
                };
                let Some(resolved_dep) = to_resolved_specifiers.get(&dep)
                else {
                  return Err(DeserializationError::InvalidJsrDependency {
                    dependency: raw_dep,
                    package: key,
                  });
                };
                dependencies.insert((*resolved_dep).clone());
              }
              jsr.insert(
                key,
                JsrPackageInfo {
                  integrity: value.integrity,
                  dependencies,
                },
              );
            }
          }
        }

        PackagesContent {
          specifiers,
          jsr,
          npm,
        }
      },
      redirects: deserialize_section(&mut json, "redirects")?,
      remote: deserialize_section(&mut json, "remote")?,
      workspace: {
        let mut workspace: WorkspaceConfigContent =
          deserialize_section(&mut json, "workspace")?;
        // copy overrides from packageJson section to npm_overrides field
        if workspace.npm_overrides.is_none()
          && let Some(overrides) = workspace.root.package_json.overrides.take()
        {
          workspace.npm_overrides = Some(overrides);
        }
        workspace
      },
    })
  }

  /// Merges the two sides of a resolved git merge conflict into a single
  /// content. Identity-keyed sections (`npm`, `jsr`, `remote`, `redirects`)
  /// are unioned because a given key always maps to the same deterministic
  /// value regardless of which branch wrote it, so a conflict there is
  /// spurious. The only genuine conflict surface is `specifiers` (a version
  /// requirement mapped to a resolved version); there the higher resolved
  /// version is kept and the normal resolution pass validates it against the
  /// manifest afterwards. The `workspace` section is recomputed from the
  /// manifests by `set_workspace_config`, so "ours" is kept as-is.
  ///
  /// By design, a same-key/different-value collision in an identity-keyed
  /// section (for example two branches recording a different integrity hash
  /// for the same `name@version` or `url`) is treated as spurious and "ours"
  /// is kept via `or_insert`; such a divergence is not expected in normal
  /// operation and is not surfaced as a conflict.
  fn merge_conflict_sides(mut ours: Self, theirs: Self) -> Self {
    for (req, version) in theirs.packages.specifiers {
      match ours.packages.specifiers.entry(req) {
        HashMapEntry::Vacant(entry) => {
          entry.insert(version);
        }
        HashMapEntry::Occupied(mut entry) => {
          if specifier_version_is_higher(&version, entry.get()) {
            entry.insert(version);
          }
        }
      }
    }
    for (nv, info) in theirs.packages.jsr {
      ours.packages.jsr.entry(nv).or_insert(info);
    }
    for (id, info) in theirs.packages.npm {
      ours.packages.npm.entry(id).or_insert(info);
    }
    for (from, to) in theirs.redirects {
      ours.redirects.entry(from).or_insert(to);
    }
    for (url, hash) in theirs.remote {
      ours.remote.entry(url).or_insert(hash);
    }
    ours
  }

  pub fn is_empty(&self) -> bool {
    self.packages.is_empty()
      && self.redirects.is_empty()
      && self.remote.is_empty()
      && self.workspace.is_empty()
  }
}

pub struct NewLockfileOptions<'a> {
  pub file_path: PathBuf,
  pub content: &'a str,
  pub overwrite: bool,
}

#[derive(Debug, Clone)]
pub struct Lockfile {
  pub overwrite: bool,
  pub has_content_changed: bool,
  pub content: LockfileContent,
  pub filename: PathBuf,
}

impl Lockfile {
  pub fn new_empty(filename: PathBuf, overwrite: bool) -> Lockfile {
    Lockfile {
      overwrite,
      has_content_changed: false,
      content: LockfileContent::default(),
      filename,
    }
  }

  pub async fn new(
    opts: NewLockfileOptions<'_>,
    provider: &dyn NpmPackageInfoProvider,
  ) -> Result<Lockfile, Box<LockfileError>> {
    async fn parse_content(
      content: &str,
      provider: &dyn NpmPackageInfoProvider,
    ) -> Result<LockfileContent, LockfileErrorReason> {
      let value: serde_json::Map<String, serde_json::Value> =
        serde_json::from_str(content)
          .map_err(LockfileErrorReason::ParseError)?;
      let version = value.get("version").and_then(|v| v.as_str());
      // When the value is transformed, we don't consider that a lockfile
      // change that should update the lockfile because we want to reduce
      // lockfile churn. For example, say someone with a new version of
      // Deno does a PR to a repo that has an old lockfile, but they
      // don't update any dependencies. In that case, we don't want to
      // have that PR include a lockfile change.
      let value = match version {
        Some("5") => value,
        Some("4") => transforms::transform4_to_5(value, provider).await?,
        Some("3") => {
          transforms::transform4_to_5(
            transforms::transform3_to_4(value)?,
            provider,
          )
          .await?
        }
        Some("2") => {
          transforms::transform4_to_5(
            transforms::transform3_to_4(transforms::transform2_to_3(value))?,
            provider,
          )
          .await?
        }
        None => {
          transforms::transform4_to_5(
            transforms::transform3_to_4(transforms::transform2_to_3(
              transforms::transform1_to_2(value),
            ))?,
            provider,
          )
          .await?
        }
        Some(version) => {
          return Err(LockfileErrorReason::UnsupportedVersion {
            version: version.to_string(),
          });
        }
      };
      let content = LockfileContent::from_json(value.into())
        .map_err(LockfileErrorReason::DeserializationError)?;

      Ok(content)
    }

    // Parses the lockfile, transparently resolving git merge conflict markers
    // when present. The clean (no-conflict) path is untouched: conflict
    // handling only runs after `serde_json` has already failed to parse, so
    // there is no added cost on a valid lockfile. The returned bool indicates
    // whether a conflict was resolved, so the caller can mark the lockfile as
    // changed and have it rewritten without the markers.
    async fn load_content(
      content: &str,
      provider: &dyn NpmPackageInfoProvider,
    ) -> Result<(LockfileContent, bool), LockfileErrorReason> {
      match parse_content(content, provider).await {
        Ok(content) => Ok((content, false)),
        Err(LockfileErrorReason::ParseError(parse_err)) => {
          let Some((ours, theirs)) = split_git_conflict_sides(content) else {
            // Not a merge conflict, just invalid JSON.
            return Err(LockfileErrorReason::ParseError(parse_err));
          };
          // Both reconstructed sides must parse on their own. If either does
          // not, the file is corrupt beyond a plain merge conflict, so report
          // the original parse error rather than guessing.
          let Ok(ours) = parse_content(&ours, provider).await else {
            return Err(LockfileErrorReason::ParseError(parse_err));
          };
          let Ok(theirs) = parse_content(&theirs, provider).await else {
            return Err(LockfileErrorReason::ParseError(parse_err));
          };
          Ok((LockfileContent::merge_conflict_sides(ours, theirs), true))
        }
        Err(other) => Err(other),
      }
    }

    // Writing a lock file always uses the new format.
    if opts.overwrite {
      return Ok(Lockfile {
        overwrite: opts.overwrite,
        filename: opts.file_path,
        has_content_changed: false,
        content: LockfileContent::default(),
      });
    }

    if opts.content.trim().is_empty() {
      return Err(Box::new(LockfileError {
        file_path: opts.file_path.display().to_string(),
        source: LockfileErrorReason::Empty,
      }));
    }
    let (content, resolved_conflict) = load_content(opts.content, provider)
      .await
      .map_err(|reason| LockfileError {
        file_path: opts.file_path.display().to_string(),
        source: reason,
      })?;
    Ok(Lockfile {
      overwrite: opts.overwrite,
      // A resolved merge conflict means the on-disk text still contains
      // markers, so mark the content as changed to have it rewritten cleanly.
      // In frozen mode this surfaces through the existing changed-lockfile
      // check rather than silently rewriting.
      has_content_changed: resolved_conflict,
      content,
      filename: opts.file_path,
    })
  }

  pub fn as_json_string(&self) -> String {
    let mut text = printer::print_v5_content(&self.content);
    text.reserve(1);
    text.push('\n');
    text
  }

  pub fn set_workspace_config(
    &mut self,
    mut options: SetWorkspaceConfigOptions,
  ) {
    fn update_workspace_member(
      has_content_changed: &mut bool,
      removed_deps: &mut HashSet<JsrDepPackageReq>,
      current: &mut WorkspaceMemberConfigContent,
      new: WorkspaceMemberConfig,
    ) {
      if new.dependencies != current.dependencies {
        let old_deps =
          std::mem::replace(&mut current.dependencies, new.dependencies);

        removed_deps.extend(old_deps);

        *has_content_changed = true;
      }

      if new.package_json_deps != current.package_json.dependencies {
        // update self.content.package_json
        let old_package_json_deps = std::mem::replace(
          &mut current.package_json.dependencies,
          new.package_json_deps,
        );

        removed_deps.extend(old_package_json_deps);

        *has_content_changed = true;
      }
    }

    // if specified, don't modify the package.json dependencies
    if options.no_npm || options.no_config {
      if options.config.root.package_json_deps.is_empty() {
        options
          .config
          .root
          .package_json_deps
          .clone_from(&self.content.workspace.root.package_json.dependencies);
      }
      for (key, value) in options.config.members.iter_mut() {
        if value.package_json_deps.is_empty() {
          value.package_json_deps = self
            .content
            .workspace
            .members
            .get(key)
            .map(|m| m.package_json.dependencies.clone())
            .unwrap_or_default();
        }
      }
      if options.config.npm_overrides.is_none() {
        options
          .config
          .npm_overrides
          .clone_from(&self.content.workspace.npm_overrides);
      }
    }
    if options.no_config {
      if options.config.root.dependencies.is_empty() {
        options
          .config
          .root
          .dependencies
          .clone_from(&self.content.workspace.root.dependencies);
      }
      for (key, value) in options.config.members.iter_mut() {
        if value.dependencies.is_empty() {
          value.dependencies = self
            .content
            .workspace
            .members
            .get(key)
            .map(|m| m.dependencies.clone())
            .unwrap_or_default();
        }
      }
      for (key, value) in self.content.workspace.members.iter() {
        if !options.config.members.contains_key(key) {
          options.config.members.insert(
            key.clone(),
            WorkspaceMemberConfig {
              dependencies: value.dependencies.clone(),
              package_json_deps: value.package_json.dependencies.clone(),
            },
          );
        }
      }
    }

    // If the lockfile is empty, it's most likely not created yet and so
    // we don't want this information being added to the lockfile to cause
    // a lockfile to be created. If this is the case, revert the lockfile back
    // to !self.has_content_changed after populating it with this information
    let allow_content_changed =
      self.has_content_changed || !self.content.is_empty();

    // check if npm overrides changed
    if options.config.npm_overrides != self.content.workspace.npm_overrides {
      self.has_content_changed = true;
      self.content.workspace.npm_overrides =
        options.config.npm_overrides.clone();
    }

    let has_any_patch_changed =
      options.config.links != self.content.workspace.links;

    let mut removed_deps = HashSet::new();
    let mut changed_links = HashSet::new();
    if has_any_patch_changed {
      self.has_content_changed = true;
      let mut unhandled_links = self
        .content
        .workspace
        .links
        .keys()
        .cloned()
        .collect::<HashSet<_>>();
      changed_links.reserve(options.config.links.len());
      for (link_name, new) in options.config.links {
        if !unhandled_links.remove(&link_name) {
          if let Ok(dep_req) = JsrDepPackageReq::from_str(&link_name) {
            let had_change = (|| match dep_req.kind {
              PackageKind::Jsr => {
                for (key, package) in &self.content.packages.jsr {
                  if key.name != dep_req.req.name {
                    continue;
                  }
                  if !dep_req.req.version_req.matches(&key.version)
                    || !package.matches_link(&new)
                  {
                    return true;
                  }
                }
                false
              }
              PackageKind::Npm => {
                for (key, package) in &self.content.packages.npm {
                  let Some(key) = key.strip_prefix(dep_req.req.name.as_str())
                  else {
                    continue;
                  };
                  let Some(key) = key.strip_prefix('@') else {
                    continue;
                  };
                  let version =
                    key.split_once('_').map(|(l, _)| l).unwrap_or(key);
                  let Ok(version) = Version::parse_from_npm(version) else {
                    continue;
                  };
                  if !dep_req.req.version_req.matches(&version)
                    || !package.matches_link(&new)
                  {
                    return true;
                  }
                }
                false
              }
            })();

            if had_change {
              changed_links.insert(dep_req);
            }
          }
          self.content.workspace.links.insert(link_name.clone(), new);
        } else {
          let current = self
            .content
            .workspace
            .links
            .entry(link_name.clone())
            .or_default();
          if new != *current {
            *current = new;
            if let Ok(dep_req) = JsrDepPackageReq::from_str(&link_name) {
              changed_links.insert(dep_req);
            }
          }
        }
      }

      for member in unhandled_links {
        if let Some(member) = self.content.workspace.links.remove(&member) {
          removed_deps.extend(member.dep_reqs().cloned());
        }
      }
    }

    // set the root
    update_workspace_member(
      &mut self.has_content_changed,
      &mut removed_deps,
      &mut self.content.workspace.root,
      options.config.root,
    );

    // now go through the workspaces
    let mut unhandled_members = self
      .content
      .workspace
      .members
      .keys()
      .cloned()
      .collect::<HashSet<_>>();
    for (member_name, new_member) in options.config.members {
      unhandled_members.remove(&member_name);
      let current_member = self
        .content
        .workspace
        .members
        .entry(member_name)
        .or_default();
      update_workspace_member(
        &mut self.has_content_changed,
        &mut removed_deps,
        current_member,
        new_member,
      );
    }

    for member in unhandled_members {
      if let Some(member) = self.content.workspace.members.remove(&member) {
        removed_deps.extend(member.dep_reqs().cloned());
        self.has_content_changed = true;
      }
    }

    // update the removed deps to keep what's still found in the workspace
    for dep in self.content.workspace.get_all_dep_reqs() {
      removed_deps.remove(dep);
    }

    if !removed_deps.is_empty() || !changed_links.is_empty() {
      let packages = std::mem::take(&mut self.content.packages);
      let remotes = std::mem::take(&mut self.content.remote);

      // create the graph
      let mut graph = LockfilePackageGraph::from_lockfile(packages, remotes);

      // remove the packages
      graph.remove_root_packages(removed_deps.into_iter());

      // remove the changed links
      graph.remove_links(changed_links.into_iter());

      // now populate the graph back into the packages
      graph.populate_packages(
        &mut self.content.packages,
        &mut self.content.remote,
      );
    }

    if !allow_content_changed {
      // revert it back so this change doesn't by itself cause
      // a lockfile to be created.
      self.has_content_changed = false;
    }
  }

  /// Gets the bytes that should be written to the disk.
  ///
  /// Ideally when the caller should use an "atomic write"
  /// when writing this—write to a temporary file beside the
  /// lockfile, then rename to overwrite. This will make the
  /// lockfile more resilient when multiple processes are
  /// writing to it.
  pub fn resolve_write_bytes(&mut self) -> Option<Vec<u8>> {
    if !self.has_content_changed && !self.overwrite {
      return None;
    }

    self.has_content_changed = false;
    Some(self.as_json_string().into_bytes())
  }

  pub fn remote(&self) -> &BTreeMap<String, String> {
    &self.content.remote
  }

  /// Inserts a remote specifier into the lockfile replacing the existing package if it exists.
  ///
  /// WARNING: It is up to the caller to ensure checksums of remote modules are
  /// valid before it is inserted here.
  pub fn insert_remote(&mut self, specifier: String, hash: String) {
    let entry = self.content.remote.entry(specifier);
    match entry {
      BTreeMapEntry::Vacant(entry) => {
        entry.insert(hash);
        self.has_content_changed = true;
      }
      BTreeMapEntry::Occupied(mut entry) => {
        if entry.get() != &hash {
          entry.insert(hash);
          self.has_content_changed = true;
        }
      }
    }
  }

  /// Inserts an npm package into the lockfile replacing the existing package if it exists.
  ///
  /// WARNING: It is up to the caller to ensure checksums of packages are
  /// valid before it is inserted here.
  pub fn insert_npm_package(&mut self, package_info: NpmPackageLockfileInfo) {
    let optional_dependencies = package_info
      .optional_dependencies
      .into_iter()
      .map(|dep| (dep.name, dep.id))
      .collect::<BTreeMap<StackString, StackString>>();
    let dependencies = package_info
      .dependencies
      .into_iter()
      .map(|dep| (dep.name, dep.id))
      .collect::<BTreeMap<StackString, StackString>>();
    let optional_peers = package_info
      .optional_peers
      .into_iter()
      .map(|dep| (dep.name, dep.id))
      .collect::<BTreeMap<StackString, StackString>>();

    let entry = self.content.packages.npm.entry(package_info.serialized_id);
    let package_info = NpmPackageInfo {
      integrity: package_info.integrity,
      dependencies,
      optional_dependencies,
      optional_peers,
      os: package_info.os,
      cpu: package_info.cpu,
      tarball: package_info.tarball,
      deprecated: package_info.deprecated,
      scripts: package_info.scripts,
      bin: package_info.bin,
    };
    match entry {
      BTreeMapEntry::Vacant(entry) => {
        entry.insert(package_info);
        self.has_content_changed = true;
      }
      BTreeMapEntry::Occupied(mut entry) => {
        if *entry.get() != package_info {
          entry.insert(package_info);
          self.has_content_changed = true;
        }
      }
    }
  }

  /// Inserts a package specifier into the lockfile.
  pub fn insert_package_specifier(
    &mut self,
    package_req: JsrDepPackageReq,
    serialized_package_id: SmallStackString,
  ) {
    let entry = self.content.packages.specifiers.entry(package_req);
    match entry {
      HashMapEntry::Vacant(entry) => {
        entry.insert(serialized_package_id);
        self.has_content_changed = true;
      }
      HashMapEntry::Occupied(mut entry) => {
        if *entry.get() != serialized_package_id {
          entry.insert(serialized_package_id);
          self.has_content_changed = true;
        }
      }
    }
  }

  /// Inserts a JSR package into the lockfile replacing the existing package's integrity
  /// if they differ.
  ///
  /// WARNING: It is up to the caller to ensure checksums of packages are
  /// valid before it is inserted here.
  pub fn insert_package(&mut self, name: PackageNv, integrity: String) {
    let entry = self.content.packages.jsr.entry(name);
    match entry {
      BTreeMapEntry::Vacant(entry) => {
        entry.insert(JsrPackageInfo {
          integrity,
          dependencies: Default::default(),
        });
        self.has_content_changed = true;
      }
      BTreeMapEntry::Occupied(mut entry) => {
        if *entry.get().integrity != integrity {
          entry.get_mut().integrity = integrity;
          self.has_content_changed = true;
        }
      }
    }
  }

  /// Adds package dependencies of a JSR package. This is only used to track
  /// when packages can be removed from the lockfile.
  ///
  /// Note: You MUST insert the package specifiers for any dependencies before
  /// adding them here as unresolved dependencies will be ignored.
  pub fn add_package_deps(
    &mut self,
    nv: &PackageNv,
    deps: impl Iterator<Item = JsrDepPackageReq>,
  ) {
    if let Some(pkg) = self.content.packages.jsr.get_mut(nv) {
      let start_count = pkg.dependencies.len();
      // don't include unresolved dependendencies
      let resolved_deps =
        deps.filter(|dep| self.content.packages.specifiers.contains_key(dep));
      pkg.dependencies.extend(resolved_deps);
      let end_count = pkg.dependencies.len();
      if start_count != end_count {
        self.has_content_changed = true;
      }
    }
  }

  pub fn insert_redirect(&mut self, from: String, to: String) {
    if from.starts_with("jsr:") {
      return;
    }

    let entry = self.content.redirects.entry(from);
    match entry {
      BTreeMapEntry::Vacant(entry) => {
        entry.insert(to);
        self.has_content_changed = true;
      }
      BTreeMapEntry::Occupied(mut entry) => {
        if *entry.get() != to {
          entry.insert(to);
          self.has_content_changed = true;
        }
      }
    }
  }
}

/// Reconstructs the two sides of every git merge conflict hunk in `content`
/// so each side can be parsed as a standalone lockfile. Lines outside any
/// conflict are kept in both sides; a diff3 base section (between `|||||||`
/// and `=======`) is discarded. Returns `None` when there are no conflict
/// markers at all.
fn split_git_conflict_sides(content: &str) -> Option<(String, String)> {
  enum Section {
    Both,
    Ours,
    Base,
    Theirs,
  }

  let mut saw_conflict = false;
  let mut section = Section::Both;
  let mut ours = String::with_capacity(content.len());
  let mut theirs = String::with_capacity(content.len());
  for line in content.split_inclusive('\n') {
    let marker = line.trim_end_matches(['\n', '\r']);
    if marker.starts_with("<<<<<<<") {
      saw_conflict = true;
      section = Section::Ours;
      continue;
    }
    if matches!(section, Section::Ours) && marker.starts_with("|||||||") {
      section = Section::Base;
      continue;
    }
    if matches!(section, Section::Ours | Section::Base)
      && marker.starts_with("=======")
    {
      section = Section::Theirs;
      continue;
    }
    if matches!(section, Section::Ours | Section::Base | Section::Theirs)
      && marker.starts_with(">>>>>>>")
    {
      section = Section::Both;
      continue;
    }
    match section {
      Section::Both => {
        ours.push_str(line);
        theirs.push_str(line);
      }
      Section::Ours => ours.push_str(line),
      Section::Theirs => theirs.push_str(line),
      // diff3 base content is the common ancestor; drop it.
      Section::Base => {}
    }
  }

  if saw_conflict {
    Some((ours, theirs))
  } else {
    None
  }
}

/// Whether `candidate` is a higher resolved version than `current`. The values
/// may carry a `__peer-dep` suffix, which is ignored for comparison. Returns
/// `false` when either side cannot be parsed, keeping the existing ("ours")
/// value.
fn specifier_version_is_higher(candidate: &str, current: &str) -> bool {
  fn parse(value: &str) -> Option<Version> {
    let base = value.split("__").next().unwrap_or(value);
    Version::parse_from_npm(base).ok()
  }
  match (parse(candidate), parse(current)) {
    (Some(candidate), Some(current)) => candidate > current,
    _ => false,
  }
}

#[cfg(test)]
mod tests {
  use std::path::Path;

  use deno_semver::package::PackageReq;
  use futures::FutureExt;
  use pretty_assertions::assert_eq;

  use super::*;
  #[derive(Default)]
  struct TestNpmPackageInfoProvider {
    cache: HashMap<PackageNv, Lockfile5NpmInfo>,
  }

  #[derive(Debug)]
  struct PackageNotFound(PackageNv);

  impl std::fmt::Display for PackageNotFound {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
      write!(f, "Package not found: {}", self.0)
    }
  }

  impl std::error::Error for PackageNotFound {}

  #[async_trait::async_trait(?Send)]
  impl NpmPackageInfoProvider for TestNpmPackageInfoProvider {
    async fn get_npm_package_info(
      &self,
      packages: &[PackageNv],
    ) -> Result<Vec<Lockfile5NpmInfo>, Box<dyn std::error::Error + Send + Sync>>
    {
      let mut infos = Vec::with_capacity(packages.len());
      for package in packages {
        if let Some(info) = self.cache.get(package) {
          infos.push(info.clone());
        } else {
          return Err(Box::new(PackageNotFound(package.clone())) as _);
        }
      }
      Ok(infos)
    }
  }

  const LOCKFILE_JSON: &str = r#"
{
  "version": "4",
  "npm": {
    "nanoid@3.3.4": {
      "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw=="
    },
    "picocolors@1.0.0": {
      "integrity": "sha512-foobar",
      "dependencies": []
    }
  },
  "remote": {
    "https://deno.land/std@0.71.0/textproto/mod.ts": "3118d7a42c03c242c5a49c2ad91c8396110e14acca1324e7aaefd31a999b71a4",
    "https://deno.land/std@0.71.0/async/delay.ts": "35957d585a6e3dd87706858fb1d6b551cb278271b03f52c5a2cb70e65e00c26a"
  }
}"#;

  fn new_lockfile(
    options: NewLockfileOptions,
  ) -> Result<Lockfile, Box<LockfileError>> {
    Lockfile::new(
      options,
      &TestNpmPackageInfoProvider {
        cache: HashMap::from_iter([
          (
            PackageNv::from_str("nanoid@3.3.4").unwrap(),
            Lockfile5NpmInfo {
              ..Default::default()
            },
          ),
          (
            PackageNv::from_str("picocolors@1.0.0").unwrap(),
            Lockfile5NpmInfo {
              ..Default::default()
            },
          ),
        ]),
      },
    )
    .now_or_never()
    .unwrap()
  }

  fn setup(overwrite: bool) -> Result<Lockfile, Box<LockfileError>> {
    let file_path = Path::new(file!())
      .parent()
      .unwrap()
      .join("valid_lockfile.json");
    new_lockfile(NewLockfileOptions {
      file_path,
      content: LOCKFILE_JSON,
      overwrite,
    })
  }

  #[test]
  fn future_version_unsupported() {
    let file_path = PathBuf::from("lockfile.json");
    let err = new_lockfile(NewLockfileOptions {
      file_path,
      content: "{ \"version\": \"2000\" }",
      overwrite: false,
    })
    .unwrap_err();
    match err.source {
      LockfileErrorReason::UnsupportedVersion { version } => {
        assert_eq!(version, "2000")
      }
      _ => unreachable!(),
    }
  }

  #[test]
  fn resolves_git_merge_conflict() {
    // A v5 lockfile with conflicts in both the `specifiers` and `npm`
    // sections. The two sides disagree on the resolved std/assert version and
    // each adds a distinct npm package.
    let content = r#"{
  "version": "5",
  "specifiers": {
<<<<<<< HEAD
    "jsr:@std/assert@^1": "1.0.10",
    "npm:chalk@^5": "5.3.0"
=======
    "jsr:@std/assert@^1": "1.0.8",
    "npm:picocolors@^1": "1.0.0"
>>>>>>> branch
  },
  "npm": {
<<<<<<< HEAD
    "chalk@5.3.0": { "integrity": "sha512-aaa" }
=======
    "picocolors@1.0.0": { "integrity": "sha512-bbb" }
>>>>>>> branch
  }
}"#;

    let lockfile = new_lockfile(NewLockfileOptions {
      file_path: PathBuf::from("/foo"),
      content,
      overwrite: false,
    })
    .unwrap();

    // The conflict was resolved, so the lockfile is flagged for rewrite.
    assert!(lockfile.has_content_changed);

    let specifiers = &lockfile.content.packages.specifiers;
    // Union of both sides' specifiers.
    assert_eq!(specifiers.len(), 3);
    // On the genuine conflict, the higher resolved version wins.
    assert_eq!(
      specifiers
        .get(&JsrDepPackageReq::from_str("jsr:@std/assert@^1").unwrap())
        .unwrap()
        .as_str(),
      "1.0.10"
    );

    // The npm sections are unioned.
    let npm = &lockfile.content.packages.npm;
    assert_eq!(npm.len(), 2);
    assert!(npm.contains_key("chalk@5.3.0"));
    assert!(npm.contains_key("picocolors@1.0.0"));
  }

  #[test]
  fn corrupt_lockfile_without_conflict_markers_still_errors() {
    let err = new_lockfile(NewLockfileOptions {
      file_path: PathBuf::from("/foo"),
      content: "{ not valid json",
      overwrite: false,
    })
    .unwrap_err();
    assert!(matches!(err.source, LockfileErrorReason::ParseError(_)));
  }

  #[test]
  fn resolves_git_merge_conflict_unions_identity_sections() {
    // Conflicts in the identity-keyed `jsr`, `redirects`, and `remote`
    // sections. Each side adds a distinct entry; every conflict here is
    // spurious and should be unioned.
    let content = r#"{
  "version": "5",
  "jsr": {
<<<<<<< HEAD
    "@std/assert@1.0.10": { "integrity": "sha512-aaa" }
=======
    "@std/path@1.0.0": { "integrity": "sha512-bbb" }
>>>>>>> branch
  },
  "redirects": {
<<<<<<< HEAD
    "https://deno.land/std/assert/mod.ts": "https://deno.land/std@1.0.0/assert/mod.ts"
=======
    "https://deno.land/std/path/mod.ts": "https://deno.land/std@1.0.0/path/mod.ts"
>>>>>>> branch
  },
  "remote": {
<<<<<<< HEAD
    "https://deno.land/std@1.0.0/assert/mod.ts": "aaa"
=======
    "https://deno.land/std@1.0.0/path/mod.ts": "bbb"
>>>>>>> branch
  }
}"#;

    let lockfile = new_lockfile(NewLockfileOptions {
      file_path: PathBuf::from("/foo"),
      content,
      overwrite: false,
    })
    .unwrap();

    assert!(lockfile.has_content_changed);

    let jsr = &lockfile.content.packages.jsr;
    assert_eq!(jsr.len(), 2);
    assert!(
      jsr.contains_key(&PackageNv::from_str("@std/assert@1.0.10").unwrap())
    );
    assert!(jsr.contains_key(&PackageNv::from_str("@std/path@1.0.0").unwrap()));

    let redirects = &lockfile.content.redirects;
    assert_eq!(redirects.len(), 2);
    assert!(redirects.contains_key("https://deno.land/std/assert/mod.ts"));
    assert!(redirects.contains_key("https://deno.land/std/path/mod.ts"));

    let remote = &lockfile.content.remote;
    assert_eq!(remote.len(), 2);
    assert!(remote.contains_key("https://deno.land/std@1.0.0/assert/mod.ts"));
    assert!(remote.contains_key("https://deno.land/std@1.0.0/path/mod.ts"));
  }

  #[test]
  fn resolves_git_merge_conflict_with_diff3_base() {
    // A diff3-style conflict (`merge.conflictStyle = diff3`) carries a base
    // section between `|||||||` and `=======`. That base is the common
    // ancestor and must be discarded; only "ours" and "theirs" are unioned.
    let content = r#"{
  "version": "5",
  "remote": {
<<<<<<< HEAD
    "https://deno.land/std@1.0.0/a.ts": "aaa"
||||||| base
    "https://deno.land/std@0.9.0/a.ts": "ccc"
=======
    "https://deno.land/std@1.0.0/b.ts": "bbb"
>>>>>>> branch
  }
}"#;

    let lockfile = new_lockfile(NewLockfileOptions {
      file_path: PathBuf::from("/foo"),
      content,
      overwrite: false,
    })
    .unwrap();

    assert!(lockfile.has_content_changed);

    let remote = &lockfile.content.remote;
    // The base entry is dropped; only the two sides survive.
    assert_eq!(remote.len(), 2);
    assert!(remote.contains_key("https://deno.land/std@1.0.0/a.ts"));
    assert!(remote.contains_key("https://deno.land/std@1.0.0/b.ts"));
    assert!(!remote.contains_key("https://deno.land/std@0.9.0/a.ts"));
  }

  #[test]
  fn corrupt_conflict_side_falls_back_to_parse_error() {
    // Conflict markers are present, but one reconstructed side is not valid
    // JSON on its own. This is corrupt beyond a plain merge conflict, so the
    // original parse error is surfaced rather than guessing at a merge.
    let content = r#"{
  "version": "5",
  "remote": {
<<<<<<< HEAD
    "https://deno.land/std@1.0.0/a.ts": "aaa"
=======
    this side is not valid json
>>>>>>> branch
  }
}"#;

    let err = new_lockfile(NewLockfileOptions {
      file_path: PathBuf::from("/foo"),
      content,
      overwrite: false,
    })
    .unwrap_err();
    assert!(matches!(err.source, LockfileErrorReason::ParseError(_)));
  }

  #[test]
  fn new_valid_lockfile() {
    let lockfile = setup(false).unwrap();

    let remote = lockfile.content.remote;
    let keys: Vec<String> = remote.keys().cloned().collect();
    let expected_keys = vec![
      String::from("https://deno.land/std@0.71.0/async/delay.ts"),
      String::from("https://deno.land/std@0.71.0/textproto/mod.ts"),
    ];

    assert_eq!(keys.len(), 2);
    assert_eq!(keys, expected_keys);
  }

  #[test]
  fn with_lockfile_content_for_valid_lockfile() {
    let file_path = PathBuf::from("/foo");
    let result = new_lockfile(NewLockfileOptions {
      file_path,
      content: LOCKFILE_JSON,
      overwrite: false,
    })
    .unwrap();

    let remote = result.content.remote;
    let keys: Vec<String> = remote.keys().cloned().collect();
    let expected_keys = vec![
      String::from("https://deno.land/std@0.71.0/async/delay.ts"),
      String::from("https://deno.land/std@0.71.0/textproto/mod.ts"),
    ];

    assert_eq!(keys.len(), 2);
    assert_eq!(keys, expected_keys);
  }

  #[test]
  fn new_lockfile_from_file_and_insert() {
    let mut lockfile = setup(false).unwrap();

    lockfile.insert_remote(
      "https://deno.land/std@0.71.0/io/util.ts".to_string(),
      "checksum-1".to_string(),
    );

    let remote = lockfile.content.remote;
    let keys: Vec<String> = remote.keys().cloned().collect();
    let expected_keys = vec![
      String::from("https://deno.land/std@0.71.0/async/delay.ts"),
      String::from("https://deno.land/std@0.71.0/io/util.ts"),
      String::from("https://deno.land/std@0.71.0/textproto/mod.ts"),
    ];
    assert_eq!(keys.len(), 3);
    assert_eq!(keys, expected_keys);
  }

  #[test]
  fn new_lockfile_and_write() {
    let mut lockfile = setup(true).unwrap();

    // true since overwrite was true
    assert!(lockfile.resolve_write_bytes().is_some());

    lockfile.insert_remote(
      "https://deno.land/std@0.71.0/textproto/mod.ts".to_string(),
      "checksum-1".to_string(),
    );
    lockfile.insert_remote(
      "https://deno.land/std@0.71.0/io/util.ts".to_string(),
      "checksum-2".to_string(),
    );
    lockfile.insert_remote(
      "https://deno.land/std@0.71.0/async/delay.ts".to_string(),
      "checksum-3".to_string(),
    );

    let bytes = lockfile.resolve_write_bytes().unwrap();
    let contents_json =
      serde_json::from_slice::<serde_json::Value>(&bytes).unwrap();
    let object = contents_json["remote"].as_object().unwrap();

    assert_eq!(
      object
        .get("https://deno.land/std@0.71.0/textproto/mod.ts")
        .and_then(|v| v.as_str()),
      Some("checksum-1")
    );

    // confirm that keys are sorted alphabetically
    let mut keys = object.keys().map(|k| k.as_str());
    assert_eq!(
      keys.next(),
      Some("https://deno.land/std@0.71.0/async/delay.ts")
    );
    assert_eq!(keys.next(), Some("https://deno.land/std@0.71.0/io/util.ts"));
    assert_eq!(
      keys.next(),
      Some("https://deno.land/std@0.71.0/textproto/mod.ts")
    );
    assert!(keys.next().is_none());
  }

  #[test]
  fn check_or_insert_lockfile() {
    let mut lockfile = setup(false).unwrap();

    // none since overwrite was false and there's no changes
    assert!(lockfile.resolve_write_bytes().is_none());

    lockfile.insert_remote(
      "https://deno.land/std@0.71.0/textproto/mod.ts".to_string(),
      "checksum-1".to_string(),
    );
    assert!(lockfile.has_content_changed);

    lockfile.has_content_changed = false;
    lockfile.insert_remote(
      "https://deno.land/std@0.71.0/textproto/mod.ts".to_string(),
      "checksum-1".to_string(),
    );
    assert!(!lockfile.has_content_changed);

    lockfile.insert_remote(
      "https://deno.land/std@0.71.0/textproto/mod.ts".to_string(),
      "checksum-new".to_string(),
    );
    assert!(lockfile.has_content_changed);
    lockfile.has_content_changed = false;

    // Not present in lockfile yet, should be inserted and check passed.
    lockfile.insert_remote(
      "https://deno.land/std@0.71.0/http/file_server.ts".to_string(),
      "checksum-1".to_string(),
    );
    assert!(lockfile.has_content_changed);

    // true since there were changes
    assert!(lockfile.resolve_write_bytes().is_some());
  }

  #[test]
  fn check_or_insert_lockfile_npm() {
    let mut lockfile = setup(false).unwrap();

    // already in lockfile
    let npm_package = NpmPackageLockfileInfo {
      serialized_id: "nanoid@3.3.4".into(),
      integrity: Some("sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==".to_string()),
      dependencies: vec![],
      optional_dependencies: vec![],
      optional_peers: vec![],
      os: vec![],
      cpu: vec![],
      tarball: None,
      deprecated: false,
      scripts: false,
      bin: false,
    };
    lockfile.insert_npm_package(npm_package);
    assert!(!lockfile.has_content_changed);

    // insert package that exists already, but has slightly different properties
    let npm_package = NpmPackageLockfileInfo {
      serialized_id: "picocolors@1.0.0".into(),
      integrity: Some("sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==".to_string()),
      dependencies: vec![],
      optional_dependencies: vec![],
      optional_peers: vec![],
      os: vec![],
      cpu: vec![],
      tarball: None,
      deprecated: false,
      scripts: false,
      bin: false,
    };
    lockfile.insert_npm_package(npm_package);
    assert!(lockfile.has_content_changed);

    lockfile.has_content_changed = false;
    let npm_package = NpmPackageLockfileInfo {
      serialized_id: "source-map-js@1.0.2".into(),
      integrity: Some("sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==".to_string()),
      dependencies: vec![],
      optional_dependencies: vec![],
      optional_peers: vec![],
      os: vec![],
      cpu: vec![],
      tarball: None,
      deprecated: false,
      scripts: false,
      bin: false,
    };
    // Not present in lockfile yet, should be inserted
    lockfile.insert_npm_package(npm_package.clone());
    assert!(lockfile.has_content_changed);
    lockfile.has_content_changed = false;

    // this one should not say the lockfile has changed because it's the same
    lockfile.insert_npm_package(npm_package);
    assert!(!lockfile.has_content_changed);

    let npm_package = NpmPackageLockfileInfo {
      serialized_id: "source-map-js@1.0.2".into(),
      integrity: Some("sha512-foobar".to_string()),
      dependencies: vec![],
      optional_dependencies: vec![],
      optional_peers: vec![],
      os: vec![],
      cpu: vec![],
      tarball: None,
      deprecated: false,
      scripts: false,
      bin: false,
    };
    // Now present in lockfile, should be changed due to different integrity
    lockfile.insert_npm_package(npm_package);
    assert!(lockfile.has_content_changed);
  }

  #[test]
  fn lockfile_with_redirects() {
    let mut lockfile = new_lockfile(NewLockfileOptions {
      file_path: PathBuf::from("/foo/deno.lock"),
      content: r#"{
  "version": "4",
  "redirects": {
    "https://deno.land/x/std/mod.ts": "https://deno.land/std@0.190.0/mod.ts"
  }
}"#,

      overwrite: false,
    })
    .unwrap();
    lockfile.content.redirects.insert(
      "https://deno.land/x/other/mod.ts".to_string(),
      "https://deno.land/x/other@0.1.0/mod.ts".to_string(),
    );
    assert_eq!(
      lockfile.as_json_string(),
      r#"{
  "version": "5",
  "redirects": {
    "https://deno.land/x/other/mod.ts": "https://deno.land/x/other@0.1.0/mod.ts",
    "https://deno.land/x/std/mod.ts": "https://deno.land/std@0.190.0/mod.ts"
  }
}
"#,
    );
  }

  #[test]
  fn test_insert_redirect() {
    let mut lockfile = new_lockfile(NewLockfileOptions {
      file_path: PathBuf::from("/foo/deno.lock"),
      content: r#"{
  "version": "4",
  "redirects": {
    "https://deno.land/x/std/mod.ts": "https://deno.land/std@0.190.0/mod.ts"
  }
}"#,
      overwrite: false,
    })
    .unwrap();
    lockfile.insert_redirect(
      "https://deno.land/x/std/mod.ts".to_string(),
      "https://deno.land/std@0.190.0/mod.ts".to_string(),
    );
    assert!(!lockfile.has_content_changed);
    lockfile.insert_redirect(
      "https://deno.land/x/std/mod.ts".to_string(),
      "https://deno.land/std@0.190.1/mod.ts".to_string(),
    );
    assert!(lockfile.has_content_changed);
    lockfile.insert_redirect(
      "https://deno.land/x/std/other.ts".to_string(),
      "https://deno.land/std@0.190.1/other.ts".to_string(),
    );
    assert_eq!(
      lockfile.as_json_string(),
      r#"{
  "version": "5",
  "redirects": {
    "https://deno.land/x/std/mod.ts": "https://deno.land/std@0.190.1/mod.ts",
    "https://deno.land/x/std/other.ts": "https://deno.land/std@0.190.1/other.ts"
  }
}
"#,
    );
  }

  #[test]
  fn test_insert_jsr() {
    let mut lockfile = new_lockfile(NewLockfileOptions {
      file_path: PathBuf::from("/foo/deno.lock"),
      content: r#"{
  "version": "4",
  "specifiers": {
    "jsr:path": "jsr:@std/path@0.75.0"
  }
}"#,
      overwrite: false,
    })
    .unwrap();
    lockfile.insert_package_specifier(
      JsrDepPackageReq::jsr(PackageReq::from_str("path").unwrap()),
      "jsr:@std/path@0.75.0".into(),
    );
    assert!(!lockfile.has_content_changed);
    lockfile.insert_package_specifier(
      JsrDepPackageReq::jsr(PackageReq::from_str("path").unwrap()),
      "jsr:@std/path@0.75.1".into(),
    );
    assert!(lockfile.has_content_changed);
    lockfile.insert_package_specifier(
      JsrDepPackageReq::jsr(PackageReq::from_str("@foo/bar@^2").unwrap()),
      "jsr:@foo/bar@2.1.2".into(),
    );
    assert_eq!(
      lockfile.as_json_string(),
      r#"{
  "version": "5",
  "specifiers": {
    "jsr:@foo/bar@2": "jsr:@foo/bar@2.1.2",
    "jsr:path@*": "jsr:@std/path@0.75.1"
  }
}
"#,
    );
  }

  #[test]
  fn read_version_1() {
    let content: &str = r#"{
      "https://deno.land/std@0.71.0/textproto/mod.ts": "3118d7a42c03c242c5a49c2ad91c8396110e14acca1324e7aaefd31a999b71a4",
      "https://deno.land/std@0.71.0/async/delay.ts": "35957d585a6e3dd87706858fb1d6b551cb278271b03f52c5a2cb70e65e00c26a"
    }"#;
    let file_path = PathBuf::from("lockfile.json");
    let lockfile = new_lockfile(NewLockfileOptions {
      file_path,
      content,
      overwrite: false,
    })
    .unwrap();
    assert_eq!(lockfile.content.remote.len(), 2);
  }

  #[test]
  fn read_version_2() {
    let content: &str = r#"{
      "version": "2",
      "remote": {
        "https://deno.land/std@0.71.0/textproto/mod.ts": "3118d7a42c03c242c5a49c2ad91c8396110e14acca1324e7aaefd31a999b71a4",
        "https://deno.land/std@0.71.0/async/delay.ts": "35957d585a6e3dd87706858fb1d6b551cb278271b03f52c5a2cb70e65e00c26a"
      },
      "npm": {
        "specifiers": {
          "nanoid": "nanoid@3.3.4"
        },
        "packages": {
          "nanoid@3.3.4": {
            "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
            "dependencies": {}
          },
          "picocolors@1.0.0": {
            "integrity": "sha512-foobar",
            "dependencies": {}
          }
        }
      }
    }"#;
    let file_path = PathBuf::from("lockfile.json");
    let lockfile = new_lockfile(NewLockfileOptions {
      file_path,
      content,
      overwrite: false,
    })
    .unwrap();
    assert_eq!(lockfile.content.packages.npm.len(), 2);
    assert_eq!(
      lockfile.content.packages.specifiers,
      HashMap::from([(
        JsrDepPackageReq::npm(PackageReq::from_str("nanoid").unwrap()),
        "3.3.4".into()
      )])
    );
    assert_eq!(lockfile.content.remote.len(), 2);
  }

  #[test]
  fn insert_package_deps_changes_empty_insert() {
    let content: &str = r#"{
      "version": "2",
      "remote": {}
    }"#;
    let file_path = PathBuf::from("lockfile.json");
    let mut lockfile = new_lockfile(NewLockfileOptions {
      file_path,
      content,
      overwrite: false,
    })
    .unwrap();

    lockfile.insert_package_specifier(
      JsrDepPackageReq::jsr(PackageReq::from_str("dep2").unwrap()),
      "dep2@1.0.0".into(),
    );
    assert!(lockfile.has_content_changed);
    lockfile.has_content_changed = false;

    assert!(!lockfile.has_content_changed);
    let dep_nv = PackageNv::from_str("dep@1.0.0").unwrap();
    lockfile.insert_package(dep_nv.clone(), "integrity".to_string());
    // has changed even though it was empty
    assert!(lockfile.has_content_changed);

    // now try inserting the same package
    lockfile.has_content_changed = false;
    lockfile.insert_package(dep_nv.clone(), "integrity".to_string());
    assert!(!lockfile.has_content_changed);

    // now with new deps
    lockfile.add_package_deps(
      &dep_nv,
      vec![JsrDepPackageReq::jsr(PackageReq::from_str("dep2").unwrap())]
        .into_iter(),
    );
    assert!(lockfile.has_content_changed);
    lockfile.has_content_changed = false;

    // now insert a dep that doesn't have a package specifier
    lockfile.add_package_deps(
      &dep_nv,
      vec![JsrDepPackageReq::jsr(
        PackageReq::from_str("dep-non-resolved").unwrap(),
      )]
      .into_iter(),
    );
    assert!(!lockfile.has_content_changed);
  }

  #[test]
  fn empty_lockfile_nicer_error() {
    let content: &str = r#"  "#;
    let file_path = PathBuf::from("lockfile.json");
    let err = new_lockfile(NewLockfileOptions {
      file_path,
      content,
      overwrite: false,
    })
    .err()
    .unwrap();
    assert!(matches!(err.source, LockfileErrorReason::Empty));
  }
}
