///|
/// Options controlling AsyncAPI-to-MoonBit project generation.
pub(all) struct CodegenOptions {
  module_name : String
  source_dir : String
  split_files : Bool
  emit_protocol_bindings : Bool
  emit_operation_stubs : Bool
} derive(Eq, Debug)

///|
/// A file produced by the generator. Paths always use forward slashes.
pub(all) struct GeneratedFile {
  path : String
  content : String
  kind : String
} derive(Eq, Debug)

///|
/// A generator diagnostic is separate from contract validation diagnostics.
pub(all) struct CodegenDiagnostic {
  severity : String
  code : String
  path : String
  message : String
} derive(Eq, Debug)

///|
/// A deterministic plan containing every file and generator diagnostic.
pub(all) struct GenerationPlan {
  files : Array[GeneratedFile]
  diagnostics : Array[CodegenDiagnostic]
  schema_count : Int
  message_count : Int
  operation_count : Int
} derive(Eq, Debug)

///|
/// Intermediate representation for a generated MoonBit type.
pub(all) struct GeneratedType {
  source_name : String
  moonbit_name : String
  declaration : String
  dependencies : Array[String]
} derive(Eq, Debug)

///|
pub fn default_codegen_options() -> CodegenOptions {
  {
    module_name: "generated/asyncapi",
    source_dir: "src",
    split_files: true,
    emit_protocol_bindings: true,
    emit_operation_stubs: true,
  }
}

///|
fn[V] sorted_map_keys(values : Map[String, V]) -> Array[String] {
  let keys : Array[String] = []
  for key, _ in values {
    keys.push(key)
  }
  keys.sort_by(fn(left, right) { left.compare(right) })
  keys
}

///|
fn cg_is_letter(character : Char) -> Bool {
  (character >= 'A' && character <= 'Z') ||
  (character >= 'a' && character <= 'z')
}

///|
fn cg_is_digit(character : Char) -> Bool {
  character >= '0' && character <= '9'
}

///|
fn cg_words(source : String) -> Array[String] {
  let words : Array[String] = []
  let current : Array[Char] = []
  for character in source {
    if cg_is_letter(character) || cg_is_digit(character) {
      current.push(character)
    } else if current.length() > 0 {
      words.push(String::from_array(current.copy()))
      current.clear()
    }
  }
  if current.length() > 0 {
    words.push(String::from_array(current))
  }
  words
}

///|
fn cg_capitalize(source : String) -> String {
  let chars = source.to_array()
  if chars.length() == 0 {
    return ""
  }
  chars[0] = chars[0].to_ascii_uppercase()
  String::from_array(chars)
}

///|
/// Convert an arbitrary AsyncAPI component key to a MoonBit type identifier.
pub fn moonbit_type_name(source : String) -> String {
  let words = cg_words(source)
  let mut output = ""
  for word in words {
    output = output + cg_capitalize(word)
  }
  if output.length() == 0 {
    output = "Generated"
  }
  let first = output.to_array()[0]
  if cg_is_digit(first) {
    output = "T" + output
  }
  output
}

///|
fn cg_reserved(name : String) -> Bool {
  let words = [
    "as", "break", "catch", "class", "const", "continue", "derive", "else", "enum",
    "fn", "for", "guard", "if", "impl", "in", "let", "loop", "match", "mut", "priv",
    "pub", "raise", "return", "struct", "test", "trait", "try", "type", "while",
    "with", "async", "await",
  ]
  for word in words {
    if name == word {
      return true
    }
  }
  false
}

///|
/// Convert an arbitrary property or operation key to a MoonBit value identifier.
pub fn moonbit_value_name(source : String) -> String {
  let words = cg_words(source)
  let mut output = ""
  for index, word in words {
    if index == 0 {
      output = word.to_lower()
    } else {
      output = output + cg_capitalize(word)
    }
  }
  if output.length() == 0 {
    output = "generated"
  }
  let first = output.to_array()[0]
  if cg_is_digit(first) {
    output = "value" + output
  }
  if cg_reserved(output) {
    output + "Value"
  } else {
    output
  }
}

