///|
fn evaluate(
  d : Differences,
  context : ComparisonContext,
  include_targets~ : Bool = true,
) -> Array[ValidationEvent] {
  let events = []
  for shape in d.added {
    if shape.id.member_name() == None ||
      d.old_model.shapes.contains(shape.id.root()) {
      let migrated_enum = d.old_model.shapes
        .get(shape.id.root())
        .map(s => has(s, "enum"))
        .unwrap_or(false) &&
        d.new_model.shapes
        .get(shape.id.root())
        .map(s => s.body is Enum(_))
        .unwrap_or(false)
      if !migrated_enum || shape.id.member_name() == None {
        emit(
          events,
          "AddedShape",
          "NOTE",
          shape,
          "Added " + kind(shape) + " " + tick(shape.id.to_string()),
        )
      }
    }
    if has(shape, "trait") {
      emit(
        events,
        "AddedTraitDefinition",
        "NOTE",
        shape,
        "Trait definition " + tick(shape.id.to_string()) + " was added",
      )
    }
  }
  for shape in d.removed {
    if !has(shape, "private") &&
      (
        shape.id.member_name() == None ||
        d.new_model.shapes.contains(shape.id.root())
      ) {
      let scalar = match shape.body {
        Simple(Document) => false
        Simple(String) => !has(shape, "enum")
        Simple(_) => true
        _ => false
      }
      emit(
        events,
        if scalar {
          "RemovedShape.ScalarShape"
        } else {
          "RemovedShape"
        },
        if scalar {
          "WARNING"
        } else {
          "ERROR"
        },
        shape,
        "Removed " + kind(shape) + " " + tick(shape.id.to_string()),
      )
    }
    if has(shape, "trait") {
      emit(
        events,
        "RemovedTraitDefinition",
        "ERROR",
        shape,
        "Trait definition " + tick(shape.id.to_string()) + " was removed",
      )
    }
  }
  for change in d.metadata {
    let (id, severity, message) = match (change.old_value, change.new_value) {
      (None, Some(value)) =>
        (
          "AddedMetadata",
          "NOTE",
          "Metadata key " +
          tick(change.key) +
          " was added with the following value: " +
          pretty(value),
        )
      (Some(value), None) =>
        (
          "RemovedMetadata." + change.key,
          "DANGER",
          "Metadata key " +
          tick(change.key) +
          " was removed with the following value: " +
          pretty(value),
        )
      (Some(a), Some(b)) =>
        (
          "ChangedMetadata",
          "WARNING",
          "Metadata key " +
          tick(change.key) +
          " was changed from " +
          pretty(a) +
          " to " +
          pretty(b),
        )
      _ => continue
    }
    events.push({
      id,
      severity,
      message,
      shape: None,
      source: None,
      suppression_reason: None,
    })
  }
  for change in d.changed {
    let a = change.old_shape
    let b = change.new_shape
    if kind(a) != kind(b) &&
      !(a.is_legacy_set() && b.body is List(_) && has(b, "uniqueItems")) &&
      !(has(a, "enum") && b.body is Enum(_)) {
      emit(
        events,
        "ChangedShapeType",
        "ERROR",
        b,
        "Shape " +
        tick(b.id.to_string()) +
        " type was changed from " +
        tick(kind(a)) +
        " to " +
        tick(kind(b)) +
        ".",
      )
    }
    match (a.body, b.body) {
      (
        Operation(input=old_input, output=old_output, errors=old_errors),
        Operation(input=new_input, output=new_output, errors=new_errors),
      ) => {
        for
          (label, old_id, new_id) in [
            ("Input", old_input, new_input),
            ("Output", old_output, new_output),
          ] {
          if old_id != new_id {
            emit(
              events,
              "ChangedOperation" +
              label +
              ".From." +
              old_id.to_string() +
              ".To." +
              new_id.to_string(),
              "ERROR",
              b,
              "Changed operation " +
              label.to_lower() +
              " of " +
              tick(b.id.to_string()) +
              " from " +
              tick(old_id.to_string()) +
              " to " +
              tick(new_id.to_string()),
            )
          }
        }
        error_changes(events, b, old_errors, new_errors, false)
      }
      (
        Service(errors=old_errors, rename=old_rename, ..),
        Service(errors=new_errors, rename=new_rename, ..),
      ) => {
        error_changes(events, b, old_errors, new_errors, true)
        let old_closure = closure(d.old_model, a.id)
        for id, name in old_rename {
          match new_rename.get(id) {
            None =>
              emit(
                events,
                "ServiceRename",
                "ERROR",
                b,
                "Service rename of " +
                tick(id.to_string()) +
                " to " +
                tick(name) +
                " was removed",
              )
            Some(new_name) =>
              if name != new_name {
                emit(
                  events,
                  "ServiceRename",
                  "ERROR",
                  b,
                  "Service rename of " +
                  tick(id.to_string()) +
                  " was changed from " +
                  tick(name) +
                  " to " +
                  tick(new_name),
                )
              }
          }
        }
        for id, name in new_rename {
          if !old_rename.contains(id) && old_closure.contains(id) {
            emit(
              events,
              "ServiceRename",
              "ERROR",
              b,
              "Service rename of " +
              tick(id.to_string()) +
              " to " +
              tick(name) +
              " was added to an old shape",
            )
          }
        }
      }
      (Resource(identifiers=old_ids, ..), Resource(identifiers=new_ids, ..)) =>
        if old_ids != new_ids {
          emit(
            events,
            "ChangedResourceIdentifiers",
            "ERROR",
            b,
            "Identifiers of resource " +
            tick(b.id.to_string()) +
            " changed from " +
            identifier_map(old_ids) +
            " to " +
            identifier_map(new_ids) +
            ".",
          )
        }
      (Structure(old_ids), Structure(new_ids)) => {
        for id in new_ids {
          if !old_ids.contains(id) &&
            d.new_model.shapes.get(id) is Some(member_shape) &&
            has(member_shape, "required") &&
            !has(member_shape, "default") &&
            !has(member_shape, "clientOptional") {
            emit(
              events, "AddedRequiredMember", "ERROR", member_shape, "Adding a new member with the `required` trait but not the `default` trait or the `clientOptional` trait is backwards-incompatible.",
            )
          }
        }
        member_order(events, b, old_ids, new_ids)
      }
      (Union(old_ids), Union(new_ids)) =>
        member_order(events, b, old_ids, new_ids)
      _ => ()
    }
    entity_bindings(d, change, events)
    constraint_changes(change, events)
    default_changes(d, change, events)
    enum_changes(d, change, events)
    trait_changes(d, change, events)
  }
  nullability_changes(d, events)
  if include_targets {
    member_target_changes(d, events, context)
  }
  events
}

