///|
priv struct Operation {
  ctx : Context
  value : Node
  path_item : Node
  parameters : Map[String, Node]
  body : Node?
  body_object : Node?
}

///|
fn http_methods() -> Array[String] {
  ["delete", "get", "head", "options", "patch", "post", "put", "trace"]
}

///|
fn merge_parameters(
  ctx : Context,
  parent : Node,
  result : Map[String, Node],
) -> Unit {
  guard field(parent.raw, "parameters") is Some(raw) else { return }
  let collection = parent.child("parameters", raw)
  guard raw is Array(items) else {
    ctx.diagnose(
      "invalid-parameters", "error", collection, "parameters must be an array.",
    )
    return
  }
  let seen : Array[String] = []
  for index, item in items {
    guard ctx.resolve(collection.child(index.to_string(), item)) is Some(value) else {
      continue
    }
    validate_keys(ctx, value, [
      "name", "in", "description", "required", "deprecated", "allowEmptyValue", "style",
      "explode", "allowReserved", "schema", "example", "examples", "content",
    ])
    let name = string_value(field(value.raw, "name"))
    let place = string_value(field(value.raw, "in"))
    guard name is Some(name) && place is Some(place) else {
      ctx.diagnose(
        "invalid-parameter", "error", value, "Parameters require string name and in.",
      )
      continue
    }
    // OAS 3.0.3 Parameter Object: these header definitions SHALL be ignored.
    if place == "header" &&
      ["Accept", "Content-Type", "Authorization"].contains(name) {
      continue
    }
    if !["query", "header", "path", "cookie"].contains(place) {
      ctx.diagnose(
        "invalid-parameter",
        "error",
        value,
        "Unknown parameter location: " + place,
      )
    }
    validate_bool(ctx, value, "required")
    if place == "path" && !is_true(field(value.raw, "required")) {
      ctx.diagnose(
        "invalid-parameter", "error", value, "Path parameters must be required.",
      )
    }
    let key = place + ":" + name
    if seen.contains(key) {
      ctx.diagnose(
        "duplicate-parameter",
        "error",
        value,
        "Duplicate parameter within one level: " + key,
      )
    }
    seen.push(key)
    result[key] = value
  }
}

///|
fn request_schema(ctx : Context, operation : Node) -> (Node?, Node?) {
  guard field(operation.raw, "requestBody") is Some(raw) else {
    return (None, None)
  }
  guard ctx.resolve(operation.child("requestBody", raw)) is Some(body) else {
    return (None, None)
  }
  validate_keys(ctx, body, ["description", "required", "content"])
  validate_bool(ctx, body, "required")
  guard field(body.raw, "content") is Some(Object(media)) else {
    ctx.diagnose(
      "invalid-request-body", "error", body, "requestBody.content must be an object.",
    )
    return (None, Some(body))
  }
  if media.is_empty() {
    ctx.diagnose(
      "invalid-request-body", "error", body, "requestBody.content must not be empty.",
    )
  }
  for name, _ in media {
    if name != "application/json" {
      ctx.diagnose(
        "unsupported-media-type",
        "unsupported",
        body,
        "V1 supports only application/json request bodies: " + name,
      )
    }
  }
  guard media.get("application/json") is Some(json_media) else {
    return (None, Some(body))
  }
  let content_node = body
    .child("content", Json::object(media))
    .child("application/json", json_media)
  validate_keys(ctx, content_node, ["schema", "example", "examples", "encoding"])
  if field(json_media, "encoding") is Some(_) {
    ctx.diagnose(
      "unsupported-encoding", "unsupported", content_node, "Media encoding is outside V1.",
    )
  }
  guard field(json_media, "schema") is Some(schema) else {
    ctx.diagnose(
      "invalid-request-body", "error", content_node, "The JSON media type needs a schema.",
    )
    return (None, Some(body))
  }
  let schema_node = content_node.child("schema", schema)
  if ctx.resolve(schema_node) is Some(resolved) {
    if string_value(field(resolved.raw, "type")) != Some("object") {
      ctx.diagnose(
        "unsupported-request-body", "unsupported", schema_node, "V1 request bodies must be JSON objects.",
      )
    }
  }
  (Some(schema_node), Some(body))
}

