///|
fn artifact_names() -> Array[String] {
  ["report.html", "audit.json", "audit.csv", "chart.svg"]
}

///|
/// Four deterministic artifacts. Manifest construction hashes their UTF-8 bytes.
pub fn review_artifacts(
  trajectory : @evidence.Trajectory,
  language? : ReportLanguage = English,
) -> Array[(String, String)] {
  [
    ("report.html", render_audit_html_in(trajectory, language)),
    ("audit.json", render_audit_json(trajectory)),
    ("audit.csv", render_audit_csv(trajectory)),
    ("chart.svg", render_trajectory_svg_in(trajectory, language)),
  ]
}

///|
/// V2 adds exact raw-file input digests when available and always includes
/// semantic input digests. No timestamps or local source paths affect determinism.
pub fn render_bundle_manifest(
  trajectory : @evidence.Trajectory,
  artifacts : Array[(String, String)],
  language? : ReportLanguage = English,
  raw_inputs? : Array[(String, Bytes)] = [],
) -> String {
  let visits : Array[Json] = trajectory.visits.map(visit_input_json)
  let inputs : Array[Json] = [
    {
      "name": "study",
      "representation": "canonical_json",
      "sha256": sha256_hex(
        @utf8.encode(study_json(trajectory.study).stringify()),
      ),
    },
    {
      "name": "followup",
      "representation": "canonical_json",
      "sha256": sha256_hex(@utf8.encode(visits.to_json().stringify())),
    },
  ]
  for (name, bytes) in raw_inputs {
    inputs.push({
      "name": name,
      "representation": "raw_bytes",
      "sha256": sha256_hex(bytes),
    })
  }
  let files : Array[Json] = artifacts.map(pair => {
    let bytes = @utf8.encode(pair.1)
    {
      "name": pair.0,
      "byte_length": bytes.length(),
      "sha256": sha256_hex(bytes),
    }
  })
  let document : Json = {
    "schema_version": 2,
    "software": "impart563/lesion-followup",
    "software_version": "0.2.0",
    "ruleset": "recist-1.1-target-subset-1",
    "scope": "target_lesions_only",
    "language": tr(language, "en", "zh-CN"),
    "inputs": inputs.to_json(),
    "artifacts": files.to_json(),
    "summary": summary_json(trajectory),
  }
  document.stringify(indent=2) + "\n"
}

///|
fn hash_field(
  fields : Map[String, Json],
  path : String,
) -> String raise StudyDecodeError {
  let hash = string_field(fields, "sha256", path)
  if hash.length() != 64 ||
    !hash.iter().all(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
    raise StudyDecodeError(
      path + ".sha256",
      "Expected 64 lowercase hexadecimal digits.",
    )
  }
  hash
}

