///|
/// The kind of investment activity a record describes.
///
/// The variants cover the aggregates a retail brokerage statement actually
/// uses; a tag this library does not classify still produces a record, with
/// `Unknown` as its kind and the original tag kept in [`Investment::tag`], so
/// nothing is silently dropped.
pub enum InvestmentKind {
  BuyStock
  SellStock
  Income
  Reinvest
  BankTransfer
  Transfer
  MarginInterest
  Expense
  Unknown
}

///|
/// `buy`, `sell`, `income`, `reinvest`, `transfer`, `bank-transfer`,
/// `margin-interest`, `expense` or `unknown`.
pub fn InvestmentKind::to_string(self : InvestmentKind) -> String {
  match self {
    BuyStock => "buy"
    SellStock => "sell"
    Income => "income"
    Reinvest => "reinvest"
    BankTransfer => "bank-transfer"
    Transfer => "transfer"
    MarginInterest => "margin-interest"
    Expense => "expense"
    Unknown => "unknown"
  }
}

///|
/// Classify an investment aggregate name.
pub fn investment_kind_of(tag : String) -> InvestmentKind {
  match tag {
    "BUYSTOCK" | "BUYMF" | "BUYDEBT" | "BUYOPT" => BuyStock
    "SELLSTOCK" | "SELLMF" | "SELLDEBT" | "SELLOPT" => SellStock
    "INCOME" => Income
    "REINVEST" => Reinvest
    "INVBANKTRAN" => BankTransfer
    "TRANSFER" | "JOURNAL" | "CLOSUREOPT" => Transfer
    "MARGININTEREST" => MarginInterest
    "INVEXPENSE" => Expense
    _ => Unknown
  }
}

///|
/// Whether a tag starts an investment record.
fn is_investment_record(tag : String) -> Bool {
  !(investment_kind_of(tag) is Unknown)
}

///|
/// The aggregates that can appear inside an investment record.
///
/// They only matter for keeping the scope stack balanced, because a record is
/// closed by its own tag or by the next record that starts; a tag outside this
/// set is a leaf field.
fn is_investment_aggregate(tag : String) -> Bool {
  match tag {
    "INVSTMTMSGSRSV1"
    | "INVSTMTTRNRS"
    | "INVSTMTRS"
    | "INVACCTFROM"
    | "INVTRANLIST"
    | "INVTRAN"
    | "INVBUY"
    | "INVSELL"
    | "SECID"
    | "SECLIST"
    | "INVBAL"
    | "INVPOS"
    | "STMTTRN" => true
    _ => false
  }
}

///|
/// One investment transaction, flattened to the fields a ledger needs.
///
/// The nesting OFX uses (`INVTRAN` inside `INVBUY` inside `BUYSTOCK`, a
/// `STMTTRN` inside `INVBANKTRAN`) is collapsed here: `fitid`, `trade_date` and
/// `total` carry the same meaning whichever aggregate supplied them, and a
/// diagnostic still points at the line the value came from.
pub struct Investment {
  kind : InvestmentKind
  tag : String
  fitid : String
  trade_date : String
  settle_date : String
  security_id : String
  security_id_type : String
  units : String
  unit_price : String
  commission : String
  fees : String
  taxes : String
  total : String
  sub_account_sec : String
  sub_account_fund : String
  income_type : String
  buy_type : String
  sell_type : String
  currency : String
  name : String
  memo : String
}

///|
/// The total of the record at a scale, or `None` when the document stated none
/// or it cannot be represented.
pub fn Investment::total_minor_units(self : Investment, scale : Int) -> Int64? {
  if self.total == "" {
    None
  } else {
    amount_to_minor_units(self.total, scale)
  }
}

///|
/// The traded quantity at a scale, for a caller that stores share counts as
/// integers (`scale` 4 keeps four decimal places of a share).
pub fn Investment::units_at_scale(self : Investment, scale : Int) -> Int64? {
  if self.units == "" {
    None
  } else {
    amount_to_minor_units(self.units, scale)
  }
}

///|
/// The unit price at a scale, or `None` when the document stated none.
pub fn Investment::unit_price_minor_units(
  self : Investment,
  scale : Int,
) -> Int64? {
  if self.unit_price == "" {
    None
  } else {
    amount_to_minor_units(self.unit_price, scale)
  }
}

///|
/// The trade instant, or `None` when it is absent or not a real date.
pub fn Investment::trade_time(self : Investment) -> PostedDate? {
  parse_posted_date(self.trade_date)
}

///|
/// The settlement instant, or `None` when it is absent or not a real date.
pub fn Investment::settle_time(self : Investment) -> PostedDate? {
  parse_posted_date(self.settle_date)
}

