///|
fn string_set(ctx : Context, value : Node, name : String) -> Array[String] {
  let result : Array[String] = []
  match field(value.raw, name) {
    None => ()
    Some(Array(items)) => {
      if items.is_empty() {
        ctx.diagnose(
          "invalid-schema",
          "error",
          value,
          name + " must not be empty.",
        )
      }
      for item in items {
        match item {
          String(s) =>
            if result.contains(s) {
              ctx.diagnose(
                "invalid-schema",
                "error",
                value,
                name + " contains duplicates.",
              )
            } else {
              result.push(s)
            }
          _ =>
            ctx.diagnose(
              "unsupported-schema",
              "unsupported",
              value,
              name + " must contain strings in the V1 profile.",
            )
        }
      }
    }
    Some(_) =>
      ctx.diagnose(
        "invalid-schema",
        "error",
        value,
        name + " must be an array.",
      )
  }
  result.sort()
  result
}

///|
fn validate_bool(ctx : Context, value : Node, key : String) -> Unit {
  match field(value.raw, key) {
    None | Some(True) | Some(False) => ()
    _ =>
      ctx.diagnose(
        "invalid-boolean",
        "error",
        value,
        key + " must be a boolean.",
      )
  }
}

///|
fn scan_schema(ctx : Context, raw : Node, depth : Int) -> Unit {
  guard ctx.visit(raw) else { return }
  if depth > 64 {
    ctx.diagnose(
      "schema-depth-limit", "unsupported", raw, "Schema traversal exceeds 64 levels.",
    )
    return
  }
  guard ctx.resolve(raw) is Some(value) else { return }
  guard value.raw is Object(_) else {
    ctx.diagnose("invalid-schema", "error", value, "Schema must be an object.")
    return
  }
  for key in keys(value.raw) {
    if ["allOf", "anyOf", "oneOf", "not", "discriminator", "items"].contains(
        key,
      ) {
      ctx.diagnose(
        "unsupported-schema",
        "unsupported",
        value,
        "Unsupported Schema keyword: " + key,
      )
    } else if !ignored_key(key) &&
      ![
        "type", "properties", "required", "enum", "default", "nullable", "readOnly",
        "writeOnly", "additionalProperties", "format", "minimum", "maximum", "exclusiveMinimum",
        "exclusiveMaximum", "multipleOf", "minLength", "maxLength", "pattern", "minProperties",
        "maxProperties", "xml",
      ].contains(key) {
      ctx.diagnose(
        "unsupported-schema",
        "unsupported",
        value,
        "Unknown Schema keyword: " + key,
      )
    }
  }
  let kind = string_value(field(value.raw, "type"))
  numeric_constraints(ctx, value)
  if ![
      Some("object"),
      Some("string"),
      Some("number"),
      Some("integer"),
      Some("boolean"),
    ].contains(kind) {
    ctx.diagnose(
      "unsupported-schema", "unsupported", value, "V1 requires an explicit scalar or object type after reference resolution.",
    )
  }
  for
    key in [
      "nullable", "readOnly", "writeOnly", "exclusiveMinimum", "exclusiveMaximum",
    ] {
    validate_bool(ctx, value, key)
  }
  if is_true(field(value.raw, "readOnly")) ||
    is_true(field(value.raw, "writeOnly")) {
    ctx.diagnose(
      "unsupported-schema", "unsupported", value, "readOnly/writeOnly request projection is outside V1.",
    )
  }
  if field(value.raw, "enum") is Some(_) {
    if kind != Some("string") {
      ctx.diagnose(
        "unsupported-enum", "unsupported", value, "V1 checks only string enums.",
      )
    }
    ignore(string_set(ctx, value, "enum"))
  }
  match field(value.raw, "additionalProperties") {
    None | Some(True) | Some(False) => ()
    Some(Object(_)) =>
      ctx.diagnose(
        "unsupported-schema", "unsupported", value, "Schema-valued additionalProperties is outside V1.",
      )
    _ =>
      ctx.diagnose(
        "invalid-schema", "error", value, "additionalProperties must be boolean or a Schema.",
      )
  }
  let required = string_set(ctx, value, "required")
  let props = field(value.raw, "properties").unwrap_or(Json::empty_object())
  if !(props is Object(_)) {
    ctx.diagnose(
      "invalid-schema", "error", value, "properties must be an object.",
    )
  }
  if kind != Some("object") &&
    (field(value.raw, "properties") is Some(_) || !required.is_empty()) {
    ctx.diagnose(
      "unsupported-schema", "unsupported", value, "Object constraints require type object in V1.",
    )
  }
  for name in required {
    if field(props, name) is None {
      ctx.diagnose(
        "unsupported-schema",
        "unsupported",
        value,
        "Required names without declared properties are outside V1: " + name,
      )
    }
  }
  let properties = value.child("properties", props)
  for name in keys(props) {
    if ctx.budget.exhausted {
      break
    }
    scan_schema(
      ctx,
      properties.child(name, field(props, name).unwrap()),
      depth + 1,
    )
  }
}

///|
fn properties(value : Node) -> Map[String, Node] {
  let result : Map[String, Node] = Map([])
  if field(value.raw, "properties") is Some(raw) {
    let parent = value.child("properties", raw)
    for name in keys(raw) {
      result[name] = parent.child(name, field(raw, name).unwrap())
    }
  }
  result
}