///|
fn plain_field(line : Line, ds : Array[Diagnostic]) -> Attribute? {
  let field = parse_field(line, ds)
  let parts = line.text.split(":").to_array()
  if parts.length() > 1 &&
    ((parts[1] == "" && parts.length() > 2) || parts[1].has_prefix("<")) {
    diagnose(
      ds,
      "encoded-keyword",
      "error",
      line.span,
      "This structural keyword requires a plain value.",
    )
  }
  field
}

///|
fn line_name(line : Line) -> String {
  ascii_lower(line.text.split(":").to_array()[0].to_owned())
}

///|
fn parse_control(line : Line, ds : Array[Diagnostic]) -> Control? {
  // Controls have a distinct grammar: OID [criticality] [value-spec].
  let b = @utf8.encode(line.text[:])
  if b.length() > 8 && b[8] == 58 {
    diagnose(
      ds,
      "unsupported-control-encoding",
      "unsupported",
      line.span,
      "Whole-control Base64 encoding is a tool extension outside this profile. Encode only the control value after the OID and criticality.",
    )
    return None
  }
  let mut i = 8 // control:
  while i < b.length() && b[i] == 32 {
    i = i + 1
  }
  let begin = i
  while i < b.length() && b[i] != 32 && b[i] != 58 {
    i = i + 1
  }
  let oid = @utf8.decode(b[begin:i]) catch { _ => return None }
  if !valid_oid(oid) {
    diagnose(
      ds,
      "invalid-control-oid",
      "error",
      line.span,
      "Control requires a dotted numeric OID.",
    )
    return None
  }
  let space_before = i < b.length() && b[i] == 32
  while i < b.length() && b[i] == 32 {
    i = i + 1
  }
  let mut critical = false
  if i < b.length() && b[i] != 58 {
    let begin = i
    while i < b.length() && b[i] != 32 && b[i] != 58 {
      i = i + 1
    }
    let flag = @utf8.decode(b[begin:i]) catch { _ => return None }
    if !space_before ||
      !(ascii_lower(flag) == "true" || ascii_lower(flag) == "false") {
      diagnose(
        ds,
        "invalid-criticality",
        "error",
        line.span,
        "Expected true or false after the control OID.",
      )
      return None
    }
    critical = ascii_lower(flag) == "true"
    while i < b.length() && b[i] == 32 {
      i = i + 1
    }
  }
  let value = if i < b.length() {
    if b[i] != 58 {
      diagnose(
        ds,
        "invalid-control-value",
        "error",
        line.span,
        "Expected a control value separator.",
      )
      return None
    }
    let suffix = @utf8.decode(b[i:]) catch { _ => return None }
    match parse_field({ text: "controlValue" + suffix, span: line.span, }, ds) {
      Some(field) => Some(field.value)
      None => return None
    }
  } else {
    None
  }
  Some({ oid, critical, value, span: line.span, })
}

///|
fn read_attributes(
  lines : Array[Line],
  start : Int,
  ds : Array[Diagnostic],
) -> Array[Attribute] {
  let attrs = []
  for i in start.. attrs.push(a)
      None => ()
    }
  }
  if attrs.is_empty() {
    diagnose(
      ds,
      "missing-attributes",
      "error",
      lines[0].span,
      "Content and add records require at least one attribute value.",
    )
  }
  attrs
}

