///|
/// The declared header of an OFX document.
///
/// OFX 1.x files open with `KEY:VALUE` lines (`OFXHEADER:100`, `DATA:OFXSGML`,
/// `VERSION:102`, `SECURITY:NONE`, `ENCODING:USASCII`, `CHARSET:1252`,
/// `COMPRESSION:NONE`, `OLDFILEUID`, `NEWFILEUID`); OFX 2.x files carry the
/// same facts in the XML declaration and an `` processing
/// instruction. Both shapes fill this struct. A field the document does not
/// declare stays empty: the importer never invents a default, so a caller can
/// tell "the file says 1252" from "the file says nothing".
pub struct Header {
  ofx_header : String
  data : String
  version : String
  security : String
  encoding : String
  charset : String
  compression : String
  old_file_uid : String
  new_file_uid : String
}

///|
/// Whether the document declares itself as OFX 2.x XML rather than OFX 1.x
/// SGML. Only the header decides this; the body is scanned the same way either
/// way.
pub fn Header::is_xml(self : Header) -> Bool {
  self.ofx_header == "200" || self.data == "OFXXML"
}

///|
/// Whether the document declared no header field at all.
pub fn Header::is_empty(self : Header) -> Bool {
  self.ofx_header == "" &&
  self.data == "" &&
  self.version == "" &&
  self.security == "" &&
  self.encoding == "" &&
  self.charset == "" &&
  self.compression == "" &&
  self.old_file_uid == "" &&
  self.new_file_uid == ""
}

///|
/// The declared fields as `KEY:VALUE` pairs separated by a single space, in
/// header order, omitting everything the document left out.
pub fn Header::render(self : Header) -> String {
  let pairs : Array[(String, String)] = [
    ("OFXHEADER", self.ofx_header),
    ("DATA", self.data),
    ("VERSION", self.version),
    ("SECURITY", self.security),
    ("ENCODING", self.encoding),
    ("CHARSET", self.charset),
    ("COMPRESSION", self.compression),
    ("OLDFILEUID", self.old_file_uid),
    ("NEWFILEUID", self.new_file_uid),
  ]
  let parts : Array[String] = []
  for pair in pairs {
    let (key, value) = pair
    if value != "" {
      parts.push(key + ":" + value)
    }
  }
  parts.join(" ")
}

///|
/// The header keys this importer understands. Any other key is reported as
/// `unknown-header-line` instead of being stored under a guessed meaning.
fn is_known_header_key(key : String) -> Bool {
  match key {
    "OFXHEADER"
    | "DATA"
    | "VERSION"
    | "SECURITY"
    | "ENCODING"
    | "CHARSET"
    | "COMPRESSION"
    | "OLDFILEUID"
    | "NEWFILEUID" => true
    _ => false
  }
}

///|
/// The text before the first `<`, which is where an OFX 1.x header block lives.
fn prologue_of(source : String) -> String {
  let chars = source.iter().to_array()
  let out = StringBuilder()
  let mut index = 0
  while index < chars.length() && chars[index] != '<' {
    out.write_char(chars[index])
    index += 1
  }
  out.to_string()
}

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

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

///|
fn is_three_digits(value : String) -> Bool {
  is_digits(value, 3)
}

///|
/// One declared field of the header being collected, or the reason it could not
/// be stored.
///
/// The header is collected into a `Map` because a document may declare the same
/// key twice, in the prologue and in an `` instruction; the later
/// declaration wins, exactly as it would if the file were read top to bottom.
fn record_header_field(
  key : String,
  value : String,
  line : Int,
  fields : Map[String, String],
  diagnostics : Array[Diagnostic],
) -> Unit {
  if value == "" {
    diagnostics.push(warning_at("empty-header-value", at_text(key, line)))
  } else if is_known_header_key(key) {
    fields[key] = value
  } else {
    diagnostics.push(warning_at("unknown-header-line", at_text(key, line)))
  }
}

