///|
fn issue(
  severity : String,
  code : String,
  entity : String,
  message : String,
) -> Diagnostic {
  { severity, code, entity, message, }
}

///|
fn valid_id(id : String) -> Bool {
  if id.is_empty() || id.length() > 128 {
    return false
  }
  for c in id {
    if !((c >= 'a' && c <= 'z') ||
      (c >= 'A' && c <= 'Z') ||
      (c >= '0' && c <= '9') ||
      c == '-' ||
      c == '_' ||
      c == '.' ||
      c == ':') {
      return false
    }
  }
  true
}

///|
fn valid_label(label : String) -> Bool {
  !label.trim().is_empty() &&
  label.length() <= 256 &&
  !label.contains("\n") &&
  !label.contains("\r")
}

///|
fn valid_quantity(quantity : Int64) -> Bool {
  quantity > 0L && quantity <= 1000000000000L
}

///|
fn valid_time(instant : Int64) -> Bool {
  instant >= 0L && instant <= 253402300799L
}

///|
fn report_from(diagnostics : Array[Diagnostic]) -> ValidationReport {
  let mut valid = true
  for diagnostic in diagnostics {
    if diagnostic.severity == "error" {
      valid = false
    }
  }
  { valid, diagnostics, }
}

///|
/// Validate structure, references, mass accounting, and chronology before
/// interpreting any recall path. A rejected ledger never produces an impact
/// report, so malformed evidence cannot look like a clean result.
pub fn validate_ledger(ledger : Ledger) -> ValidationReport {
  let findings : Array[Diagnostic] = []
  if ledger.schema_version != 1 {
    findings.push(
      issue(
        "error", "unsupported-version", "ledger", "schema_version must be 1",
      ),
    )
  }
  if ledger.lots.length() > 100000 ||
    ledger.transformations.length() > 100000 ||
    ledger.shipments.length() > 100000 {
    findings.push(
      issue(
        "error", "record-limit", "ledger", "each record array is limited to 100000 entries",
      ),
    )
  }
  let lots : Map[String, Lot] = Map([])
  for lot in ledger.lots {
    if !valid_id(lot.id) {
      findings.push(
        issue(
          "error",
          "invalid-lot-id",
          lot.id,
          "lot id must be 1..128 ASCII letters, digits, '-', '_' , '.', or ':'",
        ),
      )
    }
    if lots.contains(lot.id) {
      findings.push(
        issue("error", "duplicate-lot", lot.id, "lot id is repeated"),
      )
    } else {
      lots[lot.id] = lot
    }
    if !valid_label(lot.product) {
      findings.push(
        issue(
          "error",
          "invalid-product",
          lot.id,
          "product must be a nonempty single-line label",
        ),
      )
    }
    if !valid_label(lot.facility) {
      findings.push(
        issue(
          "error",
          "invalid-facility",
          lot.id,
          "facility must be a nonempty single-line label",
        ),
      )
    }
    if !valid_quantity(lot.quantity_g) {
      findings.push(
        issue(
          "error",
          "invalid-lot-quantity",
          lot.id,
          "quantity_g must be 1..1e12 grams",
        ),
      )
    }
    if !valid_time(lot.produced_at) {
      findings.push(
        issue(
          "error",
          "invalid-produced-at",
          lot.id,
          "produced_at must be a valid nonnegative Unix second",
        ),
      )
    }
  }
  let transformations : Map[String, Bool] = Map([])
  let output_owner : Map[String, String] = Map([])
  let consumed : Map[String, Int64] = Map([])
  for transformation in ledger.transformations {
    if !valid_id(transformation.id) {
      findings.push(
        issue(
          "error",
          "invalid-transformation-id",
          transformation.id,
          "transformation id has an invalid shape",
        ),
      )
    }
    if transformations.contains(transformation.id) {
      findings.push(
        issue(
          "error",
          "duplicate-transformation",
          transformation.id,
          "transformation id is repeated",
        ),
      )
    } else {
      transformations[transformation.id] = true
    }
    if !valid_label(transformation.facility) {
      findings.push(
        issue(
          "error",
          "invalid-facility",
          transformation.id,
          "facility must be a nonempty single-line label",
        ),
      )
    }
    if !valid_time(transformation.occurred_at) {
      findings.push(
        issue(
          "error",
          "invalid-transformation-time",
          transformation.id,
          "occurred_at must be a valid Unix second",
        ),
      )
    }
    if transformation.inputs.is_empty() {
      findings.push(
        issue(
          "error",
          "empty-inputs",
          transformation.id,
          "transformation must consume at least one lot",
        ),
      )
    }
    if transformation.outputs.is_empty() {
      findings.push(
        issue(
          "error",
          "empty-outputs",
          transformation.id,
          "transformation must produce at least one lot",
        ),
      )
    }
    let input_ids : Map[String, Bool] = Map([])
    let output_ids : Map[String, Bool] = Map([])
    let mut input_g = 0L
    let mut output_g = 0L
    for input in transformation.inputs {
      if input_ids.contains(input.lot_id) {
        findings.push(
          issue(
            "error",
            "duplicate-input",
            transformation.id,
            "same input lot appears twice",
          ),
        )
      }
      input_ids[input.lot_id] = true
      if !valid_quantity(input.quantity_g) {
        findings.push(
          issue(
            "error",
            "invalid-input-quantity",
            transformation.id,
            "input quantity_g must be positive and bounded",
          ),
        )
      } else {
        input_g += input.quantity_g
        consumed[input.lot_id] = consumed.get(input.lot_id).unwrap_or(0L) +
          input.quantity_g
      }
      match lots.get(input.lot_id) {
        None =>
          findings.push(
            issue(
              "error",
              "unknown-input-lot",
              transformation.id,
              "input lot does not exist: \{input.lot_id}",
            ),
          )
        Some(lot) => {
          if lot.facility != transformation.facility {
            findings.push(
              issue(
                "error",
                "input-facility-mismatch",
                transformation.id,
                "input lot \{lot.id} belongs to another facility",
              ),
            )
          }
          if lot.produced_at >= transformation.occurred_at {
            findings.push(
              issue(
                "error",
                "input-not-yet-produced",
                transformation.id,
                "input lot \{lot.id} must exist before transformation",
              ),
            )
          }
        }
      }
    }
    for output in transformation.outputs {
      if output_ids.contains(output.lot_id) {
        findings.push(
          issue(
            "error",
            "duplicate-output",
            transformation.id,
            "same output lot appears twice",
          ),
        )
      }
      output_ids[output.lot_id] = true
      if input_ids.contains(output.lot_id) {
        findings.push(
          issue(
            "error",
            "self-output",
            transformation.id,
            "an input lot cannot also be an output lot",
          ),
        )
      }
      if output_owner.contains(output.lot_id) {
        findings.push(
          issue(
            "error",
            "multiple-producers",
            output.lot_id,
            "output lot has more than one producer",
          ),
        )
      } else {
        output_owner[output.lot_id] = transformation.id
      }
      if !valid_quantity(output.quantity_g) {
        findings.push(
          issue(
            "error",
            "invalid-output-quantity",
            transformation.id,
            "output quantity_g must be positive and bounded",
          ),
        )
      } else {
        output_g += output.quantity_g
      }
      match lots.get(output.lot_id) {
        None =>
          findings.push(
            issue(
              "error",
              "unknown-output-lot",
              transformation.id,
              "output lot does not exist: \{output.lot_id}",
            ),
          )
        Some(lot) => {
          if lot.facility != transformation.facility {
            findings.push(
              issue(
                "error",
                "output-facility-mismatch",
                transformation.id,
                "output lot \{lot.id} belongs to another facility",
              ),
            )
          }
          if lot.produced_at != transformation.occurred_at {
            findings.push(
              issue(
                "error",
                "output-time-mismatch",
                transformation.id,
                "output lot \{lot.id} must carry the transformation timestamp",
              ),
            )
          }
          if lot.quantity_g != output.quantity_g {
            findings.push(
              issue(
                "error",
                "output-quantity-mismatch",
                transformation.id,
                "output portion must equal declared lot quantity for \{lot.id}",
              ),
            )
          }
        }
      }
    }
    if input_g > 0L && output_g > input_g {
      findings.push(
        issue(
          "error",
          "mass-created",
          transformation.id,
          "output grams exceed input grams; include all material inputs",
        ),
      )
    }
  }
  let shipment_ids : Map[String, Bool] = Map([])
  let shipped : Map[String, Int64] = Map([])
  for shipment in ledger.shipments {
    if !valid_id(shipment.id) {
      findings.push(
        issue(
          "error",
          "invalid-shipment-id",
          shipment.id,
          "shipment id has an invalid shape",
        ),
      )
    }
    if shipment_ids.contains(shipment.id) {
      findings.push(
        issue(
          "error",
          "duplicate-shipment",
          shipment.id,
          "shipment id is repeated",
        ),
      )
    } else {
      shipment_ids[shipment.id] = true
    }
    if !valid_label(shipment.consignee) {
      findings.push(
        issue(
          "error",
          "invalid-consignee",
          shipment.id,
          "consignee must be a nonempty single-line label",
        ),
      )
    }
    if !valid_label(shipment.reference) {
      findings.push(
        issue(
          "error",
          "invalid-reference",
          shipment.id,
          "reference must be a nonempty single-line label",
        ),
      )
    }
    if !valid_time(shipment.shipped_at) {
      findings.push(
        issue(
          "error",
          "invalid-shipped-at",
          shipment.id,
          "shipped_at must be a valid Unix second",
        ),
      )
    }
    if !valid_quantity(shipment.quantity_g) {
      findings.push(
        issue(
          "error",
          "invalid-shipment-quantity",
          shipment.id,
          "shipment quantity_g must be positive and bounded",
        ),
      )
    } else {
      shipped[shipment.lot_id] = shipped.get(shipment.lot_id).unwrap_or(0L) +
        shipment.quantity_g
    }
    match lots.get(shipment.lot_id) {
      None =>
        findings.push(
          issue(
            "error",
            "unknown-shipment-lot",
            shipment.id,
            "shipped lot does not exist: \{shipment.lot_id}",
          ),
        )
      Some(lot) =>
        if shipment.shipped_at < lot.produced_at {
          findings.push(
            issue(
              "error",
              "shipment-before-production",
              shipment.id,
              "shipment predates lot production",
            ),
          )
        }
    }
  }
  for lot in ledger.lots {
    let used_g = consumed.get(lot.id).unwrap_or(0L) +
      shipped.get(lot.id).unwrap_or(0L)
    if used_g > lot.quantity_g {
      findings.push(
        issue(
          "error",
          "overallocated-lot",
          lot.id,
          "processing plus shipments exceed declared lot quantity",
        ),
      )
    }
  }
  report_from(findings)
}