///|
fn local_ref_name(reference : String) -> String? {
  let prefix = "#/components/schemas/"
  if reference.has_prefix(prefix) {
    Some(reference[prefix.length():reference.length()].to_owned())
  } else {
    None
  }
}

///|
fn schema_is_required(schema : Schema, name : String) -> Bool {
  for required in schema.required {
    if required == name {
      return true
    }
  }
  false
}

///|
fn primitive_type(kind : String?, format_name : String?) -> String {
  match kind {
    Some("boolean") => "Bool"
    Some("integer") =>
      match format_name {
        Some("int64") => "Int64"
        _ => "Int"
      }
    Some("number") => "Double"
    Some("string") => "String"
    Some("null") => "Unit"
    _ => "Json"
  }
}

///|
fn inline_schema_type(schema : Schema, fallback : String) -> String {
  match schema.ref_name {
    Some(reference) =>
      match local_ref_name(reference) {
        Some(name) => moonbit_type_name(name)
        None => primitive_type(schema.kind, schema.format_name)
      }
    None =>
      if schema.enum_values.length() > 0 {
        fallback
      } else {
        match schema.kind {
          Some("array") => {
            let item_type = match schema.items {
              Some(item) => inline_schema_type(item, fallback + "Item")
              None => "Json"
            }
            "Array[" + item_type + "]"
          }
          Some("object") =>
            if schema.properties.length() == 0 {
              "Map[String, Json]"
            } else {
              fallback
            }
          _ => primitive_type(schema.kind, schema.format_name)
        }
      }
  }
}

///|
fn schema_dependencies(schema : Schema) -> Array[String] {
  let result : Array[String] = []
  let seen : Map[String, Bool] = Map([])
  collect_schema_dependencies(schema, result, seen)
  result.sort_by(fn(left, right) { left.compare(right) })
  result
}

///|
fn collect_schema_dependencies(
  schema : Schema,
  result : Array[String],
  seen : Map[String, Bool],
) -> Unit {
  match schema.ref_name {
    Some(reference) =>
      match local_ref_name(reference) {
        Some(name) =>
          if seen.get(name) is None {
            seen[name] = true
            result.push(name)
          }
        None => ()
      }
    None => ()
  }
  for _, property in schema.properties {
    collect_schema_dependencies(property, result, seen)
  }
  match schema.items {
    Some(item) => collect_schema_dependencies(item, result, seen)
    None => ()
  }
}

///|
fn push_codegen_diagnostic(
  diagnostics : Array[CodegenDiagnostic],
  severity : String,
  code : String,
  path : String,
  message : String,
) -> Unit {
  diagnostics.push({ severity, code, path, message, })
}

///|
fn inspect_schema_for_codegen(
  schema : Schema,
  path : String,
  document : AsyncApiDocument,
  diagnostics : Array[CodegenDiagnostic],
) -> Unit {
  match schema.ref_name {
    Some(reference) =>
      match local_ref_name(reference) {
        Some(name) =>
          if document.schemas.get(name) is None {
            push_codegen_diagnostic(
              diagnostics,
              "error",
              "codegen.ref.unresolved",
              path,
              "local schema reference `" + name + "` does not exist",
            )
          }
        None =>
          push_codegen_diagnostic(
            diagnostics, "error", "codegen.ref.external", path, "external schema references are not supported by project generation",
          )
      }
    None => ()
  }
  let normalized_fields : Map[String, String] = Map([])
  let property_names = sorted_map_keys(schema.properties)
  for property_name in property_names {
    let normalized = moonbit_value_name(property_name)
    match normalized_fields.get(normalized) {
      Some(previous) =>
        push_codegen_diagnostic(
          diagnostics,
          "warning",
          "codegen.field.collision",
          path + ".properties." + property_name,
          "field name collides with `" +
          previous +
          "`; a numeric suffix is emitted",
        )
      None => normalized_fields[normalized] = property_name
    }
    inspect_schema_for_codegen(
      schema.properties.get(property_name).unwrap(),
      path + ".properties." + property_name,
      document,
      diagnostics,
    )
  }
  match schema.items {
    Some(item) =>
      inspect_schema_for_codegen(item, path + ".items", document, diagnostics)
    None => ()
  }
  match schema.kind {
    Some("object")
    | Some("array")
    | Some("string")
    | Some("integer")
    | Some("number")
    | Some("boolean")
    | Some("null")
    | None => ()
    Some(kind) =>
      push_codegen_diagnostic(
        diagnostics,
        "warning",
        "codegen.schema.fallback",
        path + ".type",
        "schema type `" + kind + "` is emitted as Json",
      )
  }
}