///|
fn collect_operations(
  root : Json,
  side : String,
  diagnostics : Array[Diagnostic],
  budget : WorkBudget,
) -> Map[String, Operation] {
  let operations : Map[String, Operation] = Map([])
  let paths = field(root, "paths").unwrap_or(Json::empty_object())
  for path in keys(paths) {
    if path.has_prefix("x-") {
      continue
    }
    let path_ctx : Context = {
      root,
      side,
      http_method: "",
      path,
      diagnostics,
      budget,
    }
    let path_node = node(
      field(paths, path).unwrap(),
      "/paths/" + pointer_token(path),
    )
    if !path.has_prefix("/") {
      path_ctx.diagnose(
        "invalid-path", "error", path_node, "Path keys must begin with /.",
      )
    }
    guard path_ctx.resolve(path_node) is Some(path_item) else { continue }
    guard path_item.raw is Object(_) else {
      path_ctx.diagnose(
        "invalid-path-item", "error", path_item, "Path items must be objects.",
      )
      continue
    }
    validate_keys(path_ctx, path_item, [
      "summary", "description", "servers", "parameters", "delete", "get", "head",
      "options", "patch", "post", "put", "trace",
    ])
    for verb in http_methods() {
      guard field(path_item.raw, verb) is Some(raw_operation) else { continue }
      let ctx : Context = {
        root,
        side,
        http_method: verb.to_upper(),
        path,
        diagnostics,
        budget,
      }
      let value = path_item.child(verb, raw_operation)
      guard raw_operation is Object(_) else {
        ctx.diagnose(
          "invalid-operation", "error", value, "Operations must be objects.",
        )
        continue
      }
      validate_keys(ctx, value, [
        "tags", "summary", "description", "externalDocs", "operationId", "parameters",
        "requestBody", "responses", "callbacks", "deprecated", "security", "servers",
      ])
      match field(raw_operation, "responses") {
        Some(Object(responses)) =>
          if responses.is_empty() {
            ctx.diagnose(
              "invalid-responses", "error", value, "responses must not be empty.",
            )
          }
        _ =>
          ctx.diagnose(
            "invalid-responses", "error", value, "An operation requires a responses object.",
          )
      }
      if field(raw_operation, "callbacks") is Some(_) {
        ctx.diagnose(
          "unsupported-callbacks", "unsupported", value, "Callback contracts are outside V1.",
        )
      }
      let parameters : Map[String, Node] = Map([])
      merge_parameters(ctx, path_item, parameters)
      merge_parameters(ctx, value, parameters)
      let parameter_keys = parameters.keys().to_array()
      parameter_keys.sort()
      for key in parameter_keys {
        let parameter = parameters[key]
        if string_value(field(parameter.raw, "in")) == Some("path") {
          let name = string_value(field(parameter.raw, "name")).unwrap_or("")
          if !path.contains("{" + name + "}") {
            ctx.diagnose(
              "invalid-path-parameter",
              "error",
              parameter,
              "Path parameter has no matching template expression: " + name,
            )
          }
        }
        if field(parameter.raw, "content") is Some(_) {
          ctx.diagnose(
            "unsupported-parameter", "unsupported", parameter, "Parameter content is outside V1.",
          )
        }
        match field(parameter.raw, "schema") {
          Some(schema) => {
            let schema_node = parameter.child("schema", schema)
            scan_schema(ctx, schema_node, 0)
            if ctx.resolve(schema_node) is Some(resolved) {
              if ![
                  Some("string"),
                  Some("integer"),
                  Some("number"),
                  Some("boolean"),
                ].contains(string_value(field(resolved.raw, "type"))) {
                ctx.diagnose(
                  "unsupported-parameter", "unsupported", schema_node, "V1 parameters must be scalar.",
                )
              }
            }
          }
          None =>
            ctx.diagnose(
              "unsupported-parameter", "unsupported", parameter, "V1 parameters require schema.",
            )
        }
      }
      let (body, body_object) = request_schema(ctx, value)
      if field(value.raw, "responses") is Some(responses) {
        ignore(
          resolved_response(ctx, value.child("responses", responses), true, 0),
        )
      }
      if body is Some(schema) {
        scan_schema(ctx, schema, 0)
      }
      operations[path + " " + verb] = {
        ctx,
        value,
        path_item,
        parameters,
        body,
        body_object,
      }
    }
  }
  operations
}