///|
/// The security the record moved, as `TYPE:ID` when both are known, or just the
/// identifier when the type is missing.
pub fn Investment::security_label(self : Investment) -> String {
  if self.security_id == "" {
    ""
  } else if self.security_id_type == "" {
    self.security_id
  } else {
    self.security_id_type + ":" + self.security_id
  }
}

///|
/// Whether the record moved money the way a ledger books as a debit.
///
/// The document's own total is the authority where it is present: a buy with a
/// positive total is a refund of a purchase, and this library will not turn it
/// into a debit. Only when there is no total does the kind decide.
pub fn Investment::is_debit(self : Investment) -> Bool {
  if self.total != "" && is_valid_amount(self.total) {
    for c in self.total.iter() {
      if c == '-' {
        return true
      }
    }
    return false
  }
  match self.kind {
    BuyStock | Expense | MarginInterest => true
    _ => false
  }
}

///|
/// The money the record actually moved, after the costs it states.
///
/// A brokerage total is usually the gross amount, so a buy of 1501.00 with a
/// commission of 1.00 is 1502.00 out of the account. `None` means the document
/// stated no total, or one of the amounts will not convert at `scale`, or the
/// subtraction overflows - a caller reconciling a cash balance must not be
/// handed a number this library had to guess.
pub fn Investment::proceeds_minor_units(
  self : Investment,
  scale : Int,
) -> Int64? {
  let total = match self.total_minor_units(scale) {
    Some(value) => value
    None => return None
  }
  let mut result = total
  for field in [self.commission, self.fees, self.taxes] {
    if field != "" {
      let cost = match amount_to_minor_units(field, scale) {
        Some(value) => value
        None => return None
      }
      // A cost is stated as a positive number even when it reduces a sale, so
      // it is always subtracted from the total.
      result = match checked_sub(result, cost) {
        Some(value) => value
        None => return None
      }
    }
  }
  Some(result)
}

///|
/// The record as one line, for a report or a log.
pub fn Investment::to_string(self : Investment) -> String {
  let parts : Array[String] = []
  parts.push(self.kind.to_string())
  parts.push(self.fitid)
  if self.trade_date != "" {
    parts.push("on " + self.trade_date)
  }
  if self.security_id != "" {
    parts.push("security " + self.security_label())
  }
  if self.units != "" {
    parts.push("units " + self.units)
  }
  if self.unit_price != "" {
    parts.push("at " + self.unit_price)
  }
  if self.total != "" {
    parts.push("total " + self.total)
  }
  if self.commission != "" {
    parts.push("commission " + self.commission)
  }
  parts.join(" ")
}

///|
/// The value a field was given, or the empty string when the record never stated
/// it.
fn record_field(fields : Map[String, String], key : String) -> String {
  match fields.get(key) {
    Some(value) => value
    None => ""
  }
}

///|
/// Whether the record already has a value for a field.
///
/// The first value wins, the same rule the statement-level fields follow, so a
/// document that repeats `TOTAL` does not silently use the later one.
fn record_has(fields : Map[String, String], key : String) -> Bool {
  fields.get(key) is Some(_)
}

///|
/// Turn a collected record into a transaction, or report why it cannot be one.
///
/// A record without an id cannot be de-duplicated and a record without a valid
/// trade date cannot be placed in time, so both are reported and the record is
/// dropped, the same rule the bank-transaction path applies. A total that is not
/// a plain decimal amount is reported for the same reason.
fn finish_investment(
  tag : String,
  line : Int,
  fields : Map[String, String],
  trade_tag : String,
  trade_line : Int,
  total_line : Int,
  diagnostics : Array[Diagnostic],
) -> Investment? {
  let fitid = record_field(fields, "FITID")
  if fitid == "" {
    diagnostics.push(error_at("missing-fitid", at_line(tag, line)))
    return None
  }
  let trade_date = record_field(fields, "DATE")
  if trade_date == "" {
    diagnostics.push(error_at("invalid-posted", at_line("DTTRADE", line)))
    return None
  }
  if !valid_date(trade_date) {
    diagnostics.push(error_at("invalid-posted", at_line(trade_tag, trade_line)))
    return None
  }
  let total = record_field(fields, "TOTAL")
  if total != "" && !is_valid_amount(total) {
    diagnostics.push(error_at("invalid-amount", at_line("TOTAL", total_line)))
    return None
  }
  Some({
    kind: investment_kind_of(tag),
    tag,
    fitid,
    trade_date,
    settle_date: record_field(fields, "SETTLE"),
    security_id: record_field(fields, "SECID"),
    security_id_type: record_field(fields, "SECIDTYPE"),
    units: record_field(fields, "UNITS"),
    unit_price: record_field(fields, "UNITPRICE"),
    commission: record_field(fields, "COMMISSION"),
    fees: record_field(fields, "FEES"),
    taxes: record_field(fields, "TAXES"),
    total,
    sub_account_sec: record_field(fields, "SUBACCTSEC"),
    sub_account_fund: record_field(fields, "SUBACCTFUND"),
    income_type: record_field(fields, "INCOMETYPE"),
    buy_type: record_field(fields, "BUYTYPE"),
    sell_type: record_field(fields, "SELLTYPE"),
    currency: record_field(fields, "CURRENCY"),
    name: record_field(fields, "NAME"),
    memo: record_field(fields, "MEMO"),
  })
}

