///|
pub(all) suberror JsonSchemaError {
  ConflictingShapeName(String)
  InvalidConfiguration(String)
} derive(Debug)

///|
fn member_target(entry : Json) -> String? {
  guard entry is Object(fields) && fields.get("target") is Some(String(id)) else {
    return None
  }
  Some(id)
}

///|
fn property_name(name : String, entry : Json, config : Config) -> String {
  if config.use_json_name &&
    entry is Object(fields) &&
    fields.get("traits") is Some(Object(traits)) &&
    traits.get("smithy.api#jsonName") is Some(String(value)) {
    value
  } else {
    name
  }
}

///|
fn schema_description(traits : Map[String, Json]) -> Json? {
  let mut description = match traits.get("smithy.api#documentation") {
    Some(String(text)) => text
    _ => ""
  }
  if traits.get("smithy.api#deprecated") is Some(Object(deprecated)) {
    let since = match deprecated.get("since") {
      Some(String(text)) => " since " + text
      _ => ""
    }
    let message = match deprecated.get("message") {
      Some(String(text)) => ": " + text
      _ => "."
    }
    description += "\nThis shape is deprecated" + since + message
  }
  let description = description.trim().to_owned()
  if description.is_empty() {
    None
  } else {
    Some(Json::string(description))
  }
}

///|
fn apply_traits(
  schema : Map[String, Json],
  traits : Map[String, Json],
  kind : String,
  config : Config,
) -> Unit {
  for
    (trait_id, keyword) in [
      ("smithy.api#title", "title"),
      ("smithy.api#pattern", "pattern"),
      ("smithy.api#mediaType", "contentMediaType"),
    ] {
    if traits.get(trait_id) is Some(value) {
      schema[keyword] = value
    }
  }
  if schema_description(traits) is Some(description) {
    schema["description"] = description
  }
  if traits.get("smithy.api#length") is Some(Object(length)) {
    let prefix = if kind == "list" {
      "Items"
    } else if kind == "map" {
      "Properties"
    } else {
      "Length"
    }
    for (bound, word) in [("min", "min"), ("max", "max")] {
      if length.get(bound) is Some(value) {
        schema[word + prefix] = value
      }
    }
  }
  if traits.get("smithy.api#range") is Some(Object(range)) {
    for (bound, word) in [("min", "minimum"), ("max", "maximum")] {
      if range.get(bound) is Some(value) {
        schema[word] = value
      }
    }
  }
  if traits.get("smithy.api#uniqueItems") is Some(_) {
    schema["uniqueItems"] = Json::boolean(true)
  }
  if !config.disable_default_values &&
    traits.get("smithy.api#default") is Some(value) {
    schema["default"] = value
  }
  if kind == "string" && traits.get("smithy.api#enum") is Some(Array(entries)) {
    let values : Array[Json] = []
    for entry in entries {
      if entry is Object(fields) && fields.get("value") is Some(value) {
        values.push(value)
      }
    }
    schema["enum"] = Json::array(values)
  }
}

///|
fn type_schema(kind : String, config : Config) -> Json {
  let fields : Map[String, Json] = Map([])
  match kind {
    "string" | "blob" | "timestamp" => fields["type"] = Json::string("string")
    "boolean" => fields["type"] = Json::string("boolean")
    "byte" | "short" | "integer" | "long" =>
      fields["type"] = Json::string(
        if config.use_integer_type {
          "integer"
        } else {
          "number"
        },
      )
    "float" | "double" | "bigInteger" | "bigDecimal" =>
      fields["type"] = Json::string("number")
    "list" => fields["type"] = Json::string("array")
    "map" | "structure" => fields["type"] = Json::string("object")
    "enum" => fields["type"] = Json::string("string")
    "intEnum" => fields["type"] = Json::string("number")
    _ => ()
  }
  Json::object(fields)
}

///|
fn has_enum_trait(shape : Json) -> Bool {
  shape is Object(fields) &&
  fields.get("traits") is Some(Object(traits)) &&
  traits.get("smithy.api#enum") is Some(_)
}

