///|
/// Validate the supported JSON Schema Draft 2020-12 subset.
/// Compilation rejects unsupported features before any data is checked.

///|
/// One place where a schema and something else disagree.
///
/// `path` names where the trouble is and `message` says what it is, in a
/// sentence about the value at that path: a report is the two joined with a
/// colon, one error to a line. The message never names the path itself, so the
/// same error reads the same wherever it is found.
pub(all) struct SchemaError {
  path : String
  message : String
}

///|
/// Compile first, then return data violations. Only Ok([]) means a match.
pub fn validate_schema(
  json : @pjson.Json,
  schema : @pjson.Json,
) -> Result[Array[SchemaError], SchemaValidationFailure] {
  match compile_schema(schema) {
    Ok(compiled) =>
      match validate_compiled_schema(json, compiled) {
        Ok(errors) => Ok(errors)
        Err(error) => Err(EvaluationFailed(error))
      }
    Err(problems) => Err(SchemaProblems(problems))
  }
}

///|
/// Validate data against an opaque schema that has passed compilation.
pub fn validate_compiled_schema(
  json : @pjson.Json,
  compiled : CompiledSchema,
) -> Result[Array[SchemaError], SchemaEvaluationError] {
  evaluate_schema_with_limits(
    json, compiled, schema_evaluation_depth_limit, schema_evaluation_visit_limit,
  )
}

///|
/// A schema error as one line: where it is and what it is.
///
/// The root has the empty path, which would leave nothing in front of the
/// colon, so it is named instead: a report is read line by line and a line that
/// starts with a colon says nothing about where it is about.
pub fn render_schema_error(error : SchemaError) -> String {
  (if error.path == "" { "(root)" } else { error.path }) + ": " + error.message
}

///|
/// Every keyword in `schema` that this tool cannot read.
///
/// Read before a document is: what is being checked is that the schema is one
/// this tool knows how to apply, and a schema that is not is answered with the
/// place to fix rather than with a document that was never compared to it.
pub fn schema_problems(schema : @pjson.Json) -> Array[SchemaProblem] {
  precheck_schema(schema, Map([]))
}

///|
/// Check one value against one schema, adding every disagreement to `errors`.
///
/// A keyword says what it is about as much as what it wants: `required` is about
/// an object, `minLength` about a string, `items` about an array. A value of
/// another kind is left alone by it — there is no member that could be missing
/// from a number — and it is `type` that says a number is the wrong kind of value
/// to find here.
fn check_value(
  value : @pjson.Json,
  schema : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
  ctx : SchemaEvaluationContext,
) -> Unit {
  match schema {
    Object(members~) =>
      for entry in members {
        if ctx.failure != None {
          break
        }
        let (keyword, wanted) = entry
        match keyword {
          "$ref" => {
            let id = ctx.compiled.references[pointer_child(
                ctx.schema_pointer,
                "$ref",
              )]
            evaluate_schema_node(
              value,
              ctx.compiled.nodes[id].pointer,
              ctx.instance_pointer,
              path,
              errors,
              ctx,
            )
          }
          "type" => check_type(value, wanted, path, errors)
          "required" => check_required(value, wanted, path, errors)
          "propertyNames" => check_property_names(value, path, errors, ctx)
          "dependentRequired" =>
            check_dependent_required(value, wanted, path, errors)
          "dependentSchemas" =>
            check_dependent_schemas(value, wanted, path, errors, ctx)
          "properties" => check_properties(value, wanted, path, errors, ctx)
          "patternProperties" =>
            check_pattern_properties(value, wanted, path, errors, ctx)
          "minProperties" =>
            check_property_count(value, wanted, path, errors, true)
          "maxProperties" =>
            check_property_count(value, wanted, path, errors, false)
          "items" => check_items(value, wanted, members, path, errors, ctx)
          "prefixItems" => check_prefix_items(value, wanted, path, errors, ctx)
          "minItems" => check_array_length(value, wanted, path, errors, true)
          "maxItems" => check_array_length(value, wanted, path, errors, false)
          "uniqueItems" => check_unique_items(value, wanted, path, errors)
          "contains" => check_contains(value, members, path, errors, ctx)
          "if" => check_conditional(value, members, path, errors, ctx)
          "enum" => check_enum(value, wanted, path, errors)
          "const" => check_const(value, wanted, path, errors)
          "allOf" | "anyOf" | "oneOf" =>
            check_combination(value, wanted, keyword, path, errors, ctx)
          "not" =>
            match branch_errors(value, ["not"], path, ctx) {
              Ok(violations) =>
                if violations.is_empty() {
                  errors.push({
                    path,
                    message: "it matches the schema forbidden by not",
                  })
                }
              Err(_) => () // The shared context propagates this execution failure.
            }
          "additionalProperties" =>
            check_additional_properties(
              value, wanted, members, path, errors, ctx,
            )
          "minimum" =>
            check_bound(value, wanted, path, errors, InclusiveMinimum)
          "maximum" =>
            check_bound(value, wanted, path, errors, InclusiveMaximum)
          "exclusiveMinimum" =>
            check_bound(value, wanted, path, errors, ExclusiveMinimum)
          "exclusiveMaximum" =>
            check_bound(value, wanted, path, errors, ExclusiveMaximum)
          "multipleOf" => check_multiple_of(value, wanted, path, errors)
          "minLength" => check_length(value, wanted, path, errors, true)
          "maxLength" => check_length(value, wanted, path, errors, false)
          "pattern" => check_pattern(value, wanted, path, errors, ctx)
          _ => ()
        }
      }
    Bool(value=true) => ()
    Bool(value=false) =>
      errors.push({ path, message: "it is rejected by the false schema", })
    // Compilation accepts only objects and booleans.
    _ => ()
  }
}

