///|
/// One `` record.
///
/// Values are kept exactly as the document spells them, after XML entity
/// decoding: `amount` stays a decimal string and `posted` stays an OFX date
/// stamp, so the caller decides how to round or in which zone to interpret
/// them. Fields the document omits are empty strings rather than defaults.
pub struct Transaction {
  kind : String
  posted : String
  user_date : String
  amount : String
  fitid : String
  check_number : String
  ref_number : String
  payee_id : String
  sic : String
  name : String
  memo : String
}

///|
/// The side of the ledger a transaction belongs to.
///
/// It is derived from the sign of the amount, never from `TRNTYPE`: one bank
/// labels a card payment `DEBIT` and another labels it `POS`, while the sign is
/// what a ledger must trust. A zero amount or an amount that cannot be parsed
/// has no direction.
pub enum Direction {
  Debit
  Credit
  Unknown
}

///|
/// `debit`, `credit` or `unknown`.
pub fn Direction::to_string(self : Direction) -> String {
  match self {
    Debit => "debit"
    Credit => "credit"
    Unknown => "unknown"
  }
}

///|
/// Which side of the ledger the amount falls on.
pub fn Transaction::direction(self : Transaction) -> Direction {
  if !is_valid_amount(self.amount) {
    return Unknown
  }
  let mut negative = false
  let mut nonzero = false
  for c in self.amount.iter() {
    if c == '-' {
      negative = true
    } else if c >= '1' && c <= '9' {
      nonzero = true
    }
  }
  if !nonzero {
    return Unknown
  }
  if negative {
    Debit
  } else {
    Credit
  }
}

///|
/// The amount as an integer count of minor units at the given scale, or `None`
/// when the amount is malformed, has more fractional digits than `scale`, or
/// does not fit in `Int64`. See [`amount_to_minor_units`].
pub fn Transaction::amount_minor_units(
  self : Transaction,
  scale : Int,
) -> Int64? {
  amount_to_minor_units(self.amount, scale)
}

///|
/// The parsed posting date, or `None` when `DTPOSTED` is not a valid OFX date.
pub fn Transaction::posted_date(self : Transaction) -> PostedDate? {
  parse_posted_date(self.posted)
}

///|
/// The posting instant as `YYYYMMDDHHMMSS` in UTC, or `None` when the date is
/// not valid. Sorting a batch of statements by this value gives the right order
/// even when the files were written in different zones.
pub fn Transaction::utc_posted(self : Transaction) -> String? {
  match parse_posted_date(self.posted) {
    Some(date) => Some(date.to_utc_string())
    None => None
  }
}

///|
/// The result of scanning one OFX document.
///
/// `transactions` holds only the records that passed every check; everything
/// the parser rejected or found suspicious is described by `diagnostics`. Use
/// [`Statement::ok`] for a pipeline decision and [`Statement::render_diagnostics`]
/// for operator-facing text.
pub struct Statement {
  header : Header
  signon : Signon
  bank_id : String
  branch_id : String
  account_id : String
  account_type : String
  currency : String
  start_date : String
  end_date : String
  ledger_balance : Balance?
  avail_balance : Balance?
  transactions : Array[Transaction]
  investments : Array[Investment]
  diagnostics : Array[Diagnostic]
}

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

///|
/// Whether the statement can be imported: no `Error` diagnostic was raised.
///
/// Warnings never block an import, but they still deserve a look before the
/// transactions reach a ledger.
pub fn Statement::ok(self : Statement) -> Bool {
  !self.has_errors()
}

///|
/// Number of `Error` diagnostics.
pub fn Statement::error_count(self : Statement) -> Int {
  self.diagnostics.filter(d => d.severity is Error).length()
}

///|
/// Number of `Warning` diagnostics.
pub fn Statement::warning_count(self : Statement) -> Int {
  self.diagnostics.filter(d => d.severity is Warning).length()
}

///|
/// A copy of the diagnostics, in detection order.
pub fn Statement::diagnostics(self : Statement) -> Array[Diagnostic] {
  self.diagnostics.copy()
}

///|
/// The diagnostics as stable `severity:code[:location]` strings, in detection
/// order.
pub fn Statement::render_diagnostics(self : Statement) -> Array[String] {
  self.diagnostics.map(d => d.to_string())
}