///|
fn read_modifications(
  lines : Array[Line],
  start : Int,
  ds : Array[Diagnostic],
) -> Array[Modification] {
  let mods : Array[Modification] = []
  let mut i = start
  while i < lines.length() {
    let head = lines[i]
    let field = match plain_field(head, ds) {
      Some(f) => f
      None => {
        i = i + 1
        continue
      }
    }
    let operation = ascii_lower(field.name)
    let attribute = match field_text(field, ds) {
      Some(s) => s
      None => ""
    }
    if !(operation == "add" || operation == "delete" || operation == "replace") {
      diagnose(
        ds,
        "unsupported-modification",
        "unsupported",
        head.span,
        "Only add, delete and replace modification operations are supported.",
      )
    }
    if !valid_attribute(attribute) {
      diagnose(
        ds,
        "invalid-modification-attribute",
        "error",
        head.span,
        "Modification header requires an attribute description.",
      )
    }
    i = i + 1
    let values : Array[Attribute] = []
    while i < lines.length() && lines[i].text != "-" {
      match parse_field(lines[i], ds) {
        Some(a) => {
          if !attribute_equal(a.name, attribute) {
            diagnose(
              ds,
              "modification-attribute-mismatch",
              "error",
              a.span,
              "Value attribute does not match the modification header.",
            )
          }
          values.push(a)
        }
        None => ()
      }
      i = i + 1
    }
    let end_line = if i < lines.length() {
      lines[i].span.end_line
    } else {
      lines[lines.length() - 1].span.end_line
    }
    if i == lines.length() {
      diagnose(
        ds,
        "missing-modification-terminator",
        "error",
        head.span,
        "Each modification must end with a '-' line.",
      )
    } else {
      i = i + 1
    }
    mods.push({
      operation,
      attribute,
      values,
      span: { line: head.span.line, end_line, },
    })
  }
  if mods.is_empty() {
    diagnose(
      ds,
      "empty-modify",
      "warning",
      lines[0].span,
      "No modifications are present; server acceptance is not checked.",
    )
  }
  mods
}

///|
fn read_rename(
  lines : Array[Line],
  start : Int,
  ds : Array[Diagnostic],
) -> Body {
  let mut rdn = ""
  let mut delete_old = false
  let mut superior : String? = None
  let names = ["newrdn", "deleteoldrdn", "newsuperior"]
  if lines.length() < start + 2 || lines.length() > start + 3 {
    diagnose(
      ds,
      "invalid-rename-fields",
      "error",
      lines[0].span,
      "Rename requires newrdn, deleteoldrdn and optional newsuperior, in that order.",
    )
  }
  for i in start..= 3 {
      break
    }
    let field = if j == 1 {
      plain_field(lines[i], ds)
    } else {
      parse_field(lines[i], ds)
    }
    match field {
      Some(f) => {
        if ascii_lower(f.name) != names[j] {
          diagnose(
            ds,
            "unexpected-rename-field",
            "error",
            f.span,
            "Unexpected rename field or field order.",
          )
        }
        match field_text(f, ds) {
          Some(s) =>
            if j == 0 {
              rdn = s
            } else if j == 1 {
              if s != "0" && s != "1" {
                diagnose(
                  ds,
                  "invalid-deleteoldrdn",
                  "error",
                  f.span,
                  "deleteoldrdn must be 0 or 1.",
                )
              }
              delete_old = s == "1"
            } else {
              superior = Some(s)
            }
          None => ()
        }
      }
      None => ()
    }
  }
  Rename(rdn, delete_old, superior)
}

