// Copyright (c) 2026 colmugx
// SPDX-License-Identifier: Apache-2.0

// Explicit 2020-12 output collection (core chapter 12). Validation itself
// stays on the allocation-free `Schema::valid` fast path; callers opt into
// output units only when they need diagnostics or annotations.

///|
/// The four 2020-12 output formats.
///
/// * `Flag` reports only the boolean result and reuses the ordinary
///   short-circuiting evaluator.
/// * `Basic` flattens every failed keyword and every collected annotation.
/// * `Detailed` keeps a hierarchical failure tree but retains only the first
///   failed child at each level.
/// * `Verbose` keeps every collected failure and annotation.
pub(all) enum OutputFormat {
  Flag
  Basic
  Detailed
  Verbose
}

///|
/// One 2020-12 output unit.
///
/// `keyword_location` is a JSON Pointer into the source document that owns
/// the keyword; `absolute_keyword_location` re-bases it onto the containing
/// schema resource's canonical URI (using its `$id`, or the document URI when
/// the resource is anonymous). `instance_location` is a JSON Pointer into the
/// validated instance. `annotation` is present only on annotation units and
/// `error` only on assertion failures; a unit never carries both.
pub(all) struct OutputUnit {
  valid : Bool
  keyword_location : String
  absolute_keyword_location : String?
  instance_location : String
  error : String?
  annotation : Json?
  errors : Array[OutputUnit]
  annotations : Array[OutputUnit]
}

///|
/// Serialize a unit to the standard 2020-12 output object. Empty `errors` /
/// `annotations` arrays and absent optional members are omitted.
pub fn OutputUnit::to_json(self : OutputUnit) -> Json {
  let members : Map[String, Json] = Map([])
  members.set("valid", Json::boolean(self.valid))
  members.set("keywordLocation", Json::string(self.keyword_location))
  members.set("instanceLocation", Json::string(self.instance_location))
  if self.absolute_keyword_location is Some(location) {
    members.set("absoluteKeywordLocation", Json::string(location))
  }
  if self.error is Some(message) {
    members.set("error", Json::string(message))
  }
  if self.annotation is Some(value) {
    members.set("annotation", value)
  }
  if !self.errors.is_empty() {
    members.set("errors", Json::array(self.errors.map(unit => unit.to_json())))
  }
  if !self.annotations.is_empty() {
    members.set(
      "annotations",
      Json::array(self.annotations.map(unit => unit.to_json())),
    )
  }
  Json::object(members)
}

///|
pub impl Show for OutputUnit with fn output(self, logger) {
  logger.write_string(self.to_json().stringify())
}

///|
pub extend OutputUnit with Show::{to_string, output}

///|
/// Collect the requested output format for one instance. Numeric
/// representations and the traversal budget are checked before collection, so
/// an invalid numeric node or exhausted depth raises instead of producing an
/// ordinary `valid: false` unit.
pub fn Schema::output(
  self : Schema,
  value : Json,
  format~ : OutputFormat = Basic,
  max_depth~ : Int32 = 128,
) -> OutputUnit raise {
  check_instance(value, 0, max_depth)
  match format {
    Flag => {
      // The flag format must not pay for annotation or diagnostic collection.
      let valid = evaluate(
        self.plan,
        self.plan.nodes[0],
        value,
        "",
        None,
        0,
        max_depth,
      )
      if valid {
        output_unit(self.plan, 0, "", "", true)
      } else {
        output_unit(
          self.plan,
          0,
          "",
          "",
          false,
          error="schema does not validate the instance",
        )
      }
    }
    Basic => {
      let tree = collect_tree(self.plan, value, max_depth)
      basic_output(tree)
    }
    Detailed => {
      let tree = collect_tree(self.plan, value, max_depth)
      detailed_output(tree)
    }
    Verbose => collect_tree(self.plan, value, max_depth)
  }
}

///|
fn collect_tree(
  program : Program,
  value : Json,
  limit : Int32,
) -> OutputUnit raise {
  collect_node(program, 0, "", program.nodes[0], value, "", 0, limit, [], None)
}

