///|
priv struct BodyContent {
  content : Json
  required : Bool
}

///|
fn Context::body_content(
  self : Context,
  container : @smithy.ShapeId,
  subject : @smithy.Shape,
  response : Bool,
  operation : @smithy.Shape,
) -> BodyContent? raise OpenApiError {
  let bindings = self.bindings(container, response)
  let name = self.name(subject.id)
  let phase = if !response {
    RequestExample
  } else {
    self.response_phase(subject)
  }
  if self.protocol == RestJson1 {
    for binding in bindings {
      if binding.location == Payload {
        let suffix = if !response {
          "InputPayload"
        } else if subject.body is @smithy.Operation(..) {
          "OutputPayload"
        } else {
          "ErrorPayload"
        }
        return Some({
          content: content_examples(
            self.payload_content(binding, name + suffix),
            self.binding_examples(operation, binding, phase),
          ),
          required: binding.shape.trait_value("smithy.api#required") is Some(_),
        })
      }
    }
  }
  let documents = bindings
    .filter(binding => {
      self.protocol == RpcV2Json || binding.location == Document
    })
    .map(binding => binding.shape)
  if documents.is_empty() && self.protocol == RestJson1 {
    return None
  }
  let schema = if self.error_groups.get(subject.id) is Some(ids) &&
    self.protocol == RestJson1 {
    Json::object({
      "oneOf": Json::array(ids.map(id => self.schema_ref(self.index.name(id)))),
    })
  } else if documents.is_empty() {
    Json::object({ "type": Json::string("object") })
  } else {
    self.document_schema(container, documents)
  }
  let suffix = if !response {
    "RequestContent"
  } else if self.protocol == RpcV2Json &&
    subject.trait_value("smithy.api#error") is Some(_) {
    "ErrorContent"
  } else {
    "ResponseContent"
  }
  let reference = self.add_schema(name + suffix, schema)
  let content : Map[String, Json] = Map([])
  content[if self.protocol == RpcV2Json {
    "application/json"
  } else {
    self.config.json_content_type
  }] = Json::object({ "schema": reference })
  Some({
    content: if self.protocol == RpcV2Json {
      Json::object(content)
    } else {
      content_examples(
        Json::object(content),
        self.document_examples(operation, container, documents, phase),
      )
    },
    required: self.protocol == RestJson1 &&
    documents.any(shape => shape.trait_value("smithy.api#required") is Some(_)),
  })
}

///|
fn http_code(operation : @smithy.Shape) -> Int32 {
  if operation.trait_value("smithy.api#http")
    is Some(@smithy.Object(properties)) &&
    properties.get("code") is Some(@smithy.Number(value)) &&
    value.to_json() is Number(number, ..) {
    number.to_int()
  } else {
    200
  }
}

///|
fn error_code(error : @smithy.Shape) -> Int32 {
  trait_integer(error, "smithy.api#httpError").unwrap_or(
    if trait_string(error, "smithy.api#error") == Some("client") {
      400
    } else {
      500
    },
  )
}

///|
fn Context::response(
  self : Context,
  container : @smithy.ShapeId,
  subject : @smithy.Shape,
  status : Int32,
  operation : @smithy.Shape,
) -> Json raise OpenApiError {
  let response : Map[String, Json] = {
    "description": Json::string(
      if self.protocol == RpcV2Json {
        "Response Object"
      } else {
        self.component_name(self.name(subject.id)) +
        " " +
        status.to_string() +
        " response"
      },
    ),
  }
  let headers : Map[String, Json] = Map([])
  if self.protocol == RestJson1 {
    for binding in self.bindings(container, true) {
      if binding.location is Header(name) {
        let header = copy_fields(self.parameter(binding, name, "header", true))
        let examples = self.binding_examples(
          operation,
          binding,
          self.response_phase(subject),
        )
        if !examples.is_empty() {
          header["examples"] = Json::object(examples)
        }
        headers[name] = Json::object(header)
      }
    }
  }
  if !headers.is_empty() {
    response["headers"] = Json::object(headers)
  }
  if self.body_content(container, subject, true, operation) is Some(body) {
    response["content"] = body.content
  }
  Json::object(response)
}

