///|
fn Context::external_docs(self : Context, shape : @smithy.Shape) -> Json? {
  guard shape.trait_value("smithy.api#externalDocumentation")
    is Some(@smithy.Object(urls)) else {
    return None
  }
  for preferred in self.config.external_docs {
    for name, value in urls {
      if name.to_lower() == preferred.to_lower() && value is @smithy.String(url) {
        return Some(
          Json::object({
            "description": Json::string(name),
            "url": Json::string(url),
          }),
        )
      }
    }
  }
  None
}

///|
fn Context::property_name(self : Context, shape : @smithy.Shape) -> String {
  if self.schema_config.use_json_name &&
    trait_string(shape, "smithy.api#jsonName") is Some(name) {
    name
  } else {
    shape.id.member_name().unwrap_or(shape.id.name())
  }
}

///|
fn Context::component_name(self : Context, name : String) -> String {
  if !self.config.alphanumeric_only_refs {
    return name
  }
  let out = StringBuilder()
  for ch in name {
    if (ch >= 'a' && ch <= 'z') ||
      (ch >= 'A' && ch <= 'Z') ||
      (ch >= '0' && ch <= '9') {
      out.write_char(ch)
    }
  }
  out.to_string()
}

///|
fn Context::schema_ref(self : Context, name : String) -> Json {
  Json::object({
    "$ref": Json::string("#/components/schemas/" + self.component_name(name)),
  })
}

///|
fn Context::add_schema(
  self : Context,
  name : String,
  schema : Json,
) -> Json raise OpenApiError {
  let name = self.component_name(name)
  if self.schemas.get(name) is Some(old) && old != schema {
    raise InvalidConfiguration(
      "Conflicting OpenAPI component name `" + name + "`",
    )
  }
  self.schemas[name] = schema
  self.schema_ref(name)
}

