///|
fn ledger_quote(text : String) -> String {
  "\"" +
  text.replace_all(old="\\", new="\\\\").replace_all(old="\"", new="\\\"") +
  "\""
}

///|
fn ledger_metadata(meta : @ast.Meta, indent : String) -> String {
  let out = StringBuilder()
  for (key, value) in meta.entries {
    if key.has_prefix("__") || ["filename", "lineno"].contains(key) {
      continue
    }
    out.write_string(
      indent +
      key +
      ": " +
      (match value {
        MString(s) => ledger_quote(s)
        MNumber(n) => python_decimal(n)
        MCurrency(c) | MAccount(c) | MTag(c) => ledger_quote(c)
        other => other.to_string()
      }) +
      "\n",
    )
  }
  out.to_string()
}

///|
fn ledger_tags(tags : Array[String], links : Array[String]) -> String {
  let tags = tags.copy()
  let links = links.copy()
  tags.sort_by(python_text_compare)
  links.sort_by(python_text_compare)
  tags.map(fn(s) { " #" + s }).join("") + links.map(fn(s) { " ^" + s }).join("")
}

///|
fn ledger_entry(entry : @ast.Entry) -> String {
  let out = StringBuilder()
  out.write_string(entry.date.to_string() + " ")
  match entry.directive {
    DOpen(o) =>
      out.write_string(
        "open " +
        (if o.currencies.is_empty() && o.booking is None {
          o.account
        } else {
          ledger_pad_right(o.account, 47) +
          " " +
          o.currencies.join(",") +
          o.booking
          .map(fn(b) {
            " " +
            ledger_quote(
              if [
                  "STRICT", "STRICT_WITH_SIZE", "FIFO", "LIFO", "HIFO", "NONE", "AVERAGE",
                ].contains(b) {
                b
              } else {
                "STRICT"
              },
            )
          })
          .unwrap_or("")
        }),
      )
    DClose(c) => out.write_string("close " + c.account)
    DCommodity(c) => out.write_string("commodity " + c.currency)
    DPad(p) => out.write_string("pad " + p.account + " " + p.source_account)
    DBalance(b) =>
      out.write_string(
        "balance " +
        ledger_pad_right(b.account, 47) +
        " " +
        b.amount.number.to_string() +
        b.tolerance.map(fn(n) { " ~ " + python_decimal(n) }).unwrap_or("") +
        " " +
        b.amount.currency +
        entry.balance_discrepancy
        .map(fn(a) { "   ; Diff: " + a.number.to_string() + " " + a.currency })
        .unwrap_or(""),
      )
    DPrice(p) =>
      out.write_string(
        "price " +
        ledger_pad_right(p.currency, 22) +
        " " +
        pad_left(p.amount.number.to_string(), 18) +
        " " +
        p.amount.currency,
      )
    DNote(n) =>
      out.write_string(
        "note " +
        n.account +
        " " +
        "\"" +
        n.comment +
        "\"" +
        ledger_tags(n.tags, n.links),
      )
    DDocument(d) =>
      out.write_string(
        "document " +
        d.account +
        " " +
        ("\"" + document_filename(entry, d) + "\"") +
        ledger_tags(d.tags, d.links),
      )
    DEvent(e) =>
      out.write_string(
        "event " +
        ("\"" + e.event_type + "\"") +
        " " +
        "\"" +
        e.description +
        "\"",
      )
    DQuery(q) =>
      out.write_string(
        "query " + ("\"" + q.name + "\"") + " " + ("\"" + q.query + "\""),
      )
    DCustom(c) =>
      out.write_string(
        "custom " +
        ("\"" + c.custom_type + "\"") +
        c.values
        .map(fn(v) {
          " " +
          (match v {
            CString(s) => "\"" + s + "\""
            CNumber(n) => python_decimal(n)
            CAmount(a) => python_decimal(a.number) + " " + a.currency
            other => other.to_string()
          })
        })
        .join(""),
      )
    DTransaction(t) => {
      out.write_string(
        t.flag +
        " " +
        t.payee
        .map(fn(s) { if s == "" { "" } else { ledger_quote(s) + " " } })
        .unwrap_or("") +
        (if t.narration == "" && t.payee.unwrap_or("") == "" {
          ""
        } else {
          ledger_quote(t.narration)
        }) +
        ledger_tags(t.tags, t.links),
      )
      out.write_string("\n" + ledger_metadata(entry.meta, "  "))
      let postings = booked_postings(t.postings)
      let mut account_width = 0
      let mut number_width = 0
      for p in postings {
        account_width = account_width.max(
          p.account.iter().count() + p.flag.map(fn(_) { 2 }).unwrap_or(0),
        )
        if p.units is Some(a) {
          number_width = number_width.max(a.number.to_string().length())
        }
      }
      for p in postings {
        let account = p.flag.map(fn(f) { f + " " }).unwrap_or("") + p.account
        out.write_string(
          "  " +
          account +
          " ".repeat(account_width - account.iter().count()) +
          "  ",
        )
        if p.units is Some(a) {
          out.write_string(
            pad_left(a.number.to_string(), number_width) + " " + a.currency,
          )
          if posting_cost(p, a) is Some(c) {
            out.write_string(
              " {" +
              c.number.to_string() +
              " " +
              c.currency +
              c.date.map(fn(d) { ", " + d.to_string() }).unwrap_or("") +
              c.label.map(fn(s) { ", " + ledger_quote(s) }).unwrap_or("") +
              "}",
            )
          }
        }
        if posting_price(p) is QAmount(a) {
          out.write_string(" @ " + a.number.to_string() + " " + a.currency)
        }
        out.write_string("\n" + ledger_metadata(p.meta, "    "))
      }
      return out.to_string()
    }
  }
  out.write_string("\n" + ledger_metadata(entry.meta, "  "))
  out.to_string()
}

