// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

// The keywords whose work does not fit on one line of `check`: the ones that
// look at a whole array or object, and the ones the versions spell differently.

///|
/// `type`, which is one name or a list of them.
fn Schema::check_type(
  self : Schema,
  schema : Json,
  value : Json,
  at : String,
  spath : String,
  faults : Array[Fault]?,
) -> Bool {
  ignore(self)
  match schema {
    String(name) =>
      if is_type(value, name) {
        true
      } else {
        note(faults, at, spath, "type", "not of type " + name)
      }
    Array(names) => {
      for name in names {
        if name is String(text) && is_type(value, text) {
          return true
        }
      }
      note(faults, at, spath, "type", "not of any allowed type")
    }
    _ => true
  }
}

///|
/// `maximum` and `minimum`, and the flag draft-04 wrote beside them.
///
/// Before draft-06, `exclusiveMaximum` was a boolean that made `maximum`
/// exclusive; from draft-06 it is a number and stands on its own. Reading a
/// draft-04 document at a later version would silently loosen the bound, which
/// is why the version decides and not the shape.
fn Schema::check_bound(
  self : Schema,
  members : Map[String, Json],
  schema : Json,
  value : Json,
  at : String,
  spath : String,
  faults : Array[Fault]?,
  upper : Bool,
) -> Bool {
  guard schema is Number(limit, ..) && value is Number(number, ..) else {
    return true
  }
  let keyword = if upper { "maximum" } else { "minimum" }
  let exclusive = self.draft == Draft4 &&
    members.get(if upper { "exclusiveMaximum" } else { "exclusiveMinimum" })
    is Some(True)
  let ok = if upper {
    if exclusive {
      number < limit
    } else {
      number <= limit
    }
  } else if exclusive {
    number > limit
  } else {
    number >= limit
  }
  if ok {
    true
  } else {
    note(
      faults,
      at,
      spath,
      keyword,
      if upper {
        "above the maximum"
      } else {
        "below the minimum"
      },
    )
  }
}

///|
/// `prefixItems`, `items` and `additionalItems` — the one group the versions
/// really do spell differently.
///
/// Through 2019-09, `items` is either a schema for every item or a list for the
/// leading ones, and `additionalItems` takes the rest. From 2020-12 the list is
/// `prefixItems`, `items` is always the schema for the rest, and
/// `additionalItems` is not a keyword at all.
fn Schema::check_items(
  self : Schema,
  members : Map[String, Json],
  keyword : String,
  schema : Json,
  base : String,
  value : Json,
  at : String,
  spath : String,
  faults : Array[Fault]?,
  marks : Marks,
) -> Bool {
  guard value is Array(items) else { return true }
  let modern = self.draft == Draft2020
  match keyword {
    "prefixItems" =>
      if modern && schema is Array(schemas) {
        self.each_item(
          schemas, base, items, 0, at, spath, keyword, faults, marks,
        )
      } else {
        true
      }
    "additionalItems" =>
      if !modern && members.get("items") is Some(Array(prefix)) {
        self.rest_items(
          schema,
          base,
          items,
          prefix.length(),
          at,
          spath,
          keyword,
          faults,
          marks,
        )
      } else {
        true
      }
    "items" =>
      if modern {
        let prefix = match members.get("prefixItems") {
          Some(Array(schemas)) => schemas.length()
          _ => 0
        }
        self.rest_items(
          schema, base, items, prefix, at, spath, keyword, faults, marks,
        )
      } else {
        match schema {
          Array(schemas) =>
            self.each_item(
              schemas, base, items, 0, at, spath, keyword, faults, marks,
            )
          _ =>
            self.rest_items(
              schema, base, items, 0, at, spath, keyword, faults, marks,
            )
        }
      }
    _ => true
  }
}

///|
/// A list of schemas against the items at the same positions.
fn Schema::each_item(
  self : Schema,
  schemas : Array[Json],
  base : String,
  items : Array[Json],
  from : Int,
  at : String,
  spath : String,
  keyword : String,
  faults : Array[Fault]?,
  marks : Marks,
) -> Bool {
  let mut every = true
  for i = 0; i < schemas.length() && from + i < items.length(); i = i + 1 {
    let index = from + i
    if self.check(
        schemas[i],
        base,
        items[index],
        step(at, index.to_string()),
        step(step(spath, keyword), i.to_string()),
        faults,
        Marks::new(),
      ) {
      marks.idx.add(index)
    } else {
      every = false
      if faults is None {
        break
      }
    }
  }
  every
}

