///|
fn usage() -> String {
  let text =
    #|moon_proto CLI
    #|
    #|Usage:
    #|  moon run cmd/main -- gen --example
    #|  moon run cmd/main -- gen --schema ''
    #|  moon run cmd/main -- doctor --schema ''
    #|  moon run cmd/main -- inspect --schema ''
    #|  moon run cmd/main -- compat --old-schema '' --new-schema ''
    #|  moon run cmd/main -- json-roundtrip --schema '' --message User --json ''
    #|
    #|Commands:
    #|  gen --example          Generate MoonBit source for a built-in demo schema.
    #|  gen --schema     Parse, validate, and generate MoonBit source from inline proto3 text.
    #|  doctor --schema  Parse and validate inline proto3 text, printing stable diagnostics.
    #|  inspect --schema  Print a compact schema summary for reports and debugging.
    #|  compat --old-schema  --new-schema 
    #|                         Check whether a new schema preserves old field and enum contracts.
    #|  json-roundtrip --schema  --message  --json  [--lower-camel]
    #|                         Parse protobuf JSON with a schema and print canonical JSON.
  text
}

///|
fn example_schema() -> String {
  let schema =
    #|syntax = "proto3";
    #|package demo;
    #|enum Color {
    #|  COLOR_UNSPECIFIED = 0;
    #|  RED = 1;
    #|}
    #|message Address {
    #|  string street = 1;
    #|  uint64 zip = 2;
    #|}
    #|message User {
    #|  uint64 id = 1;
    #|  string name = 2;
    #|  repeated string tags = 3;
    #|  Color favorite = 4;
    #|  Address address = 5;
    #|  map counters = 6;
    #|  oneof contact {
    #|    string email = 7;
    #|    string phone = 8;
    #|  }
    #|}
  schema
}

///|
fn join_lines(lines : Array[String]) -> String {
  let mut out = ""
  for i = 0; i < lines.length(); i = i + 1 {
    if i > 0 {
      out = out + "\n"
    }
    out = out + lines[i]
  }
  out
}

///|
fn first_issue_message(result : @proto.SchemaValidationResult) -> String {
  match result {
    @proto.SchemaValid => ""
    @proto.SchemaInvalid(issues) =>
      if issues.length() == 0 {
        "unknown schema validation error"
      } else {
        issues[0].path + ": " + issues[0].message
      }
  }
}

///|
fn scalar_type_name(typ : @proto.ScalarType) -> String {
  match typ {
    @proto.DoubleType => "double"
    @proto.FloatType => "float"
    @proto.Int32Type => "int32"
    @proto.Int64Type => "int64"
    @proto.UInt32Type => "uint32"
    @proto.UInt64Type => "uint64"
    @proto.SInt32Type => "sint32"
    @proto.SInt64Type => "sint64"
    @proto.Fixed32Type => "fixed32"
    @proto.Fixed64Type => "fixed64"
    @proto.SFixed32Type => "sfixed32"
    @proto.SFixed64Type => "sfixed64"
    @proto.BoolType => "bool"
    @proto.StringType => "string"
    @proto.BytesType => "bytes"
    @proto.EnumType(name) => "enum " + name
    @proto.NamedType(name) => name
    @proto.MapType(key_typ, value_typ) =>
      "map<" +
      scalar_type_name(key_typ) +
      ", " +
      scalar_type_name(value_typ) +
      ">"
  }
}

///|
fn reserved_range_text(range : @proto.ReservedNumberRange) -> String {
  if range.start == range.end {
    range.start.to_string()
  } else {
    range.start.to_string() + " to " + range.end.to_string()
  }
}

///|
fn reserved_ranges_text(ranges : Array[@proto.ReservedNumberRange]) -> String {
  let parts : Array[String] = []
  for range in ranges {
    parts.push(reserved_range_text(range))
  }
  join_lines_with(parts, ", ")
}

///|
fn join_lines_with(parts : Array[String], sep : String) -> String {
  let mut out = ""
  for i = 0; i < parts.length(); i = i + 1 {
    if i > 0 {
      out = out + sep
    }
    out = out + parts[i]
  }
  out
}