///|
fn pad_left(text : String, width : Int32) -> String {
  " ".repeat((width - text.length()).max(0)) + text
}

///|
fn run_print(
  realized : @realization.RealizedLedger,
  filter : Cond,
  period : QuerySource,
  diagnostics : Array[@ast.Diagnostic],
) -> QueryResult {
  let realized = {
    ..realized,
    entries: print_recovered_entries(realized.entries, diagnostics),
  }
  let ctx = QueryContext::new(realized, SrcDirectives("entries"))
  let transformed = {
    ..realized,
    entries: print_period(realized, realized.entries, period),
  }
  let selected : Array[@ast.Entry] = []
  for row in directive_rows(transformed, "entries") {
    if cond_holds(ctx, filter, row) && row_entry(row) is Some(e) {
      selected.push(e)
    }
  }
  let entries = selected
  let out = StringBuilder()
  let rows : Array[Array[QueryValue]] = []
  let mut previous = ""
  for e in entries {
    let kind = directive_type(e)
    if (previous != "" && previous != kind) ||
      ["transaction", "commodity"].contains(kind) {
      out.write_char('\n')
    }
    out.write_string(ledger_entry(e))
    rows.push([directive_value(e)])
    previous = kind
  }
  {
    columns: ["ROW(*)"],
    rows,
    error: ctx.error,
    kind: DirectiveText(out.to_string()),
  }
}