///|
/// The aggregates whose nesting the importer tracks.
///
/// Only these names can open a scope. Every other tag is a leaf field, so an
/// unrecognised vendor tag cannot push the parser into a state it never leaves.
fn is_aggregate(tag : String) -> Bool {
  match tag {
    "OFX"
    | "SIGNONMSGSRSV1"
    | "SONRS"
    | "FI"
    | "STATUS"
    | "BANKMSGSRSV1"
    | "STMTTRNRS"
    | "STMTRS"
    | "CCSTMTTRNRS"
    | "CCSTMTRS"
    | "BANKACCTFROM"
    | "CCACCTFROM"
    | "BANKTRANLIST"
    | "STMTTRN"
    | "LEDGERBAL"
    | "AVAILBAL"
    | "BAL" => true
    _ => false
  }
}

///|
/// Decide the value of a statement-level field that the document sets more than
/// once.
///
/// The first declaration wins, because a file that holds several statements is
/// reported one statement at a time and the caller should get the account the
/// file opens with. A later, different value is reported as `conflicting-value`
/// rather than silently overwriting it.
fn merge_field(
  current : String,
  tag : String,
  value : String,
  line : Int,
  diagnostics : Array[Diagnostic],
) -> String {
  if current == "" {
    value
  } else if current == value {
    current
  } else {
    diagnostics.push(
      warning_at(
        "conflicting-value",
        tag + ":" + current + "!=" + value + "@" + line.to_string(),
      ),
    )
    current
  }
}

///|
/// Drop a leading UTF-8 byte-order mark.
///
/// A BOM is not part of the document. Without this, a file that starts with one
/// would have its first header key read as `\u{FEFF}OFXHEADER`, so every declared
/// field would be lost and a complete OFX 1.x file would look like one that only
/// warns about a missing header.
fn strip_leading_bom(text : String) -> String {
  match text.get_char(0) {
    Some('\u{FEFF}') => text[1:].to_owned()
    _ => text
  }
}