///|
fn read_record(
  lines : Array[Line],
  ds : Array[Diagnostic],
  options : Options,
) -> Record? {
  if line_name(lines[0]) != "dn" {
    diagnose(
      ds,
      "missing-dn",
      "error",
      lines[0].span,
      "Each record must start with dn.",
    )
    return None
  }
  let first = match parse_field(lines[0], ds) {
    Some(f) => f
    None => return None
  }
  let dn = match field_text(first, ds) {
    Some(s) => s
    None => return None
  }
  let span = {
    line: lines[0].span.line,
    end_line: lines[lines.length() - 1].span.end_line,
  }
  let controls : Array[Control] = []
  let mut i = 1
  let mut candidate = i
  while candidate < lines.length() && line_name(lines[candidate]) == "control" {
    candidate = candidate + 1
  }
  if candidate < lines.length() && line_name(lines[candidate]) == "changetype" {
    while i < candidate {
      match parse_control(lines[i], ds) {
        Some(c) => controls.push(c)
        None => ()
      }
      i = i + 1
    }
  }
  let body = if i < lines.length() && line_name(lines[i]) == "changetype" {
    let f = match plain_field(lines[i], ds) {
      Some(f) => f
      None => return None
    }
    let kind = match field_text(f, ds) {
      Some(s) => ascii_lower(s)
      None => return None
    }
    i = i + 1
    match kind {
      "add" => Add(read_attributes(lines, i, ds))
      "delete" => {
        if i < lines.length() {
          diagnose(
            ds,
            "unexpected-delete-fields",
            "error",
            lines[i].span,
            "Delete records cannot contain attributes.",
          )
        }
        if options.deny_delete {
          diagnose(
            ds, "delete-denied", "policy", span, "Entry deletion is blocked by --deny-delete.",
          )
        }
        Delete
      }
      "modify" => Modify(read_modifications(lines, i, ds))
      "moddn" | "modrdn" => read_rename(lines, i, ds)
      _ => {
        diagnose(
          ds,
          "unsupported-change",
          "unsupported",
          f.span,
          "Unknown changetype; this record cannot be interpreted.",
        )
        return None
      }
    }
  } else {
    if !controls.is_empty() {
      diagnose(
        ds, "control-without-change", "error", span, "Controls require a change record.",
      )
    }
    Entry(read_attributes(lines, i, ds))
  }
  Some({ dn, controls, body, span, })
}

///|
/// Parse a bounded LDIF v1 buffer. Diagnostics retain partial records for
/// inspection; callers must check exit_code before accepting the document.
pub fn parse(data : Bytes, options? : Options = Options::default()) -> Report {
  let ds : Array[Diagnostic] = []
  let lines = logical_lines(data, ds)
  let records : Array[Record] = []
  let mut start = 0
  while start < lines.length() && lines[start].text == "" {
    start = start + 1
  }
  if start < lines.length() && line_name(lines[start]) == "version" {
    match plain_field(lines[start], ds) {
      Some(f) =>
        if field_text(f, ds) != Some("1") {
          diagnose(
            ds,
            "unsupported-version",
            "unsupported",
            f.span,
            "Only LDIF version 1 is supported.",
          )
        }
      None => ()
    }
    start = start + 1
  } else {
    diagnose(
      ds,
      "missing-version",
      if options.allow_missing_version {
        "warning"
      } else {
        "error"
      },
      single_line(1),
      "Expected version: 1; --compat explicitly permits a missing version header.",
    )
  }
  let mut mode = "empty"
  let mut group : Array[Line] = []
  for i in start..<(lines.length() + 1) {
    if i == lines.length() || lines[i].text == "" {
      if !group.is_empty() {
        if records.length() >= 10000 {
          diagnose(
            ds,
            "record-limit",
            "error",
            group[0].span,
            "Input exceeds 10000 records.",
          )
          break
        }
        match read_record(group, ds, options) {
          Some(r) => {
            let kind = match r.body {
              Entry(_) => "content"
              _ => "changes"
            }
            if mode == "empty" {
              mode = kind
            } else if mode != kind && mode != "mixed" {
              mode = "mixed"
              diagnose(
                ds,
                "mixed-record-modes",
                "error",
                r.span,
                "Content and change records must be in separate LDIF documents.",
              )
            }
            records.push(r)
          }
          None => ()
        }
        group = []
      }
    } else {
      group.push(lines[i])
    }
  }
  if records.is_empty() &&
    !ds.any(d => d.severity == "error" || d.severity == "unsupported") {
    diagnose(
      ds,
      "no-records",
      "error",
      single_line(1),
      "No supported records were found.",
    )
  }
  { document: { mode, records, }, diagnostics: ds, names_checked: false, }
}

///|
pub fn parse_text(
  text : String,
  options? : Options = Options::default(),
) -> Report {
  parse(@utf8.encode(text[:]), options~)
}