// Loading and linting JSON Schema documents.
//
// `mizchi/jsonschema` compiles a schema into a validator. Like many
// validators it is lenient: a node it does not recognise (a typo such as
// `"type": "int"`, a `"type": ["string", "null"]` union, or a misspelt keyword
// such as `"min"`) silently becomes "accept anything". In CI that is the worst
// failure mode: a broken contract makes every document pass.
//
// MoonCheck therefore lints the schema document before compiling it and
// refuses schemas whose meaning would be lost. `mooncheck check-schema` exposes
// the same linter on its own.

///|
/// Raised when a schema document is malformed.
pub(all) suberror SchemaError {
  /// Human-readable description of what is wrong.
  InvalidSchema(String)
} derive(Eq, @debug.Debug)

///|
/// A compiled schema, ready to validate documents.
pub struct Schema {
  priv validator : @jsonschema.Validator
}

///|
/// Type names understood by the underlying validator.
let known_types : Array[String] = [
  "null", "boolean", "string", "number", "integer", "array", "object",
]

///|
/// Keywords that look plausible but are ignored by JSON Schema, with the
/// keyword that was most likely meant.
let misspelt_keywords : Array[(String, String)] = [
  ("min", "minimum"),
  ("max", "maximum"),
  ("minlength", "minLength"),
  ("maxlength", "maxLength"),
  ("minitems", "minItems"),
  ("maxitems", "maxItems"),
  ("item", "items"),
  ("property", "properties"),
  ("enums", "enum"),
]

///|
/// Lint a schema document.
///
/// Returns every problem found, each prefixed with the JSON pointer of the
/// offending schema node (`#/properties/age`). An empty array means the schema
/// is safe to compile.
pub fn lint_schema(schema : Json) -> Array[String] {
  let out : Array[String] = []
  lint_node(schema, "#", out)
  out
}

///|
/// Lint and compile a schema document.
pub fn parse_schema(json : Json) -> Schema raise SchemaError {
  let problems = lint_schema(json)
  if !problems.is_empty() {
    raise SchemaError::InvalidSchema(problems.join("; "))
  }
  let validator = @jsonschema.build_validator(json) catch {
    err => raise SchemaError::InvalidSchema("cannot compile schema: \{err}")
  }
  { validator, }
}

///|
/// Lint and compile a schema from JSON text.
pub fn parse_schema_string(input : String) -> Schema raise SchemaError {
  let json = @json.parse(input) catch {
    err => raise SchemaError::InvalidSchema("invalid JSON in schema: \{err}")
  }
  parse_schema(json)
}

///|
fn lint_node(node : Json, at : String, out : Array[String]) -> Unit {
  guard node is Object(members) else {
    out.push("\{at}: a schema must be a JSON object")
    return
  }
  match members.get("type") {
    None => ()
    Some(String(t)) =>
      if !known_types.contains(t) {
        let hint = match t {
          "int" => " (did you mean \"integer\"?)"
          "bool" => " (did you mean \"boolean\"?)"
          _ => ""
        }
        out.push("\{at}/type: unknown type \"\{t}\"\{hint}")
      }
    Some(Array(_)) =>
      out.push("\{at}/type: type unions are not supported, use \"anyOf\"")
    Some(_) => out.push("\{at}/type: must be a string")
  }
  for pair in misspelt_keywords {
    let (wrong, right) = pair
    if members.contains(wrong) {
      out.push(
        "\{at}: unknown keyword \"\{wrong}\" (did you mean \"\{right}\"?)",
      )
    }
  }
  for key in ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"] {
    if members.get(key) is Some(v) && !(v is Number(_, ..)) {
      out.push("\{at}/\{key}: must be a number")
    }
  }
  for key in ["minLength", "maxLength", "minItems", "maxItems"] {
    match members.get(key) {
      None => ()
      Some(Number(n, ..)) if n >= 0.0 && n == n.to_int().to_double() => ()
      Some(_) => out.push("\{at}/\{key}: must be a non-negative integer")
    }
  }
  match members.get("required") {
    None => ()
    Some(Array(names)) =>
      if !names.iter().all(name => name is String(_)) {
        out.push("\{at}/required: must list property names as strings")
      }
    Some(_) =>
      out.push(
        "\{at}/required: must be an array of property names, e.g. [\"name\"]",
      )
  }
  if members.get("enum") is Some(v) && !(v is Array(_)) {
    out.push("\{at}/enum: must be an array")
  }
  match members.get("properties") {
    None => ()
    Some(Object(props)) =>
      for name, sub in props {
        lint_node(sub, "\{at}/properties/\{name}", out)
      }
    Some(_) => out.push("\{at}/properties: must be an object")
  }
  match members.get("items") {
    None | Some(True) | Some(False) => ()
    Some(sub) => lint_node(sub, "\{at}/items", out)
  }
  for key in ["anyOf", "allOf", "oneOf"] {
    match members.get(key) {
      None => ()
      Some(Array(subs)) =>
        for i, sub in subs {
          lint_node(sub, "\{at}/\{key}/\{i}", out)
        }
      Some(_) => out.push("\{at}/\{key}: must be an array of schemas")
    }
  }
  for key in ["$defs", "definitions"] {
    if members.get(key) is Some(Object(defs)) {
      for name, sub in defs {
        lint_node(sub, "\{at}/\{key}/\{name}", out)
      }
    }
  }
}