///|
// Re-base a document-relative keyword pointer onto the resource identity.
// The resource root itself (relative pointer "") is the bare URI; deeper
// keywords use the URI fragment form `uri#/pointer`.
fn absolute_keyword_location(
  program : Program,
  resource : Int32,
  location : String,
) -> String? {
  let meta = program.resources[resource]
  let base = match meta.uri {
    Some(uri) => Some(uri)
    None => meta.document_uri
  }
  match base {
    Some(base) => {
      let relative = if location.has_prefix(meta.root_pointer) {
        location[meta.root_pointer.length():].to_owned()
      } else {
        location
      }
      Some(if relative == "" { base } else { base + "#" + relative })
    }
    None => None
  }
}

///|
fn output_unit(
  program : Program,
  resource : Int32,
  keyword_location : String,
  instance_location : String,
  valid : Bool,
  error? : String,
  annotation? : Json,
  errors~ : Array[OutputUnit] = [],
  annotations~ : Array[OutputUnit] = [],
) -> OutputUnit {
  {
    valid,
    keyword_location,
    absolute_keyword_location: absolute_keyword_location(
      program, resource, keyword_location,
    ),
    instance_location,
    error,
    annotation,
    errors,
    annotations,
  }
}

///|
// Result of collecting one non-annotation keyword. `errors` is nonempty only
// when the keyword failed; `annotations` only when it succeeded.
priv struct RuleOutput {
  valid : Bool
  errors : Array[OutputUnit]
  annotations : Array[OutputUnit]
}

///|
fn rule_output(
  valid : Bool,
  errors~ : Array[OutputUnit] = [],
  annotations~ : Array[OutputUnit] = [],
) -> RuleOutput {
  { valid, errors, annotations, }
}

///|
fn append_units(into : Array[OutputUnit], source : Array[OutputUnit]) -> Unit {
  for unit in source {
    into.push(unit)
  }
}

///|
// Collect one schema node. `Located` carries the owning resource and the
// node's canonical pointer, so keyword units get correct absolute locations
// even after a reference crosses into another document.
fn collect_node(
  program : Program,
  resource : Int32,
  node_path : String,
  plan : Plan,
  value : Json,
  instance_path : String,
  depth : Int32,
  limit : Int32,
  scope : Array[Int32],
  evaluated : EvaluationMarks?,
) -> OutputUnit raise {
  if depth > limit {
    raise EvaluationError::DepthLimit(limit)
  }
  match plan {
    Pending => abort("uncompiled schema arena slot")
    Located(next_resource, next_path, inner) => {
      let next_scope = if !program.track_dynamic_scope ||
        scope.last() == Some(next_resource) {
        scope
      } else {
        let next = scope.copy()
        next.push(next_resource)
        next
      }
      collect_node(
        program, next_resource, next_path, inner, value, instance_path, depth, limit,
        next_scope, evaluated,
      )
    }
    Accept => output_unit(program, resource, node_path, instance_path, true)
    Reject(location) =>
      output_unit(
        program,
        resource,
        location,
        instance_path,
        false,
        error="boolean schema rejects every instance",
      )
    Rules(rules) => {
      let marks = fresh_marks(
        program.collect_evaluated && value is (Object(_) | Array(_)),
      )
      let errors : Array[OutputUnit] = []
      let annotations : Array[OutputUnit] = []
      let metadata : Array[OutputUnit] = []
      let mut valid = true
      // Annotations are recorded separately: they are emitted only when every
      // keyword in this schema object succeeded.
      for rule in rules {
        match rule {
          Annotations(items) =>
            for item in items {
              metadata.push(
                output_unit(
                  program,
                  resource,
                  item.location,
                  instance_path,
                  true,
                  annotation=item.value,
                ),
              )
            }
          UnevaluatedProperties(_, _) | UnevaluatedItems(_, _) => ()
          _ => {
            let result = collect_rule(
              program, resource, rule, value, instance_path, depth, limit, scope,
              marks,
            )
            if result.valid {
              append_units(annotations, result.annotations)
            } else {
              valid = false
              append_units(errors, result.errors)
            }
          }
        }
      }
      // Unevaluated keywords observe every adjacent applicator, so they run
      // after the first pass exactly as in evaluation.
      if program.collect_evaluated {
        for rule in rules {
          match rule {
            UnevaluatedProperties(_, _) | UnevaluatedItems(_, _) => {
              let result = collect_rule(
                program, resource, rule, value, instance_path, depth, limit, scope,
                marks,
              )
              if result.valid {
                append_units(annotations, result.annotations)
              } else {
                valid = false
                append_units(errors, result.errors)
              }
            }
            _ => ()
          }
        }
      }
      if valid {
        merge_marks(evaluated, marks)
        append_units(metadata, annotations)
        output_unit(
          program,
          resource,
          node_path,
          instance_path,
          true,
          annotations=metadata,
        )
      } else {
        output_unit(program, resource, node_path, instance_path, false, errors~)
      }
    }
  }
}