///|
fn field_label_name(label : @proto.FieldLabel) -> String {
  match label {
    @proto.Singular => "singular"
    @proto.Optional => "optional"
    @proto.Repeated => "repeated"
    @proto.Oneof(group) => "oneof " + group
  }
}

///|
fn validation_report(file : @proto.ProtoFile) -> String {
  let result = @proto.validate_proto_file(file)
  let lines : Array[String] = []
  match result {
    @proto.SchemaValid => {
      lines.push("schema valid")
      lines.push("syntax: " + file.syntax)
      lines.push("package: " + file.package_name)
      lines.push("messages: " + file.messages.length().to_string())
      lines.push("enums: " + file.enums.length().to_string())
    }
    @proto.SchemaInvalid(issues) => {
      lines.push("schema invalid")
      lines.push("issues: " + issues.length().to_string())
      for issue in issues {
        lines.push(issue.path + ": " + issue.message)
      }
    }
  }
  join_lines(lines)
}

///|
fn inspect_report(file : @proto.ProtoFile) -> String {
  let lines : Array[String] = []
  lines.push("syntax: " + file.syntax)
  lines.push("package: " + file.package_name)
  lines.push("messages: " + file.messages.length().to_string())
  for msg in file.messages {
    lines.push(
      "message " + msg.name + " fields=" + msg.fields.length().to_string(),
    )
    for field in msg.fields {
      lines.push(
        "  " +
        field.number.to_string() +
        " " +
        field.name +
        " : " +
        field_label_name(field.label) +
        " " +
        scalar_type_name(field.typ),
      )
    }
    let reservation = reservation_for_owner(file.message_reservations, msg.name)
    if reservation.numbers.length() > 0 {
      lines.push(
        "  reserved numbers: " + reserved_ranges_text(reservation.numbers),
      )
    }
    if reservation.names.length() > 0 {
      lines.push(
        "  reserved names: " + join_lines_with(reservation.names, ", "),
      )
    }
  }
  lines.push("enums: " + file.enums.length().to_string())
  for enum_desc in file.enums {
    lines.push(
      "enum " +
      enum_desc.name +
      " values=" +
      enum_desc.values.length().to_string() +
      " allow_alias=" +
      enum_desc.allow_alias.to_string(),
    )
    for value in enum_desc.values {
      lines.push("  " + value.number.to_string() + " " + value.name)
    }
    let reservation = reservation_for_owner(
      file.enum_reservations,
      enum_desc.name,
    )
    if reservation.numbers.length() > 0 {
      lines.push(
        "  reserved numbers: " + reserved_ranges_text(reservation.numbers),
      )
    }
    if reservation.names.length() > 0 {
      lines.push(
        "  reserved names: " + join_lines_with(reservation.names, ", "),
      )
    }
  }
  join_lines(lines)
}

///|
fn parse_or_print_error(schema : String) -> @proto.ProtoFile? {
  match @proto.parse_proto(schema) {
    @proto.ProtoErr(_) => {
      println("error: failed to parse proto3 schema")
      None
    }
    @proto.ProtoOk(file) => Some(file)
  }
}

///|
fn parse_or_print_named_error(
  label : String,
  schema : String,
) -> @proto.ProtoFile? {
  match @proto.parse_proto(schema) {
    @proto.ProtoErr(_) => {
      println("error: failed to parse " + label + " proto3 schema")
      None
    }
    @proto.ProtoOk(file) => Some(file)
  }
}

///|
fn generate(schema : String) -> Unit {
  match parse_or_print_error(schema) {
    None => ()
    Some(file) =>
      match @proto.validate_proto_file(file) {
        @proto.SchemaInvalid(_) =>
          println(
            "error: " + first_issue_message(@proto.validate_proto_file(file)),
          )
        @proto.SchemaValid => println(@proto.generate_moonbit_source(file))
      }
  }
}

///|
fn doctor(schema : String) -> Unit {
  match parse_or_print_error(schema) {
    None => ()
    Some(file) => println(validation_report(file))
  }
}