///|
/// Turn one statement's worth of tokens into a `Statement`.
///
/// The scanner is deliberately tolerant: it accepts OFX 1.x SGML (`value`
/// without a closing tag) and OFX 2.x XML (`value`) in the same
/// pass, keeps the original field order, and never fails as a whole. Tags are
/// matched without regard to case, so a document that writes `` is read
/// the same way as one that writes ``. Aggregate nesting is tracked, so
/// a field is attributed to the account, period or transaction that owns it. A
/// transaction is dropped only when one of its own fields is missing or
/// malformed, and the reason is always reported.
///
/// `document_level` says whether these tokens are a whole document, where the
/// header and completeness rules apply to the file, or one statement cut out of
/// a larger file, where they apply to the statement. Everything else is
/// identical, so a single-statement export and a ten-statement one are read by
/// the same rules.
fn statement_from_tokens(
  tokens : Array[TagToken],
  header : Header,
  signon : Signon,
  diagnostics : Array[Diagnostic],
  document_level : Bool,
) -> Statement {
  let complete = if document_level {
    tokens.any(token => token.name == "OFX")
  } else {
    true
  }
  if document_level && header.is_empty() && complete {
    diagnostics.push(warning_at("missing-header", ""))
  }
  let (ledger_balance, avail_balance) = parse_balances(tokens, diagnostics)
  let investments = parse_investments(tokens, diagnostics)
  let transactions : Array[Transaction] = []
  let seen : Map[String, Bool] = Map([])
  let open_aggregates : Array[(String, Int)] = []
  let mut bank_id = ""
  let mut branch_id = ""
  let mut account_id = ""
  let mut account_type = ""
  let mut currency = ""
  let mut start_date = ""
  let mut end_date = ""
  let mut kind = ""
  let mut posted = ""
  let mut user_date = ""
  let mut amount = ""
  let mut fitid = ""
  let mut check_number = ""
  let mut ref_number = ""
  let mut payee_id = ""
  let mut sic = ""
  let mut name = ""
  let mut memo = ""
  let mut posted_line = 0
  let mut amount_line = 0
  let mut user_date_line = 0
  let mut sic_line = 0
  for token in tokens {
    let tag = token.name
    let line = token.line
    let value = token.value
    let in_transaction = open_aggregates.length() > 0 &&
      open_aggregates[open_aggregates.length() - 1].0 == "STMTTRN"
    match close_target(tag) {
      Some(target) => {
        if !is_aggregate(target) {
          // The closing half of a leaf field, e.g. ``: the value was
          // already read from the opening tag.
          continue
        }
        if !open_aggregates.any(entry => entry.0 == target) {
          diagnostics.push(error_at("unexpected-close", at_line(target, line)))
          continue
        }
        let mut closing_transaction = false
        while open_aggregates.length() > 0 {
          let (open_name, open_line) = open_aggregates[open_aggregates.length() -
            1]
          if open_name == target {
            let _ = open_aggregates.pop()
            closing_transaction = target == "STMTTRN"
            break
          }
          diagnostics.push(
            error_at("mismatched-aggregate", at_line(open_name, open_line)),
          )
          let _ = open_aggregates.pop()
        }
        if closing_transaction {
          let mut valid = true
          if kind == "" {
            diagnostics.push(error_at("missing-type", at_line("TRNTYPE", line)))
            valid = false
          }
          if !valid_date(posted) {
            diagnostics.push(
              error_at("invalid-posted", at_line("DTPOSTED", posted_line)),
            )
            valid = false
          }
          if !is_valid_amount(amount) {
            diagnostics.push(
              error_at("invalid-amount", at_line("TRNAMT", amount_line)),
            )
            valid = false
          }
          if fitid == "" {
            diagnostics.push(error_at("missing-fitid", at_line("FITID", line)))
            valid = false
          } else if seen.get(fitid) is Some(_) {
            diagnostics.push(error_at("duplicate-fitid", fitid))
            valid = false
          }
          if user_date != "" && !valid_date(user_date) {
            diagnostics.push(
              warning_at("invalid-user-date", at_line("DTUSER", user_date_line)),
            )
          }
          if sic != "" && !is_digits(sic, 4) {
            diagnostics.push(
              warning_at("invalid-sic", at_line("SIC", sic_line)),
            )
          }
          if valid {
            seen[fitid] = true
            transactions.push({
              kind,
              posted,
              user_date,
              amount,
              fitid,
              check_number,
              ref_number,
              payee_id,
              sic,
              name,
              memo,
            })
          }
        }
      }
      None =>
        if is_aggregate(tag) {
          if token.self_closing {
            // An empty element such as `` has no children and no
            // closing tag: there is no scope to open, and treating it as one
            // would report a mismatch when the enclosing aggregate closes.
            continue
          }
          if in_transaction && tag == "STMTTRN" {
            diagnostics.push(error_at("nested-aggregate", at_line(tag, line)))
          }
          if tag == "STMTTRN" {
            kind = ""
            posted = ""
            user_date = ""
            amount = ""
            fitid = ""
            check_number = ""
            ref_number = ""
            payee_id = ""
            sic = ""
            name = ""
            memo = ""
            posted_line = line
            amount_line = line
            user_date_line = line
            sic_line = line
          }
          open_aggregates.push((tag, line))
        } else if in_transaction {
          match tag {
            "TRNTYPE" => kind = value
            "DTPOSTED" => {
              posted = value
              posted_line = line
            }
            "DTUSER" => {
              user_date = value
              user_date_line = line
            }
            "TRNAMT" => {
              amount = value
              amount_line = line
            }
            "FITID" => fitid = value
            "CHECKNUM" => check_number = value
            "REFNUM" => ref_number = value
            "PAYEEID" => payee_id = value
            "SIC" => {
              sic = value
              sic_line = line
            }
            "NAME" => name = value
            "MEMO" => memo = value
            _ => ()
          }
        } else {
          match tag {
            "BANKID" =>
              bank_id = merge_field(bank_id, tag, value, line, diagnostics)
            "BRANCHID" =>
              branch_id = merge_field(branch_id, tag, value, line, diagnostics)
            "ACCTID" =>
              account_id = merge_field(
                account_id, tag, value, line, diagnostics,
              )
            "ACCTTYPE" =>
              account_type = merge_field(
                account_type, tag, value, line, diagnostics,
              )
            "CURDEF" =>
              currency = merge_field(currency, tag, value, line, diagnostics)
            "DTSTART" =>
              start_date = merge_field(
                start_date, tag, value, line, diagnostics,
              )
            "DTEND" =>
              end_date = merge_field(end_date, tag, value, line, diagnostics)
            _ => ()
          }
        }
    }
  }
  if open_aggregates.length() > 0 {
    let (open_name, open_line) = open_aggregates[open_aggregates.length() - 1]
    diagnostics.push(
      error_at("unclosed-aggregate", at_line(open_name, open_line)),
    )
  }
  if complete && !diagnostics.any(d => d.severity is Error) {
    if transactions.length() == 0 {
      diagnostics.push(warning_at("no-transactions", ""))
    } else {
      if account_id == "" {
        diagnostics.push(warning_at("missing-acctid", ""))
      }
      if currency == "" {
        diagnostics.push(warning_at("missing-curdef", ""))
      }
    }
  }
  {
    header,
    signon,
    bank_id,
    branch_id,
    account_id,
    account_type,
    currency,
    start_date,
    end_date,
    ledger_balance,
    avail_balance,
    transactions,
    investments,
    diagnostics,
  }
}