///|
// Evaluate one keyword and return the units it contributes. Assertion
// keywords produce a single error unit on failure. Applicators nest the
// failing child schema units under their keyword unit.
fn collect_rule(
  program : Program,
  resource : Int32,
  rule : Rule,
  value : Json,
  instance_path : String,
  depth : Int32,
  limit : Int32,
  scope : Array[Int32],
  evaluated : EvaluationMarks?,
) -> RuleOutput raise {
  match rule {
    Annotations(_) => rule_output(true)
    Types(types, location) => {
      let matches = types.any(kind => matches_type(kind, value, instance_path))
      if matches {
        rule_output(true)
      } else {
        rule_output(false, errors=[
          output_unit(
            program,
            resource,
            location,
            instance_path,
            false,
            error="instance has none of the permitted types",
          ),
        ])
      }
    }
    Constant(expected, location) =>
      if schema_equal(value, expected) {
        rule_output(true)
      } else {
        rule_output(false, errors=[
          output_unit(
            program,
            resource,
            location,
            instance_path,
            false,
            error="instance differs from the constant",
          ),
        ])
      }
    Enumeration(values, location) =>
      if values.any(expected => schema_equal(value, expected)) {
        rule_output(true)
      } else {
        rule_output(false, errors=[
          output_unit(
            program,
            resource,
            location,
            instance_path,
            false,
            error="instance is not an enumerated value",
          ),
        ])
      }
    Lower(bound, exclusive, location) => {
      guard value is Number(_, ..) else { return rule_output(true) }
      let ordering = numeric_value(value, instance_path).compare(bound)
      let valid = if exclusive { ordering > 0 } else { ordering >= 0 }
      if valid {
        rule_output(true)
      } else {
        rule_output(false, errors=[
          output_unit(
            program,
            resource,
            location,
            instance_path,
            false,
            error="number is below its lower bound",
          ),
        ])
      }
    }
    Upper(bound, exclusive, location) => {
      guard value is Number(_, ..) else { return rule_output(true) }
      let ordering = numeric_value(value, instance_path).compare(bound)
      let valid = if exclusive { ordering < 0 } else { ordering <= 0 }
      if valid {
        rule_output(true)
      } else {
        rule_output(false, errors=[
          output_unit(
            program,
            resource,
            location,
            instance_path,
            false,
            error="number exceeds its upper bound",
          ),
        ])
      }
    }
    Multiple(divisor, location) => {
      guard value is Number(_, ..) else { return rule_output(true) }
      if numeric_value(value, instance_path).multiple_of(divisor) {
        rule_output(true)
      } else {
        rule_output(false, errors=[
          output_unit(
            program,
            resource,
            location,
            instance_path,
            false,
            error="number is not an exact multiple",
          ),
        ])
      }
    }
    StringLength(minimum, maximum, location) => {
      guard value is String(text) else { return rule_output(true) }
      let length = text.char_length()
      let errors : Array[OutputUnit] = []
      if minimum is Some(bound) && bound.above(length) {
        errors.push(
          output_unit(
            program,
            resource,
            pointer_step(location, "minLength"),
            instance_path,
            false,
            error="string has too few Unicode code points",
          ),
        )
      }
      if maximum is Some(bound) && bound.below(length) {
        errors.push(
          output_unit(
            program,
            resource,
            pointer_step(location, "maxLength"),
            instance_path,
            false,
            error="string has too many Unicode code points",
          ),
        )
      }
      rule_output(errors.is_empty(), errors~)
    }
    Pattern(pattern, location) => {
      guard value is String(text) else { return rule_output(true) }
      if pattern_matches(pattern, text) {
        rule_output(true)
      } else {
        rule_output(false, errors=[
          output_unit(
            program,
            resource,
            location,
            instance_path,
            false,
            error="string does not match the pattern",
          ),
        ])
      }
    }
    Objects(plan) =>
      collect_object(
        program, resource, plan, value, instance_path, depth, limit, scope, evaluated,
      )
    Arrays(plan) =>
      collect_array(
        program, resource, plan, value, instance_path, depth, limit, scope, evaluated,
      )
    All(branches, location) => {
      let marks = fresh_marks(evaluated is Some(_))
      let branch_errors : Array[OutputUnit] = []
      let branch_annotations : Array[OutputUnit] = []
      let mut valid = true
      for branch in branches {
        let node = collect_node(
          program,
          resource,
          "",
          branch,
          value,
          instance_path,
          depth + 1,
          limit,
          scope,
          marks,
        )
        if node.valid {
          append_units(branch_annotations, node.annotations)
        } else {
          valid = false
          branch_errors.push(node)
        }
      }
      if valid {
        merge_marks(evaluated, marks)
        rule_output(true, annotations=branch_annotations)
      } else {
        rule_output(false, errors=[
          output_unit(
            program,
            resource,
            location,
            instance_path,
            false,
            errors=branch_errors,
          ),
        ])
      }
    }
    Any(branches, location) => {
      let marks = fresh_marks(evaluated is Some(_))
      let branch_annotations : Array[OutputUnit] = []
      let mut matched = false
      for branch in branches {
        let node = collect_node(
          program,
          resource,
          "",
          branch,
          value,
          instance_path,
          depth + 1,
          limit,
          scope,
          marks,
        )
        if node.valid {
          matched = true
          append_units(branch_annotations, node.annotations)
        }
      }
      if matched {
        merge_marks(evaluated, marks)
        rule_output(true, annotations=branch_annotations)
      } else {
        // Speculative branches are not asserted; only the applicator failure
        // is reported, matching `Schema::faults`.
        rule_output(false, errors=[
          output_unit(
            program,
            resource,
            location,
            instance_path,
            false,
            error="no branch matched",
          ),
        ])
      }
    }
    One(branches, location) => {
      let marks = fresh_marks(evaluated is Some(_))
      let matching : Array[OutputUnit] = []
      for branch in branches {
        let node = collect_node(
          program,
          resource,
          "",
          branch,
          value,
          instance_path,
          depth + 1,
          limit,
          scope,
          marks,
        )
        if node.valid {
          matching.push(node)
          if matching.length() > 1 {
            break
          }
        }
      }
      if matching.length() == 1 {
        merge_marks(evaluated, marks)
        rule_output(true, annotations=matching[0].annotations)
      } else {
        rule_output(false, errors=[
          output_unit(
            program,
            resource,
            location,
            instance_path,
            false,
            error="expected exactly one matching branch",
          ),
        ])
      }
    }
    Negate(branch, location) => {
      let node = collect_node(
        program,
        resource,
        "",
        branch,
        value,
        instance_path,
        depth + 1,
        limit,
        scope,
        None,
      )
      if node.valid {
        rule_output(false, errors=[
          output_unit(
            program,
            resource,
            location,
            instance_path,
            false,
            error="negated schema matched",
          ),
        ])
      } else {
        rule_output(true)
      }
    }
    Conditional(predicate, consequent, alternate) => {
      let predicate_marks = fresh_marks(evaluated is Some(_))
      let condition = collect_node(
        program,
        resource,
        "",
        predicate,
        value,
        instance_path,
        depth + 1,
        limit,
        scope,
        predicate_marks,
      )
      let matched = condition.valid
      if matched {
        merge_marks(evaluated, predicate_marks)
      }
      let arm = if matched { consequent } else { alternate }
      match arm {
        Some(branch) => {
          let node = collect_node(
            program,
            resource,
            "",
            branch,
            value,
            instance_path,
            depth + 1,
            limit,
            scope,
            evaluated,
          )
          if node.valid {
            rule_output(true, annotations=node.annotations)
          } else {
            rule_output(false, errors=[node])
          }
        }
        None => rule_output(true)
      }
    }
    Ref(target) =>
      collect_reference(
        program, target, value, instance_path, depth, limit, scope, evaluated,
      )
    DynamicRef(initial, name) => {
      let mut target = initial
      if name is Some(name) {
        for resource in scope {
          if program.resources[resource].dynamic_anchors.get(name) is Some(slot) {
            target = slot
            break
          }
        }
      }
      collect_reference(
        program, target, value, instance_path, depth, limit, scope, evaluated,
      )
    }
    UnevaluatedProperties(schema, location) =>
      collect_unevaluated_properties(
        program, resource, schema, location, value, instance_path, depth, limit,
        scope, evaluated,
      )
    UnevaluatedItems(schema, location) =>
      collect_unevaluated_items(
        program, resource, schema, location, value, instance_path, depth, limit,
        scope, evaluated,
      )
  }
}

