// Text and JSON reports built on the realized ledger.  All reports are
// deterministic and currency-precision preserving: JSON numbers are emitted
// as strings so that exact decimals survive serialization.

///|
pub(all) struct ReportRow {
  label : String
  amount : @ast.Amount
}

///|
pub(all) struct Report {
  title : String
  rows : Array[ReportRow]
  totals : Array[ReportRow]
  diagnostics : Array[ConversionIssue]
  display_scales : Map[String, Int32]
}

///|
/// A report retains its values when conversion is incomplete; hosts must inspect this diagnostic.
pub(all) struct ConversionIssue {
  code : String
  account : String
  currency : String
  target : String
}

///|
fn pad_right(text : String, width : Int32) -> String {
  let sb = StringBuilder()
  sb.write_string(text)
  let mut i = text.length()
  while i < width {
    sb.write_string(" ")
    i = i + 1
  }
  sb.to_string()
}

///|
fn pad_left(text : String, width : Int32) -> String {
  let sb = StringBuilder()
  let mut i = text.length()
  while i < width {
    sb.write_string(" ")
    i = i + 1
  }
  sb.write_string(text)
  sb.to_string()
}

///|
fn amount_text(amount : @ast.Amount) -> String {
  amount.number.to_string() + " " + amount.currency
}

///|
fn max_label_width(rows : Array[ReportRow], fallback : Int32) -> Int32 {
  let mut width = fallback
  for row in rows {
    if row.label.length() > width {
      width = row.label.length()
    }
  }
  width
}

///|
fn max_amount_width(rows : Array[ReportRow]) -> Int32 {
  let mut width = 1
  for row in rows {
    if amount_text(row.amount).length() > width {
      width = amount_text(row.amount).length()
    }
  }
  width
}

///|
fn render_rows(
  sb : StringBuilder,
  rows : Array[ReportRow],
  label_width : Int32,
  amount_width : Int32,
) -> Unit {
  for row in rows {
    sb.write_string(pad_right(row.label, label_width))
    sb.write_string("  ")
    sb.write_string(pad_left(amount_text(row.amount), amount_width))
    sb.write_string("\n")
  }
}

///|
fn Report::render_text(self : Report) -> String {
  let sb = StringBuilder()
  sb.write_string(self.title)
  sb.write_string("\n")
  let label_width = max_label_width(self.rows, 7)
  let amount_width = max_amount_width(self.rows.copy() + self.totals.copy())
  sb.write_string(pad_right("Account", label_width))
  sb.write_string("  ")
  sb.write_string(pad_left("Amount", amount_width))
  sb.write_string("\n")
  render_rows(sb, self.rows, label_width, amount_width)
  if self.totals.length() > 0 {
    sb.write_string("\n")
    render_rows(sb, self.totals, label_width, amount_width)
  }
  for issue in self.diagnostics {
    sb.write_string(
      "WARNING " +
      issue.code +
      ": " +
      issue.account +
      " " +
      issue.currency +
      " -> " +
      issue.target +
      "\n",
    )
  }
  sb.to_string()
}

///|
/// Renders using inferred currency precision without changing stored amounts.
pub fn Report::to_text(self : Report) -> String {
  fn display(row : ReportRow) -> ReportRow {
    match self.display_scales.get(row.amount.currency) {
      Some(scale) =>
        {
          ..row,
          amount: @ast.Amount::new(
            row.amount.number.quantize(scale),
            row.amount.currency,
          ),
        }
      None => row
    }
  }
  { ..self, rows: self.rows.map(display), totals: self.totals.map(display), }.render_text()
}

///|
fn amount_json(amount : @ast.Amount) -> String {
  "{\"number\":\"" +
  amount.number.to_string() +
  "\",\"currency\":\"" +
  json_escape(amount.currency) +
  "\"}"
}

///|
fn rows_json(rows : Array[ReportRow]) -> String {
  let sb = StringBuilder()
  sb.write_string("[")
  let mut first = true
  for row in rows {
    if !first {
      sb.write_string(",")
    }
    sb.write_string("{\"account\":\"")
    sb.write_string(json_escape(row.label))
    sb.write_string("\",\"amount\":")
    sb.write_string(amount_json(row.amount))
    sb.write_string("}")
    first = false
  }
  sb.write_string("]")
  sb.to_string()
}