///|
fn inspect(schema : String) -> Unit {
  match parse_or_print_error(schema) {
    None => ()
    Some(file) => println(inspect_report(file))
  }
}

///|
fn find_message(
  messages : Array[@proto.MessageDescriptor],
  name : String,
) -> @proto.MessageDescriptor? {
  for msg in messages {
    if msg.name == name {
      return Some(msg)
    }
  }
  None
}

///|
fn find_field_by_number(
  fields : Array[@proto.FieldDescriptor],
  number : Int,
) -> @proto.FieldDescriptor? {
  for field in fields {
    if field.number == number {
      return Some(field)
    }
  }
  None
}

///|
fn find_field_by_name(
  fields : Array[@proto.FieldDescriptor],
  name : String,
) -> @proto.FieldDescriptor? {
  for field in fields {
    if field.name == name {
      return Some(field)
    }
  }
  None
}

///|
fn find_enum(
  enums : Array[@proto.EnumDescriptor],
  name : String,
) -> @proto.EnumDescriptor? {
  for enum_desc in enums {
    if enum_desc.name == name {
      return Some(enum_desc)
    }
  }
  None
}

///|
fn reservation_for_owner(
  reservations : Array[@proto.ReservedDescriptor],
  owner : String,
) -> @proto.ReservedDescriptor {
  let numbers : Array[@proto.ReservedNumberRange] = []
  let names : Array[String] = []
  for reservation in reservations {
    if reservation.owner == owner {
      for range in reservation.numbers {
        numbers.push(range)
      }
      for name in reservation.names {
        names.push(name)
      }
    }
  }
  @proto.ReservedDescriptor::{ owner, numbers, names }
}

///|
fn reserved_range_contains(
  range : @proto.ReservedNumberRange,
  number : Int,
) -> Bool {
  number >= range.start && number <= range.end
}

///|
fn reserved_ranges_contain(
  ranges : Array[@proto.ReservedNumberRange],
  number : Int,
) -> Bool {
  for range in ranges {
    if reserved_range_contains(range, number) {
      return true
    }
  }
  false
}

///|
fn reserved_names_contain(names : Array[String], name : String) -> Bool {
  for reserved_name in names {
    if reserved_name == name {
      return true
    }
  }
  false
}

///|
fn reserved_range_covered(
  ranges : Array[@proto.ReservedNumberRange],
  wanted : @proto.ReservedNumberRange,
) -> Bool {
  for range in ranges {
    if range.start <= wanted.start && range.end >= wanted.end {
      return true
    }
  }
  false
}

///|
fn find_enum_value_by_number(
  values : Array[@proto.EnumValueDescriptor],
  number : Int,
) -> @proto.EnumValueDescriptor? {
  for value in values {
    if value.number == number {
      return Some(value)
    }
  }
  None
}

///|
fn find_enum_value_by_name(
  values : Array[@proto.EnumValueDescriptor],
  name : String,
) -> @proto.EnumValueDescriptor? {
  for value in values {
    if value.name == name {
      return Some(value)
    }
  }
  None
}

///|
fn push_compat_issue(
  issues : Array[String],
  path : String,
  message : String,
) -> Unit {
  issues.push(path + ": " + message)
}

///|
fn append_validation_issues(
  issues : Array[String],
  prefix : String,
  result : @proto.SchemaValidationResult,
) -> Unit {
  match result {
    @proto.SchemaValid => ()
    @proto.SchemaInvalid(schema_issues) =>
      for issue in schema_issues {
        push_compat_issue(issues, prefix + "." + issue.path, issue.message)
      }
  }
}

