///|
pub struct NpmPackageName {
  priv value : String
} derive(Eq, Debug)

///|
pub fn NpmPackageName::parse(text : String) -> NpmPackageName? {
  if text.is_empty() ||
    text.length() > 214 ||
    text == "node_modules" ||
    text == "favicon.ico" {
    return None
  }
  let parts = if text.has_prefix("@") {
    text[1:].split("/").to_array()
  } else {
    [text[:]]
  }
  if (text.has_prefix("@") && parts.length() != 2) ||
    parts.any(part => {
      part.is_empty() ||
      part.has_prefix(".") ||
      part.has_prefix("_") ||
      part.has_prefix("-") ||
      part
      .iter()
      .any(c => {
        !"abcdefghijklmnopqrstuvwxyz0123456789-_.".contains(c.to_string())
      })
    }) {
    return None
  }
  Some({ value: text, })
}

///|
pub fn NpmPackageName::to_string(self : NpmPackageName) -> String {
  self.value
}

///|
pub struct NpmVersion {
  priv value : String
} derive(Eq, Debug)

///|
pub fn NpmVersion::parse(text : String) -> NpmVersion? {
  if text.is_empty() || text.length() > 256 {
    return None
  }
  fn number(part : StringView) -> Bool {
    !part.is_empty() &&
    (part.length() == 1 || !part.has_prefix("0")) &&
    part.iter().all(c => "0123456789".contains(c.to_string()))
  }
  fn identifiers(text : StringView, prerelease : Bool) -> Bool {
    text
    .split(".")
    .all(part => {
      !part.is_empty() &&
      part
      .iter()
      .all(c => {
        "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-".contains(
          c.to_string(),
        )
      }) &&
      (
        !prerelease ||
        !part.iter().all(c => "0123456789".contains(c.to_string())) ||
        number(part)
      )
    })
  }
  let build = text.split("+").to_array()
  if build.length() > 2 ||
    (build.length() == 2 && !identifiers(build[1], false)) {
    return None
  }
  let main = build[0]
  let core = if main.find("-") is Some(i) {
    if !identifiers(main[i + 1:], true) {
      return None
    }
    main[:i]
  } else {
    main
  }
  let components = core.split(".").to_array()
  if components.length() != 3 || !components.all(number) {
    return None
  }
  Some({ value: text, })
}

///|
pub fn NpmVersion::to_string(self : NpmVersion) -> String {
  self.value
}

///|
pub(all) struct NpmPackageRef {
  name : NpmPackageName
  version : NpmVersion
} derive(Eq, Debug)

///|
pub fn NpmPackageRef::to_string(self : NpmPackageRef) -> String {
  self.name.value + "@" + self.version.value
}

///|
pub fn NpmPackageRef::key(self : NpmPackageRef) -> ArtifactKey {
  ArtifactKey::parse(
    Json::array([
      Json::string(self.name.value),
      Json::string(self.version.value),
    ]).stringify(),
  ).unwrap()
}

///|
pub fn NpmPackageRef::from_key(key : ArtifactKey) -> NpmPackageRef? {
  let json = @json.parse(key.to_string()) catch { _ => return None }
  guard json is [String(name), String(version)] &&
    NpmPackageName::parse(name) is Some(name) &&
    NpmVersion::parse(version) is Some(version) else {
    return None
  }
  Some({ name, version, })
}

///|
pub(all) struct NpmResolution {
  registry : RepositoryUrl
  reference : NpmPackageRef
  metadata : Acquisition
  tarball : HttpUrl
  integrity : NpmIntegrity
} derive(Eq, Debug)

///|
pub fn NpmResolution::to_string(self : NpmResolution) -> String {
  "npm:" +
  self.reference.to_string() +
  " registry=" +
  self.registry.to_string() +
  " metadata=" +
  self.metadata.requested.to_string() +
  " -> " +
  self.metadata.resolved.to_string() +
  " " +
  self.metadata.digest.to_string() +
  " " +
  self.integrity.to_string()
}

///|
pub(all) struct NpmArchive {
  registry : RepositoryUrl
  reference : NpmPackageRef
  expected_digest : ContentDigest?
} derive(Eq, Debug)

///|
pub fn Provenance::npm_requested(
  self : Provenance,
  registry : RepositoryUrl,
  reference : NpmPackageRef,
) -> Provenance {
  let history = [NpmRequestStep(registry, reference)]
  history.append(self.history)
  { ..self, history, }
}

///|
pub fn Provenance::npm(
  self : Provenance,
  resolution : NpmResolution,
) -> Provenance {
  let history = [NpmStep(resolution)]
  history.append(self.history)
  { ..self, history, }
}