///|
/// One schema against every item from `from` on.
fn Schema::rest_items(
  self : Schema,
  schema : Json,
  base : String,
  items : Array[Json],
  from : Int,
  at : String,
  spath : String,
  keyword : String,
  faults : Array[Fault]?,
  marks : Marks,
) -> Bool {
  let mut every = true
  for index = from; index < items.length(); index = index + 1 {
    if self.check(
        schema,
        base,
        items[index],
        step(at, index.to_string()),
        step(spath, keyword),
        faults,
        Marks::new(),
      ) {
      marks.idx.add(index)
    } else {
      every = false
      if faults is None {
        break
      }
    }
  }
  if every && from <= items.length() {
    // Everything from `from` on was evaluated, however many that was.
    marks.all_items = marks.all_items || from == 0
  }
  every
}

///|
/// `contains`, with the counts 2019-09 added.
fn Schema::check_contains(
  self : Schema,
  members : Map[String, Json],
  schema : Json,
  base : String,
  value : Json,
  at : String,
  spath : String,
  faults : Array[Fault]?,
  marks : Marks,
) -> Bool {
  // `contains` arrived in draft-06.
  guard self.draft != Draft4 && value is Array(items) else { return true }
  let counted = self.draft == Draft2019 || self.draft == Draft2020
  let least = match (counted, members.get("minContains")) {
    (true, Some(Number(n, ..))) => n.to_int()
    _ => 1
  }
  let most = match (counted, members.get("maxContains")) {
    (true, Some(Number(n, ..))) => n.to_int()
    _ => -1
  }
  let mut found = 0
  for index = 0; index < items.length(); index = index + 1 {
    if self.check(
        schema,
        base,
        items[index],
        step(at, index.to_string()),
        spath,
        None,
        Marks::new(),
      ) {
      found = found + 1
      marks.idx.add(index)
    }
  }
  if found < least {
    return note(
      faults, at, spath, "contains", "fewer matching items than required",
    )
  }
  if most >= 0 && found > most {
    return note(
      faults, at, spath, "contains", "more matching items than allowed",
    )
  }
  true
}

///|
/// `properties`, `patternProperties` and `additionalProperties`, which read the
/// same object and have to agree on which held each of them owns.
fn Schema::check_properties(
  self : Schema,
  members : Map[String, Json],
  keyword : String,
  schema : Json,
  base : String,
  value : Json,
  at : String,
  spath : String,
  faults : Array[Fault]?,
  marks : Marks,
) -> Bool {
  guard value is Object(instance) else { return true }
  let mut every = true
  match keyword {
    "properties" =>
      match schema {
        Object(schemas) =>
          for name, subschema in schemas {
            match instance.get(name) {
              Some(held) =>
                if self.check(
                    subschema,
                    base,
                    held,
                    step(at, name),
                    step(step(spath, keyword), name),
                    faults,
                    Marks::new(),
                  ) {
                  marks.props.add(name)
                } else {
                  every = false
                }
              None => ()
            }
          }
        _ => ()
      }
    "patternProperties" =>
      match schema {
        Object(schemas) =>
          for expression, subschema in schemas {
            for name, held in instance {
              if self.matches(expression, name) {
                if self.check(
                    subschema,
                    base,
                    held,
                    step(at, name),
                    step(step(spath, keyword), expression),
                    faults,
                    Marks::new(),
                  ) {
                  marks.props.add(name)
                } else {
                  every = false
                }
              }
            }
          }
        _ => ()
      }
    "additionalProperties" => {
      let named = match members.get("properties") {
        Some(Object(schemas)) => schemas
        _ => Map::Map([])
      }
      let patterns = match members.get("patternProperties") {
        Some(Object(schemas)) => schemas
        _ => Map::Map([])
      }
      for name, held in instance {
        if named.get(name) is Some(_) {
          continue
        }
        let mut covered = false
        for expression, _ in patterns {
          if self.matches(expression, name) {
            covered = true
            break
          }
        }
        if covered {
          continue
        }
        if self.check(
            schema,
            base,
            held,
            step(at, name),
            step(spath, keyword),
            faults,
            Marks::new(),
          ) {
          marks.props.add(name)
        } else {
          every = false
        }
      }
    }
    _ => ()
  }
  every
}