///|
/// Branch errors belong to that branch until the applicator decides what to
/// retain. The path always refers to the instance being checked.
fn branch_errors(
  value : @pjson.Json,
  schema_tokens : Array[String],
  path : String,
  ctx : SchemaEvaluationContext,
) -> Result[Array[SchemaError], SchemaEvaluationError] {
  let errors = []
  evaluate_schema_child(value, schema_tokens, None, path, errors, ctx)
  match ctx.failure {
    Some(error) => Err(error)
    None => Ok(errors)
  }
}

///|
/// Conditions and selected branches use the current instance and shared budget.
/// Every branch was prechecked; only the selected sibling branch runs here.
fn check_conditional(
  value : @pjson.Json,
  schema_members : Array[(String, @pjson.Json)],
  path : String,
  errors : Array[SchemaError],
  ctx : SchemaEvaluationContext,
) -> Unit {
  if find_member(schema_members, "if") is None {
    return
  }
  let has_then = find_member(schema_members, "then") is Some(_)
  let has_else = find_member(schema_members, "else") is Some(_)
  // We do not collect annotations, so a lone condition has no runtime effect.
  if !has_then && !has_else {
    return
  }
  let condition_matches = match branch_errors(value, ["if"], path, ctx) {
    Ok(violations) => violations.is_empty()
    Err(_) => return // The shared context propagates the execution failure.
  }
  if condition_matches {
    if has_then {
      evaluate_schema_child(value, ["then"], None, path, errors, ctx)
    }
  } else if has_else {
    evaluate_schema_child(value, ["else"], None, path, errors, ctx)
  }
}

///|
/// Check synthetic name strings without treating their diagnostics as value errors.
fn check_property_names(
  value : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
  ctx : SchemaEvaluationContext,
) -> Unit {
  if value is Object(members~) {
    for (name, _) in members {
      if ctx.failure != None {
        break
      }
      let previous_kind = ctx.instance_kind
      ctx.instance_kind = PropertyName(name)
      let violations = []
      evaluate_schema_child(
        @pjson.Text(value=name),
        ["propertyNames"],
        None,
        path,
        violations,
        ctx,
      )
      ctx.instance_kind = previous_kind
      if ctx.failure != None {
        return
      }
      for violation in violations {
        errors.push({
          path,
          message: "property name \"" +
          escape_json_string(name) +
          "\": " +
          violation.message,
        })
      }
    }
  }
}

///|
/// Dependencies are triggered by presence, including null and other falsy values.
fn check_dependent_required(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
) -> Unit {
  match (value, wanted) {
    (Object(members~), Object(members=dependencies)) =>
      for (trigger, names) in dependencies {
        if find_member(members, trigger) is Some(_) {
          if names is Array(items~) {
            for name in items {
              if name is Text(value=needed) {
                if find_member(members, needed) is None {
                  errors.push({
                    path,
                    message: "member \"" +
                    escape_json_string(trigger) +
                    "\" requires member \"" +
                    escape_json_string(needed) +
                    "\"",
                  })
                }
              }
            }
          }
        }
      }
    _ => ()
  }
}

///|
/// Each triggered schema applies to the whole object with the shared context.
fn check_dependent_schemas(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
  ctx : SchemaEvaluationContext,
) -> Unit {
  match (value, wanted) {
    (Object(members~), Object(members=dependencies)) =>
      for (trigger, _) in dependencies {
        if ctx.failure != None {
          break
        }
        if find_member(members, trigger) is Some(_) {
          evaluate_schema_child(
            value,
            ["dependentSchemas", trigger],
            None,
            path,
            errors,
            ctx,
          )
        }
      }
    _ => ()
  }
}

///|
fn check_const(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
) -> Unit {
  if !same_value(value, wanted) {
    errors.push({
      path,
      message: "it is not the constant value the schema specifies",
    })
  }
}

///|
fn check_combination(
  value : @pjson.Json,
  wanted : @pjson.Json,
  keyword : String,
  path : String,
  errors : Array[SchemaError],
  ctx : SchemaEvaluationContext,
) -> Unit {
  let branches = match wanted {
    Array(items~) => items
    // Compilation rejects nonarrays and empty arrays.
    _ => return
  }
  let mut matches = 0
  for index, _schema in branches {
    let violations = match
      branch_errors(value, [keyword, index.to_string()], path, ctx) {
      Ok(violations) => violations
      Err(_) => return
    }
    if violations.is_empty() {
      matches = matches + 1
    } else if keyword == "allOf" {
      for violation in violations {
        errors.push({
          path: violation.path,
          message: "allOf[" + index.to_string() + "]: " + violation.message,
        })
      }
    }
  }
  if keyword == "anyOf" && matches == 0 {
    errors.push({ path, message: "it does not match any anyOf branch", })
  }
  if keyword == "oneOf" && matches != 1 {
    errors.push({
      path,
      message: "it matches " +
      matches.to_string() +
      " oneOf branches; exactly one is required",
    })
  }
}

