///|
/// A GitHub repository coordinate, independent of its API host.
pub struct GitHubRepository {
  priv value : String
} derive(Eq, Debug)

///|
pub fn GitHubRepository::parse(text : String) -> GitHubRepository? {
  let parts = text.split("/").to_array()
  if parts.length() != 2 ||
    parts.any(part => {
      part.is_empty() ||
      part == "." ||
      part == ".." ||
      part.length() > 100 ||
      part
      .iter()
      .any(c => {
        !"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.".contains(
          c.to_string(),
        )
      })
    }) {
    return None
  }
  Some({ value: text, })
}

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

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

///|
pub fn GitHubTag::parse(text : String) -> GitHubTag? {
  if valid_value(text) && text.length() <= 255 && text != "." && text != ".." {
    Some({ value: text, })
  } else {
    None
  }
}

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

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

///|
pub fn GitHubAssetName::parse(text : String) -> GitHubAssetName? {
  if valid_value(text) &&
    text.length() <= 255 &&
    !text.contains("/") &&
    !text.contains("\\") &&
    text != "." &&
    text != ".." {
    Some({ value: text, })
  } else {
    None
  }
}

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

///|
pub struct GitHubReleaseId {
  priv value : Int64
} derive(Eq, Debug)

///|
pub struct GitHubAssetId {
  priv value : Int64
} derive(Eq, Debug)

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

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

///|
/// An exact name within an explicitly named release. Keys serialize both fields
/// as JSON, so tags and names containing punctuation cannot collide.
pub(all) struct GitHubAssetRef {
  tag : GitHubTag
  asset : GitHubAssetName
} derive(Eq, Debug)

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

///|
pub fn GitHubAssetRef::from_key(key : ArtifactKey) -> GitHubAssetRef? {
  let json = @json.parse(key.to_string()) catch { _ => return None }
  guard json is [String(tag), String(asset)] &&
    GitHubTag::parse(tag) is Some(tag) &&
    GitHubAssetName::parse(asset) is Some(asset) else {
    return None
  }
  Some({ tag, asset, })
}

///|
pub(all) struct GitHubResolution {
  repository : GitHubRepository
  requested : GitHubAssetRef
  release_id : GitHubReleaseId
  asset_id : GitHubAssetId
  download : HttpUrl
} derive(Eq, Debug)

///|
pub fn GitHubResolution::to_string(self : GitHubResolution) -> String {
  "github:" +
  self.repository.value +
  " tag=" +
  self.requested.tag.value +
  " release=" +
  self.release_id.to_string() +
  " asset=" +
  self.requested.asset.value +
  " id=" +
  self.asset_id.to_string()
}

///|
pub fn Provenance::github(
  self : Provenance,
  resolution : GitHubResolution,
) -> Provenance {
  let history = [GitHubStep(resolution)]
  history.append(self.history)
  { ..self, history, }
}

///|
pub(all) struct GitHubArchive {
  repository : GitHubRepository
  reference : GitHubAssetRef
  format : ArchiveFormat
  expected_digest : ContentDigest?
} derive(Eq, Debug)

///|
fn github_segment(text : String) -> String {
  let out = StringBuilder()
  let hex = "0123456789ABCDEF"
  for byte in @utf8.encode(text) {
    let n = byte.to_int()
    if (n >= 65 && n <= 90) ||
      (n >= 97 && n <= 122) ||
      (n >= 48 && n <= 57) ||
      "-._~".contains(n.unsafe_to_char().to_string()) {
      out.write_char(n.unsafe_to_char())
    } else {
      out.write_string(
        "%" +
        hex[n / 16:n / 16 + 1].to_owned() +
        hex[n % 16:n % 16 + 1].to_owned(),
      )
    }
  }
  out.to_string()
}

///|
fn github_id(json : Json) -> Int64? {
  guard json is Number(n, ..) else { return None }
  if n >= 1 && n <= 9007199254740991.0 && n.to_int64().to_double() == n {
    Some(n.to_int64())
  } else {
    None
  }
}

///|
fn github_json(bytes : Bytes, origin : Provenance) -> Json raise ResourceError {
  @json.parse(@utf8.decode(bytes)) catch {
    _ =>
      raise ResourceError(InvalidResolution, origin, "Invalid GitHub API JSON")
  }
}