///|
/// Record one `KEY:VALUE` line of an OFX 1.x header block.
fn apply_header_line(
  line : String,
  line_number : Int,
  fields : Map[String, String],
  diagnostics : Array[Diagnostic],
) -> Unit {
  let chars = line.iter().to_array()
  let mut stop = 0
  while stop < chars.length() && chars[stop] != ':' {
    stop += 1
  }
  if stop >= chars.length() {
    diagnostics.push(
      warning_at("unknown-header-line", "@" + line_number.to_string()),
    )
    return
  }
  let key = StringBuilder()
  for position in 0..` processing instruction.
///
/// The XML declaration only contributes `encoding`, because its `version` is the
/// XML version (`1.0`) and not the OFX version the caller cares about. Every
/// other processing instruction is treated as an OFX header declaration, which
/// is how OFX 2.x states `OFXHEADER`, `VERSION` and the file UIDs.
fn apply_processing_instruction(
  body : String,
  line : Int,
  fields : Map[String, String],
  diagnostics : Array[Diagnostic],
) -> Unit {
  let chars = body.iter().to_array()
  let mut index = 0
  let mut xml_declaration = false
  while index < chars.length() {
    if !is_name_start(chars[index]) {
      index += 1
      continue
    }
    let name = StringBuilder()
    let start = index
    while index < chars.length() && is_name_char(chars[index]) {
      name.write_char(chars[index])
      index += 1
    }
    let key = name.to_string()
    if start == 0 && (key == "xml" || key == "XML") {
      xml_declaration = true
    }
    let mut cursor = index
    while cursor < chars.length() && is_space(chars[cursor]) {
      cursor += 1
    }
    if cursor >= chars.length() || chars[cursor] != '=' {
      // The PI target itself, or a valueless flag: nothing to record.
      continue
    }
    cursor += 1
    while cursor < chars.length() && is_space(chars[cursor]) {
      cursor += 1
    }
    let value = StringBuilder()
    if cursor < chars.length() &&
      (chars[cursor] == '"' || chars[cursor] == '\'') {
      let quote = chars[cursor]
      cursor += 1
      while cursor < chars.length() && chars[cursor] != quote {
        value.write_char(chars[cursor])
        cursor += 1
      }
      cursor += 1
    } else {
      // An unquoted value, as an OFX 1.x prologue line would write it. Read up
      // to the next whitespace instead of discarding the declaration.
      while cursor < chars.length() && !is_space(chars[cursor]) {
        value.write_char(chars[cursor])
        cursor += 1
      }
    }
    if xml_declaration {
      if key == "encoding" {
        record_header_field(
          "ENCODING",
          value.to_string(),
          line,
          fields,
          diagnostics,
        )
      }
    } else {
      record_header_field(
        key.to_upper(),
        value.to_string(),
        line,
        fields,
        diagnostics,
      )
    }
    index = cursor
  }
}

///|
/// Find every `` processing instruction in the source and record its
/// attributes.
fn apply_processing_instructions(
  source : String,
  fields : Map[String, String],
  diagnostics : Array[Diagnostic],
) -> Unit {
  let chars = source.iter().to_array()
  let mut index = 0
  let mut line = 1
  while index < chars.length() {
    if chars[index] == '\n' {
      line += 1
      index += 1
      continue
    }
    let is_pi = chars[index] == '<' &&
      index + 1 < chars.length() &&
      chars[index + 1] == '?'
    if is_pi {
      let open_line = line
      index += 2
      let body = StringBuilder()
      while index < chars.length() {
        if chars[index] == '?' &&
          index + 1 < chars.length() &&
          chars[index + 1] == '>' {
          index += 2
          break
        }
        if chars[index] == '\n' {
          line += 1
        }
        body.write_char(chars[index])
        index += 1
      }
      apply_processing_instruction(
        body.to_string(),
        open_line,
        fields,
        diagnostics,
      )
      continue
    }
    index += 1
  }
}

///|
fn field_or(fields : Map[String, String], key : String) -> String {
  match fields.get(key) {
    Some(value) => value
    None => ""
  }
}

///|
fn header_from_fields(fields : Map[String, String]) -> Header {
  {
    ofx_header: field_or(fields, "OFXHEADER"),
    data: field_or(fields, "DATA"),
    version: field_or(fields, "VERSION"),
    security: field_or(fields, "SECURITY"),
    encoding: field_or(fields, "ENCODING"),
    charset: field_or(fields, "CHARSET"),
    compression: field_or(fields, "COMPRESSION"),
    old_file_uid: field_or(fields, "OLDFILEUID"),
    new_file_uid: field_or(fields, "NEWFILEUID"),
  }
}

///|
/// Check the few header facts whose value changes how the body must be read.
///
/// A compressed body is an error: the importer cannot inflate it, so whatever
/// it extracts would be noise. An unrecognised `OFXHEADER` or a `VERSION` that
/// is not the documented three digits is a warning, because the body is still
/// worth attempting.
fn validate_header(header : Header, diagnostics : Array[Diagnostic]) -> Unit {
  if header.compression != "" && header.compression.to_upper() != "NONE" {
    diagnostics.push(error_at("unsupported-compression", header.compression))
  }
  if header.ofx_header != "" &&
    header.ofx_header != "100" &&
    header.ofx_header != "200" &&
    header.ofx_header != "220" {
    diagnostics.push(
      warning_at("invalid-header-version", "OFXHEADER:" + header.ofx_header),
    )
  }
  if header.version != "" && !is_three_digits(header.version) {
    diagnostics.push(
      warning_at("invalid-header-version", "VERSION:" + header.version),
    )
  }
}

///|
/// Read the declared header of an OFX document.
///
/// Both the OFX 1.x `KEY:VALUE` prologue and OFX 2.x processing instructions
/// are read from the same source. Nothing here is required: a document without a
/// header parses exactly like one with a header, it just declares nothing.
fn parse_header(source : String, diagnostics : Array[Diagnostic]) -> Header {
  let fields : Map[String, String] = Map([])
  let mut line_number = 1
  for raw_line in prologue_of(source).split("\n") {
    let line = raw_line.trim().to_owned()
    if line != "" {
      apply_header_line(line, line_number, fields, diagnostics)
    }
    line_number += 1
  }
  apply_processing_instructions(source, fields, diagnostics)
  let header = header_from_fields(fields)
  validate_header(header, diagnostics)
  header
}