///|
fn inspect_codegen_input(
  document : AsyncApiDocument,
  diagnostics : Array[CodegenDiagnostic],
) -> Unit {
  let normalized_types : Map[String, String] = Map([])
  let schema_names = sorted_map_keys(document.schemas)
  for schema_name in schema_names {
    let normalized = moonbit_type_name(schema_name)
    match normalized_types.get(normalized) {
      Some(previous) =>
        push_codegen_diagnostic(
          diagnostics,
          "error",
          "codegen.type.collision",
          "$.components.schemas." + schema_name,
          "schema name collides with `" + previous + "` after normalization",
        )
      None => normalized_types[normalized] = schema_name
    }
    inspect_schema_for_codegen(
      document.schemas.get(schema_name).unwrap(),
      "$.components.schemas." + schema_name,
      document,
      diagnostics,
    )
  }
  let operation_names : Map[String, String] = Map([])
  for operation in document.operations {
    let normalized = moonbit_type_name(operation.name)
    match operation_names.get(normalized) {
      Some(previous) =>
        push_codegen_diagnostic(
          diagnostics,
          "error",
          "codegen.operation.collision",
          "$.operations." + operation.name,
          "operation name collides with `" + previous + "` after normalization",
        )
      None => operation_names[normalized] = operation.name
    }
  }
  let channel_names = sorted_map_keys(document.channels)
  for channel_name in channel_names {
    let channel = document.channels.get(channel_name).unwrap()
    let message_names = sorted_map_keys(channel.messages)
    for message_name in message_names {
      let message = channel.messages.get(message_name).unwrap()
      match message.payload {
        Some(payload) =>
          inspect_schema_for_codegen(
            payload,
            "$.channels." +
            channel_name +
            ".messages." +
            message_name +
            ".payload",
            document,
            diagnostics,
          )
        None => ()
      }
      match message.headers {
        Some(headers) =>
          inspect_schema_for_codegen(
            headers,
            "$.channels." +
            channel_name +
            ".messages." +
            message_name +
            ".headers",
            document,
            diagnostics,
          )
        None => ()
      }
    }
  }
}

///|
fn escape_doc_text(source : String) -> String {
  source.replace(old="\n", new=" ").replace(old="\r", new=" ")
}

///|
fn emit_enum(schema_name : String, schema : Schema) -> String {
  let type_name = moonbit_type_name(schema_name)
  let mut output = "///|\n/// Generated from AsyncAPI schema `" +
    escape_doc_text(schema_name) +
    "`.\n"
  output = output + "pub enum " + type_name + " {\n"
  let used : Map[String, Int] = Map([])
  for raw_value in schema.enum_values {
    let base = moonbit_type_name(raw_value)
    let count = used.get(base).unwrap_or(0)
    used[base] = count + 1
    let variant = if count == 0 { base } else { base + (count + 1).to_string() }
    output = output + "  " + variant + "\n"
  }
  output + "} derive(Eq, Debug)\n"
}