///|
pub fn Report::to_json(self : Report) -> String {
  let currencies = self.display_scales.keys().to_array()
  currencies.sort()
  "{\"title\":\"" +
  json_escape(self.title) +
  "\",\"rows\":" +
  rows_json(self.rows) +
  ",\"totals\":" +
  rows_json(self.totals) +
  ",\"display_precision\":{" +
  currencies
  .map(fn(currency) {
    "\"" +
    json_escape(currency) +
    "\":" +
    self.display_scales.get(currency).unwrap().to_string()
  })
  .join(",") +
  "}" +
  ",\"complete\":" +
  (if self.diagnostics.is_empty() { "true" } else { "false" }) +
  ",\"diagnostics\":[" +
  self.diagnostics
  .map(fn(d) {
    "{\"code\":\"" +
    d.code +
    "\",\"account\":\"" +
    json_escape(d.account) +
    "\",\"currency\":\"" +
    json_escape(d.currency) +
    "\",\"target\":\"" +
    json_escape(d.target) +
    "\"}"
  })
  .join(",") +
  "]" +
  "}"
}

///|
// Report aggregation does not introduce another 28-digit rounding step.
fn report_add(a : @decimal.Decimal, b : @decimal.Decimal) -> @decimal.Decimal {
  let scale = if a.scale() > b.scale() { a.scale() } else { b.scale() }
  @decimal.Decimal::new(
    a
    .shift_scale(scale - a.scale())
    .mantissa()
    .add(b.shift_scale(scale - b.scale()).mantissa()),
    scale,
  )
}