///|
fn target_schema(
  target : String,
  shapes : Map[String, Json],
  active : Array[String],
  config : Config,
  names : Map[String, String],
) -> Json {
  if target == "smithy.api#Unit" {
    return Json::object({
      "$ref": Json::string(config.definition_pointer + "/Unit"),
    })
  }
  if target.has_prefix("smithy.api#") {
    let parts = target.split("#").to_array()
    let kind = if parts.length() == 2 { parts[1].to_owned() } else { "" }
    let keyword = match kind {
      "String" => "string"
      "Blob" => "blob"
      "Boolean" => "boolean"
      "Byte" => "byte"
      "Short" => "short"
      "Integer" => "integer"
      "Long" => "long"
      "Float" => "float"
      "Double" => "double"
      "BigInteger" => "bigInteger"
      "BigDecimal" => "bigDecimal"
      "Timestamp" => "timestamp"
      _ => "document"
    }
    if keyword == "timestamp" {
      if config.default_timestamp_format == "date-time" {
        return Json::object({
          "type": Json::string("string"),
          "format": Json::string("date-time"),
        })
      }
      if config.default_timestamp_format.has_prefix("epoch-") {
        return Json::object({ "type": Json::string("number") })
      }
    }
    if (keyword == "float" || keyword == "double") &&
      config.support_non_numeric_floats {
      return Json::object({
        "oneOf": Json::array([
          Json::object({ "type": Json::string("number") }),
          Json::object({
            "type": Json::string("string"),
            "enum": Json::array([
              Json::string("NaN"),
              Json::string("Infinity"),
              Json::string("-Infinity"),
            ]),
          }),
        ]),
      })
    }
    return type_schema(keyword, config)
  }
  if shapes.get(target) is Some(shape) {
    if !active.contains(target) &&
      shape is Object(body) &&
      body.get("type") is Some(String(kind)) &&
      (
        kind == "list" ||
        (kind == "string" && !has_enum_trait(shape)) ||
        kind == "blob" ||
        kind == "boolean" ||
        kind == "byte" ||
        kind == "short" ||
        kind == "integer" ||
        kind == "long" ||
        kind == "float" ||
        kind == "double" ||
        kind == "bigInteger" ||
        kind == "bigDecimal" ||
        kind == "timestamp" ||
        kind == "document" ||
        (kind == "intEnum" && config.disable_int_enums)
      ) {
      return shape_schema(shape, shapes, active + [target], config, names)
    }
    let parts = target.split("#").to_array()
    let name = names
      .get(target)
      .unwrap_or(if parts.length() == 2 { parts[1].to_owned() } else { target })
    return Json::object({
      "$ref": Json::string(config.definition_pointer + "/" + name),
    })
  }
  Json::object(Map([]))
}

///|
fn member_schema(
  entry : Json,
  shapes : Map[String, Json],
  active : Array[String],
  config : Config,
  names : Map[String, String],
) -> Json {
  guard member_target(entry) is Some(target) else {
    return Json::object(Map([]))
  }
  let base = target_schema(target, shapes, active, config, names)
  guard base is Object(schema) else { return base }
  if entry is Object(entry_fields) &&
    entry_fields.get("traits") is Some(Object(traits)) {
    let kind = match schema.get("type") {
      Some(String("array")) => "list"
      Some(String("object")) => "map"
      Some(String("string")) => "string"
      _ => ""
    }
    if schema.get("$ref") is None {
      apply_traits(schema, traits, kind, config)
    }
    if schema.get("$ref") is None &&
      traits.get("smithy.api#timestampFormat") is Some(String(format)) {
      schema["type"] = Json::string(
        if format.has_prefix("epoch-") {
          "number"
        } else {
          "string"
        },
      )
      if format == "date-time" {
        schema["format"] = Json::string("date-time")
      }
      if format == "epoch-seconds" {
        ignore(schema.remove("format"))
      }
    }
    if schema_description(traits) is Some(description) &&
      (schema.get("$ref") is None || config.add_reference_descriptions) {
      schema["description"] = description
    } else if schema.get("$ref") is Some(_) {
      schema.remove("description") |> ignore
    }
    if !config.disable_default_values &&
      schema.get("$ref") is Some(_) &&
      traits.get("smithy.api#default") is Some(value) {
      schema.remove("default") |> ignore
      return Json::object({
        "allOf": Json::array([
          Json::object(schema),
          Json::object({ "default": value }),
        ]),
      })
    }
  }
  Json::object(schema)
}