///|
fn Context::operation(
  self : Context,
  operation : @smithy.Shape,
  template : UriTemplate,
) -> Json raise OpenApiError {
  guard operation.body is @smithy.Operation(input~, output~, errors~) else {
    raise InvalidConfiguration("Expected an operation")
  }
  let out : Map[String, Json] = {
    "operationId": Json::string(self.name(operation.id)),
  }
  self.operation_security(operation, out)
  if operation.trait_value("smithy.api#deprecated") is Some(_) {
    out["deprecated"] = Json::boolean(true)
  }
  if trait_string(operation, "smithy.api#documentation") is Some(description) {
    out["description"] = Json::string(description)
  }
  if self.external_docs(operation) is Some(docs) {
    out["externalDocs"] = docs
  }
  let parameters : Array[Json] = []
  if self.protocol == RestJson1 {
    let bindings = self.bindings(input, false)
    for binding in bindings {
      if binding.location == Label {
        let member_name = binding.shape.id.member_name().unwrap()
        guard template.label(member_name) is Some(label) else {
          raise InvalidConfiguration(
            "Missing URI label for `" + binding.shape.id.to_string() + "`",
          )
        }
        let name = label.name +
          (if label.greedy && !self.config.remove_greedy_parameter_suffix {
            "+"
          } else {
            ""
          })
        let parameter = copy_fields(
          self.parameter(binding, name, "path", false),
        )
        let examples = self.binding_examples(operation, binding, RequestExample)
        if !examples.is_empty() {
          parameter["examples"] = Json::object(examples)
        }
        parameters.push(Json::object(parameter))
      }
    }
    for binding in bindings {
      match binding.location {
        Query(name) =>
          parameters.push(
            self.parameter_with_examples(operation, binding, name, "query"),
          )
        QueryParams =>
          parameters.push(
            self.parameter_with_examples(
              operation,
              binding,
              binding.shape.id.member_name().unwrap(),
              "query",
            ),
          )
        _ => ()
      }
    }
    let header_bindings = bindings.filter(binding => {
      binding.location is Header(_)
    })
    header_bindings.sort_by((a, b) => {
      match (a.location, b.location) {
        (Header(a), Header(b)) => a.lexical_compare(b)
        _ => 0
      }
    })
    for binding in header_bindings {
      if binding.location is Header(name) {
        parameters.push(
          self.parameter_with_examples(operation, binding, name, "header"),
        )
      }
    }
  }
  if !parameters.is_empty() {
    out["parameters"] = Json::array(parameters)
  }
  if self.body_content(input, operation, false, operation) is Some(body) {
    let request : Map[String, Json] = { "content": body.content }
    if body.required {
      request["required"] = Json::boolean(true)
    }
    if self.protocol == RpcV2Json {
      request["description"] = Json::string("Request Object")
    }
    out["requestBody"] = Json::object(request)
  }
  let responses : Map[String, Json] = Map([])
  let status = if self.protocol == RpcV2Json {
    200
  } else {
    http_code(operation)
  }
  responses[status.to_string()] = self.response(
    output, operation, status, operation,
  )
  let all_errors : Array[@smithy.ShapeId] = []
  let seen : Set[@smithy.ShapeId] = Set([])
  let service_errors = if self.config.combine_error_responses {
    []
  } else {
    match self.service.body {
      @smithy.Service(errors~, ..) => errors
      _ => []
    }
  }
  for error_id in errors + service_errors {
    if !seen.contains(error_id) {
      seen.add(error_id)
      all_errors.push(error_id)
    }
  }
  for error_id in all_errors {
    if self.model.shapes.get(error_id) is Some(error) {
      let status = error_code(error)
      responses[status.to_string()] = self.response(
        error_id, error, status, operation,
      )
    }
  }
  out["responses"] = Json::object(responses)
  let tags = self.tags(operation)
  if !tags.is_empty() {
    out["tags"] = Json::array(tags.map(Json::string))
  }
  Json::object(out)
}

///|
fn Context::tags(self : Context, shape : @smithy.Shape) -> Array[String] {
  guard self.config.tags &&
    shape.trait_value("smithy.api#tags") is Some(@smithy.Array(tags)) else {
    return []
  }
  let result : Array[String] = []
  for tag in tags {
    if tag is @smithy.String(name) &&
      self.config.supported_tags
      .map(allowed => allowed.contains(name))
      .unwrap_or(true) {
      result.push(name)
    }
  }
  result
}

