///|
/// One schema validation issue with a stable path for diagnostics.
pub(all) struct SchemaIssue {
  path : String
  message : String
} derive(Debug, Eq)

///|
/// Result of validating a proto schema model.
pub(all) enum SchemaValidationResult {
  SchemaValid
  SchemaInvalid(Array[SchemaIssue])
} derive(Debug, Eq)

///|
fn schema_issue(path : String, message : String) -> SchemaIssue {
  SchemaIssue::{ path, message }
}

///|
fn push_issue(
  issues : Array[SchemaIssue],
  path : String,
  message : String,
) -> Unit {
  issues.push(schema_issue(path, message))
}

///|
fn validate_nonempty_name(
  issues : Array[SchemaIssue],
  path : String,
  name : String,
) -> Unit {
  if name.length() == 0 {
    push_issue(issues, path, "name must not be empty")
  }
}

///|
/// Protobuf field numbers must be positive, at most 2^29 - 1, and must not
/// fall inside the implementation-reserved 19000..19999 range.
pub fn is_valid_field_number(number : Int) -> Bool {
  number > 0 && number <= 536870911 && (number < 19000 || number > 19999)
}

///|
fn validate_field_descriptor(
  issues : Array[SchemaIssue],
  message_name : String,
  field : FieldDescriptor,
) -> Unit {
  let path = "message." + message_name + ".field." + field.name
  validate_nonempty_name(issues, path + ".name", field.name)
  if !is_valid_field_number(field.number) {
    push_issue(issues, path + ".number", "invalid protobuf field number")
  }
  match field.label {
    Singular | Optional | Repeated => ()
    Oneof(group) => validate_nonempty_name(issues, path + ".oneof", group)
  }
  match field.typ {
    MapType(key_typ, value_typ) => {
      if field.label != Repeated {
        push_issue(issues, path + ".label", "map field must be repeated")
      }
      if !is_valid_map_key_type(key_typ) {
        push_issue(issues, path + ".key", "invalid map key type")
      }
      match value_typ {
        MapType(_, _) =>
          push_issue(issues, path + ".value", "map value must not be a map")
        _ => ()
      }
    }
    _ => ()
  }
}

///|
/// Protobuf map keys may be integral, bool or string scalar types.
pub fn is_valid_map_key_type(typ : ScalarType) -> Bool {
  match typ {
    Int32Type
    | Int64Type
    | UInt32Type
    | UInt64Type
    | SInt32Type
    | SInt64Type
    | Fixed32Type
    | Fixed64Type
    | SFixed32Type
    | SFixed64Type
    | BoolType
    | StringType => true
    DoubleType
    | FloatType
    | BytesType
    | EnumType(_)
    | NamedType(_)
    | MapType(_, _) => false
  }
}

///|
fn has_duplicate_field_name(
  fields : Array[FieldDescriptor],
  index : Int,
) -> Bool {
  for i = 0; i < index; i = i + 1 {
    if fields[i].name == fields[index].name {
      return true
    }
  }
  false
}

///|
fn has_duplicate_field_number(
  fields : Array[FieldDescriptor],
  index : Int,
) -> Bool {
  for i = 0; i < index; i = i + 1 {
    if fields[i].number == fields[index].number {
      return true
    }
  }
  false
}

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

///|
fn reserved_ranges_contain(
  ranges : Array[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 reservation_for_owner(
  reservations : Array[ReservedDescriptor],
  owner : String,
) -> ReservedDescriptor {
  let numbers : Array[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)
      }
    }
  }
  ReservedDescriptor::{ owner, numbers, names }
}

///|
fn has_duplicate_reserved_name(names : Array[String], index : Int) -> Bool {
  for i = 0; i < index; i = i + 1 {
    if names[i] == names[index] {
      return true
    }
  }
  false
}

///|
fn reserved_range_overlaps(
  ranges : Array[ReservedNumberRange],
  index : Int,
) -> Bool {
  let current = ranges[index]
  for i = 0; i < index; i = i + 1 {
    let other = ranges[i]
    if current.start <= other.end && other.start <= current.end {
      return true
    }
  }
  false
}