///|
fn Context::adapt_schema(
  self : Context,
  schema : Json,
  source : @smithy.Shape,
) -> Json {
  let out = copy_fields(schema)
  let target = self.target(source)
  if self.config.version == V3_0_2 {
    ignore(out.remove("propertyNames"))
    ignore(out.remove("contentMediaType"))
  }
  if out.get("$ref") is Some(String(_)) {
    return Json::object(out)
  }
  if source.trait_value("smithy.api#deprecated") is Some(_) ||
    target.trait_value("smithy.api#deprecated") is Some(_) {
    out["deprecated"] = Json::boolean(true)
  }
  let docs = match self.external_docs(source) {
    Some(docs) => Some(docs)
    None => self.external_docs(target)
  }
  if docs is Some(docs) {
    out["externalDocs"] = docs
  }
  match target.body {
    @smithy.Simple(Blob) =>
      if self.config.version == V3_1_0 {
        out["contentEncoding"] = Json::string(self.config.default_blob_format)
      } else {
        out["format"] = Json::string(self.config.default_blob_format)
      }
    @smithy.Simple(kind) => {
      let number_format = match kind {
        Float => Some("float")
        Double => Some("double")
        Integer if self.schema_config.use_integer_type &&
          !self.config.disable_integer_format => Some("int32")
        Long if self.schema_config.use_integer_type &&
          !self.config.disable_integer_format => Some("int64")
        Timestamp if out.get("type") == Some(Json::string("number")) &&
          source.trait_value("smithy.api#timestampFormat") is None =>
          Some("double")
        _ => None
      }
      if number_format is Some(format) && out.get("format") is None {
        if out.get("oneOf") is Some(Array(options)) {
          out["oneOf"] = Json::array(
            options.map(option => {
              let fields = copy_fields(option)
              if fields.get("type") == Some(Json::string("number")) {
                fields["format"] = Json::string(format)
              }
              Json::object(fields)
            }),
          )
        } else {
          out["format"] = Json::string(format)
        }
      }
      if (
          self.config.use_strings_for_arbitrary_precision ||
          self.protocol == RpcV2Json
        ) &&
        (kind == BigInteger || kind == BigDecimal) {
        out["type"] = Json::string("string")
        out["format"] = Json::string(
          if kind == BigInteger {
            "bigint"
          } else {
            "decimal"
          },
        )
      }
      if out.get("format") is None &&
        (
          source.trait_value("smithy.api#sensitive") is Some(_) ||
          target.trait_value("smithy.api#sensitive") is Some(_)
        ) {
        out["format"] = Json::string("password")
      }
    }
    @smithy.List(item) =>
      if out.get("items") is Some(items) &&
        self.model.shapes.get(item) is Some(shape) {
        out["items"] = self.adapt_schema(items, shape)
      }
    @smithy.Map(key~, value~) => {
      if out.get("propertyNames") is Some(value) &&
        self.model.shapes.get(key) is Some(shape) {
        out["propertyNames"] = self.adapt_schema(value, shape)
      }
      if out.get("additionalProperties") is Some(schema) &&
        self.model.shapes.get(value) is Some(shape) {
        out["additionalProperties"] = self.adapt_schema(schema, shape)
      }
      if out.get("patternProperties") is Some(Object(patterns)) &&
        self.model.shapes.get(value) is Some(shape) {
        let converted : Map[String, Json] = Map([])
        for pattern, schema in patterns {
          converted[pattern] = self.adapt_schema(schema, shape)
        }
        out["patternProperties"] = Json::object(converted)
      }
    }
    @smithy.Structure(members) | @smithy.Union(members) => {
      if out.get("properties") is Some(Object(properties)) {
        let converted : Map[String, Json] = Map([])
        for name, value in properties {
          converted[name] = value
        }
        for id in members {
          if self.model.shapes.get(id) is Some(shape) {
            let name = self.property_name(shape)
            if properties.get(name) is Some(value) {
              converted[name] = self.adapt_schema(value, shape)
            }
          }
        }
        out["properties"] = Json::object(converted)
      }
      if out.get("oneOf") is Some(Array(options)) {
        out["oneOf"] = Json::array(
          options.map(option => {
            let wrapper = copy_fields(option)
            if wrapper.get("properties") is Some(Object(properties)) {
              let converted : Map[String, Json] = Map([])
              for name, value in properties {
                converted[name] = value
              }
              for id in members {
                if self.model.shapes.get(id) is Some(shape) {
                  let name = self.property_name(shape)
                  if properties.get(name) is Some(value) {
                    converted[name] = self.adapt_schema(value, shape)
                  }
                }
              }
              wrapper["properties"] = Json::object(converted)
            }
            Json::object(wrapper)
          }),
        )
      }
    }
    _ => ()
  }
  Json::object(out)
}

///|
fn Context::prepare_schemas(self : Context) -> Unit {
  let document = self.index.document()
  guard document is { "components": { "schemas": Object(schemas), .. }, .. } else {
    return
  }
  for name, schema in schemas {
    self.schemas[name] = match
      self.index.definition(name).bind(id => self.model.shapes.get(id)) {
      Some(shape) => self.adapt_schema(schema, shape)
      None => schema
    }
  }
}

///|
fn Context::member_schema(
  self : Context,
  shape : @smithy.Shape,
) -> Json raise OpenApiError {
  let schema = self.index.schema_for_member(shape.id) catch {
    @jsonschema.InvalidConfiguration(message)
    | @jsonschema.ConflictingShapeName(message) =>
      raise SchemaConversion(message)
  }
  self.adapt_schema(schema, shape)
}

///|
fn Context::document_schema(
  self : Context,
  container : @smithy.ShapeId,
  members : Array[@smithy.Shape],
) -> Json raise OpenApiError {
  let schema = self.index.structure(container, members.map(shape => shape.id)) catch {
    @jsonschema.InvalidConfiguration(message)
    | @jsonschema.ConflictingShapeName(message) =>
      raise SchemaConversion(message)
  }
  guard self.model.shapes.get(container) is Some(shape) else { return schema }
  self.adapt_schema(schema, shape)
}