///|
pub fn NpmArchive::open(
  self : NpmArchive,
  transport : HttpTransport,
  acquisition_limits~ : AcquisitionLimits = AcquisitionLimits::new().unwrap(),
  limits~ : Limits = Limits::new(),
  cache~ : VerifiedCache? = None,
  mode~ : AcquisitionMode = Online,
  credentials~ : Credentials = Credentials::new().unwrap(),
) -> EntryTree raise ResourceError {
  self.open_resolved(
    transport,
    acquisition_limits~,
    limits~,
    cache~,
    mode~,
    credentials~,
  ).1
}

///|
fn NpmArchive::open_resolved(
  self : NpmArchive,
  transport : HttpTransport,
  acquisition_limits~ : AcquisitionLimits,
  limits~ : Limits,
  cache~ : VerifiedCache?,
  mode~ : AcquisitionMode,
  credentials~ : Credentials,
) -> (NpmResolution, EntryTree) raise ResourceError {
  let origin = Provenance::new(self.registry.location()).npm_requested(
    self.registry,
    self.reference,
  )
  check_limits(limits, self.registry.location())
  if limits.max_archive_bytes == 0 {
    raise ResourceError(ResourceLimit, origin, "Archive byte limit is zero")
  }
  try {
    let endpoint = self.registry.endpoint(
      encode_repository_segment(self.reference.name.value) +
      "/" +
      encode_repository_segment(self.reference.version.value),
    )
    let data = acquire(
      endpoint,
      transport,
      limits={
        ..acquisition_limits,
        max_bytes: Int32::min(1048576, acquisition_limits.max_bytes),
      },
      cache~,
      mode~,
      credentials~,
    ) catch {
      ResourceError(MissingAsset, at, _) =>
        raise ResourceError(
          MissingPackageVersion,
          at,
          "No npm package version `" + self.reference.to_string() + "`",
        )
      error => raise error
    }
    let document = provider_json(data)
    guard document
      is {
        "name": String(name),
        "version": String(version),
        "dist": Object(dist),
        ..
      } &&
      name == self.reference.name.value &&
      version == self.reference.version.value &&
      dist.get("tarball") is Some(String(tarball)) &&
      HttpUrl::parse(tarball) is Some(tarball) else {
      raise ResourceError(
        InvalidResolution,
        origin,
        "npm metadata must match the exact package/version and contain a credential-free tarball URL",
      )
    }
    let integrity = match dist.get("integrity") {
      Some(String(text)) => NpmIntegrity::parse_sri(text)
      None =>
        match dist.get("shasum") {
          Some(String(text)) => NpmIntegrity::parse_shasum(text)
          _ => None
        }
      _ => None
    }
    guard integrity is Some(integrity) else {
      raise ResourceError(
        UnsupportedIntegrity,
        origin,
        "Expected supported npm SRI integrity or a legacy SHA-1 shasum",
      )
    }
    let resolution : NpmResolution = {
      registry: self.registry,
      reference: self.reference,
      metadata: data.metadata,
      tarball,
      integrity,
    }
    let provenance = origin.npm(resolution)
    let tree = PublicArchive::{
      url: tarball,
      format: Tgz,
      expected_digest: self.expected_digest,
    }.open(
      transport,
      acquisition_limits~,
      limits~,
      cache~,
      mode~,
      credentials~,
      verify=(body, at) => integrity.verify(body, at),
    ) catch {
      ResourceError(code, at, message) =>
        raise ResourceError(code, at.through(provenance), message)
    }
    (resolution, { ..tree, provenance: tree.provenance.npm(resolution), })
  } catch {
    ResourceError(code, at, message) =>
      raise ResourceError(code, at.through(origin), message)
  }
}

///|
pub fn register_npm(
  registry : Registry,
  transport : HttpTransport,
  expected_digest~ : ContentDigest? = None,
  acquisition_limits~ : AcquisitionLimits = AcquisitionLimits::new().unwrap(),
  limits~ : Limits = Limits::new(),
  cache~ : VerifiedCache? = None,
  mode~ : AcquisitionMode = Online,
  credentials~ : Credentials = Credentials::new().unwrap(),
) -> Registry raise ResourceError {
  registry.register_provider(ProviderId::parse("npm").unwrap(), (
    repository,
    reference,
  ) => {
    guard RepositoryUrl::parse(repository.location.to_string()) is Some(base) &&
      NpmPackageRef::from_key(reference.key) is Some(requested) else {
      raise ResourceError(
        InvalidResolution,
        Provenance::new(Location::Source(repository.location)),
        "npm requires a repository URL and an exact package/version key",
      )
    }
    let (resolution, tree) = NpmArchive::{
      registry: base,
      reference: requested,
      expected_digest,
    }.open_resolved(
      transport,
      acquisition_limits~,
      limits~,
      cache~,
      mode~,
      credentials~,
    )
    {
      reference,
      identity: ArtifactIdentity::parse(resolution.to_string()).unwrap(),
      tree,
    }
  })
}