///|
fn shape_schema(
  shape : Json,
  shapes : Map[String, Json],
  active : Array[String],
  config : Config,
  names : Map[String, String],
) -> Json {
  guard shape is Object(fields) && fields.get("type") is Some(String(kind)) else {
    return Json::object(Map([]))
  }
  let schema = match type_schema(kind, config) {
    Object(fields) => fields
    _ => Map([])
  }
  if (kind == "float" || kind == "double") && config.support_non_numeric_floats {
    schema.remove("type") |> ignore
    schema["oneOf"] = Json::array([
      Json::object({ "type": Json::string("number") }),
      Json::object({
        "type": Json::string("string"),
        "enum": Json::array([
          Json::string("NaN"),
          Json::string("Infinity"),
          Json::string("-Infinity"),
        ]),
      }),
    ])
  }
  if (kind == "enum" || (kind == "intEnum" && !config.disable_int_enums)) &&
    fields.get("members") is Some(Object(members)) {
    let values : Array[Json] = []
    let options : Array[Json] = []
    for name, entry in members {
      let value = match entry {
        Object(entry_fields) =>
          match entry_fields.get("traits") {
            Some(Object(traits)) =>
              traits.get("smithy.api#enumValue").unwrap_or(Json::string(name))
            _ => Json::string(name)
          }
        _ => Json::string(name)
      }
      values.push(value)
      if kind == "enum" && config.enum_strategy == "oneOf" {
        let option : Map[String, Json] = { "const": value }
        if entry is Object(entry_fields) &&
          entry_fields.get("traits") is Some(Object(traits)) {
          if traits.get("smithy.api#documentation") is Some(description) {
            option["description"] = description
          }
          if traits.get("smithy.api#title") is Some(title) {
            option["title"] = title
          }
        }
        options.push(Json::object(option))
      }
    }
    if kind == "enum" && config.enum_strategy == "oneOf" {
      schema["oneOf"] = Json::array(options)
    } else {
      schema["enum"] = Json::array(values)
    }
  }
  if fields.get("traits") is Some(Object(traits)) {
    apply_traits(schema, traits, kind, config)
  }
  if kind == "timestamp" {
    let mut format = config.default_timestamp_format
    if fields.get("traits") is Some(Object(traits)) &&
      traits.get("smithy.api#timestampFormat") is Some(String(value)) {
      format = value
    }
    if format == "date-time" {
      schema["format"] = Json::string("date-time")
    } else if format.has_prefix("epoch-") {
      schema["type"] = Json::string("number")
    }
  }
  if kind == "map" &&
    fields.get("key") is Some(key_entry) &&
    fields.get("value") is Some(value_entry) &&
    member_target(key_entry) is Some(_) &&
    member_target(value_entry) is Some(_) {
    let value_schema = member_schema(value_entry, shapes, active, config, names)
    if config.map_strategy == "patternProperties" {
      let mut pattern = ".+"
      if member_target(key_entry) is Some(target) &&
        shapes.get(target)
        is Some({ "traits": { "smithy.api#pattern": String(value), .. }, .. }) {
        pattern = value
      }
      if key_entry is Object(key_fields) &&
        key_fields.get("traits") is Some(Object(traits)) &&
        traits.get("smithy.api#pattern") is Some(String(value)) {
        pattern = value
      }
      let patterns : Map[String, Json] = Map([])
      patterns[pattern] = value_schema
      schema["patternProperties"] = Json::object(patterns)
    } else {
      schema["propertyNames"] = member_schema(
        key_entry, shapes, active, config, names,
      )
      schema["additionalProperties"] = value_schema
    }
  }
  if kind == "list" &&
    fields.get("member") is Some(entry) &&
    member_target(entry) is Some(_) {
    schema["items"] = member_schema(entry, shapes, active, config, names)
  }
  if (
      kind == "structure" ||
      (kind == "union" && config.union_strategy == "structure")
    ) &&
    fields.get("members") is Some(Object(members)) {
    let properties : Map[String, Json] = Map([])
    let required : Array[Json] = []
    for name, entry in members {
      if member_target(entry) is Some(_) {
        let property = property_name(name, entry, config)
        properties[property] = member_schema(
          entry, shapes, active, config, names,
        )
        if entry is Object(entry_fields) &&
          entry_fields.get("traits") is Some(Object(traits)) &&
          traits.get("smithy.api#required") is Some(_) {
          required.push(Json::string(property))
        }
      }
    }
    if !properties.is_empty() {
      schema["properties"] = Json::object(properties)
    }
    if !required.is_empty() {
      schema["required"] = Json::array(required)
    }
    if kind == "union" {
      schema["type"] = Json::string("object")
    }
  }
  if kind == "union" && config.union_strategy == "object" {
    schema["type"] = Json::string("object")
  }
  if kind == "union" &&
    config.union_strategy == "oneOf" &&
    fields.get("members") is Some(Object(members)) {
    let options : Array[Json] = []
    for name, entry in members {
      if member_target(entry) is Some(_) {
        let name = property_name(name, entry, config)
        let properties : Map[String, Json] = Map([])
        properties[name] = member_schema(entry, shapes, active, config, names)
        options.push(
          Json::object({
            "type": Json::string("object"),
            "title": Json::string(name),
            "required": Json::array([Json::string(name)]),
            "properties": Json::object(properties),
          }),
        )
      }
    }
    schema["oneOf"] = Json::array(options)
  }
  Json::object(schema)
}