///|
/// Request checks include existence and cutoff consistency. The ledger is
/// independently validated by `analyze` before this function is used.
pub fn validate_request(
  ledger : Ledger,
  request : RecallRequest,
) -> ValidationReport {
  let findings : Array[Diagnostic] = []
  if !valid_id(request.id) {
    findings.push(
      issue(
        "error",
        "invalid-recall-id",
        request.id,
        "recall id has an invalid shape",
      ),
    )
  }
  if !valid_label(request.reason) {
    findings.push(
      issue(
        "error",
        "invalid-reason",
        request.id,
        "reason must be a nonempty single-line label",
      ),
    )
  }
  if !valid_time(request.as_of) {
    findings.push(
      issue(
        "error",
        "invalid-cutoff",
        request.id,
        "as_of must be a valid Unix second",
      ),
    )
  }
  if request.seed_lots.is_empty() {
    findings.push(
      issue(
        "error",
        "empty-seeds",
        request.id,
        "at least one suspect lot is required",
      ),
    )
  }
  if request.seed_lots.length() > 128 {
    findings.push(
      issue(
        "error",
        "too-many-seeds",
        request.id,
        "a recall request is limited to 128 suspect lots",
      ),
    )
  }
  let lots : Map[String, Lot] = Map([])
  for lot in ledger.lots {
    lots[lot.id] = lot
  }
  let seen : Map[String, Bool] = Map([])
  for id in request.seed_lots {
    if seen.contains(id) {
      findings.push(
        issue("error", "duplicate-seed", id, "seed lot is repeated"),
      )
    }
    seen[id] = true
    match lots.get(id) {
      None =>
        findings.push(
          issue("error", "unknown-seed", id, "seed lot does not exist"),
        )
      Some(lot) =>
        if lot.produced_at > request.as_of {
          findings.push(
            issue(
              "error", "future-seed", id, "seed lot is newer than the cutoff",
            ),
          )
        }
    }
  }
  report_from(findings)
}