///|
fn price_key(base : String, quote : String) -> String {
  base + "\u{1f}" + quote
}

///|
fn ensure_query_prices(ctx : QueryContext) -> Unit {
  if ctx.prices is Some(_) {
    return
  }
  let raw : Map[String, Array[(@ast.Date, @decimal.Decimal)]] = Map([])
  let pairs : Array[(String, String)] = []
  for entry in ctx.realized.entries {
    if entry.directive is DPrice(p) {
      let key = price_key(p.currency, p.amount.currency)
      if !raw.contains(key) {
        raw.set(key, [])
        pairs.push((p.currency, p.amount.currency))
      }
      raw.get(key).unwrap().push((entry.date, p.amount.number))
    }
  }
  let result : Map[String, Array[(@ast.Date, @decimal.Decimal)]] = Map([])
  for (a, b) in pairs {
    if result.contains(price_key(a, b)) {
      continue
    }
    let forward = raw.get(price_key(a, b)).unwrap_or([])
    let reverse = raw.get(price_key(b, a)).unwrap_or([])
    let (base, quote, direct, inverse) = if forward.length() >= reverse.length() {
      (a, b, forward, reverse)
    } else {
      (b, a, reverse, forward)
    }
    let rates = direct.copy()
    for (date, number) in inverse {
      if @decimal.Decimal::one().div(number) is Some(n) {
        rates.push((date, n))
      }
    }
    // Stable order retains the last quote on a repeated date.
    rates.sort_by(fn(a, b) { a.0.to_string().compare(b.0.to_string()) })
    let inverted : Array[(@ast.Date, @decimal.Decimal)] = []
    for (date, number) in rates {
      if @decimal.Decimal::one().div(number) is Some(n) {
        inverted.push((date, n))
      }
    }
    result.set(price_key(base, quote), rates)
    result.set(price_key(quote, base), inverted)
  }
  ctx.prices = Some(result)
}

///|
fn query_price(
  ctx : QueryContext,
  base : String,
  quote : String,
  date : @ast.Date?,
) -> @decimal.Decimal? {
  if base == quote {
    return Some(@decimal.Decimal::one())
  }
  ensure_query_prices(ctx)
  let rates = ctx.prices.unwrap().get(price_key(base, quote)).unwrap_or([])
  let mut found = None
  for (at, number) in rates {
    if date is Some(end) && end.less_than(at) {
      break
    }
    found = Some(number)
  }
  found
}

///|
fn convert_query_amount(
  ctx : QueryContext,
  amount : @ast.Amount,
  target : String,
  date : @ast.Date?,
  via : String?,
) -> @ast.Amount {
  if amount.currency == target {
    return amount
  }
  let mut rate = query_price(ctx, amount.currency, target, date)
  if rate is None && via is Some(currency) {
    if query_price(ctx, amount.currency, currency, date) is Some(a) &&
      query_price(ctx, currency, target, date) is Some(b) {
      rate = Some(a.mul(b).round_to_significant(28))
    }
  }
  match rate {
    Some(n) =>
      @ast.Amount::new(amount.number.mul(n).round_to_significant(28), target)
    None => amount
  }
}

///|
fn query_conversion(
  ctx : QueryContext,
  value : QueryValue,
  target : String?,
  date : @ast.Date?,
) -> QueryValue {
  let convert_position = fn(p : @booking.Position) {
    let currency = match target {
      Some(s) => Some(s)
      None => p.cost.map(fn(c) { c.currency })
    }
    match currency {
      Some(s) =>
        convert_query_amount(
          ctx,
          p.units,
          s,
          date,
          p.cost.map(fn(c) { c.currency }),
        )
      None => p.units
    }
  }
  match value {
    QAmount(a) =>
      QAmount(convert_query_amount(ctx, a, target.unwrap(), date, None))
    QPosition(p) => QAmount(convert_position(p))
    QInventory(inv) => {
      let result = @booking.Inventory::new()
      for p in inv.positions {
        result.add(@booking.Position::new(convert_position(p), None))
      }
      QInventory(result)
    }
    _ => QNull
  }
}