///|
pub(all) enum DeliveryMode {
  ExactCopy
  CompleteFetch
} derive(Eq)

///|
pub extend DeliveryMode with Eq::{equal, not_equal}

///|
pub(all) struct DeliveryReport {
  accepted : Bool
  mode : String
  source : Report
  received : Report
  preserved_files : Int32
  completed_files : Int32
  diagnostics : Array[Diagnostic]
} derive(ToJson)

///|
pub extend DeliveryReport with ToJson::{to_json}

///|
fn delivery_issue(
  diagnostics : Array[Diagnostic],
  code : String,
  path : String,
  message : String,
) -> Unit {
  issue(diagnostics, "delivery_" + code, path, message)
}

///|
fn delivery_record(
  before : FileRecord,
  after : FileRecord,
  diagnostics : Array[Diagnostic],
) -> Bool {
  if before.size != after.size {
    delivery_issue(
      diagnostics,
      "size",
      before.path,
      "previously present file changed byte length",
    )
    return false
  }
  let mut shared_strong = false
  let mut mismatch = false
  for name in sorted_map_keys(before.digests) {
    let alg = algorithm(name) catch { Invalid(_) => continue }
    if after.digests.get(name) is Some(value) {
      if !valid_checksum(before.digests[name], alg) ||
        !valid_checksum(value, alg) {
        delivery_issue(
          diagnostics,
          "digest_format",
          before.path,
          "invalid shared raw-byte digest",
        )
        mismatch = true
        continue
      }
      if alg == Sha256 || alg == Sha512 {
        shared_strong = true
      }
      if before.digests[name][:].to_lower().to_owned() !=
        value[:].to_lower().to_owned() {
        delivery_issue(
          diagnostics,
          "changed",
          before.path,
          "previously present bytes changed according to " + name,
        )
        mismatch = true
      }
    }
  }
  if !shared_strong {
    delivery_issue(
      diagnostics,
      "unverifiable",
      before.path,
      "a shared valid SHA-256 or SHA-512 raw-byte digest is required",
    )
  }
  shared_strong && !mismatch
}

///|
/// Verify a read-only handoff. This does not authenticate the sender or sign a receipt.
pub fn verify_delivery(
  source : BagView,
  received : BagView,
  mode : DeliveryMode,
) -> DeliveryReport {
  let before = validate(source)
  let after = validate(received)
  let diagnostics : Array[Diagnostic] = []
  let expected_new : Map[String, FetchEntry] = Map([])
  if mode == ExactCopy {
    if !before.valid {
      delivery_issue(
        diagnostics, "source_invalid", "", "exact copy requires a fully valid source bag",
      )
    }
  } else {
    try {
      let pending = plan_fetch(source)
      ignore(check_transfer_paths(source, pending))
      for entry in pending {
        expected_new[entry.path] = entry
      }
    } catch {
      Invalid(message) =>
        delivery_issue(diagnostics, "source_invalid", "", message)
    }
  }
  if !after.valid {
    delivery_issue(
      diagnostics, "received_invalid", "", "received bag failed full validation",
    )
  }
  let source_files : Map[String, FileRecord] = Map([])
  let target_files : Map[String, FileRecord] = Map([])
  for file in source.files {
    source_files[file.path] = file
  }
  for file in received.files {
    target_files[file.path] = file
  }
  let mut preserved_files = 0
  for path in sorted_map_keys(source_files) {
    match target_files.get(path) {
      None =>
        delivery_issue(
          diagnostics, "missing", path, "previously present file was removed",
        )
      Some(file) =>
        if delivery_record(source_files[path], file, diagnostics) {
          preserved_files = preserved_files + 1
        }
    }
  }
  let mut completed_files = 0
  for path in sorted_map_keys(target_files) {
    if source_files.contains(path) {
      continue
    }
    match expected_new.get(path) {
      None =>
        delivery_issue(
          diagnostics, "unexpected", path, "new file is not a declared pending fetch destination",
        )
      Some(entry) =>
        if entry.size is Some(size) && size != target_files[path].size {
          delivery_issue(
            diagnostics, "fetch_size", path, "completed file does not match the declared fetch length",
          )
        } else {
          completed_files = completed_files + 1
        }
    }
  }
  for path in sorted_map_keys(expected_new) {
    if !target_files.contains(path) {
      delivery_issue(
        diagnostics, "fetch_missing", path, "declared pending file remains missing",
      )
    }
  }
  let source_dirs : Map[String, Bool] = Map([])
  let target_dirs : Map[String, Bool] = Map([])
  let allowed_new_dirs : Map[String, Bool] = Map([])
  for path in source.directories {
    source_dirs[path] = true
  }
  for path in received.directories {
    target_dirs[path] = true
  }
  for path in sorted_map_keys(expected_new) {
    let mut end = path.length()
    while path[:end].to_owned().rev_find("/") is Some(slash) {
      allowed_new_dirs[path[:slash].to_owned()] = true
      end = slash
    }
  }
  for path in sorted_map_keys(source_dirs) {
    if !target_dirs.contains(path) {
      delivery_issue(
        diagnostics, "directory_missing", path, "existing directory was removed, including empty directories",
      )
    }
  }
  for path in sorted_map_keys(target_dirs) {
    if !source_dirs.contains(path) && !allowed_new_dirs.contains(path) {
      delivery_issue(
        diagnostics, "directory_unexpected", path, "new directory is not needed by a declared fetch destination",
      )
    }
  }
  for path in sorted_map_keys(source.texts) {
    if received.texts.get(path) != Some(source.texts[path]) {
      delivery_issue(
        diagnostics, "tag_text", path, "decoded source tag text changed or is unavailable",
      )
    }
  }
  {
    accepted: after.valid && !diagnostics.any(d => d.severity == Error),
    mode: if mode == ExactCopy {
      "exact-copy"
    } else {
      "complete-fetch"
    },
    source: before,
    received: after,
    preserved_files,
    completed_files,
    diagnostics,
  }
}

