///|
/// Scan an OFX document and return its first statement plus every diagnostic.
///
/// This is the entry point for the common case: one export, one account. A file
/// that holds several statements is reported one statement at a time — the
/// first declaration of a statement-level field wins and later different values
/// raise `conflicting-value` — so use [`parse_document`] when the caller needs
/// every statement in the file, and this function when it needs the one a
/// single-account import expects.
///
/// The scanner never fails as a whole: a transaction is dropped only when one of
/// its own fields is missing or malformed, and the reason is always reported.
pub fn parse_statement(source : String) -> Statement {
  let text = strip_leading_bom(source)
  let diagnostics : Array[Diagnostic] = []
  let header = parse_header(text, diagnostics)
  let tokens = lex(text, diagnostics)
  let signon = parse_signon(tokens, diagnostics)
  statement_from_tokens(tokens, header, signon, diagnostics, true)
}

///|
/// Every statement in an OFX file, in the order the file states them.
///
/// `header` and `signon` belong to the file and are shared by every statement,
/// while `diagnostics` holds what could be said about the file itself: the
/// lexer's complaints, the header, and the sign-on. The diagnostics that belong
/// to one account's statement live on that statement, so a caller can report
/// "statement 3 of 5 failed" instead of one undifferentiated list.
pub struct Document {
  header : Header
  signon : Signon
  statements : Array[Statement]
  diagnostics : Array[Diagnostic]
}

///|
/// How many statements the file holds.
pub fn Document::statement_count(self : Document) -> Int {
  self.statements.length()
}

///|
/// How many distinct accounts the file holds.
///
/// A file that states the same account twice is still one account to a ledger,
/// so the count is of names, not of statements.
pub fn Document::account_count(self : Document) -> Int {
  let found : Array[String] = []
  for label in self.account_labels() {
    if !found.contains(label) {
      found.push(label)
    }
  }
  found.length()
}

///|
/// The distinct currencies the file uses, in the order they first appear.
///
/// A caller that sums the whole file needs one scale, and this is how it finds
/// out whether the file has one.
pub fn Document::currencies(self : Document) -> Array[String] {
  let found : Array[String] = []
  for statement in self.statements {
    if statement.currency != "" && !found.contains(statement.currency) {
      found.push(statement.currency)
    }
  }
  found
}

///|
/// The statement that belongs to an account, or `None` when the file has none.
///
/// The account is matched against `ACCTID` exactly; matching a bank id as well
/// is left to the caller, because two institutions can use the same account
/// number.
pub fn Document::statement_for_account(
  self : Document,
  account_id : String,
) -> Statement? {
  for statement in self.statements {
    if statement.account_id == account_id {
      return Some(statement)
    }
  }
  None
}

///|
/// The period the whole file covers, as `(start, end)`.
///
/// `None` means no statement stated a period at all. A statement that states
/// only one end is included from that end, so the answer is never narrower than
/// the file.
pub fn Document::period(self : Document) -> (String, String)? {
  let mut start = ""
  let mut end = ""
  for statement in self.statements {
    if statement.start_date != "" &&
      (start == "" || statement.start_date < start) {
      start = statement.start_date
    }
    if statement.end_date != "" && (end == "" || statement.end_date > end) {
      end = statement.end_date
    }
  }
  if start == "" && end == "" {
    None
  } else {
    Some(
      (
        if start == "" {
          end
        } else {
          start
        },
        if end == "" {
          start
        } else {
          end
        },
      ),
    )
  }
}

///|
/// Whether the file held no statement at all.
pub fn Document::is_empty(self : Document) -> Bool {
  self.statements.length() == 0
}

///|
/// The statement at an index, or `None` when the index is out of range.
pub fn Document::statement(self : Document, index : Int) -> Statement? {
  if index < 0 || index >= self.statements.length() {
    None
  } else {
    Some(self.statements[index])
  }
}

///|
/// Every diagnostic in the file: the file-level ones first, then each
/// statement's in order.
pub fn Document::diagnostics(self : Document) -> Array[Diagnostic] {
  let all : Array[Diagnostic] = self.diagnostics.copy()
  for statement in self.statements {
    all.push_iter(statement.diagnostics.iter())
  }
  all
}

///|
/// Whether any diagnostic, at either level, has severity `Error`.
pub fn Document::has_errors(self : Document) -> Bool {
  self.diagnostics().any(d => d.severity is Error)
}

///|
/// Whether every statement can be imported: no `Error` at either level.
pub fn Document::ok(self : Document) -> Bool {
  !self.has_errors()
}

///|
/// Number of `Error` diagnostics across the file and its statements.
pub fn Document::error_count(self : Document) -> Int {
  self.diagnostics().filter(d => d.severity is Error).length()
}

///|
/// Number of `Warning` diagnostics across the file and its statements.
pub fn Document::warning_count(self : Document) -> Int {
  self.diagnostics().filter(d => d.severity is Warning).length()
}

///|
/// Every diagnostic as a stable `severity:code[:location]` string.
pub fn Document::render_diagnostics(self : Document) -> Array[String] {
  self.diagnostics().map(d => d.to_string())
}

///|
/// Every transaction in the file, statement by statement.
pub fn Document::transactions(self : Document) -> Array[Transaction] {
  let all : Array[Transaction] = []
  for statement in self.statements {
    all.push_iter(statement.transactions.iter())
  }
  all
}