///|
/// Names declared explicitly or matched by a pattern in this object are covered,
/// even when their values fail. Other branches and required names do not count.
fn check_additional_properties(
  value : @pjson.Json,
  wanted : @pjson.Json,
  schema_members : Array[(String, @pjson.Json)],
  path : String,
  errors : Array[SchemaError],
  ctx : SchemaEvaluationContext,
) -> Unit {
  let declared = match find_member(schema_members, "properties") {
    Some(Object(members~)) => members
    _ => []
  }
  let patterned = match find_member(schema_members, "patternProperties") {
    Some(Object(members~)) => members
    _ => []
  }
  match value {
    Object(members~) =>
      for (name, held) in members {
        if ctx.failure != None {
          break
        }
        if !declared.any(fn(entry) { entry.0 == name }) &&
          !patterned.any(fn(entry) {
            match_compiled_pattern(
              compiled_schema_pattern(ctx.compiled.patterns, entry.0),
              name,
            )
          }) {
          let here = child_path(path, name)
          match wanted {
            Bool(value=false) => {
              // Count the boolean child as a node visit while preserving the
              // established additional-property diagnostic.
              evaluate_schema_child(
                held,
                ["additionalProperties"],
                Some(name),
                here,
                [],
                ctx,
              )
              if ctx.failure == None {
                errors.push({
                  path: here,
                  message: "additional property is not allowed",
                })
              }
            }
            _ =>
              evaluate_schema_child(
                held,
                ["additionalProperties"],
                Some(name),
                here,
                errors,
                ctx,
              )
          }
        }
      }
    _ => ()
  }
}

///|
fn check_pattern_properties(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
  ctx : SchemaEvaluationContext,
) -> Unit {
  match (value, wanted) {
    (Object(members~), Object(members=rules)) =>
      for (text, _schema) in rules {
        if ctx.failure != None {
          break
        }
        let pattern = compiled_schema_pattern(ctx.compiled.patterns, text)
        for (name, held) in members {
          if ctx.failure != None {
            break
          }
          if match_compiled_pattern(pattern, name) {
            evaluate_schema_child(
              held,
              ["patternProperties", text],
              Some(name),
              child_path(path, name),
              errors,
              ctx,
            )
          }
        }
      }
    _ => ()
  }
}

///|
fn check_property_count(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
  least : Bool,
) -> Unit {
  let limit = match length_argument(wanted) {
    Ok(limit) => limit
    Err(_) => return
  }
  match value {
    Object(members~) => {
      let count = members.length()
      if (least && count < limit) || (!least && count > limit) {
        errors.push({
          path,
          message: "it has " +
          count.to_string() +
          (if count == 1 { " property" } else { " properties" }) +
          (if least {
            ", but the schema requires at least "
          } else {
            ", but the schema allows at most "
          }) +
          limit.to_string(),
        })
      }
    }
    _ => ()
  }
}

///|
/// Whether a value is of the type a name stands for.
fn type_matches(json : @pjson.Json, name : String) -> Bool {
  match name {
    "object" =>
      match json {
        Object(_) => true
        _ => false
      }
    "array" =>
      match json {
        Array(_) => true
        _ => false
      }
    "string" =>
      match json {
        Text(_) => true
        _ => false
      }
    "number" =>
      match json {
        Number(_) => true
        _ => false
      }
    // An integer is a number with nothing after the point rather than a number
    // that is not written as a fraction: `1.0` and `1e3` are both integers, and
    // reading them as doubles to find that out would answer for a numeral too
    // long for a double by rounding it first.
    "integer" =>
      match json {
        Number(raw~) => is_whole_number(raw)
        _ => false
      }
    "boolean" =>
      match json {
        Bool(_) => true
        _ => false
      }
    "null" =>
      match json {
        Null => true
        _ => false
      }
    // Unreachable: `schema_problems` refuses any other name.
    _ => false
  }
}

///|
/// Determine integrality from the normalized mathematical value, including
/// fractional mantissas shifted by positive or negative exponents.
fn is_whole_number(raw : String) -> Bool {
  schema_number(raw).is_integer()
}

///|
/// How a JSON type name reads in a sentence, article and all.
fn type_name(name : String) -> String {
  match name {
    "object" => "an object"
    "array" => "an array"
    "string" => "a string"
    "number" => "a number"
    "integer" => "an integer"
    "boolean" => "a boolean"
    "null" => "null"
    // Unreachable: `schema_problems` refuses any other name.
    other => other
  }
}