///|
fn emit_struct(schema_name : String, schema : Schema) -> String {
  let type_name = moonbit_type_name(schema_name)
  let mut output = "///|\n/// Generated from AsyncAPI schema `" +
    escape_doc_text(schema_name) +
    "`.\n"
  output = output + "pub(all) struct " + type_name + " {\n"
  let property_names = sorted_map_keys(schema.properties)
  let used : Map[String, Int] = Map([])
  for property_name in property_names {
    let property = schema.properties.get(property_name).unwrap()
    let base = moonbit_value_name(property_name)
    let count = used.get(base).unwrap_or(0)
    used[base] = count + 1
    let field_name = if count == 0 {
      base
    } else {
      base + (count + 1).to_string()
    }
    let nested_name = type_name + moonbit_type_name(property_name)
    let base_type = inline_schema_type(property, nested_name)
    let field_type = if schema_is_required(schema, property_name) {
      base_type
    } else {
      base_type + "?"
    }
    output = output + "  " + field_name + " : " + field_type + "\n"
  }
  output + "} derive(Eq, Debug)\n"
}

///|
fn emit_inline_schema_declarations(
  type_name : String,
  schema : Schema,
) -> String {
  if schema.ref_name is Some(_) {
    return ""
  }
  let mut output = ""
  if schema.kind == Some("array") {
    match schema.items {
      Some(item) =>
        output = output +
          emit_inline_schema_declarations(type_name + "Item", item)
      None => ()
    }
    return output
  }
  if schema.kind != Some("object") || schema.properties.length() == 0 {
    if schema.enum_values.length() > 0 {
      return emit_enum(type_name, schema) + "\n"
    }
    return ""
  }
  let names = sorted_map_keys(schema.properties)
  for name in names {
    let property = schema.properties.get(name).unwrap()
    output = output +
      emit_inline_schema_declarations(
        type_name + moonbit_type_name(name),
        property,
      )
  }
  output + emit_struct(type_name, schema) + "\n"
}

///|
fn emit_alias(schema_name : String, schema : Schema) -> String {
  let type_name = moonbit_type_name(schema_name)
  let target = inline_schema_type(schema, type_name + "Value")
  "///|\n/// Generated from AsyncAPI schema `" +
  escape_doc_text(schema_name) +
  "`.\npub type " +
  type_name +
  " = " +
  target +
  "\n"
}

///|
fn emit_generated_type(schema_name : String, schema : Schema) -> GeneratedType {
  let nested = if schema.kind == Some("object") {
    let mut output = ""
    let names = sorted_map_keys(schema.properties)
    for name in names {
      output = output +
        emit_inline_schema_declarations(
          moonbit_type_name(schema_name) + moonbit_type_name(name),
          schema.properties.get(name).unwrap(),
        )
    }
    output
  } else {
    ""
  }
  let primary = if schema.enum_values.length() > 0 {
    emit_enum(schema_name, schema)
  } else if schema.kind == Some("object") && schema.properties.length() > 0 {
    emit_struct(schema_name, schema)
  } else {
    emit_alias(schema_name, schema)
  }
  let declaration = nested + primary
  {
    source_name: schema_name,
    moonbit_name: moonbit_type_name(schema_name),
    declaration,
    dependencies: schema_dependencies(schema),
  }
}

///|
fn order_generated_types(
  document : AsyncApiDocument,
  diagnostics : Array[CodegenDiagnostic],
) -> Array[GeneratedType] {
  let names = sorted_map_keys(document.schemas)
  let result : Array[GeneratedType] = []
  let emitted : Map[String, Bool] = Map([])
  let mut progress = true
  while result.length() < names.length() && progress {
    progress = false
    for name in names {
      if emitted.get(name) is Some(_) {
        continue
      }
      let schema = document.schemas.get(name).unwrap()
      let dependencies = schema_dependencies(schema)
      let mut ready = true
      for dependency in dependencies {
        if dependency != name &&
          document.schemas.get(dependency) is Some(_) &&
          emitted.get(dependency) is None {
          ready = false
        }
      }
      if ready {
        result.push(emit_generated_type(name, schema))
        emitted[name] = true
        progress = true
      }
    }
  }
  if result.length() < names.length() {
    for name in names {
      if emitted.get(name) is None {
        diagnostics.push({
          severity: "warning",
          code: "codegen.schema.cycle",
          path: "$.components.schemas." + name,
          message: "schema dependency cycle emitted in stable lexical order",
        })
        result.push(
          emit_generated_type(name, document.schemas.get(name).unwrap()),
        )
      }
    }
  }
  result
}

