///|
priv suberror SchemaRead {
  SchemaRead(@json.Diagnostic)
} derive(Debug)

///|
fn invalid(message : String, path~ : Array[String] = []) -> @json.Diagnostic {
  @json.Diagnostic::new(@json.InvalidSchema, message, path~)
}

///|
fn string_value(value : @json.Value) -> String raise SchemaRead {
  match value.text() {
    Some(text) => text
    None => raise SchemaRead(invalid("Expected a string"))
  }
}

///|
fn boolean_value(value : @json.Value) -> Bool raise SchemaRead {
  match value.kind() {
    @json.Boolean(b) => b
    _ => raise SchemaRead(invalid("Expected a boolean"))
  }
}

///|
fn number_value(value : @json.Value) -> @json.Number raise SchemaRead {
  match value.kind() {
    @json.Numeric(n) => n
    _ => raise SchemaRead(invalid("Expected a number"))
  }
}

///|
fn array_value(value : @json.Value) -> Array[@json.Value] raise SchemaRead {
  match value.kind() {
    @json.Array(items) => items
    _ => raise SchemaRead(invalid("Expected an array"))
  }
}

///|
fn object_value(
  value : @json.Value,
) -> Array[(String, @json.Value)] raise SchemaRead {
  match value.kind() {
    @json.Object(fields) => fields
    _ => raise SchemaRead(invalid("Expected an object"))
  }
}

///|
fn shallow_schema(value : @json.Value) -> Schema raise SchemaRead {
  if !schema_kind(value) {
    raise SchemaRead(invalid("Expected an object or boolean schema"))
  }
  { value, }
}

///|
fn schema_array(value : @json.Value) -> Array[Schema] raise SchemaRead {
  let items = array_value(value)
  if items.is_empty() {
    raise SchemaRead(invalid("A schema array must be nonempty"))
  }
  let result = []
  for i, value in items {
    if !schema_kind(value) {
      raise SchemaRead(
        invalid("Expected an object or boolean schema", path=[i.to_string()]),
      )
    }
    result.push(Schema::{ value, })
  }
  result
}

///|
fn schema_dictionary(
  value : @json.Value,
) -> Array[(String, Schema)] raise SchemaRead {
  object_value(value).map(entry => {
    if !schema_kind(entry.1) {
      raise SchemaRead(
        invalid("Expected an object or boolean schema", path=[entry.0]),
      )
    }
    (entry.0, Schema::{ value: entry.1, })
  })
}

///|
fn names_value(value : @json.Value) -> Array[String] raise SchemaRead {
  let names = []
  let seen : Set[String] = Set([])
  for i, item in array_value(value) {
    guard item.text() is Some(name) else {
      raise SchemaRead(
        invalid("Expected a property name string", path=[i.to_string()]),
      )
    }
    if seen.contains(name) {
      raise SchemaRead(
        invalid("Property names must be unique", path=[i.to_string()]),
      )
    }
    seen.add(name)
    names.push(name)
  }
  names
}

///|
fn reference_value(
  value : @json.Value,
  limits : @json.Limits,
) -> @reference.Reference raise SchemaRead {
  match @reference.Reference::parse(string_value(value), limits~) {
    Ok(ref_) => ref_
    Err(d) => raise SchemaRead(d)
  }
}

///|
fn uri_value(
  value : @json.Value,
  limits : @json.Limits,
) -> @reference.UriReference raise SchemaRead {
  match @reference.UriReference::parse(string_value(value), limits~) {
    Ok(uri) => uri
    Err(d) => raise SchemaRead(d)
  }
}

///|
fn simple_type(value : @json.Value) -> SimpleType raise SchemaRead {
  match string_value(value) {
    "array" => ArrayType
    "boolean" => BooleanType
    "integer" => IntegerType
    "null" => NullType
    "number" => NumberType
    "object" => ObjectType
    "string" => StringType
    _ => raise SchemaRead(invalid("Unknown draft-07 simple type"))
  }
}

