// BQL evaluation over a realized ledger.  The engine works on a flat posting
// table (or an inventory-position table for FROM balances), supports a WHERE
// predicate, grouping with sum()/count(), ordering and LIMIT.

///|
priv struct BqlRow {
  date : @ast.Date
  flag : String
  payee : String
  narration : String
  tags : Array[String]
  links : Array[String]
  meta : @ast.Meta
  account : String
  units : @ast.Amount
  cost : @ast.Cost?
  value : @ast.Amount?
  position : @booking.Position
}

///|
fn posting_cost(posting : @ast.Posting, units : @ast.Amount) -> @ast.Cost? {
  match posting.cost {
    Some(spec) => {
      let currency = match spec.currency {
        Some(name) => name
        None => units.currency
      }
      let number = match spec.number_per {
        Some(per) => Some(per)
        None =>
          match spec.number_total {
            Some(total) =>
              if units.number.is_zero() {
                None
              } else {
                total.div(units.number)
              }
            None => None
          }
      }
      match number {
        Some(value) =>
          Some(@ast.Cost::new(value, currency, spec.date, spec.label))
        None => None
      }
    }
    None => None
  }
}

///|
fn posting_value(
  units : @ast.Amount,
  cost : @ast.Cost?,
  price : @ast.PriceAnno?,
) -> @ast.Amount? {
  match cost {
    Some(c) => Some(@ast.Amount::new(units.number.mul(c.number), c.currency))
    None =>
      match price {
        Some(@ast.PerUnit(amount)) =>
          Some(
            @ast.Amount::new(units.number.mul(amount.number), amount.currency),
          )
        Some(@ast.Total(amount)) => Some(amount)
        None => None
      }
  }
}

///|
fn last_entry_date(realized : @realization.RealizedLedger) -> @ast.Date? {
  let mut latest : @ast.Date? = None
  for entry in realized.entries {
    latest = match latest {
      Some(date) =>
        if date.less_than(entry.date) {
          Some(entry.date)
        } else {
          Some(date)
        }
      None => Some(entry.date)
    }
  }
  latest
}

///|
fn posting_rows(realized : @realization.RealizedLedger) -> Array[BqlRow] {
  let rows : Array[BqlRow] = []
  for entry in realized.entries {
    match entry.directive {
      DTransaction(txn) =>
        for posting in txn.postings {
          match posting.units {
            Some(units) => {
              let cost = posting_cost(posting, units)
              rows.push({
                date: entry.date,
                flag: txn.flag,
                payee: match txn.payee {
                  Some(payee) => payee
                  None => ""
                },
                narration: txn.narration,
                tags: txn.tags,
                links: txn.links,
                meta: posting.meta,
                account: posting.account,
                units,
                cost,
                value: posting_value(units, cost, posting.price),
                position: @booking.Position::new(units, cost),
              })
            }
            None => ()
          }
        }
      _ => ()
    }
  }
  rows
}

///|
fn balance_rows(realized : @realization.RealizedLedger) -> Array[BqlRow] {
  let rows : Array[BqlRow] = []
  let date = match realized.at {
    Some(at) => at
    None =>
      match last_entry_date(realized) {
        Some(latest) => latest
        None => @ast.Date::new(1970, 1, 1).unwrap()
      }
  }
  for account in realized.account_names() {
    for position in realized.inventory(account).positions {
      rows.push({
        date,
        flag: "",
        payee: "",
        narration: "",
        tags: [],
        links: [],
        meta: @ast.Meta::new(),
        account,
        units: position.units,
        cost: position.cost,
        value: posting_value(position.units, position.cost, None),
        position,
      })
    }
  }
  rows
}

///|
fn source_rows(
  realized : @realization.RealizedLedger,
  source : QuerySource,
) -> Array[BqlRow] {
  match source {
    SrcPostings => posting_rows(realized)
    SrcBalances => balance_rows(realized)
    SrcOpen(start, close) => opening_rows(realized, start, close)
    SrcClose(date) =>
      posting_rows(realized).filter(fn(row) { row.date.less_than(date) })
  }
}