///|
fn connected_shapes(
  service : String,
  shapes : Map[String, Json],
) -> Map[String, Bool] {
  let seen : Map[String, Bool] = Map([])
  let pending = [service]
  while !pending.is_empty() {
    let id = pending.pop().unwrap()
    if seen.get(id) is Some(_) {
      continue
    }
    guard shapes.get(id) is Some(shape) else { continue }
    seen[id] = true
    let nodes = [shape]
    while !nodes.is_empty() {
      let node = nodes.pop().unwrap()
      match node {
        Object(fields) => {
          if fields.get("target") is Some(String(target)) &&
            shapes.get(target) is Some(_) {
            pending.push(target)
          }
          for key, value in fields {
            if key != "traits" && key != "rename" {
              nodes.push(value)
            }
          }
        }
        Array(values) =>
          for value in values {
            nodes.push(value)
          }
        _ => ()
      }
    }
  }
  seen
}

///|
fn schema_names(
  shapes : Map[String, Json],
  service : String,
) -> Map[String, String] {
  let names : Map[String, String] = Map([])
  let renames = match shapes.get(service) {
    Some(Object(fields)) =>
      match fields.get("rename") {
        Some(Object(values)) => values
        _ => Map([])
      }
    _ => Map([])
  }
  for id, _ in shapes {
    let parts = id.split("#").to_array()
    if parts.length() == 2 {
      names[id] = match renames.get(id) {
        Some(String(name)) => name
        _ => parts[1].to_owned()
      }
    }
  }
  names
}

///|
fn is_definition_shape(shape : Json, config : Config) -> Bool {
  guard shape is Object(body) && body.get("type") is Some(String(kind)) else {
    return false
  }
  if body.get("traits") is Some(Object(traits)) &&
    traits.get("smithy.api#mixin") is Some(_) {
    return false
  }
  !(kind == "service" ||
  kind == "resource" ||
  kind == "operation" ||
  kind == "list" ||
  (kind == "string" && !has_enum_trait(shape)) ||
  [
    "blob", "boolean", "byte", "short", "integer", "long", "float", "double", "bigInteger",
    "bigDecimal", "timestamp", "document",
  ].contains(kind) ||
  (kind == "intEnum" && config.disable_int_enums))
}

///|
fn namespace_suffix(id : String) -> String {
  let parts = id.split("#").to_array()
  let mut suffix = ""
  for part in parts[0].split(".") {
    let word = part.to_owned()
    if !word.is_empty() {
      suffix += word[:1].to_owned().to_upper() + word[1:].to_owned()
    }
  }
  suffix
}

