///|
fn scan_illegal(lines : Array[String], diags : Array[Diagnostic]) -> Unit {
  let mut line_no = 0
  while line_no < lines.length() {
    let line = lines[line_no]
    let mut col = 0
    while col < line.length() {
      if !is_mrz_char(line[col]) {
        diags.push(
          error_diag(
            "MRZ003",
            "character is not in the ICAO 9303 MRZ alphabet",
            line_no + 1,
            col + 1,
            col,
            col + 1,
          ),
        )
      }
      col += 1
    }
    line_no += 1
  }
}

///|
fn check_field(
  field : String,
  actual : String,
  code : String,
  message : String,
  line : Int,
  column : Int,
  start : Int,
  diags : Array[Diagnostic],
) -> Unit {
  if !field_matches_check(field, actual) {
    diags.push(
      error_diag(code, message, line, column, start, start + actual.length()),
    )
  }
}

///|
fn parse_td3(lines : Array[String], diags : Array[Diagnostic]) -> Document? {
  let l1 = lines[0]
  let l2 = lines[1]
  let doc_raw = l2[0:9].to_owned()
  let birth = l2[13:19].to_owned()
  let expiry = l2[21:27].to_owned()
  let optional = l2[28:42].to_owned()
  check_field(
    doc_raw,
    unit_at(l2, 9),
    "MRZ005",
    "document number check digit is wrong",
    2,
    10,
    9,
    diags,
  )
  check_field(
    birth,
    unit_at(l2, 19),
    "MRZ006",
    "date of birth check digit is wrong",
    2,
    20,
    19,
    diags,
  )
  check_field(
    expiry,
    unit_at(l2, 27),
    "MRZ007",
    "date of expiry check digit is wrong",
    2,
    28,
    27,
    diags,
  )
  check_field(
    optional,
    unit_at(l2, 42),
    "MRZ008",
    "optional data check digit is wrong",
    2,
    43,
    42,
    diags,
  )
  let composite = l2[0:10].to_owned() +
    l2[13:20].to_owned() +
    l2[21:43].to_owned()
  check_field(
    composite,
    unit_at(l2, 43),
    "MRZ009",
    "composite check digit is wrong",
    2,
    44,
    43,
    diags,
  )
  if !is_valid_yymmdd(birth) {
    diags.push(
      error_diag(
        "MRZ010", "date of birth is not a valid YYMMDD value", 2, 14, 13, 19,
      ),
    )
  }
  if !is_valid_yymmdd(expiry) {
    diags.push(
      error_diag(
        "MRZ010", "date of expiry is not a valid YYMMDD value", 2, 22, 21, 27,
      ),
    )
  }
  let sex = unit_at(l2, 20)
  if !is_valid_sex(sex) {
    diags.push(error_diag("MRZ011", "sex must be M, F, or <", 2, 21, 20, 21))
  }
  let issuer = l1[2:5].to_owned()
  let nationality = l2[10:13].to_owned()
  if !is_valid_alpha3(issuer) {
    diags.push(
      error_diag("MRZ012", "issuing state is not three MRZ letters", 1, 3, 2, 5),
    )
  }
  if !is_valid_alpha3(nationality) {
    diags.push(
      error_diag(
        "MRZ012", "nationality is not three MRZ letters", 2, 11, 10, 13,
      ),
    )
  }
  let names = split_names(l1[5:44].to_owned())
  if names.primary.length() == 0 {
    diags.push(error_diag("MRZ013", "primary name is missing", 1, 6, 5, 44))
  }
  if rtrim_fillers(doc_raw).length() == 0 {
    diags.push(error_diag("MRZ014", "document number is empty", 2, 1, 0, 9))
  }
  Some({
    layout: TD3,
    document_code: rtrim_fillers(l1[0:2].to_owned()),
    issuer: rtrim_fillers(issuer),
    names,
    document_number: rtrim_fillers(doc_raw),
    nationality: rtrim_fillers(nationality),
    birth,
    sex,
    expiry,
    optional: rtrim_fillers(optional),
    optional_secondary: "",
    raw_lines: [l1, l2],
  })
}

///|
fn parse_td2(lines : Array[String], diags : Array[Diagnostic]) -> Document? {
  let l1 = lines[0]
  let l2 = lines[1]
  let doc_raw = l2[0:9].to_owned()
  let birth = l2[13:19].to_owned()
  let expiry = l2[21:27].to_owned()
  let optional = l2[28:35].to_owned()
  check_field(
    doc_raw,
    unit_at(l2, 9),
    "MRZ005",
    "document number check digit is wrong",
    2,
    10,
    9,
    diags,
  )
  check_field(
    birth,
    unit_at(l2, 19),
    "MRZ006",
    "date of birth check digit is wrong",
    2,
    20,
    19,
    diags,
  )
  check_field(
    expiry,
    unit_at(l2, 27),
    "MRZ007",
    "date of expiry check digit is wrong",
    2,
    28,
    27,
    diags,
  )
  let composite = l2[0:10].to_owned() +
    l2[13:20].to_owned() +
    l2[21:35].to_owned()
  check_field(
    composite,
    unit_at(l2, 35),
    "MRZ009",
    "composite check digit is wrong",
    2,
    36,
    35,
    diags,
  )
  if !is_valid_yymmdd(birth) {
    diags.push(
      error_diag(
        "MRZ010", "date of birth is not a valid YYMMDD value", 2, 14, 13, 19,
      ),
    )
  }
  if !is_valid_yymmdd(expiry) {
    diags.push(
      error_diag(
        "MRZ010", "date of expiry is not a valid YYMMDD value", 2, 22, 21, 27,
      ),
    )
  }
  let sex = unit_at(l2, 20)
  if !is_valid_sex(sex) {
    diags.push(error_diag("MRZ011", "sex must be M, F, or <", 2, 21, 20, 21))
  }
  let names = split_names(l1[5:36].to_owned())
  Some({
    layout: TD2,
    document_code: rtrim_fillers(l1[0:2].to_owned()),
    issuer: rtrim_fillers(l1[2:5].to_owned()),
    names,
    document_number: rtrim_fillers(doc_raw),
    nationality: rtrim_fillers(l2[10:13].to_owned()),
    birth,
    sex,
    expiry,
    optional: rtrim_fillers(optional),
    optional_secondary: "",
    raw_lines: [l1, l2],
  })
}