///|
fn compare_field_compat(
  issues : Array[String],
  message_name : String,
  old_field : @proto.FieldDescriptor,
  new_fields : Array[@proto.FieldDescriptor],
  new_reservation : @proto.ReservedDescriptor,
) -> Unit {
  let path = "message." +
    message_name +
    ".field." +
    old_field.number.to_string()
  match find_field_by_number(new_fields, old_field.number) {
    Some(new_field) => {
      if new_field.name != old_field.name {
        push_compat_issue(
          issues,
          path,
          "field name changed from " + old_field.name + " to " + new_field.name,
        )
      }
      if new_field.typ != old_field.typ {
        push_compat_issue(
          issues,
          path,
          "field type changed from " +
          scalar_type_name(old_field.typ) +
          " to " +
          scalar_type_name(new_field.typ),
        )
      }
      if new_field.label != old_field.label {
        push_compat_issue(
          issues,
          path,
          "field label changed from " +
          field_label_name(old_field.label) +
          " to " +
          field_label_name(new_field.label),
        )
      }
    }
    None =>
      match find_field_by_name(new_fields, old_field.name) {
        Some(new_field) =>
          push_compat_issue(
            issues,
            "message." + message_name + ".field." + old_field.name,
            "field number changed from " +
            old_field.number.to_string() +
            " to " +
            new_field.number.to_string(),
          )
        None => {
          let reserved_number = reserved_ranges_contain(
            new_reservation.numbers,
            old_field.number,
          )
          let reserved_name = reserved_names_contain(
            new_reservation.names,
            old_field.name,
          )
          if !(reserved_number && reserved_name) {
            push_compat_issue(
              issues,
              path,
              "field removed without reserving number and name: " +
              old_field.name,
            )
          }
        }
      }
  }
}

///|
fn compare_message_reservation_compat(
  issues : Array[String],
  owner : String,
  old_reservation : @proto.ReservedDescriptor,
  new_reservation : @proto.ReservedDescriptor,
) -> Unit {
  for range in old_reservation.numbers {
    if !reserved_range_covered(new_reservation.numbers, range) {
      push_compat_issue(
        issues,
        "message." + owner + ".reserved." + range.start.to_string(),
        "reserved number range not preserved: " + reserved_range_text(range),
      )
    }
  }
  for name in old_reservation.names {
    if !reserved_names_contain(new_reservation.names, name) {
      push_compat_issue(
        issues,
        "message." + owner + ".reserved." + name,
        "reserved name not preserved",
      )
    }
  }
}

///|
fn compare_message_compat(
  issues : Array[String],
  old_msg : @proto.MessageDescriptor,
  new_msg : @proto.MessageDescriptor,
  old_reservation : @proto.ReservedDescriptor,
  new_reservation : @proto.ReservedDescriptor,
) -> Unit {
  for old_field in old_msg.fields {
    compare_field_compat(
      issues,
      old_msg.name,
      old_field,
      new_msg.fields,
      new_reservation,
    )
  }
  compare_message_reservation_compat(
    issues,
    old_msg.name,
    old_reservation,
    new_reservation,
  )
  for new_field in new_msg.fields {
    if reserved_ranges_contain(old_reservation.numbers, new_field.number) {
      push_compat_issue(
        issues,
        "message." + old_msg.name + ".field." + new_field.number.to_string(),
        "field uses previously reserved number",
      )
    }
    if reserved_names_contain(old_reservation.names, new_field.name) {
      push_compat_issue(
        issues,
        "message." + old_msg.name + ".field." + new_field.name,
        "field uses previously reserved name",
      )
    }
  }
}

