///|
pub(all) enum SchemaProblemKind {
  InvalidSchema
  UnsupportedFeature
  UnsupportedVersion
} derive(Eq, Debug)

///|
/// A problem at a path in the schema, rather than in the business input.
pub(all) struct SchemaProblem {
  path : String
  message : String
  kind : SchemaProblemKind
} derive(Eq, Debug)

///|
/// A checked schema with a private snapshot. Callers cannot construct or
/// change it; mutable arrays in the original JSON are copied at compilation.
pub struct CompiledSchema {
  priv patterns : Map[String, Pattern]
  priv nodes : Array[SchemaNode]
  priv node_ids : Map[String, Int]
  priv references : Map[String, Int]
}

///|
priv struct SchemaNode {
  id : Int
  pointer : String
  display : String
  schema : @pjson.Json
}

///|
priv struct SchemaCompileContext {
  root : @pjson.Json
  nodes : Array[SchemaNode]
  node_ids : Map[String, Int]
  references : Map[String, Int]
  mut pointer : String
}

///|
fn enqueue_schema(
  ctx : SchemaCompileContext,
  schema : @pjson.Json,
  pointer : String,
  display : String,
) -> Int {
  match ctx.node_ids.get(pointer) {
    Some(id) => id
    None => {
      let id = ctx.nodes.length()
      ctx.node_ids[pointer] = id
      ctx.nodes.push({ id, pointer, display, schema, })
      id
    }
  }
}

///|
fn enqueue_schema_child(
  ctx : SchemaCompileContext,
  schema : @pjson.Json,
  tokens : Array[String],
  display : String,
) -> Unit {
  let mut pointer = ctx.pointer
  for token in tokens {
    pointer = pointer_child(pointer, token)
  }
  enqueue_schema(ctx, schema, pointer, display) |> ignore
}

///|
/// Scan iteratively before copying; ordinary data also counts toward these
/// limits so annotations cannot exhaust the copying stack before precheck.
fn schema_snapshot_problem(root : @pjson.Json) -> SchemaProblem? {
  schema_snapshot_problem_with_limits(
    root, schema_compile_depth_limit, schema_compile_node_limit,
  )
}

///|
fn schema_snapshot_problem_with_limits(
  root : @pjson.Json,
  depth_limit : Int,
  node_limit : Int,
) -> SchemaProblem? {
  let stack = [(root, 0, "")]
  let mut count = 0
  while !stack.is_empty() {
    let (value, depth, pointer) = stack.pop().unwrap()
    count = count + 1
    if depth > depth_limit || count > node_limit {
      return Some({
        path: pointer,
        kind: UnsupportedFeature,
        message: "schema snapshot exceeds the depth limit of " +
        depth_limit.to_string() +
        " or node limit of " +
        node_limit.to_string(),
      })
    }
    let children = match value {
      Object(members~) => members.length()
      Array(items~) => items.length()
      _ => 0
    }
    if children > node_limit - count - stack.length() {
      return Some({
        path: pointer,
        kind: UnsupportedFeature,
        message: "schema snapshot exceeds the node limit of " +
        node_limit.to_string(),
      })
    }
    match value {
      Object(members~) =>
        for (name, child) in members {
          stack.push((child, depth + 1, pointer_child(pointer, name)))
        }
      Array(items~) =>
        for i, child in items {
          stack.push((child, depth + 1, pointer_child(pointer, i.to_string())))
        }
      _ => ()
    }
  }
  None
}

///|
pub fn compile_schema(
  schema : @pjson.Json,
) -> Result[CompiledSchema, Array[SchemaProblem]] {
  match schema_snapshot_problem(schema) {
    Some(problem) => return Err([problem])
    None => ()
  }
  let snapshot = copy_schema_json(schema)
  let cache : Map[String, Result[Pattern, PatternProblem]] = Map([])
  compile_schema_snapshot(snapshot, cache)
}

///|
fn compile_schema_snapshot(
  snapshot : @pjson.Json,
  cache : Map[String, Result[Pattern, PatternProblem]],
) -> Result[CompiledSchema, Array[SchemaProblem]] {
  let problems = []
  let ctx : SchemaCompileContext = {
    root: snapshot,
    nodes: [],
    node_ids: Map([]),
    references: Map([]),
    pointer: "",
  }
  enqueue_schema(ctx, snapshot, "", "") |> ignore
  let mut index = 0
  while index < ctx.nodes.length() {
    let node = ctx.nodes[index]
    ctx.pointer = node.pointer
    check_schema(node.schema, node.display, problems, cache, ctx)
    index = index + 1
  }
  if problems.is_empty() {
    let patterns : Map[String, Pattern] = Map([])
    for text, result in cache {
      match result {
        Ok(pattern) => patterns[text] = pattern
        Err(_) => abort("schema precheck lost a pattern compilation problem")
      }
    }
    Ok({
      patterns,
      nodes: ctx.nodes,
      node_ids: ctx.node_ids,
      references: ctx.references,
    })
  } else {
    Err(problems)
  }
}

///|
fn precheck_schema(
  schema : @pjson.Json,
  cache : Map[String, Result[Pattern, PatternProblem]],
) -> Array[SchemaProblem] {
  match schema_snapshot_problem(schema) {
    Some(problem) => [problem]
    None =>
      match compile_schema_snapshot(schema, cache) {
        Ok(_) => []
        Err(problems) => problems
      }
  }
}

///|
/// Cache errors too so duplicate patterns are parsed once while every schema
/// location still receives its own diagnostic.
fn cache_schema_pattern(
  text : String,
  path : String,
  problems : Array[SchemaProblem],
  cache : Map[String, Result[Pattern, PatternProblem]],
) -> Unit {
  let result = match cache.get(text) {
    Some(result) => result
    None => {
      let result = read_pattern(text)
      cache[text] = result
      result
    }
  }
  match result {
    Ok(_) => ()
    Err(problem) =>
      problems.push({
        path,
        kind: problem.kind(),
        message: "\"" +
        escape_json_string(text) +
        "\" is not a pattern this tool reads: " +
        problem.message(),
      })
  }
}

///|
fn compiled_schema_pattern(
  patterns : Map[String, Pattern],
  text : String,
) -> Pattern {
  match patterns.get(text) {
    Some(pattern) => pattern
    None => abort("missing compiled schema pattern: " + text)
  }
}

///|
fn copy_schema_json(json : @pjson.Json) -> @pjson.Json {
  match json {
    Object(members~) =>
      Object(
        members=members.map(fn(entry) { (entry.0, copy_schema_json(entry.1)) }),
      )
    Array(items~) => Array(items=items.map(copy_schema_json))
    _ => json
  }
}

///|
pub fn render_schema_problem(problem : SchemaProblem) -> String {
  (if problem.path == "" { "(root)" } else { problem.path }) +
  ": " +
  problem.message
}