///|
fn ascii_lower(s : String) -> String {
  let out = StringBuilder()
  for c in s.iter() {
    let n = c.to_int()
    out.write_char(
      if n >= 65 && n <= 90 {
        (n + 32).unsafe_to_char()
      } else {
        c
      },
    )
  }
  out.to_string()
}

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

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

///|
fn valid_oid(s : String) -> Bool {
  let parts = s.split(".").to_array()
  parts.length() >= 2 && parts.all(p => !p.is_empty() && p.iter().all(is_digit))
}

///|
fn valid_attribute(s : String) -> Bool {
  let parts = s.split(";").to_array()
  if parts.is_empty() || parts[0].is_empty() {
    return false
  }
  let head = parts[0].to_owned()
  let chars = head.iter().to_array()
  if !(valid_oid(head) ||
    (
      is_alpha(chars[0]) &&
      chars.all(c => is_alpha(c) || is_digit(c) || c == '-')
    )) {
    return false
  }
  for i in 1.. is_alpha(c) || is_digit(c) || c == '-') {
      return false
    }
  }
  true
}

///|
fn attribute_equal(a : String, b : String) -> Bool {
  let aa = ascii_lower(a).split(";").map(p => p.to_owned()).to_array()
  let bb = ascii_lower(b).split(";").map(p => p.to_owned()).to_array()
  aa[0] == bb[0] && aa.all(p => bb.contains(p)) && bb.all(p => aa.contains(p))
}

///|
/// LDAP attribute-description spelling only; does not compare DN or values.
pub fn same_attribute_description(a : String, b : String) -> Bool {
  valid_attribute(a) && valid_attribute(b) && attribute_equal(a, b)
}

///|
fn parse_field(line : Line, ds : Array[Diagnostic]) -> Attribute? {
  let bytes = @utf8.encode(line.text[:])
  let mut colon = -1
  for i in 0.. return None }
  if !valid_attribute(name) {
    diagnose(
      ds,
      "invalid-attribute-name",
      "error",
      line.span,
      "Invalid attribute description.",
    )
    return None
  }
  let mut start = colon + 1
  let mut encoding = "plain"
  if start < bytes.length() && bytes[start] == 58 {
    encoding = "base64"
    start = start + 1
  } else if start < bytes.length() && bytes[start] == 60 {
    encoding = "url"
    start = start + 1
  }
  while start < bytes.length() && bytes[start] == 32 {
    start = start + 1
  }
  let raw = bytes[start:].to_owned()
  let value = if encoding == "base64" {
    let encoded = @utf8.decode(raw[:]) catch { _ => return None }
    if !encoded
      .iter()
      .all(c => is_alpha(c) || is_digit(c) || c == '+' || c == '/' || c == '=') {
      diagnose(
        ds,
        "invalid-base64",
        "error",
        line.span,
        "Base64 contains characters outside its alphabet.",
      )
      return None
    }
    let decoded = @base64.decode(encoded[:], ignore_whitespace=false) catch {
      _ => {
        diagnose(
          ds,
          "invalid-base64",
          "error",
          line.span,
          "Invalid Base64 length or padding.",
        )
        return None
      }
    }
    Inline(decoded)
  } else if encoding == "url" {
    let url = @utf8.decode(raw[:]) catch { _ => return None }
    if url == "" || url.iter().any(c => c <= ' ') {
      diagnose(
        ds,
        "invalid-url-value",
        "error",
        line.span,
        "Expected a nonempty URL without whitespace.",
      )
    } else {
      diagnose(
        ds,
        "external-value-unresolved",
        "unsupported",
        line.span,
        "URL value is preserved but will not be read.",
      )
    }
    External(url)
  } else {
    if raw.length() > 0 && (raw[0] == 58 || raw[0] == 60) {
      diagnose(
        ds,
        "unsafe-initial-value",
        "error",
        line.span,
        "This initial character requires Base64 encoding.",
      )
    }
    if raw.iter().any(b => b == 0 || b == 10 || b == 13 || b >= 128) {
      diagnose(
        ds,
        "unsafe-plain-value",
        "error",
        line.span,
        "Non-ASCII or forbidden bytes require Base64 encoding.",
      )
    }
    Inline(raw)
  }
  Some({ name, value, span: line.span, })
}

///|
fn field_text(field : Attribute, ds : Array[Diagnostic]) -> String? {
  match field.value {
    Inline(bytes) => {
      let text = @utf8.decode(bytes[:]) catch {
        _ => {
          diagnose(
            ds,
            "invalid-text-value",
            "error",
            field.span,
            "This field must be valid UTF-8.",
          )
          return None
        }
      }
      Some(text)
    }
    External(_) => {
      diagnose(
        ds,
        "external-structural-field",
        "error",
        field.span,
        "This structural field cannot use a URL reference.",
      )
      None
    }
  }
}