///|
fn compare_enum_compat(
  issues : Array[String],
  old_enum : @proto.EnumDescriptor,
  new_enum : @proto.EnumDescriptor,
  old_reservation : @proto.ReservedDescriptor,
  new_reservation : @proto.ReservedDescriptor,
) -> Unit {
  for old_value in old_enum.values {
    let path = "enum." +
      old_enum.name +
      ".value." +
      old_value.number.to_string()
    match find_enum_value_by_number(new_enum.values, old_value.number) {
      Some(new_value) =>
        if new_value.name != old_value.name {
          push_compat_issue(
            issues,
            path,
            "enum value name changed from " +
            old_value.name +
            " to " +
            new_value.name,
          )
        }
      None =>
        match find_enum_value_by_name(new_enum.values, old_value.name) {
          Some(new_value) =>
            push_compat_issue(
              issues,
              "enum." + old_enum.name + ".value." + old_value.name,
              "enum value number changed from " +
              old_value.number.to_string() +
              " to " +
              new_value.number.to_string(),
            )
          None => {
            let reserved_number = reserved_ranges_contain(
              new_reservation.numbers,
              old_value.number,
            )
            let reserved_name = reserved_names_contain(
              new_reservation.names,
              old_value.name,
            )
            if !(reserved_number && reserved_name) {
              push_compat_issue(
                issues,
                path,
                "enum value removed without reserving number and name: " +
                old_value.name,
              )
            }
          }
        }
    }
  }
  for range in old_reservation.numbers {
    if !reserved_range_covered(new_reservation.numbers, range) {
      push_compat_issue(
        issues,
        "enum." + old_enum.name + ".reserved." + range.start.to_string(),
        "reserved number range not preserved: " + reserved_range_text(range),
      )
    }
  }
  for name in old_reservation.names {
    if !reserved_names_contain(new_reservation.names, name) {
      push_compat_issue(
        issues,
        "enum." + old_enum.name + ".reserved." + name,
        "reserved name not preserved",
      )
    }
  }
  for new_value in new_enum.values {
    if reserved_ranges_contain(old_reservation.numbers, new_value.number) {
      push_compat_issue(
        issues,
        "enum." + old_enum.name + ".value." + new_value.number.to_string(),
        "enum value uses previously reserved number",
      )
    }
    if reserved_names_contain(old_reservation.names, new_value.name) {
      push_compat_issue(
        issues,
        "enum." + old_enum.name + ".value." + new_value.name,
        "enum value uses previously reserved name",
      )
    }
  }
}

///|
fn compatibility_issues(
  old_file : @proto.ProtoFile,
  new_file : @proto.ProtoFile,
) -> Array[String] {
  let issues : Array[String] = []
  append_validation_issues(issues, "old", @proto.validate_proto_file(old_file))
  append_validation_issues(issues, "new", @proto.validate_proto_file(new_file))
  if issues.length() > 0 {
    return issues
  }
  if old_file.syntax != new_file.syntax {
    push_compat_issue(
      issues,
      "syntax",
      "changed from " + old_file.syntax + " to " + new_file.syntax,
    )
  }
  if old_file.package_name != new_file.package_name {
    push_compat_issue(
      issues,
      "package",
      "changed from " + old_file.package_name + " to " + new_file.package_name,
    )
  }
  for old_msg in old_file.messages {
    match find_message(new_file.messages, old_msg.name) {
      None =>
        push_compat_issue(issues, "message." + old_msg.name, "message removed")
      Some(new_msg) =>
        compare_message_compat(
          issues,
          old_msg,
          new_msg,
          reservation_for_owner(old_file.message_reservations, old_msg.name),
          reservation_for_owner(new_file.message_reservations, old_msg.name),
        )
    }
  }
  for old_enum in old_file.enums {
    match find_enum(new_file.enums, old_enum.name) {
      None => push_compat_issue(issues, "enum." + old_enum.name, "enum removed")
      Some(new_enum) =>
        compare_enum_compat(
          issues,
          old_enum,
          new_enum,
          reservation_for_owner(old_file.enum_reservations, old_enum.name),
          reservation_for_owner(new_file.enum_reservations, old_enum.name),
        )
    }
  }
  issues
}

///|
fn compat_report(
  old_file : @proto.ProtoFile,
  new_file : @proto.ProtoFile,
) -> String {
  let issues = compatibility_issues(old_file, new_file)
  let lines : Array[String] = []
  if issues.length() == 0 {
    lines.push("schema compatible")
    lines.push("old messages: " + old_file.messages.length().to_string())
    lines.push("new messages: " + new_file.messages.length().to_string())
    lines.push("old enums: " + old_file.enums.length().to_string())
    lines.push("new enums: " + new_file.enums.length().to_string())
  } else {
    lines.push("schema incompatible")
    lines.push("issues: " + issues.length().to_string())
    for issue in issues {
      lines.push(issue)
    }
  }
  join_lines(lines)
}

///|
fn compat(old_schema : String, new_schema : String) -> Unit {
  match
    (
      parse_or_print_named_error("old", old_schema),
      parse_or_print_named_error("new", new_schema),
    ) {
    (Some(old_file), Some(new_file)) =>
      println(compat_report(old_file, new_file))
    _ => ()
  }
}