///|
/// `dependencies` and the two keywords 2019-09 split it into.
fn Schema::check_dependencies(
  self : Schema,
  keyword : String,
  schema : Json,
  base : String,
  value : Json,
  at : String,
  spath : String,
  faults : Array[Fault]?,
  marks : Marks,
) -> Bool {
  guard value is Object(instance) && schema is Object(entries) else {
    return true
  }
  // `dependencies` was split in 2019-09; reading one at the other's version
  // would apply a keyword that version does not have.
  let split = self.draft == Draft2019 || self.draft == Draft2020
  if (keyword == "dependencies" && split) ||
    (keyword != "dependencies" && !split) {
    return true
  }
  let mut every = true
  for name, requirement in entries {
    if instance.get(name) is None {
      continue
    }
    let ok = match requirement {
      Array(names) => {
        let mut all = true
        for needed in names {
          if needed is String(text) && instance.get(text) is None {
            all = note(
              faults,
              at,
              step(spath, name),
              keyword,
              "missing " + text,
            )
          }
        }
        all
      }
      _ => {
        if keyword == "dependentRequired" {
          continue
        }
        let inner = Marks::new()
        let passed = self.check(
          requirement,
          base,
          value,
          at,
          step(spath, name),
          faults,
          inner,
        )
        if passed {
          marks.take(inner)
        }
        passed
      }
    }
    if !ok {
      every = false
      if faults is None {
        break
      }
    }
  }
  every
}

///|
/// `unevaluatedItems` and `unevaluatedProperties`, which apply to whatever every
/// other keyword in this schema — and every in-place applicator it reached —
/// left untouched.
fn Schema::check_unevaluated(
  self : Schema,
  members : Map[String, Json],
  base : String,
  value : Json,
  at : String,
  spath : String,
  faults : Array[Fault]?,
  marks : Marks,
) -> Bool {
  let mut every = true
  match (members.get("unevaluatedItems"), value) {
    (Some(schema), Array(items)) =>
      if !marks.all_items {
        for index = 0; index < items.length(); index = index + 1 {
          if marks.idx.contains(index) {
            continue
          }
          if self.check(
              schema,
              base,
              items[index],
              step(at, index.to_string()),
              step(spath, "unevaluatedItems"),
              faults,
              Marks::new(),
            ) {
            marks.idx.add(index)
          } else {
            every = false
          }
        }
      }
    _ => ()
  }
  match (members.get("unevaluatedProperties"), value) {
    (Some(schema), Object(instance)) =>
      for name, held in instance {
        if marks.props.contains(name) {
          continue
        }
        if self.check(
            schema,
            base,
            held,
            step(at, name),
            step(spath, "unevaluatedProperties"),
            faults,
            Marks::new(),
          ) {
          marks.props.add(name)
        } else {
          every = false
        }
      }
    _ => ()
  }
  every
}

// -------------------------------------------------------------- the instance

///|
/// The name the specification gives this instance's type.
fn kind(value : Json) -> String {
  match value {
    Null => "null"
    True | False => "boolean"
    Number(_, ..) => "number"
    String(_) => "string"
    Array(_) => "array"
    Object(_) => "object"
  }
}

///|
/// Whether an instance is of the named type.
///
/// `integer` is a number with nothing after the decimal point, which is what
/// JSON leaves it as: `1.0` and `1` are the same number.
fn is_type(value : Json, name : String) -> Bool {
  match name {
    "integer" =>
      match value {
        Number(number, ..) =>
          number == number.floor() && number.is_inf() == false
        _ => false
      }
    _ => kind(value) == name
  }
}

///|
/// Whether two instances are the same value.
///
/// Numbers compare as numbers, so `1.0` and `1` are equal however each was
/// written, and objects compare by their members rather than by the order the
/// members arrived in.
fn same(left : Json, right : Json) -> Bool {
  match (left, right) {
    (Null, Null) => true
    (True, True) => true
    (False, False) => true
    (Number(a, ..), Number(b, ..)) => a == b
    (String(a), String(b)) => a == b
    (Array(a), Array(b)) => {
      if a.length() != b.length() {
        return false
      }
      for i = 0; i < a.length(); i = i + 1 {
        if !same(a[i], b[i]) {
          return false
        }
      }
      true
    }
    (Object(a), Object(b)) => {
      if a.length() != b.length() {
        return false
      }
      for name, value in a {
        match b.get(name) {
          Some(other) => if !same(value, other) { return false }
          None => return false
        }
      }
      true
    }
    _ => false
  }
}

///|
/// Whether no two items are the same value.
fn unique(items : Array[Json]) -> Bool {
  for i = 0; i < items.length(); i = i + 1 {
    for j = i + 1; j < items.length(); j = j + 1 {
      if same(items[i], items[j]) {
        return false
      }
    }
  }
  true
}