///|
pub(all) struct CollectionItem {
  identifier : String
  view : BagView
}

///|
pub(all) struct CollectionResult {
  identifier : String
  acceptance : AcceptanceReport
} derive(ToJson)

///|
pub extend CollectionResult with ToJson::{to_json}

///|
pub(all) struct CollectionReport {
  accepted : Bool
  accepted_bags : Int32
  rejected_bags : Int32
  payload_files : Int64
  payload_bytes : Int64
  items : Array[CollectionResult]
} derive(ToJson)

///|
pub extend CollectionReport with ToJson::{to_json}

///|
/// Every bag gets an independent report; one bad bag cannot hide another result.
pub fn evaluate_collection(
  collection : Array[CollectionItem],
  policy : ReceivingPolicy,
) -> CollectionReport raise FormatError {
  check_receiving_policy(policy)
  let identifiers : Map[String, BagView] = Map([])
  for item in collection {
    guard item.identifier.trim().to_owned() != "" &&
      !identifiers.contains(item.identifier) else {
      raise Invalid("collection identifiers must be nonempty and unique")
    }
    identifiers[item.identifier] = item.view
  }
  let items : Array[CollectionResult] = []
  let mut accepted_bags = 0
  let mut rejected_bags = 0
  let mut payload_files = 0L
  let mut payload_bytes = 0L
  for identifier in sorted_map_keys(identifiers) {
    let acceptance = evaluate_receiving_policy(identifiers[identifier], policy)
    if acceptance.accepted {
      accepted_bags = accepted_bags + 1
    } else {
      rejected_bags = rejected_bags + 1
    }
    payload_files = checked_add(
      payload_files,
      acceptance.bag.payload_files.to_int64(),
      "collection file count",
    )
    payload_bytes = checked_add(
      payload_bytes,
      acceptance.bag.payload_bytes,
      "collection payload bytes",
    )
    items.push({ identifier, acceptance, })
  }
  {
    accepted: rejected_bags == 0 && !items.is_empty(),
    accepted_bags,
    rejected_bags,
    payload_files,
    payload_bytes,
    items,
  }
}