///|
fn verify_manifest(
  value : Json,
  artifacts : Array[(String, Bytes)],
) -> Array[Diagnostic] raise StudyDecodeError {
  let fields = object(value, "$", [
    "schema_version", "software", "software_version", "ruleset", "scope", "language",
    "inputs", "artifacts", "summary",
  ])
  guard required(fields, "schema_version", "$") is Number(2.0, ..) else {
    raise StudyDecodeError(
      "$.schema_version", "Only bundle schema version 2 is supported.",
    )
  }
  for
    (key, expected) in [
      ("software", "impart563/lesion-followup"),
      ("software_version", "0.2.0"),
      ("ruleset", "recist-1.1-target-subset-1"),
      ("scope", "target_lesions_only"),
    ] {
    if string_field(fields, key, "$") != expected {
      raise StudyDecodeError("$." + key, "Unsupported bundle profile.")
    }
  }
  if !["en", "zh-CN"].contains(string_field(fields, "language", "$")) {
    raise StudyDecodeError("$.language", "Unsupported language.")
  }
  ignore(required(fields, "summary", "$"))
  guard required(fields, "inputs", "$") is Array(inputs) else {
    raise StudyDecodeError("$.inputs", "Expected input fingerprints.")
  }
  let input_names : Map[String, Bool] = Map([])
  let input_hashes : Map[String, String] = Map([])
  for i, item in inputs {
    let path = "$.inputs[\{i}]"
    let item = object(item, path, ["name", "representation", "sha256"])
    let name = string_field(item, "name", path)
    let representation = string_field(item, "representation", path)
    if !["study", "followup"].contains(name) ||
      !["canonical_json", "raw_bytes"].contains(representation) ||
      input_names.contains(name + representation) {
      raise StudyDecodeError(path, "Unknown or duplicate input fingerprint.")
    }
    input_names[name + representation] = true
    input_hashes[name + representation] = hash_field(item, path)
  }
  if !input_names.contains("studycanonical_json") ||
    !input_names.contains("followupcanonical_json") {
    raise StudyDecodeError(
      "$.inputs", "Semantic input fingerprints are required.",
    )
  }
  guard required(fields, "artifacts", "$") is Array(files) else {
    raise StudyDecodeError("$.artifacts", "Expected an array.")
  }
  let expected_names = artifact_names()
  let seen : Map[String, Bool] = Map([])
  let supplied : Map[String, Bytes] = Map([])
  for (name, bytes) in artifacts {
    if !expected_names.contains(name) || supplied.contains(name) {
      raise StudyDecodeError(
        "$.artifacts", "Unknown or duplicate supplied artifact.",
      )
    }
    supplied[name] = bytes
  }
  let issues = []
  for i, item in files {
    let path = "$.artifacts[\{i}]"
    let item = object(item, path, ["name", "byte_length", "sha256"])
    let name = string_field(item, "name", path)
    if !expected_names.contains(name) || seen.contains(name) {
      raise StudyDecodeError(
        path + ".name",
        "Unknown or duplicate artifact name.",
      )
    }
    seen[name] = true
    let digest = hash_field(item, path)
    let length = match required(item, "byte_length", path) {
      Number(value, ..) if value >= 0.0 &&
        value <= 2147483647.0 &&
        value == value.to_int().to_double() => value.to_int()
      _ =>
        raise StudyDecodeError(
          path + ".byte_length",
          "Expected a nonnegative byte length.",
        )
    }
    match supplied.get(name) {
      None =>
        issues.push(
          diagnostic(
            "bundle_missing",
            "Missing regular file: " + name,
            field=path,
          ),
        )
      Some(bytes) =>
        if bytes.length() != length || sha256_hex(bytes) != digest {
          issues.push(
            diagnostic(
              "bundle_mismatch",
              "Length or SHA-256 mismatch: " + name,
              field=path,
            ),
          )
        }
    }
  }
  if seen.length() != 4 {
    raise StudyDecodeError(
      "$.artifacts", "Exactly four named artifacts are required.",
    )
  }
  if issues.is_empty() && supplied.get("audit.json") is Some(bytes) {
    let text = @utf8.decode(bytes) catch {
      _ =>
        raise StudyDecodeError("$.artifacts", "Audit JSON must be valid UTF-8.")
    }
    let audit = @json.parse(text) catch {
      _ => raise StudyDecodeError("$.artifacts", "Audit JSON is malformed.")
    }
    let audit = object(audit, "$.audit", [
      "schema_version", "ruleset", "scope", "study", "visits", "assessments", "summary",
    ])
    let study_value = required(audit, "study", "$.audit")
    let study = match parse_study_json(study_value.stringify()) {
      Ok(study) => study
      Err(_) =>
        raise StudyDecodeError(
          "$.audit.study", "Audit study profile is invalid.",
        )
    }
    let study_digest = sha256_hex(@utf8.encode(study_json(study).stringify()))
    let visit_digest = sha256_hex(
      @utf8.encode(required(audit, "visits", "$.audit").stringify()),
    )
    if input_hashes.get("studycanonical_json") != Some(study_digest) ||
      input_hashes.get("followupcanonical_json") != Some(visit_digest) ||
      required(audit, "summary", "$.audit") != required(fields, "summary", "$") ||
      string_field(audit, "scope", "$.audit") != "target_lesions_only" ||
      string_field(audit, "ruleset", "$.audit") != "recist-1.1-target-subset-1" {
      issues.push(
        diagnostic(
          "bundle_metadata", "Semantic input fingerprint, profile or summary differs from audit.json.",
        ),
      )
    }
  }
  issues
}

///|
/// Validate metadata and exact supplied artifact bytes. Fixed artifact names are
/// the only allowed names; callers must not resolve arbitrary manifest paths.
pub fn verify_bundle(
  manifest : String,
  artifacts : Array[(String, Bytes)],
) -> Array[Diagnostic] {
  let value = @json.parse(manifest) catch {
    error => return [diagnostic("bundle_schema", error.to_string())]
  }
  try verify_manifest(value, artifacts) catch {
    StudyDecodeError(path, message) =>
      [diagnostic("bundle_schema", message, field=path)]
  } noraise {
    issues => issues
  }
}