///|
/// The names a `type` keyword holds, whether it holds one or a list of them.
fn type_names(wanted : @pjson.Json) -> Array[String] {
  match wanted {
    Text(value~) => [value]
    Array(items~) =>
      items.filter_map(fn(item) {
        match item {
          Text(value~) => Some(value)
          // Unreachable: `schema_problems` refuses the rest.
          _ => None
        }
      })
    // A `type` that is neither is one this tool cannot read, and there is
    // nothing to expect of the value: `schema_problems` is where it is answered.
    _ => []
  }
}

///|
/// Join a list of names into a phrase: `a`, `a or b`, `a, b or c`.
fn join_or(names : Array[String]) -> String {
  match names.length() {
    0 => ""
    1 => names[0]
    _ => {
      let head = names[0:names.length() - 1].join(", ")
      head + " or " + names[names.length() - 1]
    }
  }
}

///|
/// `type`: the value has to be of one of the kinds the schema names.
fn check_type(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
) -> Unit {
  let names = type_names(wanted)
  if names.length() == 0 {
    return
  }
  let mut matched = false
  for name in names {
    if type_matches(value, name) {
      matched = true
    }
  }
  if !matched {
    errors.push({
      path,
      message: "it is " +
      kind_name(value) +
      " where the schema expects " +
      join_or(names.map(type_name)),
    })
  }
}

///|
/// `required`: every name has to be a member of the object.
///
/// The names are checked in the order the schema lists them, so a report reads
/// in that order rather than in the order the object happens to be missing them.
fn check_required(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
) -> Unit {
  let names = match wanted {
    Array(items~) =>
      items.filter_map(fn(item) {
        match item {
          Text(value~) => Some(value)
          // Unreachable: `schema_problems` refuses the rest.
          _ => None
        }
      })
    // Unreachable: `schema_problems` refuses a `required` that is not a list.
    _ => []
  }
  match value {
    Object(members~) =>
      for name in names {
        let mut found = false
        for entry in members {
          if entry.0 == name {
            found = true
          }
        }
        if !found {
          errors.push({
            path,
            message: "required member \"" + name + "\" is missing",
          })
        }
      }
    // A `required` says nothing about a value that has no members, and `type`
    // is the keyword that says the value should not be one.
    _ => ()
  }
}

///|
/// `properties`: every member the schema names is checked against the schema it
/// is given, and a member it does not name is left as it is.
///
/// Members the document does not have are not reported here: a schema that
/// requires them says so with `required`, and one that does not means what it
/// says. The walk follows the schema rather than the document, so a report reads
/// in the order the schema lists what it is about, which is the order its reader
/// reads.
fn check_properties(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
  ctx : SchemaEvaluationContext,
) -> Unit {
  let subschemas = match wanted {
    Object(members~) => members
    // Unreachable: `schema_problems` refuses a `properties` that is not an
    // object.
    _ => []
  }
  let members = match value {
    Object(members~) => members
    // A `properties` says nothing about a value that has no members, and `type`
    // is the keyword that says the value should not be one.
    _ => []
  }
  for entry in subschemas {
    if ctx.failure != None {
      break
    }
    match find_member(members, entry.0) {
      Some(held) =>
        evaluate_schema_child(
          held,
          ["properties", entry.0],
          Some(entry.0),
          child_path(path, entry.0),
          errors,
          ctx,
        )
      None => ()
    }
  }
}

///|
/// Only the suffix after this schema object's prefixItems is checked by items.
/// Declarations in other objects and prefix validation outcomes do not affect it.
fn check_items(
  value : @pjson.Json,
  _wanted : @pjson.Json,
  schema_members : Array[(String, @pjson.Json)],
  path : String,
  errors : Array[SchemaError],
  ctx : SchemaEvaluationContext,
) -> Unit {
  let start = match find_member(schema_members, "prefixItems") {
    Some(Array(items~)) => items.length()
    _ => 0
  }
  match value {
    Array(items~) =>
      for index, item in items {
        if ctx.failure != None {
          break
        }
        if index >= start {
          evaluate_schema_child(
            item,
            ["items"],
            Some(index.to_string()),
            path + "[" + index.to_string() + "]",
            errors,
            ctx,
          )
        }
      }
    _ => ()
  }
}

///|
fn check_prefix_items(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
  ctx : SchemaEvaluationContext,
) -> Unit {
  match (value, wanted) {
    (Array(items~), Array(items=schemas)) =>
      for index in 0.. ()
  }
}

///|
/// Only contains triggers traversal. Bounds belong to this same schema object;
/// all positions count independently, including positions covered by prefixItems.
fn check_contains(
  value : @pjson.Json,
  schema_members : Array[(String, @pjson.Json)],
  path : String,
  errors : Array[SchemaError],
  ctx : SchemaEvaluationContext,
) -> Unit {
  let items = match value {
    Array(items~) => items
    _ => return
  }
  let minimum = match find_member(schema_members, "minContains") {
    None => 1
    Some(wanted) =>
      match length_argument(wanted) {
        Ok(n) => n
        Err(_) => abort("invalid minContains in compiled schema")
      }
  }
  let maximum = match find_member(schema_members, "maxContains") {
    None => None
    Some(wanted) =>
      match length_argument(wanted) {
        Ok(n) => Some(n)
        Err(_) => abort("invalid maxContains in compiled schema")
      }
  }
  let mut count = 0
  for index, item in items {
    let violations = []
    evaluate_schema_child(
      item,
      ["contains"],
      Some(index.to_string()),
      path + "[" + index.to_string() + "]",
      violations,
      ctx,
    )
    // An execution error never becomes a nonmatching item. Do not stop after
    // satisfying the minimum: later items may exhaust the shared budget/cycle.
    if ctx.failure != None {
      return
    }
    if violations.is_empty() {
      count = count + 1
    }
  }
  let prefix = "contains matched " +
    count.to_string() +
    (if count == 1 { " item" } else { " items" })
  if count < minimum {
    errors.push({
      path,
      message: prefix + "; expected at least " + minimum.to_string(),
    })
  }
  match maximum {
    Some(n) =>
      if count > n {
        errors.push({
          path,
          message: prefix + "; expected at most " + n.to_string(),
        })
      }
    None => ()
  }
}

