///|
/// The only manifest schema this client understands.
pub let supported_schema_version : Int = 2

///|
/// Number of hexadecimal characters in a SHA-256 digest.
let sha256_hex_length : Int = 64

///|
/// One platform's full-artifact update.
pub struct PlatformUpdate {
  url : String
  size : Int64
  sha256_hex : String
  signature_hex : String
} derive(Eq, Debug)

///|
pub fn PlatformUpdate::url(self : PlatformUpdate) -> String {
  self.url
}

///|
pub fn PlatformUpdate::size(self : PlatformUpdate) -> Int64 {
  self.size
}

///|
pub fn PlatformUpdate::sha256_hex(self : PlatformUpdate) -> String {
  self.sha256_hex
}

///|
pub fn PlatformUpdate::signature_hex(self : PlatformUpdate) -> String {
  self.signature_hex
}

///|
/// A decoded update manifest.
///
/// Decoding proves the document is well formed. It proves nothing about
/// authenticity: the caller verifies the manifest signature before trusting any
/// of this, and enforces revision monotonicity and freshness afterwards.
pub struct Manifest {
  version : Version
  revision : UInt64
  published_at : Timestamp
  notes_url : String?
  platforms : Map[String, PlatformUpdate]
}

///|
pub fn Manifest::version(self : Manifest) -> Version {
  self.version
}

///|
/// The signed, monotonically increasing release order.
///
/// Unlike the display version, this value is compared at install time and
/// never resets. It is what prevents an older signed release from replacing a
/// newer installed application.
pub fn Manifest::revision(self : Manifest) -> UInt64 {
  self.revision
}

///|
pub fn Manifest::published_at(self : Manifest) -> Timestamp {
  self.published_at
}

///|
pub fn Manifest::notes_url(self : Manifest) -> String? {
  self.notes_url
}

///|
/// Returns the update offered for one platform identifier, if any.
///
/// A platform that is absent, or whose entry names a `kind` this client does
/// not implement, has no update on offer. Neither is an error: a manifest may
/// legitimately describe artifacts that a older client cannot install.
pub fn Manifest::platform(
  self : Manifest,
  platform : String,
) -> PlatformUpdate? {
  self.platforms.get(platform)
}

///|
/// Returns every platform identifier this manifest offers an update for.
pub fn Manifest::platforms(self : Manifest) -> Array[String] {
  let names : Array[String] = []
  for name, _ in self.platforms {
    names.push(name)
  }
  names.sort()
  names
}

///|
/// Decodes a manifest document.
pub fn Manifest::parse(text : String) -> Manifest raise ManifestError {
  let json = @json.parse(text) catch { _ => raise NotJson }
  Manifest::from_json(json)
}

///|
/// Decodes a manifest from an already parsed JSON value.
pub fn Manifest::from_json(json : Json) -> Manifest raise ManifestError {
  guard json is Object(fields) else { raise NotAnObject(path="manifest") }
  for key, _ in fields {
    guard key
      is ("schema_version"
      | "version"
      | "revision"
      | "published_at"
      | "notes_url"
      | "platforms") else {
      raise UnknownField(path="manifest.\{key}")
    }
  }
  let schema_version = required_int(fields, "schema_version", "manifest")
  guard schema_version == supported_schema_version else {
    raise UnknownSchemaVersion(found=schema_version)
  }
  let version = match
    Version::parse(required_string(fields, "version", "manifest")) {
    Some(version) => version
    None =>
      raise InvalidField(
        path="manifest.version",
        expectation="must be major.minor.patch with no leading zeros",
      )
  }
  let revision = required_uint64(fields, "revision", "manifest")
  guard revision > 0UL else {
    raise InvalidField(
      path="manifest.revision",
      expectation="must be a positive unsigned 64-bit integer",
    )
  }
  let published_at = match
    Timestamp::parse(required_string(fields, "published_at", "manifest")) {
    Some(timestamp) => timestamp
    None =>
      raise InvalidField(
        path="manifest.published_at",
        expectation="must be YYYY-MM-DDTHH:MM:SSZ",
      )
  }
  guard fields
    is { "notes_url"? : notes_url_json, "platforms"? : platforms_json, .. }
  let notes_url = match notes_url_json {
    None => None
    Some(String(value)) => Some(value)
    Some(_) =>
      raise InvalidField(
        path="manifest.notes_url",
        expectation="must be a string",
      )
  }
  Manifest::{
    version,
    revision,
    published_at,
    notes_url,
    platforms: parse_platforms(platforms_json),
  }
}