///|
/// Decode a type union, naming the offending element on failure.
fn simple_types(value : @json.Value) -> Array[SimpleType] raise SchemaRead {
  let types = []
  for i, item in array_value(value) {
    let decoded = simple_type(item) catch {
      SchemaRead(d) =>
        raise SchemaRead(
          @json.Diagnostic::new(d.code(), d.message(), path=[
            i.to_string(),
            ..d.path(),
          ]),
        )
    }
    types.push(decoded)
  }
  types
}

///|
fn count_value(value : @json.Value) -> NonNegativeInteger raise SchemaRead {
  match NonNegativeInteger::from_number(number_value(value)) {
    Ok(n) => n
    Err(d) => raise SchemaRead(d)
  }
}

///|
fn decode_keyword(
  name : String,
  value : @json.Value,
  limits~ : @json.Limits = @json.Limits::new(),
) -> Result[Keyword, @json.Diagnostic] {
  Ok(
    match name {
      "$id" => Id(uri_value(value, limits))
      "$schema" => Dialect(uri_value(value, limits))
      "$ref" => Ref(reference_value(value, limits))
      "$comment" => Comment(string_value(value))
      "title" => Title(string_value(value))
      "description" => Description(string_value(value))
      "default" => Default(value)
      "readOnly" => ReadOnly(boolean_value(value))
      "writeOnly" => WriteOnly(boolean_value(value))
      "examples" => Examples(array_value(value))
      "multipleOf" =>
        match PositiveNumber::from_number(number_value(value)) {
          Ok(n) => MultipleOf(n)
          Err(d) => raise SchemaRead(d)
        }
      "maximum" => Maximum(number_value(value))
      "exclusiveMaximum" => ExclusiveMaximum(number_value(value))
      "minimum" => Minimum(number_value(value))
      "exclusiveMinimum" => ExclusiveMinimum(number_value(value))
      "maxLength" => MaxLength(count_value(value))
      "minLength" => MinLength(count_value(value))
      "pattern" => Pattern(string_value(value))
      "additionalItems" => AdditionalItems(shallow_schema(value))
      "items" =>
        Items(
          match value.kind() {
            @json.Array(_) => TupleItems(schema_array(value))
            _ => SingleItems(shallow_schema(value))
          },
        )
      "maxItems" => MaxItems(count_value(value))
      "minItems" => MinItems(count_value(value))
      "uniqueItems" => UniqueItems(boolean_value(value))
      "contains" => Contains(shallow_schema(value))
      "maxProperties" => MaxProperties(count_value(value))
      "minProperties" => MinProperties(count_value(value))
      "required" => Required(names_value(value))
      "additionalProperties" => AdditionalProperties(shallow_schema(value))
      "definitions" => Definitions(schema_dictionary(value))
      "properties" => Properties(schema_dictionary(value))
      "patternProperties" => PatternProperties(schema_dictionary(value))
      "dependencies" =>
        Dependencies(
          object_value(value).map(entry => {
            let dependency = match entry.1.kind() {
              @json.Array(_) => {
                let names = names_value(entry.1) catch {
                  SchemaRead(d) => {
                    let path = [entry.0]
                    for part in d.path() {
                      path.push(part)
                    }
                    raise SchemaRead(
                      @json.Diagnostic::new(d.code(), d.message(), path~),
                    )
                  }
                }
                PropertyDependencies(names)
              }
              _ => {
                if !schema_kind(entry.1) {
                  raise SchemaRead(
                    invalid("Expected a schema or property-name array", path=[
                      entry.0,
                    ]),
                  )
                }
                SchemaDependency(Schema::{ value: entry.1, })
              }
            }
            (entry.0, dependency)
          }),
        )
      "propertyNames" => PropertyNames(shallow_schema(value))
      "const" => Const(value)
      "enum" => {
        let items = array_value(value)
        if items.is_empty() {
          raise SchemaRead(invalid("Enum values must be nonempty"))
        }
        Enum(items)
      }
      "type" =>
        Type(
          match value.kind() {
            @json.String(_) => TypeConstraint::one(simple_type(value))
            _ =>
              match TypeConstraint::many(simple_types(value)) {
                Ok(types) => types
                Err(d) => raise SchemaRead(d)
              }
          },
        )
      "format" => Format(string_value(value))
      "contentMediaType" => ContentMediaType(string_value(value))
      "contentEncoding" => ContentEncoding(string_value(value))
      "if" => If(shallow_schema(value))
      "then" => Then(shallow_schema(value))
      "else" => Else(shallow_schema(value))
      "allOf" => AllOf(schema_array(value))
      "anyOf" => AnyOf(schema_array(value))
      "oneOf" => OneOf(schema_array(value))
      "not" => Not(shallow_schema(value))
      _ => raise SchemaRead(invalid("Unknown typed keyword"))
    },
  ) catch {
    SchemaRead(d) => Err(d)
  }
}

