///|
fn fields(
  value : @ion.IonValue,
  owner : String,
) -> Map[String, @ion.IonValue] raise {
  guard value.kind() is Struct(entries) else {
    raise Invalid("metadata.invalid_object", owner, "Expected an Ion struct")
  }
  let result = Map([])
  for entry in entries {
    guard entry.name().text() is Some(key) else {
      raise Invalid(
        "metadata.symbol_catalog_missing", owner, "Resolve field symbols before interpretation",
      )
    }
    if result.contains(key) {
      raise Invalid(
        "metadata.duplicate_field",
        owner,
        "Duplicate field '" + key + "'; retain one unambiguous value",
      )
    }
    result[key] = entry.value()
  }
  result
}

///|
fn required(
  values : Map[String, @ion.IonValue],
  key : String,
  owner : String,
) -> @ion.IonValue raise {
  guard values.get(key) is Some(value) else {
    raise Invalid(
      "metadata.missing_field",
      owner,
      "Supply required field '" + key + "'",
    )
  }
  value
}

///|
fn text(value : @ion.IonValue, owner : String) -> String raise {
  guard value.annotations().is_empty() && value.kind() is String(result) else {
    raise Invalid(
      "metadata.invalid_string", owner, "Expected an unannotated string",
    )
  }
  result
}

///|
pub fn valid_language_id(value : String) -> Bool {
  let parts = value.split("-").collect()
  if parts.is_empty() || parts.any(p => p.is_empty()) {
    return false
  }
  let first = value.to_array()[0]
  if first < 'a' || first > 'z' {
    return false
  }
  parts.all(p => {
    p.iter().all(c => (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'))
  })
}

///|
/// One closed typed data object, with both maps required. Empty maps are valid.
pub fn TargetNames::from_ion(
  value : @ion.IonValue,
  owner~ : String = "targetNames",
  language_ids~ : Bool = true,
) -> TargetNames raise {
  if !value.annotations().is_empty() {
    raise Invalid(
      "metadata.invalid_data", owner, "TargetNames must be plain typed @json data",
    )
  }
  let record = fields(value, owner)
  if record.length() != 2 ||
    !record.contains("frontend") ||
    !record.contains("backend") {
    raise Invalid(
      "metadata.invalid_target_names", owner, "Supply exactly frontend and backend maps; use empty maps when no spelling is supplied",
    )
  }
  fn names(value : @ion.IonValue, role : String) -> Map[String, String] raise {
    if !value.annotations().is_empty() {
      raise Invalid(
        "metadata.invalid_data", owner, "Language maps must be unannotated objects",
      )
    }
    let result = Map([])
    for language, value in fields(value, owner + "/" + role) {
      if language_ids && !valid_language_id(language) {
        raise Invalid(
          "metadata.invalid_language_id",
          owner,
          "Language ID '" +
          language +
          "' must match ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$",
        )
      }
      result[language] = text(value, owner + "/" + role + "/" + language)
    }
    result
  }
  {
    frontend: names(record["frontend"], "frontend"),
    backend: names(record["backend"], "backend"),
  }
}

///|
fn digest(value : String) -> Bool {
  value.length() == 64 &&
  value.to_array().all(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))
}