///|
fn print_period(
  realized : @realization.RealizedLedger,
  entries : Array[@ast.Entry],
  period : QuerySource,
) -> Array[@ast.Entry] {
  match period {
    SrcFrom(_, start, end, close_all, clear) => {
      let initial = match start {
        Some(start) => print_period(realized, entries, SrcOpen(start, end))
        None =>
          match end {
            Some(end) => print_period(realized, entries, SrcClose(end))
            None => entries
          }
      }
      let closed = if close_all && initial.last() is Some(last) {
        print_close_at(
          realized,
          initial,
          last.date,
          "account_current_conversions",
          "Conversions:Current",
        )
      } else {
        initial
      }
      if clear {
        let date = closed.last().map(fn(e) { QDate(e.date) }).unwrap_or(QNull)
        match date {
          QDate(date) => print_clear(realized, closed, date)
          _ => closed
        }
      } else {
        closed
      }
    }
    SrcClose(end) =>
      print_close(
        realized, entries, end, "account_current_conversions", "Conversions:Current",
      )
    SrcOpen(start, end) => {
      let result : Array[@ast.Entry] = []
      let active : Map[String, @ast.Entry] = Map([])
      let prices : Map[String, @ast.Entry] = Map([])
      for e in entries {
        if e.date.less_than(start) {
          match e.directive {
            DOpen(o) =>
              if !active.contains(o.account) {
                active.set(o.account, e)
              }
            DClose(c) => active.remove(c.account) |> ignore
            DPrice(p) => prices.set(p.currency + ":" + p.amount.currency, e)
            _ => ()
          }
        }
      }
      for e in entries {
        if e.date.less_than(start) &&
          e.directive is DOpen(o) &&
          active.get(o.account) is Some(first) &&
          first.loc.file == e.loc.file &&
          first.loc.line == e.loc.line {
          result.push(e)
        }
      }
      for e in entries {
        if e.date.less_than(start) &&
          e.directive is DPrice(p) &&
          prices.get(p.currency + ":" + p.amount.currency) is Some(last) &&
          e.loc.file == last.loc.file &&
          e.loc.line == last.loc.line {
          result.push(e)
        }
      }
      let before = print_close(
        realized, entries, start, "account_previous_conversions", "Conversions:Previous",
      )
      let filtered = {
        ..realized,
        entries: before + entries.filter(fn(e) { !e.date.less_than(start) }),
      }
      let summaries = opening_rows(filtered, start, end)
      let mut i = 0
      while i + 1 < summaries.length() && summaries[i].flag == "S" {
        let a = summaries[i]
        let t = @ast.Transaction::new("S", None, a.narration, [], [])
        while i + 1 < summaries.length() &&
              summaries[i].flag == "S" &&
              summaries[i].account == a.account {
          for row in [summaries[i], summaries[i + 1]] {
            let p = @ast.Posting::new(row.account)
            p.units = Some(row.units)
            p.cost = row.cost.map(fn(c) {
              @ast.CostSpec::new_per(c.number, c.currency, c.date, c.label)
            })
            t.postings.push(p)
          }
          i = i + 2
        }
        result.push(
          @ast.Entry::new(
            a.date,
            @ast.Loc::new("", 0),
            DTransaction(t),
          ),
        )
      }
      for e in entries {
        if !e.date.less_than(start) &&
          end.map(fn(d) { e.date.less_than(d) }).unwrap_or(true) {
          result.push(e)
        }
      }
      result
      .mut_view()
      .stable_sort_by(fn(a, b) {
        let date = a.date.compare(b.date)
        if date != 0 {
          return date
        }
        match (a.directive, b.directive) {
          (DTransaction(_), DPrice(_)) => if a.loc.line == 0 { -1 } else { 0 }
          (DPrice(_), DTransaction(_)) => if b.loc.line == 0 { 1 } else { 0 }
          _ => 0
        }
      })
      match end {
        Some(end) =>
          print_close(
            realized, result, end, "account_current_conversions", "Conversions:Current",
          )
        None => result
      }
    }
    _ => entries
  }
}

///|
fn print_close(
  realized : @realization.RealizedLedger,
  entries : Array[@ast.Entry],
  end : @ast.Date,
  option : String,
  suffix : String,
) -> Array[@ast.Entry] {
  let entries = entries.filter(fn(e) { e.date.less_than(end) })
  match ordinal_date(ordinal(end) - 1) {
    QDate(date) => print_close_at(realized, entries, date, option, suffix)
    _ => entries
  }
}