///|
fn validate_reserved_descriptor(
  issues : Array[SchemaIssue],
  path : String,
  reservation : ReservedDescriptor,
  is_enum : Bool,
) -> Unit {
  for i = 0; i < reservation.numbers.length(); i = i + 1 {
    let range = reservation.numbers[i]
    let range_path = path + ".reserved." + range.start.to_string()
    if range.start > range.end {
      push_issue(issues, range_path, "reserved range start must be <= end")
    }
    let start_valid = if is_enum {
      is_valid_enum_reserved_number(range.start)
    } else {
      is_valid_field_number(range.start)
    }
    let end_valid = if is_enum {
      is_valid_enum_reserved_number(range.end)
    } else {
      is_valid_field_number(range.end)
    }
    if !start_valid || !end_valid {
      push_issue(issues, range_path, "invalid reserved number range")
    }
    if reserved_range_overlaps(reservation.numbers, i) {
      push_issue(issues, range_path, "overlapping reserved number range")
    }
  }
  for i = 0; i < reservation.names.length(); i = i + 1 {
    let name = reservation.names[i]
    let name_path = path + ".reserved." + name
    validate_nonempty_name(issues, name_path, name)
    if has_duplicate_reserved_name(reservation.names, i) {
      push_issue(issues, name_path, "duplicate reserved name")
    }
  }
}

///|
fn validate_message_reservations(
  issues : Array[SchemaIssue],
  message : MessageDescriptor,
  reservation : ReservedDescriptor,
) -> Unit {
  let path = "message." + message.name
  validate_reserved_descriptor(issues, path, reservation, false)
  for field in message.fields {
    if reserved_ranges_contain(reservation.numbers, field.number) {
      push_issue(
        issues,
        path + ".field." + field.name + ".number",
        "field uses reserved number",
      )
    }
    if reserved_names_contain(reservation.names, field.name) {
      push_issue(
        issues,
        path + ".field." + field.name + ".name",
        "field uses reserved name",
      )
    }
  }
}

///|
pub fn validate_message_descriptor(
  message : MessageDescriptor,
) -> SchemaValidationResult {
  let issues : Array[SchemaIssue] = []
  validate_nonempty_name(issues, "message.name", message.name)
  for i = 0; i < message.fields.length(); i = i + 1 {
    let field = message.fields[i]
    validate_field_descriptor(issues, message.name, field)
    if has_duplicate_field_name(message.fields, i) {
      push_issue(
        issues,
        "message." + message.name + ".field." + field.name,
        "duplicate field name",
      )
    }
    if has_duplicate_field_number(message.fields, i) {
      push_issue(
        issues,
        "message." + message.name + ".field." + field.number.to_string(),
        "duplicate field number",
      )
    }
  }
  if issues.length() == 0 {
    SchemaValid
  } else {
    SchemaInvalid(issues)
  }
}

///|
fn has_duplicate_enum_value_name(
  values : Array[EnumValueDescriptor],
  index : Int,
) -> Bool {
  for i = 0; i < index; i = i + 1 {
    if values[i].name == values[index].name {
      return true
    }
  }
  false
}

///|
fn has_duplicate_enum_value_number(
  values : Array[EnumValueDescriptor],
  index : Int,
) -> Bool {
  for i = 0; i < index; i = i + 1 {
    if values[i].number == values[index].number {
      return true
    }
  }
  false
}

///|
fn is_valid_enum_reserved_number(number : Int) -> Bool {
  number >= -2147483647 - 1 && number <= 2147483647
}

///|
fn validate_enum_reservations(
  issues : Array[SchemaIssue],
  enum_desc : EnumDescriptor,
  reservation : ReservedDescriptor,
) -> Unit {
  let path = "enum." + enum_desc.name
  validate_reserved_descriptor(issues, path, reservation, true)
  for value in enum_desc.values {
    if reserved_ranges_contain(reservation.numbers, value.number) {
      push_issue(
        issues,
        path + ".value." + value.name + ".number",
        "enum value uses reserved number",
      )
    }
    if reserved_names_contain(reservation.names, value.name) {
      push_issue(
        issues,
        path + ".value." + value.name + ".name",
        "enum value uses reserved name",
      )
    }
  }
}

