///|
/// Escape one field for CSV, following RFC 4180.
///
/// A field is quoted when it contains a comma, a quote or a line break, and its
/// quotes are doubled inside the quotes. This matters here more than in most
/// exporters: a bank memo is free text and routinely contains commas, and a
/// ledger that splits `memo` into two columns because of one is worse than no
/// export at all.
pub fn csv_escape(field : String) -> String {
  if field.contains_char(',') ||
    field.contains_char('"') ||
    field.contains_char('\n') ||
    field.contains_char('\r') {
    "\"" + field.replace_all(old="\"", new="\"\"") + "\""
  } else {
    field
  }
}

///|
/// One CSV line from already-escaped-or-not fields.
fn csv_line(fields : Array[String]) -> String {
  fields.map(csv_escape).join(",")
}

///|
/// The column names of the transaction table.
pub fn transaction_columns() -> Array[String] {
  [
    "fitid", "kind", "posted", "user_date", "amount", "check_number", "ref_number",
    "payee_id", "sic", "name", "memo",
  ]
}

///|
/// One transaction as CSV fields, without escaping.
fn transaction_fields(transaction : Transaction) -> Array[String] {
  [
    transaction.fitid,
    transaction.kind,
    transaction.posted,
    transaction.user_date,
    transaction.amount,
    transaction.check_number,
    transaction.ref_number,
    transaction.payee_id,
    transaction.sic,
    transaction.name,
    transaction.memo,
  ]
}

///|
/// The column names of the investment table.
pub fn investment_columns() -> Array[String] {
  [
    "fitid", "kind", "tag", "trade_date", "settle_date", "security", "units", "unit_price",
    "commission", "fees", "total", "sub_account_sec", "sub_account_fund", "income_type",
    "buy_type", "sell_type", "name", "memo",
  ]
}

///|
/// One investment record as CSV fields, without escaping.
fn investment_fields(investment : Investment) -> Array[String] {
  [
    investment.fitid,
    investment.kind.to_string(),
    investment.tag,
    investment.trade_date,
    investment.settle_date,
    investment.security_label(),
    investment.units,
    investment.unit_price,
    investment.commission,
    investment.fees,
    investment.total,
    investment.sub_account_sec,
    investment.sub_account_fund,
    investment.income_type,
    investment.buy_type,
    investment.sell_type,
    investment.name,
    investment.memo,
  ]
}

///|
/// A statement's transactions as a CSV table with a header row.
///
/// Amounts are written exactly as the document spelled them; the conversion to
/// minor units is the caller's decision, because the scale belongs to the
/// ledger being written, not to the export.
pub fn Statement::to_csv(self : Statement) -> String {
  let lines : Array[String] = [csv_line(transaction_columns())]
  for transaction in self.transactions {
    lines.push(csv_line(transaction_fields(transaction)))
  }
  lines.join("\n")
}

///|
/// A statement's investment records as a CSV table with a header row.
pub fn Statement::investments_to_csv(self : Statement) -> String {
  let lines : Array[String] = [csv_line(investment_columns())]
  for investment in self.investments {
    lines.push(csv_line(investment_fields(investment)))
  }
  lines.join("\n")
}

///|
/// Every statement's transactions as one CSV table, with the account and the
/// currency leading each row.
///
/// The two extra columns are what makes a multi-statement export usable: a row
/// that cannot be attributed to an account is a row a spreadsheet cannot
/// filter, and a row without its currency cannot be summed with another
/// statement's rows.
pub fn Document::to_csv(self : Document) -> String {
  let header : Array[String] = ["account", "currency"]
  header.push_iter(transaction_columns().iter())
  let lines : Array[String] = [csv_line(header)]
  for statement in self.statements {
    let prefix : Array[String] = [
      account_label(statement),
      currency_label(statement.currency),
    ]
    for transaction in statement.transactions {
      let fields = prefix.copy()
      fields.push_iter(transaction_fields(transaction).iter())
      lines.push(csv_line(fields))
    }
  }
  lines.join("\n")
}

///|
/// Every statement's investment records as one CSV table, with the account and
/// the currency leading each row.
pub fn Document::investments_to_csv(self : Document) -> String {
  let header : Array[String] = ["account", "currency"]
  header.push_iter(investment_columns().iter())
  let lines : Array[String] = [csv_line(header)]
  for statement in self.statements {
    let prefix : Array[String] = [
      account_label(statement),
      currency_label(statement.currency),
    ]
    for investment in statement.investments {
      let fields = prefix.copy()
      fields.push_iter(investment_fields(investment).iter())
      lines.push(csv_line(fields))
    }
  }
  lines.join("\n")
}