///|
fn deconflict_names(
  shapes : Map[String, Json],
  connected : Map[String, Bool],
  names : Map[String, String],
  service : String,
  config : Config,
) -> Unit raise JsonSchemaError {
  let claimed : Map[String, String] = Map([])
  let ids = shapes.keys().to_array()
  ids.sort()
  for id in ids {
    if service != "" && connected.get(id) is None {
      continue
    }
    guard shapes.get(id) is Some(shape) &&
      is_definition_shape(shape, config) &&
      names.get(id) is Some(name) else {
      continue
    }
    let mut resolved = name
    if claimed.get(name) is Some(other) {
      let kind = match shape {
        Object(body) => body.get("type")
        _ => None
      }
      if kind != Some(Json::string("map")) {
        raise ConflictingShapeName(
          "Shape " +
          id +
          " conflicts with " +
          other +
          " using JSON Schema name " +
          name,
        )
      }
      resolved = name + namespace_suffix(id)
      if claimed.get(resolved) is Some(other) {
        raise ConflictingShapeName(
          "Shape " +
          id +
          " conflicts with " +
          other +
          " using JSON Schema name " +
          resolved,
        )
      }
      names[id] = resolved
    }
    claimed[resolved] = id
  }
}

///|
/// Convert value shapes in an assembled model to a JSON Schema document.
pub fn convert(
  model : @smithy.Model,
  service~ : String = "",
  config~ : Config = Config::new(),
) -> Json raise {
  if !["propertyNames", "patternProperties"].contains(config.map_strategy) ||
    !["oneOf", "object", "structure"].contains(config.union_strategy) ||
    !["enum", "oneOf"].contains(config.enum_strategy) {
    raise InvalidConfiguration("Unknown JSON Schema strategy")
  }
  if !["date-time", "http-date", "epoch-seconds"].contains(
      config.default_timestamp_format,
    ) {
    raise InvalidConfiguration(
      "Unknown timestamp format: " + config.default_timestamp_format,
    )
  }
  if !config.definition_pointer.has_prefix("#/") {
    raise InvalidConfiguration("definitionPointer must start with #/")
  }
  if !(config.schema_document_extensions is Object(_)) {
    raise InvalidConfiguration("schemaDocumentExtensions must be an object")
  }
  if service != "" &&
    !(model.get(service) is Some({ body: @smithy.Service(..), .. })) {
    raise InvalidConfiguration("Unknown service shape: " + service)
  }
  let ast = @syntax.encode_json_ast(model.to_json_ast())
  guard ast is Object(fields) && fields.get("shapes") is Some(Object(shapes)) else {
    return Json::object(Map([]))
  }
  let definitions : Map[String, Json] = Map([])
  let mut unit_targeted = false
  let connected = connected_shapes(service, shapes)
  let names = schema_names(shapes, service)
  deconflict_names(shapes, connected, names, service, config)
  for id, shape in shapes {
    if service != "" && connected.get(id) is None {
      continue
    }
    guard shape is Object(body) && body.get("type") is Some(String(kind)) else {
      continue
    }
    if kind == "service" || kind == "resource" || kind == "operation" {
      continue
    }
    if body.get("traits") is Some(Object(traits)) &&
      traits.get("smithy.api#mixin") is Some(_) {
      continue
    }
    if kind == "union" && body.get("members") is Some(Object(members)) {
      for _, entry in members {
        if member_target(entry) == Some("smithy.api#Unit") {
          unit_targeted = true
        }
      }
    }
    if kind == "list" ||
      (kind == "string" && !has_enum_trait(shape)) ||
      kind == "blob" ||
      kind == "boolean" ||
      kind == "byte" ||
      kind == "short" ||
      kind == "integer" ||
      kind == "long" ||
      kind == "float" ||
      kind == "double" ||
      kind == "bigInteger" ||
      kind == "bigDecimal" ||
      kind == "timestamp" ||
      kind == "document" ||
      (kind == "intEnum" && config.disable_int_enums) {
      continue
    }
    guard names.get(id) is Some(name) else { continue }
    definitions[name] = shape_schema(shape, shapes, [id], config, names)
  }
  if unit_targeted {
    definitions["Unit"] = Json::object({ "type": Json::string("object") })
  }
  let document : Map[String, Json] = Map([])
  if config.schema_document_extensions is Object(extensions) {
    for key, value in extensions {
      document[key] = value
    }
  }
  let pointer = config.definition_pointer.split("/").to_array()
  let mut nested = Json::object(definitions)
  for index = pointer.length() - 1; index >= 1; index = index - 1 {
    let parent : Map[String, Json] = Map([])
    parent[pointer[index].to_owned()] = nested
    nested = Json::object(parent)
  }
  if nested is Object(fields) {
    for key, value in fields {
      document[key] = value
    }
  }
  Json::object(document)
}