///|
fn member_order(
  events : Array[ValidationEvent],
  shape : @smithy.Shape,
  old_ids : Array[@smithy.ShapeId],
  new_ids : Array[@smithy.ShapeId],
) -> Unit {
  let mut reordered = false
  for i = 0; i < old_ids.length() && i < new_ids.length(); i = i + 1 {
    reordered = reordered ||
      old_ids[i].member_name() != new_ids[i].member_name()
  }
  if reordered {
    emit(
      events,
      "ChangedMemberOrder",
      "DANGER",
      shape,
      kind(shape) +
      " shape members were reordered. This can cause ABI compatibility issues in languages like C and C++ where the layout and alignment of a data structure matters.",
    )
  }
}

///|
fn error_changes(
  events : Array[ValidationEvent],
  shape : @smithy.Shape,
  old_ids : Array[@smithy.ShapeId],
  new_ids : Array[@smithy.ShapeId],
  service : Bool,
) -> Unit {
  let category = if service { "Service" } else { "Operation" }
  let noun = category.to_lower()
  for id in old_ids {
    if !new_ids.contains(id) {
      emit(
        events,
        "Removed" +
        category +
        "Error" +
        (if service { "" } else { "." + id.name() }),
        "WARNING",
        shape,
        "The " +
        tick(id.to_string()) +
        " error was removed from the " +
        tick(shape.id.to_string()) +
        " " +
        noun +
        "." +
        (if service {
          " This means that it is no longer considered an error common to all operations within the service."
        } else {
          ""
        }),
      )
    }
  }
  for id in new_ids {
    if !old_ids.contains(id) {
      emit(
        events,
        "Added" +
        category +
        "Error" +
        (if service { "" } else { "." + id.name() }),
        "WARNING",
        shape,
        "The " +
        tick(id.to_string()) +
        " error was added to the " +
        tick(shape.id.to_string()) +
        " " +
        noun +
        (if service {
          ", making this error common to all operations within the service."
        } else {
          "."
        }) +
        " This is backward-compatible if the error is only encountered as a result of a change in behavior of the client (for example, the client sends a new parameter to an operation).",
      )
    }
  }
}

///|
fn identifier_map(ids : Map[String, @smithy.ShapeId]) -> String {
  "{" +
  ids.to_array().map(pair => pair.0 + "=" + pair.1.to_string()).join(", ") +
  "}"
}

///|
fn constraint_changes(
  change : ShapeChange,
  events : Array[ValidationEvent],
) -> Unit {
  for
    (name, event_id) in [
      ("length", "ChangedLengthTrait"),
      ("range", "ChangedRangeTrait"),
    ] {
    let a = change.old_shape.trait_value("smithy.api#" + name)
    let b = change.new_shape.trait_value("smithy.api#" + name)
    // The pinned Java evaluator uses getChangedTrait, which requires both
    // traits. Newly applied constraints are handled by the generic evaluator.
    if a == None || b == None || a == b {
      continue
    }
    let zero = @smithy.NodeValue::Number(
      @smithy.NodeNumber::parse("0").unwrap(),
    )
    // AbstractLengthAndRangeValidator defaults missing minima to zero for
    // both traits, including range. Preserve the reference event contract.
    let old_min = field(a, "min").unwrap_or(zero)
    let new_min = field(b, "min").unwrap_or(zero)
    if old_min is Number(old_number) &&
      new_min is Number(new_number) &&
      new_number.numeric_compare(old_number) > 0 {
      emit(
        events,
        event_id,
        "ERROR",
        change.new_shape,
        "smithy.api#" +
        name +
        " trait value `min` was made more restrictive by raising from " +
        json(old_min) +
        " to " +
        json(new_min),
      )
    }
    if field(b, "max") is Some(Number(new_number)) {
      let old_max = field(a, "max")
      let restricted = match old_max {
        Some(Number(old_number)) => old_number.numeric_compare(new_number) > 0
        None => true
        _ => false
      }
      if restricted {
        emit(
          events,
          event_id,
          "ERROR",
          change.new_shape,
          "smithy.api#" +
          name +
          " trait value `max` was made more restrictive by lowering from " +
          old_max.map(json).unwrap_or("null") +
          " to " +
          new_number.to_json().stringify(),
        )
      }
    }
  }
}

///|
fn pretty(value : @smithy.NodeValue) -> String {
  value.to_json().stringify(indent=4)
}