///|
fn print_close_at(
  realized : @realization.RealizedLedger,
  entries : Array[@ast.Entry],
  date : @ast.Date,
  option : String,
  suffix : String,
) -> Array[@ast.Entry] {
  let result = entries.copy()
  let inventory = @booking.Inventory::new()
  for row in posting_rows({ ..realized, entries: result, }, false) {
    inventory.add(row.position)
  }
  let residual = @booking.Inventory::new()
  for p in inventory.positions {
    residual.add_amount(
      match p.cost {
        Some(c) => @ast.Amount::new(p.units.number.mul(c.number), c.currency)
        None => p.units
      },
    )
  }
  if residual.is_empty() {
    return result
  }
  let t = @ast.Transaction::new(
    "C",
    None,
    "Conversion for " + inventory_repr(inventory),
    [],
    [],
  )
  for p in residual.positions {
    let posting = @ast.Posting::new(
      realized.options.equity_account(option, suffix),
    )
    posting.units = Some(
      @ast.Amount::new(p.units.number.neg(), p.units.currency),
    )
    posting.price = Some(
      PerUnit(@ast.Amount::new(@decimal.Decimal::zero(), "NOTHING")),
    )
    t.postings.push(posting)
  }
  result.push(
    @ast.Entry::new(date, @ast.Loc::new("", 0), DTransaction(t)),
  )
  result
}

///|
fn print_recovered_entries(
  entries : Array[@ast.Entry],
  diagnostics : Array[@ast.Diagnostic],
) -> Array[@ast.Entry] {
  let result : Array[@ast.Entry] = []
  for e in entries {
    if e.directive is DTransaction(t) {
      let same_location = fn(a : @ast.Loc, b : @ast.Loc) {
        a.file == b.file && a.line == b.line
      }
      if t.postings.filter(fn(p) { p.units is None }).length() > 1 &&
        diagnostics.any(fn(d) {
          d.code == "validation.missing" &&
          (
            same_location(d.loc, e.loc) ||
            t.postings.any(fn(p) { same_location(d.loc, p.loc) })
          )
        }) {
        continue
      }
      if diagnostics.any(fn(d) {
          d.code == "validation.booking" && same_location(d.loc, e.loc)
        }) {
        let recovered = @ast.Transaction::new(
          t.flag,
          t.payee,
          t.narration,
          t.tags,
          t.links,
        )
        result.push({ ..e, directive: DTransaction(recovered), })
        continue
      }
    }
    result.push(e)
  }
  result
}

///|
fn print_clear(
  realized : @realization.RealizedLedger,
  entries : Array[@ast.Entry],
  date : @ast.Date,
) -> Array[@ast.Entry] {
  let balances : Map[String, @booking.Inventory] = Map([])
  for row in posting_rows({ ..realized, entries, }, false) {
    if ["Income", "Expenses"].contains(@ast.account_root(row.account)) {
      balances
      .get_or_init(row.account, fn() { @booking.Inventory::new() })
      .add(row.position)
    }
  }
  let result = entries.copy()
  let accounts = balances.keys().to_array()
  accounts.sort_by(python_text_compare)
  for account in accounts {
    let inventory = balances.get(account).unwrap()
    if inventory.is_empty() {
      continue
    }
    let t = @ast.Transaction::new(
      "T",
      None,
      "Transfer balance for '" + account + "' (Transfer balance)",
      [],
      [],
    )
    for position in inventory.positions {
      let p = @ast.Posting::new(account)
      p.units = Some(
        @ast.Amount::new(
          position.units.number.neg().round_to_significant(28),
          position.units.currency,
        ),
      )
      p.cost = position.cost.map(fn(c) {
        @ast.CostSpec::new_per(c.number, c.currency, c.date, c.label)
      })
      let q = @ast.Posting::new(
        realized.options.equity_account(
          "account_current_earnings", "Earnings:Current",
        ),
      )
      q.units = Some(
        match position.cost {
          Some(c) =>
            @ast.Amount::new(
              position.units.number.mul(c.number).round_to_significant(28),
              c.currency,
            )
          None => position.units
        },
      )
      t.postings.append([p, q])
    }
    result.push(
      @ast.Entry::new(
        date,
        @ast.Loc::new("", 0),
        DTransaction(t),
      ),
    )
  }
  result
}

///|
fn ledger_pad_right(text : String, width : Int32) -> String {
  text + " ".repeat((width - text.iter().count()).max(0))
}