///|
fn check_array_length(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
  least : Bool,
) -> Unit {
  let limit = match length_argument(wanted) {
    Ok(limit) => limit
    // Compilation rejects invalid or unsupported counts.
    Err(_) => return
  }
  match value {
    Array(items~) => {
      let count = items.length()
      if (least && count < limit) || (!least && count > limit) {
        errors.push({
          path,
          message: "it has " +
          count.to_string() +
          (if count == 1 { " item" } else { " items" }) +
          (if least {
            ", but the schema requires at least "
          } else {
            ", but the schema allows at most "
          }) +
          limit.to_string(),
        })
      }
    }
    _ => ()
  }
}

///|
/// Pairwise deep equality requires O(n^2) element comparisons in the worst
/// case. Report only the first equal pair without rewriting any input values.
fn check_unique_items(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
) -> Unit {
  match (value, wanted) {
    (Array(items~), Bool(value=true)) =>
      for first in 0.. ()
  }
}

///|
/// `enum`: the value has to be one of the values listed.
///
/// The message gives the number of them rather than the values: the list is in
/// the schema the reader has to hand, and a schema naming fifty values would
/// otherwise put all fifty on one line of the report.
fn check_enum(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
) -> Unit {
  let allowed = match wanted {
    Array(items~) => items
    // A keyword this tool cannot read is a fact about the schema, which
    // `schema_problems` answers, and not a document that does not match it: a
    // list of no values would have said every document is wrong.
    _ => return
  }
  let mut found = false
  for allowed_value in allowed {
    if same_value(value, allowed_value) {
      found = true
    }
  }
  if !found {
    errors.push({ path, message: "it is not " + listed(allowed.length()), })
  }
}

///|
/// How a number of listed values reads in a sentence: `the value the schema
/// lists`, `one of the 3 values the schema lists`.
///
/// A list with nothing in it lists no values at all, and a value it holds
/// nothing for is a value the schema cannot accept rather than one it does not
/// know.
fn listed(count : Int) -> String {
  match count {
    0 => "one of the values the schema lists"
    1 => "the value the schema lists"
    _ => "one of the " + count.to_string() + " values the schema lists"
  }
}

///|
priv enum SchemaBound {
  InclusiveMinimum
  InclusiveMaximum
  ExclusiveMinimum
  ExclusiveMaximum
}

///|
/// Bounds use exact decimal comparison, without rounding, overflow or underflow.
fn check_bound(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
  mode : SchemaBound,
) -> Unit {
  let bound = match wanted {
    Number(raw~) => Some(raw)
    // Unreachable: `schema_problems` refuses a bound that is not a number.
    _ => None
  }
  match (value, bound) {
    (Number(raw~), Some(bound)) => {
      let order = schema_number_compare(raw, bound)
      let (violated, prefix) = match mode {
        InclusiveMinimum => (order < 0, "it is less than the minimum ")
        InclusiveMaximum => (order > 0, "it is more than the maximum ")
        ExclusiveMinimum =>
          (order <= 0, "it is not greater than the exclusive minimum ")
        ExclusiveMaximum =>
          (order >= 0, "it is not less than the exclusive maximum ")
      }
      if violated {
        errors.push({ path, message: prefix + raw_of(wanted), })
      }
    }
    _ => ()
  }
}

///|
fn check_multiple_of(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
) -> Unit {
  match (value, wanted) {
    (Number(raw~), Number(raw=divisor)) =>
      if !schema_number_multiple_of(raw, divisor) {
        errors.push({ path, message: "it is not a multiple of " + divisor, })
      }
    _ => ()
  }
}

///|
/// `minLength` and `maxLength`: the string has to be long enough, or short
/// enough. The length is counted in characters, as the columns of a diagnostic
/// are.
fn check_length(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
  least : Bool,
) -> Unit {
  let length = match length_argument(wanted) {
    Ok(length) => length
    // Unreachable: `schema_problems` refuses a length that is not a count.
    Err(_) => return
  }
  match value {
    Text(value~) => {
      let count = count_chars(value)
      if least && count < length {
        errors.push({
          path,
          message: "it is " +
          characters(count) +
          " long, and the schema asks for at least " +
          length.to_string(),
        })
      }
      if !least && count > length {
        errors.push({
          path,
          message: "it is " +
          characters(count) +
          " long, and the schema asks for at most " +
          length.to_string(),
        })
      }
    }
    _ => ()
  }
}

