///|
fn add_finding(
  findings : Array[Finding],
  ctx : Context,
  rule_id : String,
  old : Node?,
  new : Node?,
  reason : String,
) -> Unit {
  findings.push({
    rule_id,
    http_method: ctx.http_method,
    path: ctx.path,
    old_location: old.map(fn(n) { n.location }),
    new_location: new.map(fn(n) { n.location }),
    reason,
  })
}

///|
fn compare_schema(
  old_ctx : Context,
  new_ctx : Context,
  old_raw : Node,
  new_raw : Node,
  findings : Array[Finding],
  depth : Int,
) -> Unit {
  guard new_ctx.visit(new_raw) else { return }
  if depth > 64 {
    return
  } // The validation pass reports the depth limit.
  guard old_ctx.resolve(old_raw) is Some(old) &&
    new_ctx.resolve(new_raw) is Some(new) else {
    return
  }
  if !(old.raw is Object(_)) || !(new.raw is Object(_)) {
    return
  }
  let old_kind = string_value(field(old.raw, "type"))
  let new_kind = string_value(field(new.raw, "type"))
  compare_extra(new_ctx, old, new, [
    "properties", "required", "enum", "default", "xml",
  ])
  if old_kind == Some("string") && new_kind == Some("string") {
    let old_values = string_set(old_ctx, old, "enum")
    let new_values = string_set(new_ctx, new, "enum")
    if !new_values.is_empty() {
      if field(old.raw, "enum") is None {
        add_finding(
          findings,
          new_ctx,
          "request-enum-value-removed",
          Some(old),
          Some(new),
          "Previously unrestricted string values are restricted to: " +
          new_values.join(", ") +
          ".",
        )
      } else {
        let removed = old_values.filter(fn(v) { !new_values.contains(v) })
        if !removed.is_empty() {
          add_finding(
            findings,
            new_ctx,
            "request-enum-value-removed",
            Some(old),
            Some(new),
            "Previously allowed string values are no longer accepted: " +
            removed.join(", ") +
            ".",
          )
        }
      }
    }
  } else if canonical(field(old.raw, "enum").unwrap_or(Json::null())) !=
    canonical(field(new.raw, "enum").unwrap_or(Json::null())) {
    new_ctx.diagnose(
      "unanalysed-enum-change", "unsupported", new, "Enum analysis requires string schemas on both sides.",
    )
  }
  if old_kind != Some("object") || new_kind != Some("object") {
    return
  }
  let old_required = string_set(old_ctx, old, "required")
  let new_required = string_set(new_ctx, new, "required")
  let old_props = properties(old)
  let new_props = properties(new)
  for name in new_required {
    if !old_required.contains(name) {
      let new_property = new_props.get(name)
      let read_only = match new_property {
        Some(p) =>
          match new_ctx.resolve(p) {
            Some(p) => is_true(field(p.raw, "readOnly"))
            None => false
          }
        None => false
      }
      if !read_only {
        add_finding(
          findings,
          new_ctx,
          "request-required-property-added",
          Some(old_props.get(name).unwrap_or(old)),
          Some(new_property.unwrap_or(new)),
          "Request property '" +
          name +
          "' is now required. A default does not remove the required constraint.",
        )
      }
    }
  }
  let names = old_props.keys().to_array()
  names.sort()
  for name in names {
    match new_props.get(name) {
      Some(new_property) =>
        compare_schema(
          old_ctx,
          new_ctx,
          old_props[name],
          new_property,
          findings,
          depth + 1,
        )
      None =>
        new_ctx.diagnose(
          "unanalysed-property-removal",
          "unsupported",
          new,
          "Removed request property constraints require manual review: " + name,
        )
    }
  }
}