///|
fn collect_reference(
  program : Program,
  target : Int32,
  value : Json,
  instance_path : String,
  depth : Int32,
  limit : Int32,
  scope : Array[Int32],
  evaluated : EvaluationMarks?,
) -> RuleOutput raise {
  let node = collect_node(
    program,
    0,
    "",
    program.nodes[target],
    value,
    instance_path,
    depth + 1,
    limit,
    scope,
    evaluated,
  )
  if node.valid {
    rule_output(true, annotations=node.annotations)
  } else {
    rule_output(false, errors=[node])
  }
}

///|
fn collect_unevaluated_properties(
  program : Program,
  resource : Int32,
  schema : Plan,
  location : String,
  value : Json,
  instance_path : String,
  depth : Int32,
  limit : Int32,
  scope : Array[Int32],
  evaluated : EvaluationMarks?,
) -> RuleOutput raise {
  guard value is Object(fields) else { return rule_output(true) }
  let errors : Array[OutputUnit] = []
  let annotations : Array[OutputUnit] = []
  let applied : Array[String] = []
  let mut valid = true
  for name, child in fields {
    if evaluated is Some(marks) && marks.properties.contains(name) {
      continue
    }
    applied.push(name)
    let node = collect_node(
      program,
      resource,
      "",
      schema,
      child,
      pointer_step(instance_path, name),
      depth + 1,
      limit,
      scope,
      None,
    )
    if node.valid {
      append_units(annotations, node.annotations)
    } else {
      valid = false
      errors.push(node)
    }
  }
  if valid {
    if evaluated is Some(marks) {
      for name in applied {
        marks.properties.add(name)
      }
    }
    rule_output(true, annotations=[
      output_unit(
        program,
        resource,
        location,
        instance_path,
        true,
        annotation=Json::array(applied.map(name => Json::string(name))),
        annotations~,
      ),
    ])
  } else {
    rule_output(false, errors=[
      output_unit(program, resource, location, instance_path, false, errors~),
    ])
  }
}