///|
/// Resolve a release by tag, enumerate assets in bounded pages and match
/// one exact name. Metadata and archive bytes share the verified HTTP cache.
pub fn GitHubArchive::open(
  self : GitHubArchive,
  transport : HttpTransport,
  api~ : HttpUrl = HttpUrl::parse("https://api.github.com").unwrap(),
  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,
    api~,
    acquisition_limits~,
    limits~,
    cache~,
    mode~,
    credentials~,
  ).1
}

///|
fn GitHubArchive::open_resolved(
  self : GitHubArchive,
  transport : HttpTransport,
  api~ : HttpUrl = HttpUrl::parse("https://api.github.com").unwrap(),
  acquisition_limits~ : AcquisitionLimits = AcquisitionLimits::new().unwrap(),
  limits~ : Limits = Limits::new(),
  cache~ : VerifiedCache? = None,
  mode~ : AcquisitionMode = Online,
  credentials~ : Credentials = Credentials::new().unwrap(),
) -> (GitHubResolution, EntryTree) raise ResourceError {
  let origin = Provenance::new(api.location()).github_requested(
    self.repository,
    self.reference,
  )
  check_limits(limits, api.location())
  if limits.max_archive_bytes == 0 {
    raise ResourceError(ResourceLimit, origin, "Archive byte limit is zero")
  }
  let base = api.to_string().trim_end(chars="/").to_owned()
  if base.contains("?") {
    raise ResourceError(
      InvalidResolution,
      origin,
      "GitHub API base must not contain a query",
    )
  }
  let prefix = base + "/repos/" + self.repository.value + "/releases/"
  let metadata_limits = {
    ..acquisition_limits,
    max_bytes: Int32::min(acquisition_limits.max_bytes, 1048576),
  }
  fn metadata(path : String) -> Json raise ResourceError {
    let checked_transport : HttpTransport = (url, bounds) => {
      let response = transport(url, bounds)
      if response.body.length() > bounds.max_bytes {
        raise ResourceError(
          ResourceLimit,
          Provenance::new(url.location()),
          "GitHub metadata exceeds byte limit",
        )
      }
      if response.status == 403 {
        let text = @utf8.decode(response.body) catch { _ => "" }
        if text.contains("rate limit") {
          raise ResourceError(
            RateLimited,
            Provenance::new(response.resolved.location()),
            "GitHub API rate limit exceeded",
          )
        }
      }
      response
    }
    guard HttpUrl::parse(prefix + path) is Some(requested) else {
      raise ResourceError(
        InvalidResolution,
        origin,
        "GitHub API URL exceeds supported URL bounds",
      )
    }
    let acquired = acquire(
      requested,
      checked_transport,
      limits=metadata_limits,
      cache~,
      mode~,
      credentials~,
    ) catch {
      ResourceError(code, at, message) =>
        raise ResourceError(code, at.through(origin), message)
    }
    github_json(
      acquired.body,
      Provenance::new(acquired.metadata.resolved.location())
      .acquired(acquired.metadata)
      .through(origin),
    )
  }
  let release = metadata("tags/" + github_segment(self.reference.tag.value)) catch {
    ResourceError(MissingAsset, at, message) =>
      raise ResourceError(MissingRelease, at, message)
    error => raise error
  }
  guard release is { "id": id, "tag_name": String(tag), "draft": False, .. } &&
    github_id(id) is Some(id) &&
    tag == self.reference.tag.value else {
    raise ResourceError(
      InvalidResolution,
      origin,
      "GitHub release must have a positive ID, matching tag and draft=false",
    )
  }
  let release_id : GitHubReleaseId = { value: id, }
  let matches : Array[Json] = []
  for page = 1; ; page = page + 1 {
    if page > 10 {
      raise ResourceError(
        ResourceLimit,
        origin,
        "GitHub asset enumeration exceeds ten pages",
      )
    }
    guard metadata(
        release_id.to_string() + "/assets?per_page=100&page=" + page.to_string(),
      )
      is Array(assets) &&
      assets.length() <= 100 else {
      raise ResourceError(
        InvalidResolution,
        origin,
        "Expected a GitHub asset page of at most 100 entries",
      )
    }
    for asset in assets {
      guard asset is { "name": String(name), .. } else {
        raise ResourceError(
          InvalidResolution,
          origin,
          "Expected a GitHub asset name",
        )
      }
      if name == self.reference.asset.value {
        matches.push(asset)
      }
    }
    if assets.length() < 100 {
      break
    }
  }
  if matches.is_empty() {
    raise ResourceError(
      MissingAsset,
      origin,
      "No GitHub asset named `" +
      self.reference.asset.value +
      "` in tag `" +
      self.reference.tag.value +
      "`",
    )
  }
  if matches.length() != 1 {
    raise ResourceError(
      AmbiguousAsset,
      origin,
      "Multiple GitHub assets have the exact requested name",
    )
  }
  let asset = matches[0]
  guard asset
    is {
      "id": asset_id,
      "browser_download_url": String(download),
      "state": "uploaded",
      ..
    } &&
    github_id(asset_id) is Some(asset_id) &&
    HttpUrl::parse(download) is Some(download) else {
    raise ResourceError(
      InvalidResolution,
      origin,
      "GitHub asset requires a positive ID, uploaded state and credential-free download URL",
    )
  }
  let authenticated = credentials.for_url(api) is Some(_)
  let download = if authenticated {
    HttpUrl::parse(prefix + "assets/" + asset_id.to_string()).unwrap()
  } else {
    download
  }
  let resolution : GitHubResolution = {
    repository: self.repository,
    requested: self.reference,
    release_id,
    asset_id: { value: asset_id, },
    download,
  }
  let provenance = origin.github(resolution)
  let api_digest = match asset {
    { "digest": Null, .. } => None
    { "digest": String(text), .. } => {
      guard ContentDigest::parse(text) is Some(digest) else {
        raise ResourceError(
          InvalidResolution,
          provenance,
          "Invalid GitHub asset SHA-256 digest",
        )
      }
      Some(digest)
    }
    Object(fields) => {
      if fields.contains("digest") {
        raise ResourceError(
          InvalidResolution,
          provenance,
          "Invalid GitHub asset digest",
        )
      }
      None
    }
    _ => None
  }
  if api_digest is Some(digest) &&
    self.expected_digest is Some(expected) &&
    digest != expected {
    raise ResourceError(
      IntegrityFailure,
      provenance,
      "Expected digest differs from GitHub asset digest",
    )
  }
  let tree = PublicArchive::{
    url: download,
    format: self.format,
    expected_digest: if self.expected_digest is Some(_) {
      self.expected_digest
    } else {
      api_digest
    },
  }.open(
    transport,
    acquisition_limits~,
    limits~,
    cache~,
    mode~,
    credentials~,
    accept=if authenticated { OctetStream } else { AnyContent },
  ) catch {
    ResourceError(code, at, message) =>
      raise ResourceError(code, at.through(provenance), message)
  }
  (resolution, { ..tree, provenance: tree.provenance.github(resolution), })
}

///|
/// Repository location is owner/repository; artifact keys come from GitHubAssetRef.
pub fn register_github(
  registry : Registry,
  transport : HttpTransport,
  api~ : HttpUrl = HttpUrl::parse("https://api.github.com").unwrap(),
  format~ : ArchiveFormat = Zip,
  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("github").unwrap(), (
    repository,
    reference,
  ) => {
    guard GitHubRepository::parse(repository.location.to_string())
      is Some(coordinate) &&
      GitHubAssetRef::from_key(reference.key) is Some(requested) else {
      raise ResourceError(
        InvalidResolution,
        Provenance::new(Location::Source(repository.location)),
        "GitHub requires owner/repository and an exact tag/asset key",
      )
    }
    let (resolution, tree) = GitHubArchive::{
      repository: coordinate,
      reference: requested,
      format,
      expected_digest,
    }.open_resolved(
      transport,
      api~,
      acquisition_limits~,
      limits~,
      cache~,
      mode~,
      credentials~,
    )
    {
      reference,
      identity: ArtifactIdentity::parse(resolution.to_string()).unwrap(),
      tree,
    }
  })
}