///|
/// A single validation failure.
///
/// Both locations are expressed as JSON Pointers (RFC 6901):
/// `instance_path` locates the offending value inside the validated document
/// (e.g. `/users/0/age`), `schema_path` locates the failing keyword inside the
/// schema document (e.g. `#/properties/users/items/properties/age/type`).
pub(all) struct ValidationError {
  instance_path : String
  schema_path : String
  keyword : String
  message : String
} derive(Debug, Eq)

///|
/// Error type of the string-level entry point `validate_json`.
pub(all) enum JsonSchemaError {
  SchemaParseError(String)
  InstanceParseError(String)
  ValidationErrors(Array[ValidationError])
} derive(Debug)

///|
/// Internal path used while recursing through the instance / schema.
/// Linked list so descending into a child is O(1); rendered only on error.
priv enum Path {
  PRoot
  PKey(String, Path)
  PIdx(Int, Path)
}

///|
fn Path::key(self : Path, k : String) -> Path {
  PKey(k, self)
}

///|
fn Path::idx(self : Path, i : Int) -> Path {
  PIdx(i, self)
}

///|
/// Escape a token per RFC 6901: `~` -> `~0`, `/` -> `~1`.
fn escape_pointer_token(s : String) -> String {
  let buf = StringBuilder()
  for c in s {
    match c {
      '~' => buf.write_string("~0")
      '/' => buf.write_string("~1")
      _ => buf.write_char(c)
    }
  }
  buf.to_string()
}

///|
fn render_path(p : Path) -> String {
  match p {
    PRoot => ""
    PKey(k, parent) => render_path(parent) + "/" + escape_pointer_token(k)
    PIdx(i, parent) => render_path(parent) + "/" + i.to_string()
  }
}

///|
/// Validation context threaded through the recursion.
priv struct Ctx {
  root : Json
  errors : Array[ValidationError]
  regex_cache : Map[String, @regexp.Regexp]
  mut depth : Int
  // Annotation tracking for unevaluatedProperties / unevaluatedItems,
  // keyed by rendered instance path. A stack of scopes: applicator
  // branches (allOf/anyOf/oneOf/if) push a scope so annotations do not
  // leak between cousin branches; a successful branch merges its scope
  // into the parent, a failed branch drops it.
  anno_stack : Array[Annos]
  // Resource scope stack for $dynamicRef / fragment resolution: the
  // root schema plus every schema resource ($id node or external
  // document) currently being applied, innermost last.
  resource_stack : Array[Json]
  // External documents available for $ref resolution, URI -> document.
  docs : Map[String, Json]
  // Registry of $id -> schema node (absolute ids), built once.
  ids : Map[String, Json]
  mut ids_built : Bool
  // How many resource scopes the last $ref resolution jumped into;
  // kw_ref pops them after validating the target.
  mut jumped : Int
  // Stack of absolute $id base URIs of the schemas being applied.
  id_stack : Array[String]
}

///|
/// Annotations collected at one scope level.
priv struct Annos {
  props : Map[String, Map[String, Bool]]
  items : Map[String, Map[Int, Bool]]
}

///|
fn Annos::empty() -> Annos {
  { props: {}, items: {}, }
}

///|
fn Ctx::current_annos(self : Ctx) -> Annos {
  self.anno_stack[self.anno_stack.length() - 1]
}

///|
fn Ctx::push_scope(self : Ctx) -> Unit {
  self.anno_stack.push(Annos::empty())
}

///|
/// Merge the top scope into its parent and pop it.
fn Ctx::merge_scope(self : Ctx) -> Unit {
  if self.anno_stack.length() < 2 {
    return
  }
  let top = self.anno_stack[self.anno_stack.length() - 1]
  let _ = self.anno_stack.pop()
  let parent = self.current_annos()
  for ip, set in top.props {
    for key, _v in set {
      note_prop_in(parent, ip, key)
    }
  }
  for ip, set in top.items {
    for idx, _v in set {
      note_item_in(parent, ip, idx)
    }
  }
}

///|
fn Ctx::drop_scope(self : Ctx) -> Unit {
  if self.anno_stack.length() > 1 {
    let _ = self.anno_stack.pop()
  }
}

///|
fn note_prop_in(annos : Annos, ip : String, key : String) -> Unit {
  match annos.props.get(ip) {
    Some(set) => set.set(key, true)
    None => {
      let set : Map[String, Bool] = {}
      set.set(key, true)
      annos.props.set(ip, set)
    }
  }
}

///|
fn note_item_in(annos : Annos, ip : String, idx : Int) -> Unit {
  match annos.items.get(ip) {
    Some(set) => set.set(idx, true)
    None => {
      let set : Map[Int, Bool] = {}
      set.set(idx, true)
      annos.items.set(ip, set)
    }
  }
}

///|
fn Ctx::note_prop(self : Ctx, ip : String, key : String) -> Unit {
  note_prop_in(self.current_annos(), ip, key)
}

///|
fn Ctx::note_item(self : Ctx, ip : String, idx : Int) -> Unit {
  note_item_in(self.current_annos(), ip, idx)
}

///|
fn Ctx::prop_evaluated(self : Ctx, ip : String, key : String) -> Bool {
  let annos = self.current_annos()
  annos.props.get(ip) is Some(set) && set.get(key) is Some(true)
}

///|
fn Ctx::item_evaluated(self : Ctx, ip : String, idx : Int) -> Bool {
  let annos = self.current_annos()
  annos.items.get(ip) is Some(set) && set.get(idx) is Some(true)
}

///|
const MAX_DEPTH : Int = 300

///|
fn Ctx::add_error(
  self : Ctx,
  ip : Path,
  sp : Path,
  kw : String,
  msg : String,
) -> Unit {
  self.errors.push({
    instance_path: render_path(ip),
    schema_path: "#" + render_path(sp),
    keyword: kw,
    message: msg,
  })
}

///|
/// Run a subschema in "probe" mode: returns whether it validates,
/// discarding any errors and annotations a failed branch would have
/// produced. Used by anyOf / oneOf / not / if / contains.
fn Ctx::probe(
  self : Ctx,
  schema : Json,
  inst : Json,
  ip : Path,
  sp : Path,
) -> Bool {
  let mark = self.errors.length()
  self.push_scope()
  validate_node(self, schema, inst, ip, sp)
  let ok = self.errors.length() == mark
  if self.errors.length() > mark {
    self.errors.truncate(mark)
  }
  if ok {
    self.merge_scope()
  } else {
    self.drop_scope()
  }
  ok
}

///|
/// Compile (and cache) a regex, or None if the pattern is invalid.
fn Ctx::regex(self : Ctx, pattern : String) -> @regexp.Regexp? {
  match self.regex_cache.get(pattern) {
    Some(re) => Some(re)
    None => {
      let compiled : @regexp.Regexp? = Some(@regexp.compile(pattern)) catch {
        _ => None
      }
      match compiled {
        Some(re) => {
          self.regex_cache.set(pattern, re)
          Some(re)
        }
        None => None
      }
    }
  }
}