///|
fn eval_column(row : BqlRow, name : String) -> QueryValue {
  match name {
    "date" => QDate(row.date)
    "year" => QNumber(@decimal.Decimal::from_int(row.date.year))
    "month" => QNumber(@decimal.Decimal::from_int(row.date.month))
    "flag" => QText(row.flag)
    "payee" => if row.payee == "" { QNull } else { QText(row.payee) }
    "narration" => QText(row.narration)
    "tags" => QSet(row.tags)
    "links" => QSet(row.links)
    "account" => QText(row.account)
    "root" => QText(@ast.account_root(row.account))
    "parent" => QText(@ast.account_parent(row.account))
    "leaf" => QText(@ast.account_leaf(row.account))
    "units" => QAmount(row.units)
    "currency" => QText(row.units.currency)
    "weight" =>
      match row.cost {
        Some(cost) =>
          QAmount(
            @ast.Amount::new(row.units.number.mul(cost.number), cost.currency),
          )
        None => QAmount(row.units)
      }
    "cost" =>
      match row.cost {
        Some(cost) => QAmount(@ast.Amount::new(cost.number, cost.currency))
        None => QNull
      }
    "value" =>
      match row.value {
        Some(amount) => QAmount(amount)
        None => QNull
      }
    "position" => QPosition(row.position)
    _ => QNull
  }
}

///|
fn eval_expr(expr : Expr, row : BqlRow) -> QueryValue {
  match expr {
    EBinary(left, op, right) =>
      arithmetic(eval_expr(left, row), op, eval_expr(right, row))
    ECol(name) => eval_column(row, name)
    EStr(text) => QText(text)
    ENum(number) => QNumber(number)
    EDate(date) => QDate(date)
    ENull => QNull
    EStar => QNull
    EBool(value) =>
      if value {
        QNumber(@decimal.Decimal::one())
      } else {
        QNumber(@decimal.Decimal::zero())
      }
    ECall(name, args) =>
      match (name, args) {
        ("meta", [EStr(key)]) =>
          match row.meta.get(key) {
            Some(@ast.MString(s))
            | Some(@ast.MAccount(s))
            | Some(@ast.MCurrency(s))
            | Some(@ast.MTag(s)) => QText(s)
            Some(@ast.MNumber(n)) => QNumber(n)
            Some(@ast.MDate(d)) => QDate(d)
            Some(@ast.MBool(b)) =>
              QNumber(@decimal.Decimal::from_int(if b { 1 } else { 0 }))
            None => QNull
          }
        ("year", [arg]) =>
          match eval_expr(arg, row) {
            QDate(date) => QNumber(@decimal.Decimal::from_int(date.year))
            _ => QNull
          }
        ("month", [arg]) =>
          match eval_expr(arg, row) {
            QDate(date) => QNumber(@decimal.Decimal::from_int(date.month))
            _ => QNull
          }
        ("root", [arg]) =>
          match eval_expr(arg, row) {
            QText(account) => QText(@ast.account_root(account))
            _ => QNull
          }
        ("parent", [arg]) =>
          match eval_expr(arg, row) {
            QText(account) => QText(@ast.account_parent(account))
            _ => QNull
          }
        ("leaf", [arg]) =>
          match eval_expr(arg, row) {
            QText(account) => QText(@ast.account_leaf(account))
            _ => QNull
          }
        _ => QNull
      }
  }
}

///|
fn cond_holds(cond : Cond, row : BqlRow) -> Bool {
  match cond {
    CRegex(e, regex) =>
      match eval_expr(e, row) {
        QText(s) => regex.execute(s) is Some(_)
        _ => false
      }
    CTrue => true
    CNot(inner) => !cond_holds(inner, row)
    CAnd(left, right) => cond_holds(left, row) && cond_holds(right, row)
    COr(left, right) => cond_holds(left, row) || cond_holds(right, row)
    CIsNull(expr, negated) => {
      let is_null = eval_expr(expr, row) is QNull
      if negated {
        !is_null
      } else {
        is_null
      }
    }
    CTruthy(expr) =>
      match eval_expr(expr, row) {
        QSet(values) => values.length() > 0
        QNull => false
        QText(text) => text != ""
        QNumber(number) => !number.is_zero()
        QAmount(amount) => !amount.number.is_zero()
        QPosition(position) => !position.units.number.is_zero()
        QInventory(inventory) => !inventory.is_empty()
        QDate(_) => true
      }
    CCmp(left, op, right) => {
      let a = eval_expr(left, row)
      let b = eval_expr(right, row)
      match value_compare(a, b) {
        Some(order) =>
          match op {
            "=" | "==" => order == 0
            "!=" | "<>" => order != 0
            "<" => order < 0
            "<=" => order <= 0
            ">" => order > 0
            ">=" => order >= 0
            _ => false
          }
        None => false
      }
    }
  }
}