///|
fn collect_unevaluated_items(
  program : Program,
  resource : Int32,
  schema : Plan,
  location : String,
  value : Json,
  instance_path : String,
  depth : Int32,
  limit : Int32,
  scope : Array[Int32],
  evaluated : EvaluationMarks?,
) -> RuleOutput raise {
  guard value is Array(values) else { return rule_output(true) }
  let errors : Array[OutputUnit] = []
  let annotations : Array[OutputUnit] = []
  let mut valid = true
  for index, child in values {
    if evaluated is Some(marks) && marks.items.contains(index) {
      continue
    }
    let node = collect_node(
      program,
      resource,
      "",
      schema,
      child,
      pointer_step(instance_path, index.to_string()),
      depth + 1,
      limit,
      scope,
      None,
    )
    if node.valid {
      append_units(annotations, node.annotations)
    } else {
      valid = false
      errors.push(node)
    }
  }
  if valid {
    if evaluated is Some(marks) {
      for index in 0.. RuleOutput raise {
  guard value is Object(fields) else { return rule_output(true) }
  let errors : Array[OutputUnit] = []
  let annotations : Array[OutputUnit] = []
  let mut valid = true
  if plan.minimum is Some(bound) && bound.above(fields.length()) {
    valid = false
    errors.push(
      output_unit(
        program,
        resource,
        pointer_step(plan.path, "minProperties"),
        instance_path,
        false,
        error="object has too few properties",
      ),
    )
  }
  if plan.maximum is Some(bound) && bound.below(fields.length()) {
    valid = false
    errors.push(
      output_unit(
        program,
        resource,
        pointer_step(plan.path, "maxProperties"),
        instance_path,
        false,
        error="object has too many properties",
      ),
    )
  }
  for name in plan.required {
    if !fields.contains(name) {
      valid = false
      errors.push(
        output_unit(
          program,
          resource,
          pointer_step(plan.path, "required"),
          instance_path,
          false,
          error="missing required property: " + name,
        ),
      )
    }
  }
  let properties_present = !plan.properties.is_empty()
  let patterns_present = !plan.patterns.is_empty()
  let additional_present = plan.additional is Some(_)
  let names_present = plan.names is Some(_)
  let property_marks = fresh_marks(evaluated is Some(_))
  let pattern_marks = fresh_marks(evaluated is Some(_))
  let additional_marks = fresh_marks(evaluated is Some(_))
  let property_errors : Array[OutputUnit] = []
  let property_annotations : Array[OutputUnit] = []
  let property_names : Array[String] = []
  let pattern_errors : Array[OutputUnit] = []
  let pattern_annotations : Array[OutputUnit] = []
  let pattern_names : Array[String] = []
  let additional_errors : Array[OutputUnit] = []
  let additional_annotations : Array[OutputUnit] = []
  let additional_names : Array[String] = []
  let names_errors : Array[OutputUnit] = []
  let mut properties_valid = true
  let mut patterns_valid = true
  let mut additional_valid = true
  let mut names_valid = true
  for name, child in fields {
    let child_path = pointer_step(instance_path, name)
    let mut covered = false
    if plan.properties.get(name) is Some(schema) {
      covered = true
      property_names.push(name)
      let node = collect_node(
        program,
        resource,
        "",
        schema,
        child,
        child_path,
        depth + 1,
        limit,
        scope,
        None,
      )
      if node.valid {
        append_units(property_annotations, node.annotations)
      } else {
        properties_valid = false
        property_errors.push(node)
      }
      if property_marks is Some(marks) {
        marks.properties.add(name)
      }
    }
    for entry in plan.patterns {
      let (pattern, schema) = entry
      if pattern_matches(pattern, name) {
        covered = true
        pattern_names.push(name)
        let node = collect_node(
          program,
          resource,
          "",
          schema,
          child,
          child_path,
          depth + 1,
          limit,
          scope,
          None,
        )
        if node.valid {
          append_units(pattern_annotations, node.annotations)
        } else {
          patterns_valid = false
          pattern_errors.push(node)
        }
        if pattern_marks is Some(marks) {
          marks.properties.add(name)
        }
      }
    }
    if !covered && plan.additional is Some(schema) {
      additional_names.push(name)
      let node = collect_node(
        program,
        resource,
        "",
        schema,
        child,
        child_path,
        depth + 1,
        limit,
        scope,
        None,
      )
      if node.valid {
        append_units(additional_annotations, node.annotations)
      } else {
        additional_valid = false
        additional_errors.push(node)
      }
      if additional_marks is Some(marks) {
        marks.properties.add(name)
      }
    }
    if plan.names is Some(schema) {
      let node = collect_node(
        program,
        resource,
        "",
        schema,
        Json::string(name),
        child_path,
        depth + 1,
        limit,
        scope,
        None,
      )
      if !node.valid {
        names_valid = false
        names_errors.push(node)
      }
    }
  }
  if properties_present {
    if properties_valid {
      annotations.push(
        output_unit(
          program,
          resource,
          pointer_step(plan.path, "properties"),
          instance_path,
          true,
          annotation=Json::array(property_names.map(name => Json::string(name))),
          annotations=property_annotations,
        ),
      )
    } else {
      valid = false
      errors.push(
        output_unit(
          program,
          resource,
          pointer_step(plan.path, "properties"),
          instance_path,
          false,
          errors=property_errors,
        ),
      )
    }
  }
  if properties_valid {
    merge_marks(evaluated, property_marks)
  }
  if patterns_present {
    if patterns_valid {
      annotations.push(
        output_unit(
          program,
          resource,
          pointer_step(plan.path, "patternProperties"),
          instance_path,
          true,
          annotation=Json::array(pattern_names.map(name => Json::string(name))),
          annotations=pattern_annotations,
        ),
      )
    } else {
      valid = false
      errors.push(
        output_unit(
          program,
          resource,
          pointer_step(plan.path, "patternProperties"),
          instance_path,
          false,
          errors=pattern_errors,
        ),
      )
    }
  }
  if patterns_valid {
    merge_marks(evaluated, pattern_marks)
  }
  if additional_present {
    if additional_valid {
      annotations.push(
        output_unit(
          program,
          resource,
          pointer_step(plan.path, "additionalProperties"),
          instance_path,
          true,
          annotation=Json::array(
            additional_names.map(name => Json::string(name)),
          ),
          annotations=additional_annotations,
        ),
      )
    } else {
      valid = false
      errors.push(
        output_unit(
          program,
          resource,
          pointer_step(plan.path, "additionalProperties"),
          instance_path,
          false,
          errors=additional_errors,
        ),
      )
    }
  }
  if additional_valid {
    merge_marks(evaluated, additional_marks)
  }
  if names_present && !names_valid {
    valid = false
    errors.push(
      output_unit(
        program,
        resource,
        pointer_step(plan.path, "propertyNames"),
        instance_path,
        false,
        errors=names_errors,
      ),
    )
  }
  for entry in plan.dependent_required {
    let (trigger, names) = entry
    if fields.contains(trigger) {
      for name in names {
        if !fields.contains(name) {
          valid = false
          errors.push(
            output_unit(
              program,
              resource,
              pointer_step(
                pointer_step(plan.path, "dependentRequired"),
                trigger,
              ),
              instance_path,
              false,
              error="missing dependent property: " + name,
            ),
          )
        }
      }
    }
  }
  let dependency_marks = fresh_marks(evaluated is Some(_))
  let dependency_errors : Array[OutputUnit] = []
  let dependency_annotations : Array[OutputUnit] = []
  let mut dependencies_valid = true
  for entry in plan.dependent_schemas {
    let (trigger, schema) = entry
    if fields.contains(trigger) {
      let node = collect_node(
        program,
        resource,
        "",
        schema,
        value,
        instance_path,
        depth + 1,
        limit,
        scope,
        dependency_marks,
      )
      if node.valid {
        append_units(dependency_annotations, node.annotations)
      } else {
        dependencies_valid = false
        dependency_errors.push(node)
      }
    }
  }
  if dependencies_valid {
    merge_marks(evaluated, dependency_marks)
    append_units(annotations, dependency_annotations)
  } else {
    valid = false
    errors.push(
      output_unit(
        program,
        resource,
        pointer_step(plan.path, "dependentSchemas"),
        instance_path,
        false,
        errors=dependency_errors,
      ),
    )
  }
  if valid {
    rule_output(true, annotations~)
  } else {
    rule_output(false, errors~)
  }
}

///|
fn collect_array(
  program : Program,
  resource : Int32,
  plan : ArrayPlan,
  value : Json,
  instance_path : String,
  depth : Int32,
  limit : Int32,
  scope : Array[Int32],
  evaluated : EvaluationMarks?,
) -> RuleOutput raise {
  guard value is Array(values) else { return rule_output(true) }
  let errors : Array[OutputUnit] = []
  let annotations : Array[OutputUnit] = []
  let mut valid = true
  if plan.minimum is Some(bound) && bound.above(values.length()) {
    valid = false
    errors.push(
      output_unit(
        program,
        resource,
        pointer_step(plan.path, "minItems"),
        instance_path,
        false,
        error="array has too few items",
      ),
    )
  }
  if plan.maximum is Some(bound) && bound.below(values.length()) {
    valid = false
    errors.push(
      output_unit(
        program,
        resource,
        pointer_step(plan.path, "maxItems"),
        instance_path,
        false,
        error="array has too many items",
      ),
    )
  }
  let prefix_errors : Array[OutputUnit] = []
  let prefix_annotations : Array[OutputUnit] = []
  let items_errors : Array[OutputUnit] = []
  let items_annotations : Array[OutputUnit] = []
  let mut prefix_valid = true
  let mut items_valid = true
  let mut matches = 0
  let contains_annotations : Array[OutputUnit] = []
  let contains_indices : Array[Int32] = []
  for index, child in values {
    let child_path = pointer_step(instance_path, index.to_string())
    if index < plan.prefix.length() {
      let node = collect_node(
        program,
        resource,
        "",
        plan.prefix[index],
        child,
        child_path,
        depth + 1,
        limit,
        scope,
        None,
      )
      if node.valid {
        append_units(prefix_annotations, node.annotations)
      } else {
        prefix_valid = false
        prefix_errors.push(node)
      }
    } else if plan.items is Some(schema) {
      let node = collect_node(
        program,
        resource,
        "",
        schema,
        child,
        child_path,
        depth + 1,
        limit,
        scope,
        None,
      )
      if node.valid {
        append_units(items_annotations, node.annotations)
      } else {
        items_valid = false
        items_errors.push(node)
      }
    }
    if plan.contains is Some(schema) {
      let node = collect_node(
        program,
        resource,
        "",
        schema,
        child,
        child_path,
        depth + 1,
        limit,
        scope,
        None,
      )
      if node.valid {
        matches += 1
        contains_indices.push(index)
        append_units(contains_annotations, node.annotations)
      }
    }
    if plan.unique {
      let mut duplicate = false
      for previous in 0.. 0 {
        Some(Json::number((applied - 1).to_double()))
      } else {
        None
      }
      annotations.push(
        output_unit(
          program,
          resource,
          pointer_step(plan.path, "prefixItems"),
          instance_path,
          true,
          annotation?,
          annotations=prefix_annotations,
        ),
      )
    } else {
      valid = false
      errors.push(
        output_unit(
          program,
          resource,
          pointer_step(plan.path, "prefixItems"),
          instance_path,
          false,
          errors=prefix_errors,
        ),
      )
    }
  }
  if plan.items is Some(_) {
    if items_valid {
      annotations.push(
        output_unit(
          program,
          resource,
          pointer_step(plan.path, "items"),
          instance_path,
          true,
          annotation=Json::boolean(true),
          annotations=items_annotations,
        ),
      )
    } else {
      valid = false
      errors.push(
        output_unit(
          program,
          resource,
          pointer_step(plan.path, "items"),
          instance_path,
          false,
          errors=items_errors,
        ),
      )
    }
  }
  if plan.contains is Some(_) {
    let mut contains_valid = true
    if plan.min_contains.above(matches) {
      valid = false
      contains_valid = false
      errors.push(
        output_unit(
          program,
          resource,
          pointer_step(plan.path, "contains"),
          instance_path,
          false,
          error="too few items matched contains",
        ),
      )
    }
    if plan.max_contains is Some(bound) && bound.below(matches) {
      valid = false
      contains_valid = false
      errors.push(
        output_unit(
          program,
          resource,
          pointer_step(plan.path, "maxContains"),
          instance_path,
          false,
          error="too many items matched contains",
        ),
      )
    }
    if contains_valid {
      if evaluated is Some(marks) {
        for index in contains_indices {
          marks.items.add(index)
        }
      }
      annotations.push(
        output_unit(
          program,
          resource,
          pointer_step(plan.path, "contains"),
          instance_path,
          true,
          annotation=Json::array(
            contains_indices.map(index => Json::number(index.to_double())),
          ),
          annotations=contains_annotations,
        ),
      )
    }
  }
  if valid {
    rule_output(true, annotations~)
  } else {
    rule_output(false, errors~)
  }
}

///|
// The basic format is flat: one unit per failed assertion keyword and one
// unit per collected annotation.
fn basic_output(tree : OutputUnit) -> OutputUnit {
  let errors : Array[OutputUnit] = []
  let annotations : Array[OutputUnit] = []
  flatten_errors(tree, errors)
  flatten_annotations(tree, annotations)
  {
    valid: tree.valid,
    keyword_location: tree.keyword_location,
    absolute_keyword_location: tree.absolute_keyword_location,
    instance_location: tree.instance_location,
    error: None,
    annotation: None,
    errors,
    annotations,
  }
}

///|
fn flatten_errors(unit : OutputUnit, into : Array[OutputUnit]) -> Unit {
  if unit.error is Some(_) {
    into.push({
      valid: false,
      keyword_location: unit.keyword_location,
      absolute_keyword_location: unit.absolute_keyword_location,
      instance_location: unit.instance_location,
      error: unit.error,
      annotation: None,
      errors: [],
      annotations: [],
    })
    return
  }
  for child in unit.errors {
    flatten_errors(child, into)
  }
}

///|
fn flatten_annotations(unit : OutputUnit, into : Array[OutputUnit]) -> Unit {
  if unit.annotation is Some(_) {
    into.push({
      valid: true,
      keyword_location: unit.keyword_location,
      absolute_keyword_location: unit.absolute_keyword_location,
      instance_location: unit.instance_location,
      error: None,
      annotation: unit.annotation,
      errors: [],
      annotations: [],
    })
  }
  for child in unit.annotations {
    flatten_annotations(child, into)
  }
}

///|
// The detailed format keeps the failure hierarchy but retains only the first
// failed child at each level, while preserving every collected annotation.
fn detailed_output(tree : OutputUnit) -> OutputUnit {
  {
    valid: tree.valid,
    keyword_location: tree.keyword_location,
    absolute_keyword_location: tree.absolute_keyword_location,
    instance_location: tree.instance_location,
    error: tree.error,
    annotation: tree.annotation,
    errors: if tree.errors.is_empty() {
      []
    } else {
      [detailed_output(tree.errors[0])]
    },
    annotations: tree.annotations.map(unit => detailed_output(unit)),
  }
}