///|
fn message_payload_type(message : AsyncApiMessage, fallback : String) -> String {
  match message.payload {
    Some(payload) => inline_schema_type(payload, fallback)
    None => "Json"
  }
}

///|
fn message_headers_type(message : AsyncApiMessage, fallback : String) -> String {
  match message.headers {
    Some(headers) => inline_schema_type(headers, fallback)
    None => "Map[String, String]"
  }
}

///|
fn emit_messages(document : AsyncApiDocument) -> String {
  let mut output = "/// Generated message envelopes.\n\n"
  let emitted : Map[String, Bool] = Map([])
  let component_names = sorted_map_keys(document.messages)
  for name in component_names {
    let message = document.messages.get(name).unwrap()
    output = output + emit_message(name, message)
    emitted[name] = true
  }
  let channel_names = sorted_map_keys(document.channels)
  for channel_name in channel_names {
    let channel = document.channels.get(channel_name).unwrap()
    let names = sorted_map_keys(channel.messages)
    for name in names {
      let qualified = moonbit_type_name(channel_name) + moonbit_type_name(name)
      if emitted.get(qualified) is None {
        output = output +
          emit_message(qualified, channel.messages.get(name).unwrap())
        emitted[qualified] = true
      }
    }
  }
  output
}

///|
fn emit_message(name : String, message : AsyncApiMessage) -> String {
  let type_name = moonbit_type_name(name) + "Message"
  let payload_type = message_payload_type(message, type_name + "Payload")
  let headers_type = message_headers_type(message, type_name + "Headers")
  let payload_declarations = match message.payload {
    Some(payload) =>
      emit_inline_schema_declarations(type_name + "Payload", payload)
    None => ""
  }
  let header_declarations = match message.headers {
    Some(headers) =>
      emit_inline_schema_declarations(type_name + "Headers", headers)
    None => ""
  }
  payload_declarations +
  header_declarations +
  "///|\n/// Typed envelope for AsyncAPI message `" +
  escape_doc_text(name) +
  "`.\npub(all) struct " +
  type_name +
  " {\n" +
  "  payload : " +
  payload_type +
  "\n" +
  "  headers : " +
  headers_type +
  "\n" +
  "  correlationId : String?\n" +
  "} derive(Eq, Debug)\n\n"
}

///|
fn operation_message_type(
  document : AsyncApiDocument,
  operation : AsyncApiOperation,
) -> String {
  if operation.messages.length() > 0 {
    let message_name = operation.messages[0]
    moonbit_type_name(operation.channel) +
    moonbit_type_name(message_name) +
    "Message"
  } else {
    match document.channels.get(operation.channel) {
      Some(channel) => {
        let names = sorted_map_keys(channel.messages)
        if names.length() > 0 {
          moonbit_type_name(operation.channel) +
          moonbit_type_name(names[0]) +
          "Message"
        } else {
          "Json"
        }
      }
      None => "Json"
    }
  }
}

///|
fn emit_operations(document : AsyncApiDocument) -> String {
  let mut output = "/// Generated transport-neutral operation ports.\n\n"
  let operations = document.operations.copy()
  operations.sort_by(fn(left, right) { left.name.compare(right.name) })
  for operation in operations {
    let function_name = moonbit_value_name(operation.name)
    let message_type = operation_message_type(document, operation)
    let channel_address = match document.channels.get(operation.channel) {
      Some(channel) => channel.address
      None => operation.channel
    }
    output = output +
      "///|\n/// " +
      operation.action +
      " on `" +
      escape_doc_text(channel_address) +
      "`.\n"
    if operation.action == "receive" {
      output = output +
        "pub trait " +
        moonbit_type_name(operation.name) +
        "Handler {\n  " +
        function_name +
        "(Self, " +
        message_type +
        ") -> Unit\n}\n\n"
    } else {
      output = output +
        "pub trait " +
        moonbit_type_name(operation.name) +
        "Publisher {\n  " +
        function_name +
        "(Self, " +
        message_type +
        ") -> Unit\n}\n\n"
    }
  }
  output
}