///|
fn parse_td1(lines : Array[String], diags : Array[Diagnostic]) -> Document? {
  let l1 = lines[0]
  let l2 = lines[1]
  let l3 = lines[2]
  let doc_raw = l1[5:14].to_owned()
  let opt1 = l1[15:30].to_owned()
  let birth = l2[0:6].to_owned()
  let expiry = l2[8:14].to_owned()
  let opt2 = l2[18:29].to_owned()
  check_field(
    doc_raw,
    unit_at(l1, 14),
    "MRZ005",
    "document number check digit is wrong",
    1,
    15,
    14,
    diags,
  )
  check_field(
    birth,
    unit_at(l2, 6),
    "MRZ006",
    "date of birth check digit is wrong",
    2,
    7,
    6,
    diags,
  )
  check_field(
    expiry,
    unit_at(l2, 14),
    "MRZ007",
    "date of expiry check digit is wrong",
    2,
    15,
    14,
    diags,
  )
  let composite = l1[5:30].to_owned() +
    l2[0:7].to_owned() +
    l2[8:15].to_owned() +
    l2[18:29].to_owned()
  check_field(
    composite,
    unit_at(l2, 29),
    "MRZ009",
    "composite check digit is wrong",
    2,
    30,
    29,
    diags,
  )
  if !is_valid_yymmdd(birth) {
    diags.push(
      error_diag(
        "MRZ010", "date of birth is not a valid YYMMDD value", 2, 1, 0, 6,
      ),
    )
  }
  if !is_valid_yymmdd(expiry) {
    diags.push(
      error_diag(
        "MRZ010", "date of expiry is not a valid YYMMDD value", 2, 9, 8, 14,
      ),
    )
  }
  let sex = unit_at(l2, 7)
  if !is_valid_sex(sex) {
    diags.push(error_diag("MRZ011", "sex must be M, F, or <", 2, 8, 7, 8))
  }
  let names = split_names(l3)
  Some({
    layout: TD1,
    document_code: rtrim_fillers(l1[0:2].to_owned()),
    issuer: rtrim_fillers(l1[2:5].to_owned()),
    names,
    document_number: rtrim_fillers(doc_raw),
    nationality: rtrim_fillers(l2[15:18].to_owned()),
    birth,
    sex,
    expiry,
    optional: rtrim_fillers(opt1),
    optional_secondary: rtrim_fillers(opt2),
    raw_lines: [l1, l2, l3],
  })
}

///|
/// Parse MRZ text and always return diagnostics. A document is present even
/// when check-digit errors were recorded, so callers can inspect extracted fields.
pub fn parse_with_diagnostics(input : String) -> (Document?, Array[Diagnostic]) {
  let diags : Array[Diagnostic] = []
  if trim_ascii(input).length() == 0 {
    diags.push(error_diag("MRZ001", "MRZ input is empty", 1, 1, 0, 0))
    return (None, diags)
  }
  let mut lines = split_raw_lines(input)
  if lines.length() == 1 {
    match expand_single_line(lines[0]) {
      None => ()
      Some(expanded) => lines = expanded
    }
  }
  scan_illegal(lines, diags)
  match detect_layout(lines) {
    None => {
      diags.push(
        error_diag(
          "MRZ002", "MRZ layout is not TD1 (3x30), TD2 (2x36), or TD3 (2x44)", 1,
          1, 0, 0,
        ),
      )
      (None, diags)
    }
    Some(layout) => {
      let doc = match layout {
        TD3 => parse_td3(lines, diags)
        TD2 => parse_td2(lines, diags)
        TD1 => parse_td1(lines, diags)
      }
      (doc, diags)
    }
  }
}

///|
pub fn parse(input : String) -> Document? {
  let (doc, diags) = parse_with_diagnostics(input)
  if has_error(diags) {
    None
  } else {
    doc
  }
}

///|
pub fn is_valid(input : String) -> Bool {
  match parse(input) {
    None => false
    Some(_) => true
  }
}

///|
/// Detect TD1/TD2/TD3 from line lengths after CRLF splitting and 88/72/90 unfolding.
pub fn layout_of(input : String) -> Layout? {
  let mut lines = split_raw_lines(input)
  if lines.length() == 1 {
    match expand_single_line(lines[0]) {
      None => ()
      Some(expanded) => lines = expanded
    }
  }
  detect_layout(lines)
}