///|
/// How a length reads in a sentence, singular and all: `1 character`, `2
/// characters`.
fn characters(count : Int) -> String {
  if count == 1 {
    "1 character"
  } else {
    count.to_string() + " characters"
  }
}

///|
/// `pattern`: the string has to hold a match of the expression.
///
/// The expression is matched by `pattern.mbt`, whose subset of regular
/// expressions is the one schemas are written with; a pattern it cannot read has
/// already been reported by `schema_problems`, and a document is not the place
/// to say so again.
fn check_pattern(
  value : @pjson.Json,
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaError],
  ctx : SchemaEvaluationContext,
) -> Unit {
  let pattern = match text_of(wanted) {
    Some(pattern) => pattern
    // Unreachable: `schema_problems` refuses a `pattern` that is not a string.
    None => return
  }
  match text_of(value) {
    Some(text) =>
      if !match_compiled_pattern(
          compiled_schema_pattern(ctx.compiled.patterns, pattern),
          text,
        ) {
        errors.push({
          path,
          message: "it does not match the pattern \"" + pattern + "\"",
        })
      }
    _ => ()
  }
}

///|
/// Every keyword in the schema that this tool cannot read, with the path of the
/// keyword it is under.
///
/// Check one queued node and enqueue its supported subschemas, definitions and
/// resolved reference targets. The shared node table prevents cyclic compilation.
/// Only supported assertions and explicit annotations are accepted.
/// Values inside const, enum, default and examples stay data unless explicitly referenced.
fn check_schema(
  schema : @pjson.Json,
  path : String,
  errors : Array[SchemaProblem],
  cache : Map[String, Result[Pattern, PatternProblem]],
  ctx : SchemaCompileContext,
) -> Unit {
  match schema {
    Object(members~) =>
      for entry in members {
        let (keyword, wanted) = entry
        let here = child_path(path, keyword)
        match keyword {
          "type" => check_type_argument(wanted, here, errors)
          "required" => check_name_list(wanted, here, errors)
          "dependentRequired" =>
            match wanted {
              Object(members=dependencies) =>
                for (name, names) in dependencies {
                  check_name_list(names, child_path(here, name), errors)
                }
              _ =>
                errors.push({
                  path: here,
                  kind: InvalidSchema,
                  message: "expected an object mapping member names to lists of member names",
                })
            }
          "const" => ()
          "$ref" =>
            match wanted {
              Text(value=raw) =>
                match resolve_schema_ref(ctx.root, raw) {
                  Ok((pointer, target)) => {
                    let id = enqueue_schema(ctx, target, pointer, pointer)
                    ctx.references[pointer_child(ctx.pointer, "$ref")] = id
                  }
                  Err((kind, message)) =>
                    errors.push({ path: here, kind, message, })
                }
              _ =>
                errors.push({
                  path: here,
                  kind: InvalidSchema,
                  message: "expected a reference string",
                })
            }
          "allOf" | "anyOf" | "oneOf" | "prefixItems" =>
            match wanted {
              Array(items~) => {
                if items.is_empty() {
                  errors.push({
                    path: here,
                    message: "expected a nonempty array of schemas",
                    kind: InvalidSchema,
                  })
                }
                for index, subschema in items {
                  enqueue_schema_child(
                    ctx,
                    subschema,
                    [keyword, index.to_string()],
                    here + "[" + index.to_string() + "]",
                  )
                }
              }
              _ =>
                errors.push({
                  path: here,
                  message: "expected a nonempty array of schemas, but this is " +
                  kind_name(wanted),
                  kind: InvalidSchema,
                })
            }
          "not"
          | "propertyNames"
          | "additionalProperties"
          | "contains"
          | "if"
          | "then"
          | "else" => enqueue_schema_child(ctx, wanted, [keyword], here)
          "patternProperties" =>
            match wanted {
              Object(members~) =>
                for (text, subschema) in members {
                  let location = here + "[\"" + escape_json_string(text) + "\"]"
                  cache_schema_pattern(text, location, errors, cache)
                  enqueue_schema_child(
                    ctx,
                    subschema,
                    [keyword, text],
                    location,
                  )
                }
              _ =>
                errors.push({
                  path: here,
                  kind: InvalidSchema,
                  message: "expected an object mapping patterns to schemas, but this is " +
                  kind_name(wanted),
                })
            }
          "properties" | "$defs" | "dependentSchemas" =>
            match wanted {
              Object(members=subschemas) =>
                for entry in subschemas {
                  enqueue_schema_child(
                    ctx,
                    entry.1,
                    [keyword, entry.0],
                    child_path(here, entry.0),
                  )
                }
              _ =>
                errors.push({
                  kind: InvalidSchema,
                  path: here,
                  message: "expected an object naming the members it is about, but this is " +
                  kind_name(wanted),
                })
            }
          "items" =>
            match wanted {
              Object(_) | Bool(_) =>
                enqueue_schema_child(ctx, wanted, [keyword], here)
              _ =>
                errors.push({
                  kind: InvalidSchema,
                  path: here,
                  message: "expected one schema for the items, but this is " +
                  kind_name(wanted) +
                  "; a list of schemas is not read here",
                })
            }
          "enum" =>
            match wanted {
              Array(_) => ()
              _ =>
                errors.push({
                  kind: InvalidSchema,
                  path: here,
                  message: "expected a list of values, but this is " +
                  kind_name(wanted),
                })
            }
          "minimum" | "maximum" | "exclusiveMinimum" | "exclusiveMaximum" =>
            match wanted {
              Number(_) => ()
              _ =>
                errors.push({
                  kind: InvalidSchema,
                  path: here,
                  message: "expected a number, but this is " + kind_name(wanted),
                })
            }
          "multipleOf" =>
            match wanted {
              Number(raw~) =>
                if schema_number_compare(raw, "0") <= 0 {
                  errors.push({
                    path: here,
                    message: "expected a strictly positive number, but this is " +
                    raw,
                    kind: InvalidSchema,
                  })
                }
              _ =>
                errors.push({
                  path: here,
                  message: "expected a strictly positive number, but this is " +
                  kind_name(wanted),
                  kind: InvalidSchema,
                })
            }
          "minItems" | "maxItems" | "minContains" | "maxContains" =>
            match length_argument(wanted) {
              Ok(_) => ()
              Err(kind) =>
                errors.push({
                  path: here,
                  kind,
                  message: match wanted {
                    Number(raw~) =>
                      if kind == UnsupportedFeature {
                        raw +
                        " exceeds the supported item count limit of 1000000000"
                      } else {
                        raw +
                        " is not a count of items; a count must be a nonnegative integer"
                      }
                    _ =>
                      "expected a count of items, but this is " +
                      kind_name(wanted)
                  },
                })
            }
          "minProperties" | "maxProperties" =>
            match length_argument(wanted) {
              Ok(_) => ()
              Err(kind) =>
                errors.push({
                  path: here,
                  kind,
                  message: match wanted {
                    Number(raw~) =>
                      if kind == UnsupportedFeature {
                        raw +
                        " exceeds the supported property count limit of 1000000000"
                      } else {
                        raw +
                        " is not a count of properties; a count must be a nonnegative integer"
                      }
                    _ =>
                      "expected a count of properties, but this is " +
                      kind_name(wanted)
                  },
                })
            }
          "uniqueItems" =>
            match wanted {
              Bool(_) => ()
              _ =>
                errors.push({
                  path: here,
                  kind: InvalidSchema,
                  message: "expected a boolean, but this is " +
                  kind_name(wanted),
                })
            }
          "minLength" | "maxLength" =>
            match length_argument(wanted) {
              Ok(_) => ()
              Err(kind) =>
                errors.push({
                  kind,
                  path: here,
                  message: match wanted {
                    Number(raw~) =>
                      if kind == UnsupportedFeature {
                        raw +
                        " exceeds the supported character count limit of 1000000000"
                      } else {
                        raw +
                        " is not a count of characters; a count must be a nonnegative integer"
                      }
                    _ =>
                      "expected a count of characters, but this is " +
                      kind_name(wanted)
                  },
                })
            }
          "pattern" => check_pattern_argument(wanted, here, errors, cache)
          "title" | "description" | "$comment" | "format" =>
            match wanted {
              Text(_) => ()
              _ =>
                errors.push({
                  path: here,
                  message: "expected a string, but this is " + kind_name(wanted),
                  kind: InvalidSchema,
                })
            }
          "default" => ()
          "examples" =>
            match wanted {
              Array(_) => ()
              _ =>
                errors.push({
                  path: here,
                  message: "expected an array, but this is " + kind_name(wanted),
                  kind: InvalidSchema,
                })
            }
          "$schema" =>
            match wanted {
              Text(value~) =>
                if value != "https://json-schema.org/draft/2020-12/schema" {
                  errors.push({
                    path: here,
                    message: "unsupported schema version: " + value,
                    kind: UnsupportedVersion,
                  })
                }
              _ =>
                errors.push({
                  path: here,
                  message: "expected a schema version URI string, but this is " +
                  kind_name(wanted),
                  kind: InvalidSchema,
                })
            }
          _ =>
            errors.push({
              path: here,
              message: "keyword is not supported",
              kind: UnsupportedFeature,
            })
        }
      }
    Bool(_) => ()
    _ =>
      errors.push({
        kind: InvalidSchema,
        path,
        message: "a schema must be an object or a boolean, but this is " +
        kind_name(schema),
      })
  }
}

