///|
fn digest(value : String, path : String) -> String raise @ledger.InputError {
  if value.length() != 64 ||
    !value.to_array().all(c => c is ('0'..<='9' | 'a'..<='f')) {
    raise bad(
      "InvalidDigest", path, "Expected exactly 64 lowercase hexadecimal SHA-256 digits",
    )
  }
  value
}

///|
fn integrity_text(
  fields : Map[String, Json],
  key : String,
  path : String,
) -> String raise @ledger.InputError {
  nonempty(string_field(fields, key, path), path + "/" + key)
}

///|
/// Deterministic JSON: receipts and missing IDs are sorted by exact report ID.
/// Refuses output beyond the configured JSON budget instead of truncating it.
pub fn IntegrityManifest::encode(
  self : IntegrityManifest,
  limits~ : @ledger.Limits = @ledger.Limits::default(),
) -> String raise @ledger.InputError {
  limits.validate()
  if self.documents.length() + self.missing_reports.length() >
    limits.max_reports {
    raise bad(
      "ReportLimit", "$/documents", "Manifest report inventory exceeds the configured budget",
    )
  }
  for document in self.documents {
    if document.utf8_bytes.to_int64() > limits.max_source_units.to_int64() * 4L {
      raise bad(
        "SourceLimit",
        "$/documents/" + document.binding.id,
        "Receipt exceeds the UTF-8 source budget upper bound",
      )
    }
  }
  let result = Json::object({
    "schema": Json::number(1),
    "kind": Json::string("moontestledger/integrity"),
    "algorithm": Json::string("sha256"),
    "canonicalization": Json::string("mtl-evidence-v1"),
    "run_id": Json::string(self.run_id),
    "revision": Json::string(self.revision),
    "deny_recovered": Json::boolean(self.deny_recovered),
    "plan_sha256": Json::string(self.plan_sha256),
    "archive_sha256": Json::string(self.archive_sha256),
    "documents": Json::array(
      self.documents.map(document => {
        let fields = binding_fields(document.binding)
        fields["sha256"] = Json::string(document.sha256)
        fields["utf8_bytes"] = Json::number(document.utf8_bytes.to_double())
        Json::object(fields)
      }),
    ),
    "missing_reports": Json::array(self.missing_reports.map(Json::string)),
  }).stringify(indent=2)
  if result.length() > limits.max_source_units {
    raise bad(
      "SourceLimit", "$", "Encoded integrity manifest exceeds the configured JSON budget",
    )
  }
  result
}

///|
/// Strict versioned manifest reader. Unknown fields/algorithms, duplicates,
/// conflicting IDs and rounded integer lexemes are refused.
pub fn decode_integrity(
  source : String,
  limits~ : @ledger.Limits = @ledger.Limits::default(),
) -> IntegrityManifest raise @ledger.InputError {
  let fields = object(strict_json(source, limits), "$")
  keys(
    fields,
    [
      "schema", "kind", "algorithm", "canonicalization", "run_id", "revision", "deny_recovered",
      "plan_sha256", "archive_sha256", "documents", "missing_reports",
    ],
    "$",
  )
  schema(fields)
  for
    (key, expected) in [
      ("kind", "moontestledger/integrity"),
      ("algorithm", "sha256"),
      ("canonicalization", "mtl-evidence-v1"),
    ] {
    if string_field(fields, key, "$") != expected {
      raise bad(
        "IntegrityFormat",
        "$/" + key,
        "Unsupported integrity format; expected " + expected,
      )
    }
  }
  let run_id = integrity_text(fields, "run_id", "$")
  let revision = integrity_text(fields, "revision", "$")
  let deny_recovered = match required(fields, "deny_recovered", "$") {
    True => true
    False => false
    _ =>
      raise bad(
        "ExpectedBoolean", "$/deny_recovered", "Expected a boolean recovery policy",
      )
  }
  let plan_sha256 = digest(
    string_field(fields, "plan_sha256", "$"),
    "$/plan_sha256",
  )
  let archive_sha256 = digest(
    string_field(fields, "archive_sha256", "$"),
    "$/archive_sha256",
  )
  let items = array(required(fields, "documents", "$"), "$/documents")
  let missing = array(
    required(fields, "missing_reports", "$"),
    "$/missing_reports",
  )
  if items.length() + missing.length() > limits.max_reports {
    raise bad(
      "ReportLimit", "$/documents", "Manifest report inventory exceeds the configured budget",
    )
  }
  let ids : Set[String] = Set([])
  let documents : Array[IntegrityDocument] = []
  for i, item in items {
    let path = "$/documents/\{i}"
    let item = object(item, path)
    keys(
      item,
      [
        "id", "run_id", "revision", "environment", "shard", "attempt", "sha256",
        "utf8_bytes",
      ],
      path,
    )
    let binding : @ledger.ReportBinding = {
      id: integrity_text(item, "id", path),
      run_id: integrity_text(item, "run_id", path),
      revision: integrity_text(item, "revision", path),
      environment: integrity_text(item, "environment", path),
      shard: integrity_text(item, "shard", path),
      attempt: positive_integer(item, "attempt", path),
    }
    if binding.run_id != run_id || binding.revision != revision {
      raise bad(
        "BindingMismatch", path, "Receipt must bind to the manifest run and revision",
      )
    }
    if ids.contains(binding.id) {
      raise bad(
        "DuplicateReport", path, "Report identity appears more than once in the manifest",
      )
    }
    ids.add(binding.id)
    let utf8_bytes = positive_integer(item, "utf8_bytes", path)
    if utf8_bytes.to_int64() > limits.max_source_units.to_int64() * 4L {
      raise bad(
        "SourceLimit",
        path + "/utf8_bytes",
        "Receipt exceeds the UTF-8 source budget upper bound",
      )
    }
    documents.push({
      binding,
      utf8_bytes,
      sha256: digest(string_field(item, "sha256", path), path + "/sha256"),
    })
  }
  let missing_reports : Array[String] = []
  for i, item in missing {
    let path = "$/missing_reports/\{i}"
    let id = match item {
      String(id) => nonempty(id, path)
      _ =>
        raise bad("ExpectedString", path, "Expected a missing report identity")
    }
    if ids.contains(id) {
      raise bad(
        "DuplicateReport", path, "Missing report identity is duplicated or already has a receipt",
      )
    }
    ids.add(id)
    missing_reports.push(id)
  }
  if ids.is_empty() {
    raise bad(
      "EmptyInventory", "$/documents", "A manifest must account for at least one expected report",
    )
  }
  documents.sort_by((a, b) => a.binding.id.compare(b.binding.id))
  missing_reports.sort()
  {
    run_id,
    revision,
    deny_recovered,
    plan_sha256,
    archive_sha256,
    documents,
    missing_reports,
  }
}