// Parse a MoonCheck schema from its JSON representation.
//
// Schemas can be written by hand as JSON documents (this is what the CLI
// reads). The format is intentionally tiny:
//
// ```json
// {
//   "type": "object",
//   "properties": {
//     "name": { "type": "string", "required": true, "minLength": 1 },
//     "age":  { "type": "int", "min": 0, "max": 150 }
//   }
// }
// ```
//
// Supported `type` values: `string`, `number`, `int`, `bool`, `object`,
// `array`.
//
// Supported keys per kind:
//   - all        : `type`
//   - string     : `minLength`, `maxLength`
//   - int/number : `min`, `max`
//   - object     : `properties` (map of property schemas)
//   - property   : `required` (bool, default false), `enum` (array of literals)
//   - array      : `items` (required), `minItems`, `maxItems`
//
// Unknown keys are ignored so schemas can carry extra documentation.

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

///|
/// Parse a schema `Type` from its JSON representation.
pub fn parse_schema(json : Json) -> Type raise SchemaError {
  let members = require_object(json, "a schema must be a JSON object")
  return parse_node(members)
}

///|
/// Parse a schema `Type` from a JSON document string.
pub fn parse_schema_string(input : String) -> Type raise SchemaError {
  let json = @json.parse(input) catch {
    err => raise SchemaError::InvalidSchema("invalid JSON in schema: \{err}")
  }
  return parse_schema(json)
}

///|
fn parse_node(members : Map[String, Json]) -> Type raise SchemaError {
  let kind = match members.get("type") {
    None => raise SchemaError::InvalidSchema("schema is missing a \"type\" key")
    Some(String(s)) => s
    Some(_) => raise SchemaError::InvalidSchema("\"type\" must be a string")
  }
  match kind {
    "string" =>
      Str({
        min_length: get_length(members, "minLength"),
        max_length: get_length(members, "maxLength"),
      })
    "number" =>
      Num({ min: get_number(members, "min"), max: get_number(members, "max"), })
    "int" =>
      Int({ min: get_number(members, "min"), max: get_number(members, "max"), })
    "bool" => Bool
    "object" => parse_object(members)
    "array" => parse_array(members)
    _ => raise SchemaError::InvalidSchema("unknown schema type: \"\{kind}\"")
  }
}

///|
fn parse_object(members : Map[String, Json]) -> Type raise SchemaError {
  let properties_json = match members.get("properties") {
    None =>
      raise SchemaError::InvalidSchema(
        "object schema is missing \"properties\"",
      )
    Some(v) => v
  }
  let property_map = match properties_json {
    Object(m) => m
    _ => raise SchemaError::InvalidSchema("\"properties\" must be an object")
  }
  let properties : Map[String, Prop] = Map([])
  for (name, node) in property_map {
    let node_members = match node {
      Object(m) => m
      _ =>
        raise SchemaError::InvalidSchema(
          "property \"\{name}\" must be an object schema",
        )
    }
    let typ = parse_node(node_members)
    let required = get_bool_default(node_members, "required", false)
    let enums = get_enum(node_members)
    properties.set(name, { typ, required, enums, })
  }
  return Obj({ properties, })
}

///|
fn parse_array(members : Map[String, Json]) -> Type raise SchemaError {
  let items_json = match members.get("items") {
    None =>
      raise SchemaError::InvalidSchema("array schema is missing \"items\"")
    Some(v) => v
  }
  let item_members = match items_json {
    Object(m) => m
    _ => raise SchemaError::InvalidSchema("\"items\" must be an object schema")
  }
  let item = parse_node(item_members)
  return Arr({
    item,
    min_length: get_length(members, "minItems"),
    max_length: get_length(members, "maxItems"),
  })
}

///|
fn require_object(
  json : Json,
  hint : String,
) -> Map[String, Json] raise SchemaError {
  match json {
    Object(m) => m
    _ => raise SchemaError::InvalidSchema(hint)
  }
}

///|
fn get_bool_default(
  members : Map[String, Json],
  key : String,
  default : Bool,
) -> Bool raise SchemaError {
  match members.get(key) {
    None => default
    Some(True) => true
    Some(False) => false
    Some(_) => raise SchemaError::InvalidSchema("\"\{key}\" must be a boolean")
  }
}

///|
fn get_length(
  members : Map[String, Json],
  key : String,
) -> Int? raise SchemaError {
  match members.get(key) {
    None => None
    Some(Number(n, ..)) if n == @double.floor(n) => Some(n.to_int())
    Some(Number(_, ..)) =>
      raise SchemaError::InvalidSchema("\"\{key}\" must be a whole number")
    Some(_) => raise SchemaError::InvalidSchema("\"\{key}\" must be a number")
  }
}

///|
fn get_number(
  members : Map[String, Json],
  key : String,
) -> Double? raise SchemaError {
  match members.get(key) {
    None => None
    Some(Number(n, ..)) => Some(n)
    Some(_) => raise SchemaError::InvalidSchema("\"\{key}\" must be a number")
  }
}

///|
fn get_enum(members : Map[String, Json]) -> Array[Json]? raise SchemaError {
  match members.get("enum") {
    None => None
    Some(Array(values)) => Some(values)
    Some(_) => raise SchemaError::InvalidSchema("\"enum\" must be an array")
  }
}