///|
/// Whether a `type` keyword holds the name of a type or a list of them.
fn check_type_argument(
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaProblem],
) -> Unit {
  match wanted {
    Text(value~) => check_type_name(value, path, errors)
    Array(items~) => {
      if items.is_empty() {
        errors.push({
          path,
          message: "expected at least one JSON type name",
          kind: InvalidSchema,
        })
      }
      for index, item in items {
        match item {
          Text(value~) => {
            check_type_name(value, path + "[" + index.to_string() + "]", errors)
            if items[:index].any(fn(previous) {
                text_of(previous) == Some(value)
              }) {
              errors.push({
                path: path + "[" + index.to_string() + "]",
                message: "duplicate JSON type name: " + value,
                kind: InvalidSchema,
              })
            }
          }
          _ =>
            errors.push({
              kind: InvalidSchema,
              path: path + "[" + index.to_string() + "]",
              message: "expected a JSON type name, but this is " +
              kind_name(item),
            })
        }
      }
    }
    _ =>
      errors.push({
        kind: InvalidSchema,
        path,
        message: "expected a JSON type name or a list of them, but this is " +
        kind_name(wanted),
      })
  }
}

///|
/// Whether a name is one of the seven JSON types.
fn check_type_name(
  name : String,
  path : String,
  errors : Array[SchemaProblem],
) -> Unit {
  match name {
    "object" | "array" | "string" | "number" | "integer" | "boolean" | "null" =>
      ()
    _ =>
      errors.push({
        kind: InvalidSchema,
        path,
        message: "\"" +
        name +
        "\" is not a JSON type; the types are object, array, string, number, integer, boolean and null",
      })
  }
}

