///|
/// The longest `&...;` reference this decoder is willing to look for. Anything
/// longer is treated as a literal ampersand in bank text rather than as a
/// reference, so a stray `&` never swallows the rest of a memo.
let max_reference_length : Int = 12

///|
/// The named character references that are understood.
///
/// OFX 2.x documents are XML, so the five predefined XML entities always
/// decode; `nbsp` is included because bank text uses it constantly. Any other
/// name is reported as `unknown-entity` and left exactly as written instead of
/// being dropped silently.
fn named_entity(name : String) -> Char? {
  match name {
    "lt" => Some('<')
    "gt" => Some('>')
    "amp" => Some('&')
    "quot" => Some('"')
    "apos" => Some('\'')
    "nbsp" => Some('\u{00A0}')
    _ => None
  }
}

///|
/// The value of one digit in `radix`, or `-1` when the character is not a digit
/// of that radix.
fn digit_value(c : Char, radix : Int) -> Int {
  let value = if c >= '0' && c <= '9' {
    c.to_int() - 48
  } else if c >= 'a' && c <= 'f' {
    c.to_int() - 87
  } else if c >= 'A' && c <= 'F' {
    c.to_int() - 55
  } else {
    -1
  }
  if value >= 0 && value < radix {
    value
  } else {
    -1
  }
}

///|
/// Decode the body of a character reference, without its `&` and `;`.
///
/// Returns `None` after reporting either `malformed-entity` (a numeric
/// reference that is empty, non-numeric, zero, above `U+10FFFF` or a surrogate)
/// or `unknown-entity` (a name this decoder does not know). The caller keeps
/// the reference exactly as written in both cases.
fn decode_reference(
  body : String,
  line : Int,
  diagnostics : Array[Diagnostic],
) -> Char? {
  let chars = body.iter().to_array()
  if chars.length() == 0 {
    diagnostics.push(warning_at("malformed-entity", at_text("&;", line)))
    return None
  }
  if chars[0] != '#' {
    match named_entity(body) {
      Some(c) => return Some(c)
      None => {
        diagnostics.push(
          warning_at("unknown-entity", at_text("&" + body + ";", line)),
        )
        return None
      }
    }
  }
  let mut radix = 10
  let mut index = 1
  if index < chars.length() && (chars[index] == 'x' || chars[index] == 'X') {
    radix = 16
    index += 1
  }
  let mut code = 0
  let mut valid = index < chars.length()
  while index < chars.length() && valid {
    let digit = digit_value(chars[index], radix)
    if digit < 0 {
      valid = false
    } else {
      code = code * radix + digit
      if code > 1114111 {
        code = 1114112
      }
      index += 1
    }
  }
  // `body` still carries the leading `#`, so the literal is `&` + body + `;`.
  let literal = at_text("&" + body + ";", line)
  if !valid || code <= 0 || code > 1114111 {
    diagnostics.push(warning_at("malformed-entity", literal))
    return None
  }
  match code.to_char() {
    Some(c) => Some(c)
    None => {
      diagnostics.push(warning_at("malformed-entity", literal))
      None
    }
  }
}

///|
/// Append `text` to `out` with character references decoded.
///
/// CDATA sections never reach this function: the lexer appends their text
/// literally, because a CDATA section is by definition not entity-encoded. Only
/// the document text between tags is decoded here, which is where a bank can
/// write `&`, `é` or ` `.
fn decode_into(
  out : StringBuilder,
  text : String,
  line : Int,
  diagnostics : Array[Diagnostic],
) -> Unit {
  if !text.contains_char('&') {
    out.write_string(text)
    return
  }
  let chars = text.iter().to_array()
  let mut index = 0
  while index < chars.length() {
    if chars[index] != '&' {
      out.write_char(chars[index])
      index += 1
      continue
    }
    let mut stop = index + 1
    while stop < chars.length() &&
          stop - index <= max_reference_length &&
          chars[stop] != ';' {
      stop += 1
    }
    if stop >= chars.length() || stop - index > max_reference_length {
      diagnostics.push(warning_at("malformed-entity", at_text("&", line)))
      out.write_char('&')
      index += 1
      continue
    }
    let body = StringBuilder()
    for position in (index + 1).. out.write_char(c)
      None =>
        for position in index..<(stop + 1) {
          out.write_char(chars[position])
        }
    }
    index = stop + 1
  }
}