///|
/// Whether `number` is a multiple of `divisor`.
///
/// The comparison allows the error a binary float carries: `0.0075` divided by
/// `0.0001` is `74.999999999999986`, and calling that not a multiple of the
/// other would be an artefact of the arithmetic rather than a fact about the
/// numbers.
fn multiple_of(number : Double, divisor : Double) -> Bool {
  if divisor == 0.0 {
    return false
  }
  let quotient = number / divisor
  if quotient.is_inf() {
    return false
  }
  let nearest = quotient.round()
  let scale = if quotient.abs() > 1.0 { quotient.abs() } else { 1.0 }
  (quotient - nearest).abs() <= 1.0e-9 * scale
}

///|
/// How many code points a string is, which is what a length is counted in: an
/// emoji is one character however many units it takes to store.
fn code_points(text : String) -> Int {
  let mut count = 0
  for _ in text {
    count = count + 1
  }
  count
}

///|
/// Whether `text` holds a match for the ECMA-262 regular expression.
///
/// `pattern` searches rather than anchors (§6.3.3), so a match anywhere is a
/// match. A pattern the engine cannot read matches nothing: a schema's own
/// mistake should refuse instances rather than admit every one of them.
///
/// A pattern is read once and kept, because the same one is applied to every
/// member of every instance and a property escape expands into a class of a
/// few thousand ranges.
fn Schema::matches(self : Schema, expression : String, text : String) -> Bool {
  match self.patterns.get(expression) {
    Some(Some(regex)) => regex.execute(text[:]) is Some(_)
    Some(None) => false
    None => {
      let read : @string.Regex? = Some(
        @string.Regex::Regex(ecma(expression)[:]),
      ) catch {
        _ => None
      }
      self.patterns[expression] = read
      match read {
        Some(regex) => regex.execute(text[:]) is Some(_)
        None => false
      }
    }
  }
}

///|
/// An ECMA-262 pattern in the spelling this engine reads.
///
/// Three things are rewritten. `\d`, `\w` and `\s` are ECMA-262's shorthand
/// classes and the engine wants the POSIX names for the same sets. A property
/// escape, `\p{Letter}` and its like, becomes the ranges that category holds —
/// the engine has no Unicode properties of its own, and its `[[:alpha:]]` is
/// ASCII, so the ranges are carried here instead. Everything else, backslash
/// escapes included, is the engine's already and passes through.
///
/// A property nobody here knows — a script, a binary property — is left as it
/// was written, which the engine refuses: a name this cannot honour should not
/// quietly match something else.
fn ecma(pattern : String) -> String {
  let text = pattern[:]
  let out = StringBuilder()
  let mut inside = false
  let mut i = 0
  while i < text.length() {
    let c = text[i]
    if c == '\\' && i + 1 < text.length() {
      let next = text[i + 1]
      let name = match next {
        'd' | 'D' => "digit"
        'w' | 'W' => "word"
        's' | 'S' => "space"
        _ => ""
      }
      let negated = next == 'D' || next == 'W' || next == 'S' || next == 'P'
      if next == 'p' || next == 'P' {
        match property(text, i + 2) {
          Some((body, after)) => {
            if inside {
              // A negated property inside a class has no spelling here, so it
              // is left alone and the engine says so.
              if negated {
                out.write_string(text[i:after].to_owned())
              } else {
                out.write_string(body)
              }
            } else if negated {
              out.write_string("[^" + body + "]")
            } else {
              out.write_string("[" + body + "]")
            }
            i = after
            continue
          }
          None => {
            out.write_string(text[i:i + 2].to_owned())
            i = i + 2
            continue
          }
        }
      }
      if name == "" {
        // Every other escape is this engine's as well, backslash included.
        out.write_string(text[i:i + 2].to_owned())
      } else if inside {
        if negated {
          out.write_string(text[i:i + 2].to_owned())
        } else {
          out.write_string("[:" + name + ":]")
        }
      } else if negated {
        out.write_string("[^[:" + name + ":]]")
      } else {
        out.write_string("[[:" + name + ":]]")
      }
      i = i + 2
      continue
    }
    if c == '[' {
      inside = true
    } else if c == ']' {
      inside = false
    }
    out.write_string(text[i:i + 1].to_owned())
    i = i + 1
  }
  out.to_string()
}

///|
/// The ranges the property escape starting at `from` names, and where it ends,
/// or `None` when the escape is not one this knows.
fn property(text : StringView, from : Int) -> (String, Int)? {
  if from >= text.length() || text[from] != '{' {
    return None
  }
  for i = from + 1; i < text.length(); i = i + 1 {
    if text[i] == '}' {
      let name = text[from + 1:i].to_owned()
      // `General_Category=Lu` names the same thing as `Lu`.
      let bare = if name.has_prefix("General_Category=") {
        name[17:].to_owned()
      } else {
        name
      }
      return match categories.get(bare) {
        Some(body) => Some((body, i + 1))
        None => None
      }
    }
  }
  None
}