///|
fn required_uint64(
  fields : Map[String, Json],
  name : String,
  path : String,
) -> UInt64 raise ManifestError {
  match fields.get(name) {
    None => raise MissingField(path="\{path}.\{name}")
    Some(Number(value, repr~)) => {
      let text = repr.unwrap_or(value.to_string())
      @string.parse_uint64(text[:]) catch {
        _ =>
          raise InvalidField(
            path="\{path}.\{name}",
            expectation="must be an unsigned 64-bit integer",
          )
      }
    }
    Some(_) =>
      raise InvalidField(
        path="\{path}.\{name}",
        expectation="must be an unsigned 64-bit integer",
      )
  }
}

///|
fn parse_platforms(
  value : Json?,
) -> Map[String, PlatformUpdate] raise ManifestError {
  guard value is Some(entry) else {
    raise MissingField(path="manifest.platforms")
  }
  guard entry is Object(entries) else {
    raise NotAnObject(path="manifest.platforms")
  }
  let platforms : Map[String, PlatformUpdate] = Map([])
  for name, platform_json in entries {
    match parse_platform(platform_json, "manifest.platforms.\{name}") {
      Some(update) => platforms[name] = update
      // An unrecognised `kind` means this client cannot install that artifact,
      // not that the manifest is broken. Failing the whole document would let a
      // newer release format lock every older client out of every platform.
      None => ()
    }
  }
  platforms
}

///|
fn parse_platform(
  json : Json,
  path : String,
) -> PlatformUpdate? raise ManifestError {
  guard json is Object(fields) else { raise NotAnObject(path~) }
  for key, _ in fields {
    guard key is ("kind" | "url" | "size" | "sha256" | "signature") else {
      raise UnknownField(path="\{path}.\{key}")
    }
  }
  guard required_string(fields, "kind", path) is "full" else { return None }
  let url = required_string(fields, "url", path)
  guard url.has_prefix("https://") else {
    raise InvalidField(path="\{path}.url", expectation="must be an https URL")
  }
  let size = required_int(fields, "size", path).to_int64()
  guard size > 0L else {
    raise InvalidField(path="\{path}.size", expectation="must be positive")
  }
  let sha256_hex = required_string(fields, "sha256", path)
  guard sha256_hex.length() == sha256_hex_length && is_hex(sha256_hex) else {
    raise InvalidField(
      path="\{path}.sha256",
      expectation="must be \{sha256_hex_length} hexadecimal characters",
    )
  }
  let signature_hex = required_string(fields, "signature", path)
  guard signature_hex.length() > 0 &&
    signature_hex.length() % 2 == 0 &&
    is_hex(signature_hex) else {
    raise InvalidField(
      path="\{path}.signature",
      expectation="must be an even number of hexadecimal characters",
    )
  }
  Some(PlatformUpdate::{ url, size, sha256_hex, signature_hex })
}

///|
fn required_string(
  fields : Map[String, Json],
  name : String,
  path : String,
) -> String raise ManifestError {
  match fields.get(name) {
    None => raise MissingField(path="\{path}.\{name}")
    Some(String(value)) => value
    Some(_) =>
      raise InvalidField(path="\{path}.\{name}", expectation="must be a string")
  }
}

///|
fn required_int(
  fields : Map[String, Json],
  name : String,
  path : String,
) -> Int raise ManifestError {
  match fields.get(name) {
    None => raise MissingField(path="\{path}.\{name}")
    Some(Number(value, ..)) => {
      let rounded = value.to_int()
      guard rounded.to_double() == value else {
        raise InvalidField(
          path="\{path}.\{name}",
          expectation="must be a whole number",
        )
      }
      rounded
    }
    Some(_) =>
      raise InvalidField(path="\{path}.\{name}", expectation="must be a number")
  }
}

///|
fn is_hex(text : String) -> Bool {
  for character in text {
    let code = character.to_int()
    let digit = code >= '0'.to_int() && code <= '9'.to_int()
    let lower = code >= 'a'.to_int() && code <= 'f'.to_int()
    if !(digit || lower) {
      return false
    }
  }
  true
}