///|
fn expr_label(expr : Expr) -> String {
  match expr {
    EBinary(left, op, right) => expr_label(left) + op + expr_label(right)
    ECol(name) => name
    EStr(text) => "\"" + text + "\""
    ENum(number) => number.to_string()
    EDate(date) => date.to_string()
    ENull => "null"
    EStar => "*"
    EBool(value) => if value { "true" } else { "false" }
    ECall(name, args) => {
      let sb = StringBuilder()
      sb.write_string(name)
      for i in 0.. Bool {
  match expr {
    EBinary(left, _, right) => contains_sum(left) || contains_sum(right)
    ECall("sum", _) | ECall("count", _) => true
    ECall(_, args) => {
      for arg in args {
        if contains_sum(arg) {
          return true
        }
      }
      false
    }
    _ => false
  }
}

///|
fn sum_amounts(values : Array[@ast.Amount]) -> QueryValue {
  if values.length() == 0 {
    return QNull
  }
  let sums : Array[@ast.Amount] = []
  for value in values {
    let mut found = false
    for i in 0.. QueryValue {
  if values.length() == 0 {
    return QNull
  }
  let mut total = @decimal.Decimal::zero()
  for value in values {
    total = total.add(value)
  }
  QNumber(total)
}

///|
fn eval_aggregate(expr : Expr, rows : Array[BqlRow]) -> QueryValue {
  match expr {
    ECall("count", _) => QNumber(@decimal.Decimal::from_int(rows.length()))
    ECall("sum", [arg]) =>
      match arg {
        ECol("position") => {
          let inventory = @booking.Inventory::new()
          for row in rows {
            inventory.add(row.position)
          }
          QInventory(inventory)
        }
        ECol("weight") => {
          let values : Array[@ast.Amount] = []
          for row in rows {
            if eval_column(row, "weight") is QAmount(a) {
              values.push(a)
            }
          }
          sum_amounts(values)
        }
        ECol("units") => {
          let values : Array[@ast.Amount] = []
          for row in rows {
            values.push(row.units)
          }
          sum_amounts(values)
        }
        ECol("cost") => {
          let values : Array[@ast.Amount] = []
          for row in rows {
            match row.cost {
              Some(cost) =>
                values.push(@ast.Amount::new(cost.number, cost.currency))
              None => ()
            }
          }
          sum_amounts(values)
        }
        ECol("value") => {
          let values : Array[@ast.Amount] = []
          for row in rows {
            match row.value {
              Some(amount) => values.push(amount)
              None => ()
            }
          }
          sum_amounts(values)
        }
        _ => {
          let numbers : Array[@decimal.Decimal] = []
          for row in rows {
            match value_number(eval_expr(arg, row)) {
              Some(number) => numbers.push(number)
              None => ()
            }
          }
          sum_numbers(numbers)
        }
      }
    _ => QNull
  }
}

///|
fn eval_grouped(expr : Expr, rows : Array[BqlRow]) -> QueryValue {
  if expr is EBinary(left, op, right) {
    arithmetic(eval_grouped(left, rows), op, eval_grouped(right, rows))
  } else if contains_sum(expr) {
    eval_aggregate(expr, rows)
  } else if rows.length() > 0 {
    eval_expr(expr, rows[0])
  } else {
    QNull
  }
}

///|
priv struct OutputRow {
  values : Array[QueryValue]
  group : Array[BqlRow]
}

///|
fn group_key(exprs : Array[Expr], row : BqlRow) -> String {
  let sb = StringBuilder()
  for expr in exprs {
    sb.write_string(eval_expr(expr, row).to_text())
    sb.write_string("\u{1f}")
  }
  sb.to_string()
}

///|
fn build_output(query : SelectQuery, rows : Array[BqlRow]) -> Array[OutputRow] {
  let outputs : Array[OutputRow] = []
  let any_aggregate = {
    let mut found = false
    for projection in query.projections {
      if contains_sum(projection) {
        found = true
      }
    }
    for order in query.orders {
      if contains_sum(order.expr) {
        found = true
      }
    }
    found
  }
  if query.groups.length() == 0 && !any_aggregate {
    for row in rows {
      let values : Array[QueryValue] = []
      for projection in query.projections {
        values.push(eval_expr(projection, row))
      }
      outputs.push({ values, group: [row], })
    }
    return outputs
  }
  let groups : Map[String, Array[BqlRow]] = Map([])
  let order : Array[String] = []
  if query.groups.length() == 0 {
    order.push("")
    groups.set("", rows)
  } else {
    for row in rows {
      let key = group_key(query.groups, row)
      match groups.get(key) {
        Some(bucket) => bucket.push(row)
        None => {
          groups.set(key, [row])
          order.push(key)
        }
      }
    }
  }
  for key in order {
    let bucket = match groups.get(key) {
      Some(found) => found
      None => []
    }
    let values : Array[QueryValue] = []
    for projection in query.projections {
      values.push(eval_grouped(projection, bucket))
    }
    outputs.push({ values, group: bucket, })
  }
  outputs
}

///|
fn sort_outputs(outputs : Array[OutputRow], orders : Array[OrderSpec]) -> Unit {
  let mut i = 1
  while i < outputs.length() {
    let current = outputs[i]
    let mut j = i
    while j > 0 {
      let prev = outputs[j - 1]
      let mut before = false
      let mut decided = false
      for spec in orders {
        if !decided {
          let a = eval_grouped(spec.expr, prev.group)
          let b = eval_grouped(spec.expr, current.group)
          let order = match value_compare(a, b) {
            Some(value) => value
            None => 0
          }
          if order != 0 {
            before = if spec.descending { order < 0 } else { order > 0 }
            decided = true
          }
        }
      }
      if before {
        outputs[j - 1] = current
        outputs[j] = prev
        j = j - 1
      } else {
        break
      }
    }
    i = i + 1
  }
}

///|
fn run_select(
  realized : @realization.RealizedLedger,
  query : SelectQuery,
) -> QueryResult {
  let all_rows = source_rows(realized, query.source)
  let rows : Array[BqlRow] = []
  for row in all_rows {
    if cond_holds(query.filter, row) {
      rows.push(row)
    }
  }
  let mut outputs = build_output(query, rows).filter(fn(output) {
    grouped_cond(query.having, output.group)
  })
  sort_outputs(outputs, query.orders)
  match query.limit {
    Some(limit) =>
      if outputs.length() > limit {
        let truncated : Array[OutputRow] = []
        for i in 0.. ()
  }
  let columns : Array[String] = []
  for projection in query.projections {
    columns.push(expr_label(projection))
  }
  let result_rows : Array[Array[QueryValue]] = []
  for output in outputs {
    result_rows.push(output.values)
  }
  { columns, rows: result_rows, error: None, }
}

///|
fn run_balances(realized : @realization.RealizedLedger) -> QueryResult {
  let columns = ["account", "balance"]
  let rows : Array[Array[QueryValue]] = []
  for account in realized.account_names() {
    let inventory = realized.inventory(account)
    if !inventory.is_empty() {
      rows.push([QText(account), QInventory(inventory)])
    }
  }
  { columns, rows, error: None, }
}

///|
fn journal_row(entry : @ast.Entry, posting : @ast.Posting) -> BqlRow? {
  match posting.units {
    Some(units) => {
      let cost = posting_cost(posting, units)
      Some({
        date: entry.date,
        flag: "",
        payee: "",
        narration: "",
        tags: [],
        links: [],
        meta: @ast.Meta::new(),
        account: posting.account,
        units,
        cost,
        value: posting_value(units, cost, posting.price),
        position: @booking.Position::new(units, cost),
      })
    }
    None => None
  }
}

///|
fn run_journal(
  realized : @realization.RealizedLedger,
  account : String,
  limit : Int32?,
) -> QueryResult {
  let columns = ["date", "flag", "payee", "narration", "account", "position"]
  let rows : Array[Array[QueryValue]] = []
  for entry in realized.entries {
    match entry.directive {
      DTransaction(txn) =>
        for posting in txn.postings {
          if posting.account == account ||
            @realization.is_subaccount(posting.account, account) {
            match journal_row(entry, posting) {
              Some(row) => {
                let done = match limit {
                  Some(max) => rows.length() >= max
                  None => false
                }
                if done {
                  break
                }
                rows.push([
                  QDate(entry.date),
                  QText(txn.flag),
                  match txn.payee {
                    Some(payee) => QText(payee)
                    None => QNull
                  },
                  QText(txn.narration),
                  QText(posting.account),
                  QPosition(row.position),
                ])
              }
              None => ()
            }
          }
        }
      _ => ()
    }
  }
  { columns, rows, error: None, }
}

///|
pub fn run_query(
  realized : @realization.RealizedLedger,
  text : String,
) -> QueryResult {
  match parse_query(text) {
    PqError(message) => { columns: [], rows: [], error: Some(message), }
    PqSelect(query) => run_select(realized, query)
    PqBalances => run_balances(realized)
    PqJournal(account, limit) => run_journal(realized, account, limit)
  }
}

///|
fn arithmetic(a : QueryValue, op : String, b : QueryValue) -> QueryValue {
  match (a, b) {
    (QNumber(x), QNumber(y)) =>
      match op {
        "+" => QNumber(x.add(y))
        "-" => QNumber(x.sub(y))
        "*" => QNumber(x.mul(y))
        "/" =>
          match x.div(y) {
            Some(n) => QNumber(n)
            None => QNull
          }
        _ => QNull
      }
    _ => QNull
  }
}

///|
fn grouped_cond(cond : Cond, rows : Array[BqlRow]) -> Bool {
  match cond {
    CRegex(e, regex) =>
      match eval_grouped(e, rows) {
        QText(s) => regex.execute(s) is Some(_)
        _ => false
      }
    CTrue => true
    CNot(c) => !grouped_cond(c, rows)
    CAnd(a, b) => grouped_cond(a, rows) && grouped_cond(b, rows)
    COr(a, b) => grouped_cond(a, rows) || grouped_cond(b, rows)
    CIsNull(e, negated) => (eval_grouped(e, rows) is QNull) != negated
    CTruthy(e) =>
      match eval_grouped(e, rows) {
        QNumber(n) => !n.is_zero()
        QNull => false
        _ => true
      }
    CCmp(a, op, b) =>
      match value_compare(eval_grouped(a, rows), eval_grouped(b, rows)) {
        Some(n) =>
          match op {
            "=" | "==" => n == 0
            "!=" | "<>" => n != 0
            "<" => n < 0
            "<=" => n <= 0
            ">" => n > 0
            ">=" => n >= 0
            _ => false
          }
        None => false
      }
  }
}

///|
fn opening_rows(
  realized : @realization.RealizedLedger,
  start : @ast.Date,
  close : @ast.Date?,
) -> Array[BqlRow] {
  let inventories : Map[String, @booking.Inventory] = Map([])
  let names : Array[String] = []
  let rows : Array[BqlRow] = []
  for row in posting_rows(realized) {
    if row.date.less_than(start) {
      let root = @ast.account_root(row.account)
      let account = if root == "Income" || root == "Expenses" {
        realized.options.equity_account(
          "account_previous_earnings", "Earnings:Previous",
        )
      } else {
        row.account
      }
      let inventory = match inventories.get(account) {
        Some(i) => i
        None => {
          let i = @booking.Inventory::new()
          inventories.set(account, i)
          names.push(account)
          i
        }
      }
      inventory.add(row.position)
    } else {
      let in_period = match close {
        Some(date) => row.date.less_than(date)
        None => true
      }
      if in_period {
        rows.push(row)
      }
    }
  }
  let date = if start.day > 1 {
    @ast.Date::new(start.year, start.month, start.day - 1).unwrap()
  } else {
    let year = if start.month == 1 { start.year - 1 } else { start.year }
    let month = if start.month == 1 { 12 } else { start.month - 1 }
    @ast.Date::new(year, month, @ast.Date::days_in_month(year, month)).unwrap()
  }
  let summaries : Array[BqlRow] = []
  names.sort()
  for account in names {
    let inventory = inventories.get(account).unwrap()
    for position in inventory.positions {
      let narration = "Opening balance for '" + account + "' (Summarization)"
      let row : BqlRow = {
        date,
        flag: "S",
        payee: "",
        narration,
        tags: [],
        links: [],
        meta: @ast.Meta::new(),
        account,
        units: position.units,
        cost: position.cost,
        value: posting_value(position.units, position.cost, None),
        position,
      }
      summaries.push(row)
      let amount = match position.cost {
        Some(c) =>
          @ast.Amount::new(
            position.units.number.mul(c.number).neg(),
            c.currency,
          )
        None =>
          @ast.Amount::new(position.units.number.neg(), position.units.currency)
      }
      summaries.push({
        ..row,
        account: realized.options.equity_account(
          "account_previous_balances", "Opening-Balances",
        ),
        units: amount,
        cost: None,
        value: None,
        position: @booking.Position::new(amount, None),
      })
    }
  }
  summaries + rows
}