///|
fn protocol_config_type(protocol : String) -> String {
  match protocol.to_lower() {
    "mqtt" => "MqttBindingConfig"
    "kafka" => "KafkaBindingConfig"
    "amqp" => "AmqpBindingConfig"
    _ => "CustomBindingConfig"
  }
}

///|
fn emit_bindings(document : AsyncApiDocument) -> String {
  let mut output = "/// Transport configuration skeletons; no network I/O is generated.\n\n"
  output = output +
    "pub(all) struct MqttBindingConfig {\n  topic : String\n  qos : Int\n  retain : Bool\n} derive(Eq, Debug)\n\n"
  output = output +
    "pub(all) struct KafkaBindingConfig {\n  topic : String\n  groupId : String?\n} derive(Eq, Debug)\n\n"
  output = output +
    "pub(all) struct AmqpBindingConfig {\n  exchange : String\n  routingKey : String\n} derive(Eq, Debug)\n\n"
  output = output +
    "pub(all) struct CustomBindingConfig {\n  protocol : String\n  address : String\n} derive(Eq, Debug)\n\n"
  let names = sorted_map_keys(document.channels)
  for name in names {
    let channel = document.channels.get(name).unwrap()
    let protocol = channel.binding.unwrap_or("custom")
    output = output +
      "/// `" +
      escape_doc_text(channel.address) +
      "` uses " +
      protocol +
      ".\npub type " +
      moonbit_type_name(name) +
      "Binding = " +
      protocol_config_type(protocol) +
      "\n\n"
  }
  output
}

///|
fn source_header(document : AsyncApiDocument) -> String {
  "// Generated by MoonAsyncAPI from " + document.title + ". DO NOT EDIT.\n\n"
}

///|
fn normalize_source_dir(source_dir : String) -> String {
  let trimmed = source_dir.trim().to_owned().replace(old="\\", new="/")
  if trimmed.length() == 0 {
    "src"
  } else {
    trimmed
  }
}

///|
fn emit_moon_mod(options : CodegenOptions) -> String {
  "name = \"" +
  options.module_name +
  "\"\n\n" +
  "version = \"0.1.0\"\n\n" +
  "license = \"Apache-2.0\"\n"
}

///|
fn schema_uses_json(schema : Schema) -> Bool {
  if schema.ref_name is Some(_) {
    return false
  }
  match schema.kind {
    Some("object") => {
      if schema.properties.length() == 0 {
        return true
      }
      for _, property in schema.properties {
        if schema_uses_json(property) {
          return true
        }
      }
      false
    }
    Some("array") =>
      match schema.items {
        Some(item) => schema_uses_json(item)
        None => true
      }
    Some("string")
    | Some("integer")
    | Some("number")
    | Some("boolean")
    | Some("null") => false
    _ => true
  }
}

///|
fn document_uses_json(document : AsyncApiDocument) -> Bool {
  for _, schema in document.schemas {
    if schema_uses_json(schema) {
      return true
    }
  }
  for _, message in document.messages {
    match message.payload {
      Some(payload) => if schema_uses_json(payload) { return true }
      None => return true
    }
    match message.headers {
      Some(headers) => if schema_uses_json(headers) { return true }
      None => ()
    }
  }
  for _, channel in document.channels {
    for _, message in channel.messages {
      match message.payload {
        Some(payload) => if schema_uses_json(payload) { return true }
        None => return true
      }
      match message.headers {
        Some(headers) => if schema_uses_json(headers) { return true }
        None => ()
      }
    }
  }
  for operation in document.operations {
    if operation.messages.length() == 0 {
      match document.channels.get(operation.channel) {
        Some(channel) => if channel.messages.length() == 0 { return true }
        None => return true
      }
    }
  }
  false
}