///|
/// Whether a `required` keyword holds a list of member names.
fn check_name_list(
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaProblem],
) -> Unit {
  match wanted {
    Array(items~) =>
      for index, item in items {
        match item {
          Text(value~) =>
            if items[:index].any(fn(previous) {
                text_of(previous) == Some(value)
              }) {
              errors.push({
                path: path + "[" + index.to_string() + "]",
                message: "duplicate required member name: " + value,
                kind: InvalidSchema,
              })
            }
          _ =>
            errors.push({
              kind: InvalidSchema,
              path: path + "[" + index.to_string() + "]",
              message: "expected a member name, but this is " + kind_name(item),
            })
        }
      }
    _ =>
      errors.push({
        kind: InvalidSchema,
        path,
        message: "expected a list of member names, but this is " +
        kind_name(wanted),
      })
  }
}

///|
/// Exact nonnegative integer count for strings and arrays, distinguishing
/// invalid parameters from
/// mathematically valid counts exceeding the current implementation limit.
fn length_argument(wanted : @pjson.Json) -> Result[Int, SchemaProblemKind] {
  match wanted {
    Number(raw~) => schema_number_count(raw)
    _ => Err(InvalidSchema)
  }
}

///|
/// Whether a `pattern` keyword holds an expression this tool can read,
/// answering with the reason it cannot if it does not.
fn check_pattern_argument(
  wanted : @pjson.Json,
  path : String,
  errors : Array[SchemaProblem],
  cache : Map[String, Result[Pattern, PatternProblem]],
) -> Unit {
  match text_of(wanted) {
    Some(pattern) => cache_schema_pattern(pattern, path, errors, cache)
    _ =>
      errors.push({
        kind: InvalidSchema,
        path,
        message: "expected a pattern, but this is " + kind_name(wanted),
      })
  }
}

///|
/// Whether two values are the same value, as `enum` means it.
///
/// Numbers are compared as the numbers they denote rather than as the text they
/// were written with, so 1, 1.0 and 1e0 are equal. Comparisons are exact even
/// beyond floating-point range. Two objects are equal when they hold the same
/// members, whatever order they were written in, and two arrays when they hold
/// the same values in the same order. Values of different kinds are never the
/// same one.
fn same_value(one : @pjson.Json, other : @pjson.Json) -> Bool {
  match (one, other) {
    (Null, Null) => true
    (Bool(value=first), Bool(value=second)) => first == second
    (Number(raw=first), Number(raw=second)) =>
      schema_number_equal(first, second)
    (Text(value=first), Text(value=second)) => first == second
    (Array(items=first), Array(items=second)) => {
      let mut equal = first.length() == second.length()
      if equal {
        for index, item in first {
          if !same_value(item, second[index]) {
            equal = false
          }
        }
      }
      equal
    }
    (Object(members=first), Object(members=second)) => {
      let mut equal = first.length() == second.length()
      if equal {
        for entry in first {
          match find_member(second, entry.0) {
            Some(value) => if !same_value(entry.1, value) { equal = false }
            None => equal = false
          }
        }
      }
      equal
    }
    _ => false
  }
}

///|
/// The text of a value, if it is a string.
fn text_of(json : @pjson.Json) -> String? {
  match json {
    Text(value~) => Some(value)
    _ => None
  }
}

///|
/// The value of one member, if the object has it.
fn find_member(
  members : Array[(String, @pjson.Json)],
  key : String,
) -> @pjson.Json? {
  for entry in members {
    if entry.0 == key {
      return Some(entry.1)
    }
  }
  None
}

///|
/// The path of a member of the value at `path`.
///
/// The root of a document has the empty path, which is the path `--paths` gives
/// it: a member of the root is named by its key alone, and everything below it
/// is joined with dots.
fn child_path(path : String, key : String) -> String {
  if path == "" {
    key
  } else {
    path + "." + key
  }
}

///|
/// The text a number was written with.
fn raw_of(wanted : @pjson.Json) -> String {
  match wanted {
    Number(raw~) => raw
    // Unreachable: every caller has already read the number out of it.
    _ => ""
  }
}