// Price database. Only explicit `price` directives define market quotes.
// Booking costs and annotations do not imply fresh market quotes. Conversion uses the
// most recent price at or before the requested date, and falls back to the
// inverse quote when only the opposite direction is available.

///|
pub(all) struct PricePoint {
  date : @ast.Date
  base : String
  quote : String
  rate : @decimal.Decimal
}

///|
pub(all) struct PriceDb {
  points : Array[PricePoint]
}

///|
pub fn PriceDb::new() -> PriceDb {
  { points: [], }
}

///|
pub fn PriceDb::add(
  self : PriceDb,
  date : @ast.Date,
  base : String,
  quote : String,
  rate : @decimal.Decimal,
) -> Unit {
  if base == quote || rate.is_zero() {
    return
  }
  self.points.push({ date, base, quote, rate, })
}

///|
pub fn PriceDb::from_entries(entries : Array[@ast.Entry]) -> PriceDb {
  let db = PriceDb::new()
  // Transaction costs and annotations are not market quotes without a plugin.
  for entry in entries {
    if entry.directive is DPrice(price) {
      db.add(
        entry.date,
        price.currency,
        price.amount.currency,
        price.amount.number,
      )
    }
  }
  db
}

///|
fn date_allows(entry_date : @ast.Date, at : @ast.Date?) -> Bool {
  match at {
    Some(date) => entry_date.less_equal(date)
    None => true
  }
}

///|
pub fn PriceDb::latest(
  self : PriceDb,
  base : String,
  quote : String,
  at : @ast.Date?,
) -> @decimal.Decimal? {
  let mut result : @decimal.Decimal? = None
  let mut found : @ast.Date? = None
  for point in self.points {
    if point.base == base && point.quote == quote && date_allows(point.date, at) {
      let is_newer = match found {
        Some(found_date) => !point.date.less_than(found_date)
        None => true
      }
      if is_newer {
        result = Some(point.rate)
        found = Some(point.date)
      }
    }
  }
  result
}

///|
fn PriceDb::edge(
  self : PriceDb,
  base : String,
  quote : String,
  at : @ast.Date?,
) -> @decimal.Decimal? {
  let mut latest : PricePoint? = None
  for point in self.points {
    if (
        (point.base == base && point.quote == quote) ||
        (point.base == quote && point.quote == base)
      ) &&
      date_allows(point.date, at) &&
      point.rate.is_positive() {
      let newer = match latest {
        None => true
        Some(old) =>
          old.date.less_than(point.date) ||
          (
            old.date.equal(point.date) &&
            (point.base == base || old.base != base)
          )
      }
      if newer {
        latest = Some(point)
      }
    }
  }
  match latest {
    Some(point) =>
      if point.base == base {
        Some(point.rate)
      } else {
        @decimal.Decimal::one().div(point.rate)
      }
    None => None
  }
}

///|
/// Finds the shortest dated quote path. Equal-length paths use lexical currency order.
/// Each currency is visited once, so cycles cannot cause nontermination.
pub fn PriceDb::rate(
  self : PriceDb,
  base : String,
  quote : String,
  at : @ast.Date?,
) -> @decimal.Decimal? {
  if base == quote {
    return Some(@decimal.Decimal::one())
  }
  let currencies : Array[String] = []
  for point in self.points {
    if date_allows(point.date, at) {
      if !currencies.contains(point.base) {
        currencies.push(point.base)
      }
      if !currencies.contains(point.quote) {
        currencies.push(point.quote)
      }
    }
  }
  currencies.sort()
  let queue = [(base, @decimal.Decimal::one())]
  let seen : Map[String, Bool] = Map([])
  seen.set(base, true)
  let mut index = 0
  while index < queue.length() {
    let (currency, accumulated) = queue[index]
    index += 1
    for next in currencies {
      if seen.get(next) is None {
        if self.edge(currency, next, at) is Some(rate) {
          let combined = accumulated.mul(rate)
          if next == quote {
            return Some(combined)
          }
          seen.set(next, true)
          queue.push((next, combined))
        }
      }
    }
  }
  None
}

///|
/// Converts one amount into a target currency, returning `None` when no price
/// path is known.
pub fn PriceDb::convert(
  self : PriceDb,
  amount : @ast.Amount,
  target : String,
  at : @ast.Date?,
) -> @ast.Amount? {
  match self.rate(amount.currency, target, at) {
    Some(rate) => Some(@ast.Amount::new(amount.number.mul(rate), target))
    None => None
  }
}

///|
pub fn PriceDb::to_json(self : PriceDb) -> String {
  let sb = StringBuilder()
  sb.write_string("[")
  let mut first = true
  for point in self.points {
    if !first {
      sb.write_string(",")
    }
    sb.write_string("{\"date\":\"")
    sb.write_string(point.date.to_string())
    sb.write_string("\",\"base\":\"")
    sb.write_string(json_escape(point.base))
    sb.write_string("\",\"quote\":\"")
    sb.write_string(json_escape(point.quote))
    sb.write_string("\",\"rate\":\"")
    sb.write_string(point.rate.to_string())
    sb.write_string("\"}")
    first = false
  }
  sb.write_string("]")
  sb.to_string()
}

///|
pub fn json_escape(text : String) -> String {
  let sb = StringBuilder()
  for i in 0..
        if ch == '"' {
          sb.write_string("\\\"")
        } else if ch == '\\' {
          sb.write_string("\\\\")
        } else if ch == '\n' {
          sb.write_string("\\n")
        } else if ch == '\r' {
          sb.write_string("\\r")
        } else if ch == '\t' {
          sb.write_string("\\t")
        } else {
          sb.write_char(ch)
        }
      None => ()
    }
  }
  sb.to_string()
}