///|
/// Read the investment transactions out of a token stream.
///
/// This runs as its own pass because investment records have their own nesting
/// and their own field names; the bank-transaction pass is left alone, which
/// also means the `STMTTRN` inside an `INVBANKTRAN` is still reported as an
/// ordinary cash transaction by [`parse_statement`].
///
/// A record ends at its own closing tag or, in an SGML document that has none,
/// at the next record that starts. Leaf fields are attributed to whichever
/// record is open, because `FITID` inside `INVTRAN` and `FITID` inside a nested
/// `STMTTRN` mean the same thing here.
fn parse_investments(
  tokens : Array[TagToken],
  diagnostics : Array[Diagnostic],
) -> Array[Investment] {
  let investments : Array[Investment] = []
  let scopes : Array[(String, Int)] = []
  let mut fields : Map[String, String] = Map([])
  let mut tag = ""
  let mut line = 0
  let mut trade_tag = ""
  let mut trade_line = 0
  let mut total_line = 0
  let mut open = false
  for token in tokens {
    let name = token.name
    let token_line = token.line
    let value = token.value
    match close_target(name) {
      Some(target) => {
        while scopes.length() > 0 {
          let (top, _) = scopes[scopes.length() - 1]
          let _ = scopes.pop()
          if top == target {
            break
          }
        }
        if open && target == tag {
          match
            finish_investment(
              tag, line, fields, trade_tag, trade_line, total_line, diagnostics,
            ) {
            Some(investment) => investments.push(investment)
            None => ()
          }
          fields = Map([])
          open = false
          tag = ""
        }
      }
      None =>
        if is_investment_record(name) {
          if open {
            match
              finish_investment(
                tag, line, fields, trade_tag, trade_line, total_line, diagnostics,
              ) {
              Some(investment) => investments.push(investment)
              None => ()
            }
            fields = Map([])
          }
          tag = name
          line = token_line
          trade_tag = ""
          trade_line = token_line
          total_line = token_line
          open = true
          scopes.push((name, token_line))
        } else if is_investment_aggregate(name) {
          scopes.push((name, token_line))
        } else if open {
          match name {
            "FITID" =>
              if !record_has(fields, "FITID") {
                fields["FITID"] = value
              }
            "DTTRADE" | "DTPOSTED" =>
              if !record_has(fields, "DATE") {
                fields["DATE"] = value
                trade_tag = name
                trade_line = token_line
              }
            "DTSETTLE" => fields["SETTLE"] = value
            "UNIQUEID" => fields["SECID"] = value
            "UNIQUEIDTYPE" => fields["SECIDTYPE"] = value
            "UNITS" => fields["UNITS"] = value
            "UNITPRICE" => fields["UNITPRICE"] = value
            "COMMISSION" => fields["COMMISSION"] = value
            "FEES" => fields["FEES"] = value
            "TAXES" => fields["TAXES"] = value
            "TOTAL" | "TRNAMT" =>
              if !record_has(fields, "TOTAL") {
                fields["TOTAL"] = value
                total_line = token_line
              }
            "SUBACCTSEC" => fields["SUBACCTSEC"] = value
            "SUBACCTFUND" => fields["SUBACCTFUND"] = value
            "INCOMETYPE" => fields["INCOMETYPE"] = value
            "BUYTYPE" => fields["BUYTYPE"] = value
            "SELLTYPE" => fields["SELLTYPE"] = value
            "CURRENCY" => fields["CURRENCY"] = value
            "NAME" => if !record_has(fields, "NAME") { fields["NAME"] = value }
            "MEMO" => if !record_has(fields, "MEMO") { fields["MEMO"] = value }
            _ => ()
          }
        }
    }
  }
  if open {
    match
      finish_investment(
        tag, line, fields, trade_tag, trade_line, total_line, diagnostics,
      ) {
      Some(investment) => investments.push(investment)
      None => ()
    }
  }
  investments
}