///|
/// Every investment record in the file, statement by statement.
pub fn Document::investments(self : Document) -> Array[Investment] {
  let all : Array[Investment] = []
  for statement in self.statements {
    all.push_iter(statement.investments.iter())
  }
  all
}

///|
/// The account labels of the file, in statement order.
///
/// A label is `BANKID/ACCTID` when both are known, the account id alone when
/// there is no bank id, and `UNKNOWN` when the statement named no account, so a
/// report can always print something.
pub fn Document::account_labels(self : Document) -> Array[String] {
  let labels : Array[String] = []
  for statement in self.statements {
    labels.push(account_label(statement))
  }
  labels
}

///|
/// Sum every transaction in the file at one scale, or `None` when an amount will
/// not convert or the total overflows.
pub fn Document::total_minor_units(self : Document, scale : Int) -> Int64? {
  let amounts : Array[String] = []
  for statement in self.statements {
    for transaction in statement.transactions {
      amounts.push(transaction.amount)
    }
  }
  sum_amounts(amounts, scale, 0L)
}

///|
/// How a statement's account is printed in a report.
fn account_label(statement : Statement) -> String {
  if statement.bank_id != "" && statement.account_id != "" {
    statement.bank_id + "/" + statement.account_id
  } else if statement.account_id != "" {
    statement.account_id
  } else {
    "UNKNOWN"
  }
}

///|
/// Whether a tag starts a statement: the aggregate that owns an account, a
/// period, balances and a transaction list.
fn is_statement_root(tag : String) -> Bool {
  tag == "STMTRS" || tag == "CCSTMTRS"
}

///|
/// Whether a tag can only appear outside a statement, which makes it a sign that
/// an unclosed statement has ended.
///
/// OFX 1.x closes its aggregates, but a truncated or hand-edited file may not,
/// and a group that swallowed the rest of the document would report the outer
/// closing tags as unexpected.
fn is_statement_container(tag : String) -> Bool {
  match tag {
    "OFX"
    | "BANKMSGSRSV1"
    | "STMTTRNRS"
    | "CCSTMTTRNRS"
    | "SIGNONMSGSRSV1"
    | "SONRS"
    | "CREDITCARDMSGSRSV1" => true
    _ => false
  }
}

///|
/// Cut a token stream into one group per statement.
///
/// A group starts at `STMTRS` or `CCSTMTRS` and ends at its closing tag, at the
/// next statement that starts, or at a closing tag that can only belong to an
/// enclosing aggregate. When the slice boundary is not the statement's own
/// closing tag, a closing token is synthesized for the group, because a slice
/// that stops mid-aggregate would otherwise be reported as an unclosed
/// statement: the file is fine, the cut is ours. A file with no statement
/// wrapper produces no groups at all, which the caller treats as a single
/// fragment.
fn split_statements(tokens : Array[TagToken]) -> Array[Array[TagToken]] {
  let groups : Array[Array[TagToken]] = []
  let mut current : Array[TagToken] = []
  let mut root = ""
  let mut open = false
  for token in tokens {
    let tag = token.name
    let line = token.line
    match close_target(tag) {
      Some(target) =>
        if open {
          if is_statement_root(target) {
            current.push(token)
            groups.push(current)
            current = []
            open = false
          } else if is_statement_container(target) {
            current.push({
              name: "/" + root,
              value: "",
              line,
              self_closing: false,
            })
            groups.push(current)
            current = []
            open = false
          } else {
            current.push(token)
          }
        }
      None =>
        if is_statement_root(tag) {
          if open {
            current.push({
              name: "/" + root,
              value: "",
              line,
              self_closing: false,
            })
            groups.push(current)
            current = []
          }
          open = true
          root = tag
          current.push(token)
        } else if open {
          current.push(token)
        }
    }
  }
  if open && current.length() > 0 {
    current.push({
      name: "/" + root,
      value: "",
      line: tokens[tokens.length() - 1].line,
      self_closing: false,
    })
    groups.push(current)
  }
  groups
}

///|
/// Scan an OFX document and return every statement it holds.
///
/// A file that holds one statement and a file that holds thirty are read by the
/// same rules, and each statement is diagnosed on its own: a bad record in the
/// third account does not make the first two unimportable, and the caller can
/// see which statement failed. A document with no statement wrapper at all is
/// still read, as a single statement, so fragments keep working exactly as they
/// do with [`parse_statement`].
pub fn parse_document(source : String) -> Document {
  let text = strip_leading_bom(source)
  let diagnostics : Array[Diagnostic] = []
  let header = parse_header(text, diagnostics)
  let tokens = lex(text, diagnostics)
  let signon = parse_signon(tokens, diagnostics)
  let statements : Array[Statement] = []
  let groups = split_statements(tokens)
  if groups.length() == 0 {
    let statement_diagnostics : Array[Diagnostic] = []
    statements.push(
      statement_from_tokens(tokens, header, signon, statement_diagnostics, true),
    )
  } else {
    for group in groups {
      let statement_diagnostics : Array[Diagnostic] = []
      statements.push(
        statement_from_tokens(
          group, header, signon, statement_diagnostics, false,
        ),
      )
    }
  }
  { header, signon, statements, diagnostics, }
}