///|
fn compare_operation(
  old : Operation,
  new : Operation,
  findings : Array[Finding],
) -> Unit {
  compare_extra(new.ctx, old.path_item, new.path_item, [
    "parameters", "delete", "get", "head", "options", "patch", "post", "put", "trace",
  ])
  compare_extra(new.ctx, old.value, new.value, [
    "parameters", "requestBody", "responses", "operationId", "tags",
  ])
  let old_responses = resolved_response(
    old.ctx,
    old.value.child(
      "responses",
      field(old.value.raw, "responses").unwrap_or(Json::null()),
    ),
    true,
    0,
  )
  let new_responses = resolved_response(
    new.ctx,
    new.value.child(
      "responses",
      field(new.value.raw, "responses").unwrap_or(Json::null()),
    ),
    true,
    0,
  )
  if !new.ctx.budget.exhausted &&
    canonical(old_responses) != canonical(new_responses) {
    new.ctx.diagnose(
      "unanalysed-response-change",
      "unsupported",
      new.value,
      "Response contracts changed; V1 checks request compatibility only.",
    )
  }
  let names = new.parameters.keys().to_array()
  names.sort()
  for key in names {
    let parameter = new.parameters[key]
    let previous = old.parameters.get(key)
    if is_true(field(parameter.raw, "required")) {
      let previously_required = match previous {
        Some(p) => is_true(field(p.raw, "required"))
        None => false
      }
      if !previously_required {
        add_finding(
          findings,
          new.ctx,
          "request-required-parameter-added",
          previous,
          Some(parameter),
          "Request parameter '" + key + "' is now required.",
        )
      }
    }
    if previous is Some(previous) {
      compare_extra(new.ctx, previous, parameter, [
        "schema", "required", "name", "in",
      ])
      match (field(previous.raw, "schema"), field(parameter.raw, "schema")) {
        (Some(a), Some(b)) =>
          compare_schema(
            old.ctx,
            new.ctx,
            previous.child("schema", a),
            parameter.child("schema", b),
            findings,
            0,
          )
        (None, None) => ()
        _ =>
          new.ctx.diagnose(
            "unanalysed-parameter-change", "unsupported", parameter, "Parameter schema availability changed.",
          )
      }
    }
  }
  // Removing parameter validation usually widens accepted requests, but serialization and server handling are outside V1.
  for key, previous in old.parameters {
    if !new.parameters.contains(key) {
      new.ctx.diagnose(
        "unanalysed-parameter-removal",
        "unsupported",
        previous,
        "Removed parameter needs manual review: " + key,
      )
    }
  }
  match (old.body_object, new.body_object) {
    (Some(a), Some(b)) => compare_extra(new.ctx, a, b, ["content"])
    (None, None) => ()
    _ =>
      new.ctx.diagnose(
        "unanalysed-request-body-change",
        "unsupported",
        new.value,
        "Request body was added or removed; manual review is required.",
      )
  }
  match (old.body, new.body) {
    (Some(a), Some(b)) => compare_schema(old.ctx, new.ctx, a, b, findings, 0)
    (None, None) => ()
    _ =>
      new.ctx.diagnose(
        "unanalysed-request-body-change",
        "unsupported",
        new.value,
        "JSON request schema availability changed.",
      )
  }
}

///|
/// Compare local OpenAPI 3.0.3 JSON using the explicitly documented V1 profile.
pub fn check_json(old_text : String, new_text : String) -> Report {
  let diagnostics : Array[Diagnostic] = []
  let findings : Array[Finding] = []
  let budget = new_budget()
  let old_root = parse_document(old_text, "old", diagnostics)
  let new_root = parse_document(new_text, "new", diagnostics)
  let old_operations = match old_root {
    Some(root) => collect_operations(root, "old", diagnostics, budget)
    None => Map([])
  }
  let new_operations = match new_root {
    Some(root) => collect_operations(root, "new", diagnostics, budget)
    None => Map([])
  }
  if old_root is Some(a) && new_root is Some(b) {
    let ctx : Context = {
      root: b,
      side: "new",
      http_method: "",
      path: "",
      diagnostics,
      budget,
    }
    compare_extra(ctx, node(a, ""), node(b, ""), [
      "openapi", "info", "paths", "components", "tags",
    ])
    compare_extra(
      ctx,
      node(
        field(a, "components").unwrap_or(Json::empty_object()),
        "/components",
      ),
      node(
        field(b, "components").unwrap_or(Json::empty_object()),
        "/components",
      ),
      [
        "schemas", "parameters", "requestBodies", "responses", "headers", "examples",
        "links", "callbacks",
      ],
    )
    let names = old_operations.keys().to_array()
    names.sort()
    for key in names {
      let old = old_operations[key]
      match new_operations.get(key) {
        Some(new) => compare_operation(old, new, findings)
        None =>
          add_finding(
            findings,
            old.ctx,
            "operation-removed",
            Some(old.value),
            None,
            "The previously available HTTP operation no longer exists.",
          )
      }
    }
  }
  findings.sort_by_key(fn(f) {
    f.path +
    "\n" +
    f.http_method +
    "\n" +
    f.rule_id +
    "\n" +
    ToJson::to_json(f).stringify()
  })
  diagnostics.sort_by_key(fn(d) {
    d.path +
    "\n" +
    d.http_method +
    "\n" +
    d.side +
    "\n" +
    d.location.pointer +
    "\n" +
    d.code +
    "\n" +
    d.reason
  })
  let unique : Array[Diagnostic] = []
  for d in diagnostics {
    if !unique.contains(d) {
      unique.push(d)
    }
  }
  let status = if unique.any(fn(d) { d.severity == "error" }) {
    "invalid"
  } else if !unique.is_empty() {
    "incomplete"
  } else {
    "complete"
  }
  { status, findings, diagnostics: unique, }
}