///|
fn decode_error_text(err : @proto.DecodeError) -> String {
  match err {
    @proto.UnexpectedEof => "unexpected EOF"
    @proto.VarintOverflow => "varint overflow"
    @proto.InvalidWireType(value) => "invalid wire type: " + value.to_string()
    @proto.InvalidFieldNumber(value) =>
      "invalid field number: " + value.to_string()
    @proto.NegativeLength => "negative length"
    @proto.Unsupported(message) => message
  }
}

///|
fn json_roundtrip(
  schema : String,
  message_name : String,
  json_input : String,
  lower_camel : Bool,
) -> Unit {
  match parse_or_print_error(schema) {
    None => ()
    Some(file) =>
      match @proto.validate_proto_file(file) {
        @proto.SchemaInvalid(_) =>
          println(
            "error: " + first_issue_message(@proto.validate_proto_file(file)),
          )
        @proto.SchemaValid =>
          match find_message(file.messages, message_name) {
            None => println("error: message not found: " + message_name)
            Some(desc) =>
              match
                @proto.json_to_message_with_schema(
                  desc,
                  file.messages,
                  file.enums,
                  json_input,
                ) {
                @proto.JsonDecodeErr(err) =>
                  println(
                    "error: json decode failed: " + decode_error_text(err),
                  )
                @proto.JsonDecodeOk(message) => {
                  let encoded = if lower_camel {
                    @proto.message_to_json_lower_camel_with_schema(
                      desc,
                      file.messages,
                      file.enums,
                      message,
                    )
                  } else {
                    @proto.message_to_json_with_schema(
                      desc,
                      file.messages,
                      file.enums,
                      message,
                    )
                  }
                  match encoded {
                    @proto.JsonErr(err) =>
                      println(
                        "error: json encode failed: " + decode_error_text(err),
                      )
                    @proto.JsonOk(text) => println(text)
                  }
                }
              }
          }
      }
  }
}

///|
fn arg_at(args : Array[String], index : Int) -> String? {
  if index < args.length() {
    Some(args[index])
  } else {
    None
  }
}

///|
fn main {
  let args = @env.args()
  match arg_at(args, 1) {
    Some("gen") =>
      match (arg_at(args, 2), arg_at(args, 3)) {
        (Some("--example"), _) => generate(example_schema())
        (Some("--schema"), Some(schema)) => generate(schema)
        _ => println(usage())
      }
    Some("doctor") =>
      match (arg_at(args, 2), arg_at(args, 3)) {
        (Some("--schema"), Some(schema)) => doctor(schema)
        _ => println(usage())
      }
    Some("inspect") =>
      match (arg_at(args, 2), arg_at(args, 3)) {
        (Some("--schema"), Some(schema)) => inspect(schema)
        _ => println(usage())
      }
    Some("compat") =>
      match
        (arg_at(args, 2), arg_at(args, 3), arg_at(args, 4), arg_at(args, 5)) {
        (
          Some("--old-schema"),
          Some(old_schema),
          Some("--new-schema"),
          Some(new_schema),
        ) => compat(old_schema, new_schema)
        _ => println(usage())
      }
    Some("json-roundtrip") =>
      match
        (
          arg_at(args, 2),
          arg_at(args, 3),
          arg_at(args, 4),
          arg_at(args, 5),
          arg_at(args, 6),
          arg_at(args, 7),
          arg_at(args, 8),
        ) {
        (
          Some("--schema"),
          Some(schema),
          Some("--message"),
          Some(message_name),
          Some("--json"),
          Some(json_input),
          None,
        ) => json_roundtrip(schema, message_name, json_input, false)
        (
          Some("--schema"),
          Some(schema),
          Some("--message"),
          Some(message_name),
          Some("--json"),
          Some(json_input),
          Some("--lower-camel"),
        ) => json_roundtrip(schema, message_name, json_input, true)
        _ => println(usage())
      }
    Some("--help") | Some("help") | None => println(usage())
    _ => println(usage())
  }
}