///|
fn Context::paths(self : Context) -> Json raise OpenApiError {
  let paths : Map[String, Json] = Map([])
  for operation in self.operations() {
    let (method_text, uri) = if self.protocol == RpcV2Json {
      (
        "POST",
        "/service/" +
        self.service.id.name() +
        "/operation/" +
        self.name(operation.id),
      )
    } else {
      guard operation.trait_value("smithy.api#http")
        is Some(@smithy.Object(http)) &&
        http.get("method") is Some(@smithy.String(verb)) &&
        http.get("uri") is Some(@smithy.String(uri)) else {
        continue
      }
      (verb, uri)
    }
    guard UriTemplate::parse(uri) is Some(template) else {
      raise InvalidConfiguration("Invalid operation URI `" + uri + "`")
    }
    if self.config.forbid_greedy_labels && template.has_greedy_label() {
      raise GreedyLabel(operation.id)
    }
    let path = template.path()
    let item = copy_fields(paths.get(path).unwrap_or(Json::object(Map([]))))
    if HttpMethod::parse(method_text) is Some(verb) {
      item[verb.key()] = self.operation(operation, template)
    } else {
      self.warnings.push({
        code: OmittedHttpMethod,
        shape: operation.id,
        message: "OpenAPI omits unsupported HTTP method `" + method_text + "`",
      })
    }
    paths[path] = Json::object(item)
  }
  Json::object(paths)
}

///|
fn referenced_schemas(value : Json) -> Array[String] {
  let pending = [value]
  let names : Array[String] = []
  let prefix = "#/components/schemas/"
  while pending.pop() is Some(node) {
    match node {
      Object(properties) => {
        if properties.get("$ref") is Some(String(reference)) &&
          reference.has_prefix(prefix) {
          names.push(reference[prefix.length():].to_owned())
        }
        for _, value in properties {
          pending.push(value)
        }
      }
      Array(values) => pending.append(values)
      _ => ()
    }
  }
  names
}

///|
fn Context::used_schemas(
  self : Context,
  paths : Json,
) -> Map[String, Json] raise OpenApiError {
  if self.config.keep_unused_components {
    return self.schemas
  }
  let selected : Map[String, Json] = Map([])
  let pending = referenced_schemas(paths)
  while pending.pop() is Some(name) {
    if selected.contains(name) {
      continue
    }
    guard self.schemas.get(name) is Some(schema) else {
      raise SchemaConversion("Dangling OpenAPI schema reference `" + name + "`")
    }
    selected[name] = schema
    pending.append(referenced_schemas(schema))
  }
  selected
}

///|
/// Convert one assembled service and retain warnings about omitted constructs.
pub fn convert_result(
  model : @smithy.Model,
  config : Config,
) -> Conversion raise OpenApiError {
  let context = Context::new(model, config)
  context.prepare_schemas()
  let service = context.service
  let version = match service.body {
    @smithy.Service(version~, ..) => version
    _ => ""
  }
  let info : Map[String, Json] = {
    "title": Json::string(
      trait_string(service, "smithy.api#title").unwrap_or(service.id.name()),
    ),
    "version": Json::string(version),
  }
  if trait_string(service, "smithy.api#documentation") is Some(description) {
    info["description"] = Json::string(description)
  }
  let paths = context.paths()
  let document : Map[String, Json] = {
    "openapi": Json::string(config.version.to_string()),
    "info": Json::object(info),
  }
  if !fields(paths).is_empty() {
    document["paths"] = paths
  }
  let schemas = context.used_schemas(paths)
  let security = context.effective_auth(service)
  if !security.is_empty() {
    document["security"] = context.security_requirements(security)
  }
  let security_schemes = context.security_schemes()
  let components : Map[String, Json] = Map([])
  if !schemas.is_empty() {
    components["schemas"] = Json::object(schemas)
  }
  if !security_schemes.is_empty() {
    components["securitySchemes"] = Json::object(security_schemes)
  }
  if !components.is_empty() {
    document["components"] = Json::object(components)
  }
  if context.external_docs(service) is Some(docs) {
    document["externalDocumentation"] = docs
  }
  let tags = context.tags(service)
  if !tags.is_empty() {
    document["tags"] = Json::array(
      tags.map(name => Json::object({ "name": Json::string(name) })),
    )
  }
  for key, value in fields(config.schema.schema_document_extensions) {
    document[key] = value
  }
  {
    document: apply_json_settings(Json::object(document), config),
    warnings: context.warnings,
  }
}

///|
pub fn convert(
  model : @smithy.Model,
  config : Config,
) -> Json raise OpenApiError {
  convert_result(model, config).document
}