///|
priv enum SchemaPath {
  RootPath
  PathStep(SchemaPath, String)
}

///|
fn path_tokens(
  path : SchemaPath,
  suffix~ : Array[String] = [],
) -> Array[String] {
  let tokens = []
  let mut current = path
  while current is PathStep(parent, token) {
    tokens.push(token)
    current = parent
  }
  let result = Array::makei(tokens.length(), i => {
    tokens[tokens.length() - 1 - i]
  })
  for token in suffix {
    result.push(token)
  }
  result
}

///|
fn with_path(d : @json.Diagnostic, path : SchemaPath) -> @json.Diagnostic {
  @json.Diagnostic::new(
    d.code(),
    d.message(),
    path=path_tokens(path, suffix=d.path()),
  )
}

///|
fn decode_schema(
  value : @json.Value,
  limits : @json.Limits,
) -> Result[Schema, @json.Diagnostic] {
  // Also bounds literal/unknown data and shared value graphs, before queuing.
  match @json.write(value, limits~) {
    Err(d) => return Err(d)
    Ok(_) => ()
  }
  let stack : Array[(@json.Value, SchemaPath)] = [(value, RootPath)]
  while stack.pop() is Some((current, path)) {
    match current.kind() {
      @json.Boolean(_) => continue
      @json.Object(fields) => {
        if current.get("$schema") is Some(declaration) {
          match decode_keyword("$schema", declaration, limits~) {
            Ok(Dialect(ref_)) => if !draft07(ref_.text()) { continue }
            Ok(_) => abort("Dialect decoding invariant")
            Err(d) => return Err(with_path(d, PathStep(path, "$schema")))
          }
        }
        if current.get("$ref") is Some(reference) {
          match decode_keyword("$ref", reference, limits~) {
            Ok(_) => continue
            Err(d) => return Err(with_path(d, PathStep(path, "$ref")))
          }
        }
        for (name, item) in fields {
          if !all_names.contains(name) {
            continue
          }
          let at = PathStep(path, name)
          let keyword = match decode_keyword(name, item, limits~) {
            Ok(keyword) => keyword
            Err(d) => return Err(with_path(d, at))
          }
          match keyword {
            AdditionalItems(child_)
            | Contains(child_)
            | AdditionalProperties(child_)
            | PropertyNames(child_)
            | If(child_)
            | Then(child_)
            | Else(child_)
            | Not(child_)
            | Items(SingleItems(child_)) => stack.push((child_.value, at))
            Items(TupleItems(schemas))
            | AllOf(schemas)
            | AnyOf(schemas)
            | OneOf(schemas) =>
              for i, schema in schemas {
                stack.push((schema.value, PathStep(at, i.to_string())))
              }
            Definitions(entries)
            | Properties(entries)
            | PatternProperties(entries) =>
              for (key, schema) in entries {
                stack.push((schema.value, PathStep(at, key)))
              }
            Dependencies(entries) =>
              for (key, dependency) in entries {
                if dependency is SchemaDependency(schema) {
                  stack.push((schema.value, PathStep(at, key)))
                }
              }
            _ => ()
          }
        }
      }
      _ =>
        return Err(
          invalid("Schema must be an object or boolean", path=path_tokens(path)),
        )
    }
  }
  Ok(Schema::{ value, })
}