///|
pub fn validate_enum_descriptor(
  enum_desc : EnumDescriptor,
) -> SchemaValidationResult {
  let issues : Array[SchemaIssue] = []
  validate_nonempty_name(issues, "enum.name", enum_desc.name)
  if enum_desc.values.length() == 0 {
    push_issue(
      issues,
      "enum." + enum_desc.name + ".values",
      "proto3 enum must have at least one value",
    )
  } else if enum_desc.values[0].number != 0 {
    push_issue(
      issues,
      "enum." + enum_desc.name + ".values[0]",
      "proto3 enum first value must be zero",
    )
  }
  for i = 0; i < enum_desc.values.length(); i = i + 1 {
    let value = enum_desc.values[i]
    validate_nonempty_name(
      issues,
      "enum." + enum_desc.name + ".value." + value.name,
      value.name,
    )
    if has_duplicate_enum_value_name(enum_desc.values, i) {
      push_issue(
        issues,
        "enum." + enum_desc.name + ".value." + value.name,
        "duplicate enum value name",
      )
    }
    if !enum_desc.allow_alias &&
      has_duplicate_enum_value_number(enum_desc.values, i) {
      push_issue(
        issues,
        "enum." + enum_desc.name + ".value." + value.number.to_string(),
        "duplicate enum value number",
      )
    }
  }
  if issues.length() == 0 {
    SchemaValid
  } else {
    SchemaInvalid(issues)
  }
}

///|
fn append_validation_result(
  issues : Array[SchemaIssue],
  result : SchemaValidationResult,
) -> Unit {
  match result {
    SchemaValid => ()
    SchemaInvalid(more) =>
      for issue in more {
        issues.push(issue)
      }
  }
}

///|
fn has_duplicate_top_level_message(
  messages : Array[MessageDescriptor],
  index : Int,
) -> Bool {
  for i = 0; i < index; i = i + 1 {
    if messages[i].name == messages[index].name {
      return true
    }
  }
  false
}

///|
fn has_duplicate_top_level_enum(
  enums : Array[EnumDescriptor],
  index : Int,
) -> Bool {
  for i = 0; i < index; i = i + 1 {
    if enums[i].name == enums[index].name {
      return true
    }
  }
  false
}

///|
fn enum_conflicts_with_message(
  messages : Array[MessageDescriptor],
  enum_name : String,
) -> Bool {
  for message in messages {
    if message.name == enum_name {
      return true
    }
  }
  false
}

///|
pub fn validate_proto_file(file : ProtoFile) -> SchemaValidationResult {
  let issues : Array[SchemaIssue] = []
  if file.syntax != "proto3" {
    push_issue(issues, "syntax", "only proto3 syntax is supported")
  }
  for i = 0; i < file.messages.length(); i = i + 1 {
    let message = file.messages[i]
    append_validation_result(issues, validate_message_descriptor(message))
    validate_message_reservations(
      issues,
      message,
      reservation_for_owner(file.message_reservations, message.name),
    )
    if has_duplicate_top_level_message(file.messages, i) {
      push_issue(
        issues,
        "message." + message.name,
        "duplicate top-level message name",
      )
    }
  }
  for i = 0; i < file.enums.length(); i = i + 1 {
    let enum_desc = file.enums[i]
    append_validation_result(issues, validate_enum_descriptor(enum_desc))
    validate_enum_reservations(
      issues,
      enum_desc,
      reservation_for_owner(file.enum_reservations, enum_desc.name),
    )
    if has_duplicate_top_level_enum(file.enums, i) {
      push_issue(
        issues,
        "enum." + enum_desc.name,
        "duplicate top-level enum name",
      )
    }
    if enum_conflicts_with_message(file.messages, enum_desc.name) {
      push_issue(
        issues,
        "enum." + enum_desc.name,
        "top-level enum name conflicts with message name",
      )
    }
  }
  if issues.length() == 0 {
    SchemaValid
  } else {
    SchemaInvalid(issues)
  }
}

///|
pub fn schema_is_valid(file : ProtoFile) -> Bool {
  match validate_proto_file(file) {
    SchemaValid => true
    SchemaInvalid(_) => false
  }
}