// The standard library currently deprecates its depth-limit option. Keep the explicit bound until a replacement is available.

///|
#warnings("-deprecated")
fn parse_document(
  text : String,
  side : String,
  diagnostics : Array[Diagnostic],
) -> Json? {
  let root = @json.parse(text, max_nesting_depth=128) catch {
    error => {
      diagnostics.push({
        code: "invalid-json",
        severity: "error",
        side,
        http_method: "",
        path: "",
        location: { pointer: "", definition_pointer: "", },
        reason: error.to_string(),
      })
      return None
    }
  }
  let ctx : Context = {
    root,
    side,
    http_method: "",
    path: "",
    diagnostics,
    budget: new_budget(),
  }
  let root_node = node(root, "")
  guard root is Object(_) else {
    ctx.diagnose(
      "invalid-document", "error", root_node, "The document must be an object.",
    )
    return None
  }
  validate_keys(ctx, root_node, [
    "openapi", "info", "paths", "components", "servers", "security", "tags", "externalDocs",
  ])
  if string_value(field(root, "openapi")) != Some("3.0.3") {
    ctx.diagnose(
      "unsupported-version", "error", root_node, "Expected OpenAPI 3.0.3 JSON.",
    )
    return None
  }
  match field(root, "info") {
    Some(Object(info)) =>
      if string_value(info.get("title")) is None ||
        string_value(info.get("version")) is None {
        ctx.diagnose(
          "invalid-info", "error", root_node, "info.title and info.version must be strings.",
        )
      }
    _ =>
      ctx.diagnose(
        "invalid-info", "error", root_node, "The info object is required.",
      )
  }
  if !(field(root, "paths") is Some(Object(_))) {
    ctx.diagnose(
      "invalid-paths", "error", root_node, "The paths object is required.",
    )
    return None
  }
  Some(root)
}

// RFC 6901 decoding: ~1 must be decoded before ~0.

///|
fn decode_pointer_token(value : String) -> String? {
  let chars = value.to_array()
  let mut index = 0
  while index < chars.length() {
    if chars[index] == '~' {
      if index + 1 >= chars.length() ||
        (chars[index + 1] != '0' && chars[index + 1] != '1') {
        return None
      }
      index += 1
    }
    index += 1
  }
  Some(value.replace_all(old="~1", new="/").replace_all(old="~0", new="~"))
}

///|
fn Context::resolve(self : Context, value : Node) -> Node? {
  let ref_value = field(value.raw, "$ref")
  guard ref_value is Some(raw_ref) else { return Some(value) }
  guard raw_ref is String(raw_reference) else {
    self.diagnose("invalid-reference", "error", value, "$ref must be a string.")
    return None
  }
  if !raw_reference.has_prefix("#") {
    self.diagnose(
      "external-reference",
      "unsupported",
      value,
      "Only same-document JSON Pointer references are supported: " +
      raw_reference,
    )
    return None
  }
  guard decode_fragment(raw_reference) is Some(reference) else {
    self.diagnose(
      "invalid-reference",
      "error",
      value,
      "Invalid percent encoding in reference: " + raw_reference,
    )
    return None
  }
  if !reference.has_prefix("#/") && reference != "#" {
    self.diagnose(
      "unsupported-reference", "unsupported", value, "Same-document references must use JSON Pointers.",
    )
    return None
  }
  if value.refs.contains(reference) || value.refs.length() >= 64 {
    self.diagnose(
      "cyclic-reference",
      "unsupported",
      value,
      "Reference cycle or reference depth limit (64): " + reference,
    )
    return None
  }
  for key in keys(value.raw) {
    if key != "$ref" {
      self.diagnose(
        "reference-sibling",
        "unsupported",
        value,
        "Reference siblings are outside V1: " + key,
      )
    }
  }
  let mut target = self.root
  let mut pointer = ""
  let mut first = true
  for part in reference.split("/") {
    if first {
      first = false
      continue
    }
    guard decode_pointer_token(part.to_owned()) is Some(token) else {
      self.diagnose(
        "invalid-reference",
        "error",
        value,
        "Invalid JSON Pointer escape: " + reference,
      )
      return None
    }
    guard pointer_member(target, token) is Some(next) else {
      self.diagnose(
        "missing-reference",
        "error",
        value,
        "Reference target does not exist: " + reference,
      )
      return None
    }
    target = next
    pointer += "/" + pointer_token(token)
  }
  let refs = value.refs.copy()
  refs.push(reference)
  self.resolve({
    raw: target,
    location: { pointer: value.location.pointer, definition_pointer: pointer, },
    refs,
  })
}

///|
fn hex_digit(byte : Byte) -> Int? {
  let value = byte.to_int()
  if value >= 48 && value <= 57 {
    Some(value - 48)
  } else if value >= 65 && value <= 70 {
    Some(value - 55)
  } else if value >= 97 && value <= 102 {
    Some(value - 87)
  } else {
    None
  }
}

///|
fn decode_fragment(value : String) -> String? {
  let bytes = @utf8.encode(value)
  let result : Array[Byte] = []
  let mut index = 0
  while index < bytes.length() {
    if bytes[index] == b'%' {
      if index + 2 >= bytes.length() {
        return None
      }
      guard hex_digit(bytes[index + 1]) is Some(high) &&
        hex_digit(bytes[index + 2]) is Some(low) else {
        return None
      }
      result.push((high * 16 + low).to_byte())
      index += 3
    } else {
      result.push(bytes[index])
      index += 1
    }
  }
  Some(@utf8.decode(Bytes::from_array(result))) catch {
    _ => None
  }
}

///|
fn pointer_member(value : Json, token : String) -> Json? {
  match value {
    Object(fields) => fields.get(token)
    Array(items) => {
      if token == "" || (token.has_prefix("0") && token != "0") {
        return None
      }
      let mut index = 0
      for char in token.to_array() {
        if char < '0' || char > '9' || index > items.length() {
          return None
        }
        index = index * 10 + char.to_int() - 48
      }
      items.get(index)
    }
    _ => None
  }
}

// Stable canonical form keeps property names intact (including names such as "description").

///|
fn canonical(value : Json) -> String {
  match value {
    Object(fields) => {
      let parts : Array[String] = []
      for key in keys(value) {
        parts.push(key.to_json().stringify() + ":" + canonical(fields[key]))
      }
      "{" + parts.join(",") + "}"
    }
    Array(items) => "[" + items.map(canonical).join(",") + "]"
    _ => value.stringify()
  }
}

///|
fn ignored_key(key : String) -> Bool {
  [
    "description", "summary", "title", "example", "examples", "externalDocs", "deprecated",
  ].contains(key) ||
  key.has_prefix("x-")
}

///|
fn extra_contract(value : Json, handled : Array[String]) -> Json {
  let result : Map[String, Json] = Map([])
  if value is Object(fields) {
    for key, item in fields {
      if !handled.contains(key) && !ignored_key(key) {
        result[key] = item
      }
    }
  }
  Json::object(result)
}

///|
fn compare_extra(
  ctx : Context,
  old : Node,
  new : Node,
  handled : Array[String],
) -> Unit {
  if canonical(extra_contract(old.raw, handled)) !=
    canonical(extra_contract(new.raw, handled)) {
    ctx.diagnose(
      "unanalysed-contract-change", "unsupported", new, "Contract fields outside the V1 rules changed; manual review is required.",
    )
  }
}