///|
/// The widest cell in each column of a table, which is what makes the columns
/// line up without a layout engine.
///
/// Widths are counted in the string's own units, so a name that is mostly
/// non-Latin will be padded by its code-unit count rather than its printed
/// width. Padding a terminal table exactly would need the font; being close is
/// enough for a report a human reads.
fn column_widths(rows : Array[Array[String]]) -> Array[Int] {
  let widths : Array[Int] = []
  for row in rows {
    let mut index = 0
    for cell in row {
      let width = cell.length()
      if index >= widths.length() {
        widths.push(width)
      } else if width > widths[index] {
        widths[index] = width
      }
      index += 1
    }
  }
  widths
}

///|
/// One table row with its cells padded to the column widths.
fn padded_row(cells : Array[String], widths : Array[Int]) -> String {
  let out = StringBuilder()
  let mut index = 0
  for cell in cells {
    if index > 0 {
      out.write_string("  ")
    }
    let width = if index < widths.length() {
      widths[index]
    } else {
      cell.length()
    }
    out.write_string(cell.pad_end(width, ' '))
    index += 1
  }
  out.to_string()
}

///|
/// A statement's transactions as an aligned text table.
///
/// The instants are printed in UTC, because a table is exactly where two rows
/// written in different zones would otherwise look out of order, and the raw
/// stamp is kept in the CSV for a caller that wants it.
pub fn Statement::render_table(self : Statement) -> String {
  let rows : Array[Array[String]] = [["fitid", "utc", "dir", "amount", "name"]]
  for transaction in self.transactions {
    let utc = match transaction.utc_posted() {
      Some(stamp) => stamp
      None => "invalid"
    }
    rows.push([
      transaction.fitid,
      utc,
      transaction.direction().to_string(),
      transaction.amount,
      transaction.name,
    ])
  }
  let widths = column_widths(rows)
  let lines : Array[String] = []
  for row in rows {
    lines.push(padded_row(row, widths))
  }
  lines.join("\n")
}

///|
/// A statement as a short human report.
///
/// This is the text an operator reads before deciding to import: who the
/// statement belongs to, what period it covers, how many records it holds, how
/// the balances look against those records, and every diagnostic. It is
/// deliberately verbose about the diagnostics and silent about the amounts —
/// the CSV is for the amounts.
pub fn Statement::render_report(self : Statement) -> String {
  let out = StringBuilder()
  out.write_string("Account: " + account_label(self))
  if self.account_type != "" {
    out.write_string(" (" + self.account_type + ")")
  }
  out.write_string(" in " + currency_label(self.currency) + "\n")
  if self.start_date != "" || self.end_date != "" {
    out.write_string(
      "Period: " +
      (if self.start_date == "" { "?" } else { self.start_date }) +
      " .. " +
      (if self.end_date == "" { "?" } else { self.end_date }) +
      "\n",
    )
  }
  let signon_text = self.signon.to_string()
  if signon_text != "" {
    out.write_string("Sign-on: " + signon_text + "\n")
  }
  match self.ledger_balance {
    Some(balance) =>
      out.write_string("Ledger balance: " + balance.to_string() + "\n")
    None => ()
  }
  match self.avail_balance {
    Some(balance) =>
      out.write_string("Available balance: " + balance.to_string() + "\n")
    None => ()
  }
  let debits = self.transactions.filter(tx => tx.direction() is Debit).length()
  out.write_string(
    "Transactions: " +
    self.transactions.length().to_string() +
    " (" +
    debits.to_string() +
    " debits, " +
    (self.transactions.length() - debits).to_string() +
    " credits)\n",
  )
  if self.investments.length() > 0 {
    out.write_string(
      "Investments: " + self.investments.length().to_string() + "\n",
    )
  }
  out.write_string(
    "Diagnostics: " +
    self.diagnostics.length().to_string() +
    " (errors " +
    self.error_count().to_string() +
    ", warnings " +
    self.warning_count().to_string() +
    ")\n",
  )
  for line in self.render_diagnostics() {
    out.write_string("  " + line + "\n")
  }
  out.to_string()
}

///|
/// A document as a short human report: one block per statement, then the file
/// totals.
pub fn Document::render_report(self : Document) -> String {
  let out = StringBuilder()
  out.write_string(
    "Header: " +
    (if self.header.is_empty() { "(none)" } else { self.header.render() }) +
    "\n",
  )
  let signon_text = self.signon.to_string()
  if signon_text != "" {
    out.write_string("Sign-on: " + signon_text + "\n")
  }
  out.write_string(
    "Statements: " +
    self.statement_count().to_string() +
    " (errors " +
    self.error_count().to_string() +
    ", warnings " +
    self.warning_count().to_string() +
    ")\n",
  )
  let mut index = 0
  for statement in self.statements {
    index += 1
    out.write_string("\n--- statement " + index.to_string() + " ---\n")
    out.write_string(statement.render_report())
  }
  out.to_string()
}