///|
fn emit_moon_pkg(needs_json : Bool) -> String {
  if needs_json {
    "import {\n  \"moonbitlang/core/json\",\n}\n"
  } else {
    ""
  }
}

///|
/// Generate a complete MoonBit project plan from a parsed AsyncAPI document.
pub fn generate_project(
  document : AsyncApiDocument,
  options? : CodegenOptions = default_codegen_options(),
) -> GenerationPlan {
  let diagnostics : Array[CodegenDiagnostic] = []
  inspect_codegen_input(document, diagnostics)
  if options.module_name.trim().length() == 0 {
    diagnostics.push({
      severity: "error",
      code: "codegen.module.empty",
      path: "$.options.module_name",
      message: "module name must not be empty",
    })
  }
  let types = order_generated_types(document, diagnostics)
  let files : Array[GeneratedFile] = [
    { path: "moon.mod", content: emit_moon_mod(options), kind: "manifest", },
  ]
  let source_dir = normalize_source_dir(options.source_dir)
  files.push({
    path: source_dir + "/moon.pkg",
    content: emit_moon_pkg(document_uses_json(document)),
    kind: "package",
  })
  if options.split_files {
    for generated_type in types {
      files.push({
        path: source_dir +
        "/types_" +
        generated_type.source_name.to_lower().replace(old="-", new="_") +
        ".mbt",
        content: source_header(document) + generated_type.declaration,
        kind: "schema",
      })
    }
    files.push({
      path: source_dir + "/messages.mbt",
      content: source_header(document) + emit_messages(document),
      kind: "messages",
    })
    if options.emit_operation_stubs {
      files.push({
        path: source_dir + "/operations.mbt",
        content: source_header(document) + emit_operations(document),
        kind: "operations",
      })
    }
    if options.emit_protocol_bindings {
      files.push({
        path: source_dir + "/bindings.mbt",
        content: source_header(document) + emit_bindings(document),
        kind: "bindings",
      })
    }
  } else {
    let mut content = source_header(document)
    for generated_type in types {
      content = content + generated_type.declaration + "\n"
    }
    content = content + emit_messages(document)
    if options.emit_operation_stubs {
      content = content + emit_operations(document)
    }
    if options.emit_protocol_bindings {
      content = content + emit_bindings(document)
    }
    files.push({ path: source_dir + "/generated.mbt", content, kind: "source", })
  }
  {
    files,
    diagnostics,
    schema_count: document.schemas.length(),
    message_count: document_inventory(document).message_count,
    operation_count: document.operations.length(),
  }
}

///|
/// Parse JSON and generate a project in one call.
pub fn generate_from_json(
  input : String,
  options? : CodegenOptions = default_codegen_options(),
) -> Result[GenerationPlan, String] {
  match parse(input) {
    Ok(document) => Ok(generate_project(document, options~))
    Err(error) => Err(error)
  }
}

///|
pub fn find_generated_file(
  plan : GenerationPlan,
  path : String,
) -> GeneratedFile? {
  for file in plan.files {
    if file.path == path {
      return Some(file)
    }
  }
  None
}

///|
pub fn generation_has_errors(plan : GenerationPlan) -> Bool {
  for diagnostic in plan.diagnostics {
    if diagnostic.severity == "error" {
      return true
    }
  }
  false
}

///|
pub fn generation_manifest(plan : GenerationPlan) -> String {
  let files = plan.files.copy()
  files.sort_by(fn(left, right) { left.path.compare(right.path) })
  let mut output = ""
  for file in files {
    output = output +
      file.kind +
      "\t" +
      file.path +
      "\t" +
      file.content.length().to_string() +
      "\n"
  }
  output
}