///|
fn add_amount(target : Array[@ast.Amount], amount : @ast.Amount) -> Unit {
  if amount.number.is_zero() {
    return
  }
  for i in 0.. Array[@ast.Amount] {
  let inventory = realized.inventory(account)
  let amounts = []
  match convert {
    Some(target) =>
      for position in inventory.positions {
        match realized.convert(position.units, target) {
          Some(converted) => add_amount(amounts, converted)
          None =>
            match position.cost {
              Some(cost) =>
                if cost.currency == target {
                  add_amount(
                    amounts,
                    @ast.Amount::new(
                      position.units.number.mul(cost.number),
                      target,
                    ),
                  )
                } else {
                  add_amount(amounts, position.units)
                }
              None => add_amount(amounts, position.units)
            }
        }
      }
    None =>
      for currency in inventory.currencies() {
        let quantity = inventory.quantity(currency)
        if !quantity.is_zero() {
          amounts.push(@ast.Amount::new(quantity, currency))
        }
      }
  }
  amounts
}

///|
fn root_allowed(account : String, roots : Array[String]) -> Bool {
  roots.contains(@ast.account_root(account))
}

///|
fn sorted_report_rows(rows : Array[ReportRow]) -> Array[ReportRow] {
  let result = rows.copy()
  let mut i = 1
  while i < result.length() {
    let current = result[i]
    let mut j = i
    while j > 0 {
      let prev = result[j - 1]
      let key_cmp = prev.label.lexical_compare(current.label)
      let ccy_cmp = prev.amount.currency.lexical_compare(
        current.amount.currency,
      )
      let after = key_cmp > 0 || (key_cmp == 0 && ccy_cmp > 0)
      if after {
        result[j - 1] = current
        result[j] = prev
        j = j - 1
      } else {
        break
      }
    }
    i = i + 1
  }
  result
}

///|
fn built_rows(
  realized : RealizedLedger,
  roots : Array[String],
  convert : String?,
) -> Array[ReportRow] {
  let rows : Array[ReportRow] = []
  for account in realized.account_names() {
    if root_allowed(account, roots) {
      for amount in account_amounts(realized, account, convert) {
        rows.push({ label: account, amount, })
      }
    }
  }
  sorted_report_rows(rows)
}

///|
fn grouped_amounts(rows : Array[ReportRow]) -> Array[@ast.Amount] {
  let amounts : Array[@ast.Amount] = []
  for row in rows {
    add_amount(amounts, row.amount)
  }
  amounts.sort_by(fn(a, b) { a.currency.lexical_compare(b.currency) })
  amounts
}

///|
fn total_row(label : String, rows : Array[ReportRow]) -> Array[ReportRow] {
  let amounts = grouped_amounts(rows)
  amounts.map(fn(amount) {
    {
      label: label +
      (if amounts.length() > 1 { " " + amount.currency } else { "" }),
      amount,
    }
  })
}

///|
fn converted_amount(
  realized : RealizedLedger,
  amount : @ast.Amount,
  convert : String?,
) -> @ast.Amount {
  match convert {
    Some(target) => realized.convert(amount, target).unwrap_or(amount)
    None => amount
  }
}

///|
fn book_amount(posting : @ast.Posting, units : @ast.Amount) -> @ast.Amount {
  match posting.cost {
    Some(spec) =>
      match (spec.number_per, spec.currency) {
        (Some(number), Some(currency)) =>
          @ast.Amount::new(units.number.mul(number), currency)
        _ => units
      }
    None => units
  }
}

///|
fn book_rows(
  realized : RealizedLedger,
  roots : Array[String],
  convert : String?,
  market : Bool,
) -> Array[ReportRow] {
  let rows : Array[ReportRow] = []
  for account in realized.account_names() {
    if root_allowed(account, roots) {
      let amounts : Array[@ast.Amount] = []
      for position in realized.inventory(account).positions {
        let cost = match position.cost {
          Some(c) =>
            @ast.Amount::new(position.units.number.mul(c.number), c.currency)
          None => position.units
        }
        let amount = if market {
          match convert {
            Some(target) =>
              match realized.convert(position.units, target) {
                Some(a) => a
                None => converted_amount(realized, cost, convert)
              }
            None => cost
          }
        } else {
          converted_amount(realized, cost, convert)
        }
        add_amount(amounts, amount)
      }
      for amount in amounts {
        rows.push({ label: account, amount, })
      }
    }
  }
  rows
}

///|
fn make_report(
  realized : RealizedLedger,
  title : String,
  rows : Array[ReportRow],
  totals : Array[ReportRow],
  convert : String?,
  roots : Array[String],
) -> Report {
  let diagnostics : Array[ConversionIssue] = []
  let seen : Map[String, Bool] = Map([])
  if convert is Some(target) {
    for account in realized.account_names() {
      if root_allowed(account, roots) {
        for position in realized.inventory(account).positions {
          let key = account + " " + position.units.currency
          if realized.convert(position.units, target) is None &&
            seen.get(key) is None {
            seen.set(key, true)
            diagnostics.push({
              code: "report.conversion_incomplete",
              account,
              currency: position.units.currency,
              target,
            })
          }
        }
      }
    }
  }
  let counts : Map[String, Map[Int32, Int32]] = Map([])
  for entry in realized.entries {
    if entry.directive is DTransaction(txn) {
      for posting in txn.postings {
        if posting.units is Some(units) {
          let scales = match counts.get(units.currency) {
            Some(m) => m
            None => {
              let m = Map([])
              counts.set(units.currency, m)
              m
            }
          }
          let scale = units.number.scale()
          scales.set(scale, scales.get(scale).unwrap_or(0) + 1)
        }
      }
    }
  }
  let display_scales : Map[String, Int32] = Map([])
  for currency in counts.keys().to_array() {
    let scales = counts.get(currency).unwrap()
    let mut best = 0
    let mut occurrences = 0
    for scale in scales.keys().to_array() {
      let count = scales.get(scale).unwrap()
      if count > occurrences || (count == occurrences && scale > best) {
        best = scale
        occurrences = count
      }
    }
    display_scales.set(currency, best)
  }
  for row in rows {
    if display_scales.get(row.amount.currency) is None {
      display_scales.set(row.amount.currency, row.amount.number.scale())
    }
  }
  for (currency, precision) in realized.options.display_precision {
    display_scales.set(currency, precision.scale())
  }
  let totals = if diagnostics.is_empty() {
    totals
  } else {
    totals.map(fn(row) {
      {
        ..row,
        label: if row.label.has_suffix(" " + row.amount.currency) {
          row.label
        } else {
          row.label + " " + row.amount.currency
        },
      }
    })
  }
  let combined : Array[ReportRow] = []
  let indices : Map[String, Int32] = Map([])
  for row in rows {
    let key = row.label + " " + row.amount.currency
    match indices.get(key) {
      Some(i) =>
        combined[i] = {
          ..row,
          amount: @ast.Amount::new(
            report_add(combined[i].amount.number, row.amount.number),
            row.amount.currency,
          ),
        }
      None => {
        indices.set(key, combined.length())
        combined.push(row)
      }
    }
  }
  {
    title,
    rows: sorted_report_rows(combined),
    totals,
    diagnostics,
    display_scales,
  }
}

///|
fn rows_with_root(rows : Array[ReportRow], root : String) -> Array[ReportRow] {
  let result = []
  for row in rows {
    if @ast.account_root(row.label) == root {
      result.push(row)
    }
  }
  result
}

///|
pub fn trial_balance(
  realized : RealizedLedger,
  convert~ : String? = None,
) -> Report {
  let all_roots = ["Assets", "Liabilities", "Equity", "Income", "Expenses"]
  let rows = built_rows(realized, all_roots, convert)
  let totals = total_row("Total", rows)
  make_report(realized, "Trial Balance", rows, totals, convert, all_roots)
}

///|
pub fn balance_sheet(
  realized : RealizedLedger,
  convert~ : String? = None,
  start~ : @ast.Date? = None,
) -> Report {
  let roots = ["Assets", "Liabilities", "Equity"]
  let all = ["Assets", "Liabilities", "Equity", "Income", "Expenses"]
  let rows = book_rows(realized, roots, convert, convert is Some(_))
  let mut year = 1
  for entry in realized.entries {
    if entry.date.year > year {
      year = entry.date.year
    }
  }
  if realized.at is Some(at) {
    year = at.year
  }
  let start = start.unwrap_or(@ast.Date::new(year, 1, 1).unwrap())
  let previous = {
    ..realized,
    entries: realized.entries.filter(fn(e) { e.date.less_than(start) }),
  }
  let prior = income_rows(previous, None, convert)
  let current = income_rows(realized, Some(start), convert)
  for
    (period, amounts) in [
      ("previous", grouped_amounts(prior)),
      ("current", grouped_amounts(current)),
    ] {
    let account = if period == "previous" {
      realized.options.equity_account(
        "account_previous_earnings", "Earnings:Previous",
      )
    } else {
      realized.options.equity_account(
        "account_current_earnings", "Earnings:Current",
      )
    }
    for amount in amounts {
      if !amount.number.is_zero() {
        rows.push({ label: account, amount, })
      }
    }
  }
  // CLOSE normalizes residual currency balances, including tolerated residuals.
  for amount in grouped_amounts(book_rows(realized, all, None, false)) {
    if !amount.number.is_zero() {
      rows.push({
        label: realized.options.equity_account(
          "account_current_conversions", "Conversions:Current",
        ),
        amount: converted_amount(
          realized,
          @ast.Amount::new(amount.number.neg(), amount.currency),
          convert,
        ),
      })
    }
  }
  if convert is Some(_) {
    let changes = book_rows(realized, roots, convert, false)
    for row in book_rows(realized, roots, convert, true) {
      changes.push({
        label: row.label,
        amount: @ast.Amount::new(row.amount.number.neg(), row.amount.currency),
      })
    }
    for amount in grouped_amounts(changes) {
      if !amount.number.is_zero() {
        rows.push({
          label: @ast.account_parent(
            realized.options.equity_account(
              "account_current_earnings", "Earnings:Current",
            ),
          ) +
          ":Unrealized",
          amount,
        })
      }
    }
  }
  // Keep conversion rounding visible in equity instead of leaving a broken identity.
  for residual in grouped_amounts(rows) {
    if !residual.number.is_zero() {
      rows.push({
        label: realized.options.equity_account(
          "account_current_conversions", "Conversions:Current",
        ),
        amount: @ast.Amount::new(residual.number.neg(), residual.currency),
      })
    }
  }
  let totals : Array[ReportRow] = []
  for root in roots {
    totals.append(total_row("Total " + root, rows_with_root(rows, root)))
  }
  make_report(realized, "Balance Sheet", rows, totals, convert, all)
}

///|
fn income_rows(
  realized : RealizedLedger,
  start : @ast.Date?,
  convert : String?,
) -> Array[ReportRow] {
  let rows : Array[ReportRow] = []
  for entry in realized.entries {
    let in_period = match start {
      Some(date) => date.less_equal(entry.date)
      None => true
    }
    if in_period {
      match entry.directive {
        DTransaction(txn) =>
          for posting in txn.postings {
            if root_allowed(posting.account, ["Income", "Expenses"]) {
              match posting.units {
                Some(units) => {
                  let amount = converted_amount(
                    realized,
                    book_amount(posting, units),
                    convert,
                  )
                  let mut found = false
                  for i in 0.. ()
              }
            }
          }
        _ => ()
      }
    }
  }
  sorted_report_rows(rows)
}

///|
pub fn income_statement(
  realized : RealizedLedger,
  start~ : @ast.Date? = None,
  convert~ : String? = None,
) -> Report {
  let rows = income_rows(realized, start, convert)
  let income = rows_with_root(rows, "Income")
  let expenses = rows_with_root(rows, "Expenses")
  let totals = []
  for row in total_row("Total Income", income) {
    totals.push(row)
  }
  for row in total_row("Total Expenses", expenses) {
    totals.push(row)
  }
  let combined = income.copy() + expenses.copy()
  for row in total_row("Net Income", combined) {
    totals.push({
      ..row,
      amount: @ast.Amount::new(row.amount.number.neg(), row.amount.currency),
    })
  }
  make_report(realized, "Income Statement", rows, totals, convert, [
    "Income", "Expenses",
  ])
}

///|
pub(all) struct InventoryReportRow {
  account : String
  units : @ast.Amount
  cost : String?
  value : @ast.Amount?
}

///|
pub fn inventory_rows(
  realized : RealizedLedger,
  convert~ : String? = None,
) -> Array[InventoryReportRow] {
  let rows = []
  for account in realized.account_names() {
    for position in realized.inventory(account).positions {
      let value = match convert {
        Some(target) =>
          match realized.convert(position.units, target) {
            Some(converted) => Some(converted)
            None =>
              match position.cost {
                Some(cost) =>
                  if cost.currency == target {
                    Some(
                      @ast.Amount::new(
                        position.units.number.mul(cost.number),
                        target,
                      ),
                    )
                  } else {
                    None
                  }
                None => None
              }
          }
        None => None
      }
      rows.push({
        account,
        units: position.units,
        cost: match position.cost {
          Some(cost) => Some(cost.to_string())
          None => None
        },
        value,
      })
    }
  }
  rows
}

///|
pub fn inventory_to_text(rows : Array[InventoryReportRow]) -> String {
  let sb = StringBuilder()
  sb.write_string("Inventory\n")
  sb.write_string("Account  Units  Cost  Value\n")
  for row in rows {
    sb.write_string(row.account)
    sb.write_string("  ")
    sb.write_string(amount_text(row.units))
    match row.cost {
      Some(cost) => {
        sb.write_string("  ")
        sb.write_string(cost)
      }
      None => ()
    }
    match row.value {
      Some(value) => {
        sb.write_string("  ")
        sb.write_string(amount_text(value))
      }
      None => ()
    }
    sb.write_string("\n")
  }
  sb.to_string()
}

///|
pub fn inventory_to_json(rows : Array[InventoryReportRow]) -> String {
  let sb = StringBuilder()
  sb.write_string("[")
  let mut first = true
  for row in rows {
    if !first {
      sb.write_string(",")
    }
    sb.write_string("{\"account\":\"")
    sb.write_string(json_escape(row.account))
    sb.write_string("\",\"units\":")
    sb.write_string(amount_json(row.units))
    sb.write_string(",\"cost\":")
    match row.cost {
      Some(cost) => {
        sb.write_string("\"")
        sb.write_string(json_escape(cost))
        sb.write_string("\"")
      }
      None => sb.write_string("null")
    }
    sb.write_string(",\"value\":")
    match row.value {
      Some(value) => sb.write_string(amount_json(value))
      None => sb.write_string("null")
    }
    sb.write_string("}")
    first = false
  }
  sb.write_string("]")
  sb.to_string()
}

///|
pub(all) struct RegisterRow {
  date : @ast.Date
  flag : String
  payee : String
  narration : String
  account : String
  amount : @ast.Amount
  running : @ast.Amount
  cost : @ast.CostSpec?
}

///|
pub fn register_rows(
  realized : RealizedLedger,
  account : String,
  subtree~ : Bool = false,
) -> Array[RegisterRow] {
  let rows = []
  let running : Map[String, @decimal.Decimal] = Map([])
  for entry in realized.entries {
    match entry.directive {
      DTransaction(txn) =>
        for posting in txn.postings {
          if posting.account == account ||
            (subtree && is_subaccount(posting.account, account)) {
            match posting.units {
              Some(units) => {
                let previous = match running.get(units.currency) {
                  Some(value) => value
                  None => @decimal.Decimal::zero()
                }
                let next = previous.add(units.number)
                running.set(units.currency, next)
                rows.push({
                  date: entry.date,
                  flag: txn.flag,
                  payee: match txn.payee {
                    Some(payee) => payee
                    None => ""
                  },
                  narration: txn.narration,
                  account: posting.account,
                  amount: units,
                  cost: posting.cost,
                  running: @ast.Amount::new(next, units.currency),
                })
              }
              None => ()
            }
          }
        }
      _ => ()
    }
  }
  rows
}

///|
fn register_description(row : RegisterRow) -> String {
  if row.payee == "" {
    row.narration
  } else {
    row.payee + " | " + row.narration
  }
}

///|
pub fn register_to_text(rows : Array[RegisterRow]) -> String {
  let sb = StringBuilder()
  sb.write_string("Register\n")
  for row in rows {
    sb.write_string(row.date.to_string())
    sb.write_string("  ")
    sb.write_string(row.flag)
    sb.write_string("  ")
    sb.write_string(pad_right(amount_text(row.amount), 18))
    sb.write_string("  ")
    sb.write_string(pad_right(amount_text(row.running), 18))
    sb.write_string("  ")
    match row.cost {
      Some(cost) => sb.write_string(cost.to_string() + "  ")
      None => ()
    }
    sb.write_string(register_description(row))
    sb.write_string("\n")
  }
  sb.to_string()
}

///|
pub fn register_to_json(rows : Array[RegisterRow]) -> String {
  let sb = StringBuilder()
  sb.write_string("[")
  let mut first = true
  for row in rows {
    if !first {
      sb.write_string(",")
    }
    sb.write_string("{\"date\":\"")
    sb.write_string(row.date.to_string())
    sb.write_string("\",\"flag\":\"")
    sb.write_string(json_escape(row.flag))
    sb.write_string("\",\"payee\":\"")
    sb.write_string(json_escape(row.payee))
    sb.write_string("\",\"narration\":\"")
    sb.write_string(json_escape(row.narration))
    sb.write_string("\",\"account\":\"")
    sb.write_string(json_escape(row.account))
    sb.write_string("\",\"cost\":")
    sb.write_string(
      match row.cost {
        Some(cost) => "\"" + json_escape(cost.to_string()) + "\""
        None => "null"
      },
    )
    sb.write_string(",\"amount\":")
    sb.write_string(amount_json(row.amount))
    sb.write_string(",\"running\":")
    sb.write_string(amount_json(row.running))
    sb.write_string("}")
    first = false
  }
  sb.write_string("]")
  sb.to_string()
}

///|
/// Renders report amounts using CCY:NUMBER precision options; JSON stays exact.
pub fn Report::to_text_with_options(
  self : Report,
  options : @ast.Options,
) -> String {
  let scales = self.display_scales.copy()
  for (currency, precision) in options.display_precision {
    scales.set(currency, precision.scale())
  }
  { ..self, display_scales: scales, }.to_text()
}