// Lots and booking.  A posting without a cost clause is a plain currency
// position: it is never matched against cost basis lots.  A posting with `{}`
// (or another cost clause) is booked against the inventory, and reductions are
// resolved with the account's booking method.

///|
pub(all) struct BookingIssue {
  code : String
  message : String
}

///|
pub fn BookingIssue::new(code : String, message : String) -> BookingIssue {
  { code, message, }
}

///|
pub(all) struct BookingOutcome {
  weights : Array[@ast.Amount]
  positions : Array[Position]
  issue : BookingIssue?
  needs_inference : Bool
}

///|
fn outcome_ok(
  weights : Array[@ast.Amount],
  positions~ : Array[Position] = [],
) -> BookingOutcome {
  { weights, positions, issue: None, needs_inference: false, }
}

///|
fn outcome_issue(code : String, message : String) -> BookingOutcome {
  {
    weights: [],
    positions: [],
    issue: Some(BookingIssue::new(code, message)),
    needs_inference: false,
  }
}

///|
fn needs_inference_outcome() -> BookingOutcome {
  { weights: [], positions: [], issue: None, needs_inference: true, }
}

///|
fn price_weight(
  units : @decimal.Decimal,
  price : @ast.PriceAnno,
) -> @ast.Amount {
  match price {
    PerUnit(amount) =>
      @ast.Amount::new(units.mul(amount.number), amount.currency)
    Total(amount) => {
      let sign = if units.signum() < 0 { -1 } else { 1 }
      @ast.Amount::new(
        amount.number.mul(@decimal.Decimal::from_int(sign)),
        amount.currency,
      )
    }
  }
}

///|
fn cost_weight(units : @decimal.Decimal, cost : @ast.Cost) -> @ast.Amount {
  @ast.Amount::new(units.mul(cost.number), cost.currency)
}

///|
fn spec_has_number(spec : @ast.CostSpec) -> Bool {
  spec.number_per is Some(_) || spec.number_total is Some(_)
}

///|
fn spec_number_per(
  spec : @ast.CostSpec,
  need : @decimal.Decimal,
) -> @decimal.Decimal? {
  match spec.number_per {
    Some(number) => Some(number)
    None =>
      match spec.number_total {
        Some(total) => if need.is_zero() { None } else { total.div(need) }
        None => None
      }
  }
}

///|
fn date_equal(a : @ast.Date?, b : @ast.Date?) -> Bool {
  match (a, b) {
    (None, None) => true
    (Some(x), Some(y)) => x.equal(y)
    _ => false
  }
}

///|
fn label_equal(a : String?, b : String?) -> Bool {
  match (a, b) {
    (None, None) => true
    (Some(x), Some(y)) => x == y
    _ => false
  }
}

///|
fn cost_matches_spec(
  cost : @ast.Cost?,
  spec : @ast.CostSpec,
  need : @decimal.Decimal,
) -> Bool {
  let target = spec_number_per(spec, need)
  match cost {
    None =>
      target is None &&
      spec.currency is None &&
      spec.date is None &&
      spec.label is None
    Some(c) => {
      let number_ok = match target {
        Some(number) => c.number.equal(number)
        None => true
      }
      let currency_ok = match spec.currency {
        Some(currency) => c.currency == currency
        None => true
      }
      let date_ok = match spec.date {
        Some(d) => date_equal(c.date, Some(d))
        None => true
      }
      let label_ok = match spec.label {
        Some(l) => label_equal(c.label, Some(l))
        None => true
      }
      number_ok && currency_ok && date_ok && label_ok
    }
  }
}

///|
fn cost_date(cost : @ast.Cost?) -> @ast.Date? {
  match cost {
    Some(c) => c.date
    None => None
  }
}

///|
fn compare_optional_date(a : @ast.Date?, b : @ast.Date?) -> Int32 {
  match (a, b) {
    (None, None) => 0
    (None, Some(_)) => -1
    (Some(_), None) => 1
    (Some(x), Some(y)) => x.compare(y)
  }
}

///|
fn cost_number(cost : @ast.Cost?) -> @decimal.Decimal {
  match cost {
    Some(c) => c.number
    None => @decimal.Decimal::zero()
  }
}

///|
fn sorted_candidates(
  inv : Inventory,
  candidates : Array[Int32],
  booking : String,
) -> Array[Int32] {
  let result = candidates.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 prev_pos = inv.positions[prev]
      let cur_pos = inv.positions[current]
      let mut swap = false
      if booking == "LIFO" {
        swap = compare_optional_date(
            cost_date(prev_pos.cost),
            cost_date(cur_pos.cost),
          ) <
          0
      } else if booking == "HIFO" {
        let cmp = cost_number(prev_pos.cost).compare(cost_number(cur_pos.cost))
        if cmp < 0 {
          swap = true
        } else if cmp == 0 {
          swap = compare_optional_date(
              cost_date(prev_pos.cost),
              cost_date(cur_pos.cost),
            ) >
            0
        }
      } else {
        swap = compare_optional_date(
            cost_date(prev_pos.cost),
            cost_date(cur_pos.cost),
          ) >
          0
      }
      if swap {
        result[j - 1] = current
        result[j] = prev
        j = j - 1
      } else {
        break
      }
    }
    i = i + 1
  }
  result
}

///|
fn min_decimal(a : @decimal.Decimal, b : @decimal.Decimal) -> @decimal.Decimal {
  if a.less_equal(b) {
    a
  } else {
    b
  }
}

///|
fn posting_label(posting : @ast.Posting) -> String {
  match posting.units {
    Some(units) => units.to_string()
    None => posting.account
  }
}

///|
fn select_lots(
  inv : Inventory,
  need : @decimal.Decimal,
  currency : String,
  spec : @ast.CostSpec,
  booking : String,
  posting : @ast.Posting,
) -> (Array[(Int32, @decimal.Decimal)], BookingIssue?) {
  let candidates = []
  for i in 0.. 1 {
      return (
        [],
        Some(
          BookingIssue::new(
            "booking.ambiguous",
            "Ambiguous matches for \"" + label + "\": " + inventory_text,
          ),
        ),
      )
    }
    let index = candidates[0]
    let available = inv.positions[index].units.number
    if available.less_than(need) {
      return (
        [],
        Some(
          BookingIssue::new(
            "booking.insufficient",
            "Not enough lots to reduce \"" + label + "\": " + inventory_text,
          ),
        ),
      )
    }
    plan.push((index, need))
    return (plan, None)
  }
  if booking == "STRICT_WITH_SIZE" {
    let mut exact = None
    for index in candidates {
      if inv.positions[index].units.number.equal(need) && exact is None {
        exact = Some(index)
      }
    }
    match exact {
      Some(index) => {
        plan.push((index, need))
        return (plan, None)
      }
      None =>
        if candidates.length() == 1 {
          let index = candidates[0]
          if inv.positions[index].units.number.less_than(need) {
            return (
              [],
              Some(
                BookingIssue::new(
                  "booking.insufficient",
                  "Not enough lots to reduce \"" +
                  label +
                  "\": " +
                  inventory_text,
                ),
              ),
            )
          }
          plan.push((index, need))
          return (plan, None)
        } else {
          return (
            [],
            Some(
              BookingIssue::new(
                "booking.ambiguous",
                "Ambiguous matches for \"" + label + "\": " + inventory_text,
              ),
            ),
          )
        }
    }
  }
  let sorted = sorted_candidates(inv, candidates, booking)
  let mut remaining = need
  for index in sorted {
    if remaining.is_zero() {
      break
    }
    let available = inv.positions[index].units.number
    if available.is_positive() {
      let take = min_decimal(remaining, available)
      plan.push((index, take))
      remaining = remaining.sub(take)
    }
  }
  if !remaining.is_zero() {
    return (
      [],
      Some(
        BookingIssue::new(
          "booking.insufficient",
          "Not enough lots to reduce \"" + label + "\": " + inventory_text,
        ),
      ),
    )
  }
  (plan, None)
}

///|
fn apply_plan(inv : Inventory, plan : Array[(Int32, @decimal.Decimal)]) -> Unit {
  if plan.length() == 0 {
    return
  }
  let old_positions = inv.positions.copy()
  inv.positions.clear()
  for i in 0.. Unit {
  if amount.number.is_zero() && target.length() > 0 {
    // Still record zero weights: they carry currency information used by
    // cost inference.
  }
  for i in 0.. Array[@ast.Amount] {
  let weights = []
  match average {
    Some(cost) => {
      let mut total = @decimal.Decimal::zero()
      for entry in plan {
        total = total.add(entry.1)
      }
      add_weights(weights, cost_weight(total, cost))
    }
    None =>
      for entry in plan {
        let position = inv.positions[entry.0]
        match position.cost {
          Some(cost) => add_weights(weights, cost_weight(entry.1, cost))
          None =>
            match price {
              Some(price_anno) =>
                add_weights(weights, price_weight(entry.1, price_anno))
              None =>
                add_weights(weights, @ast.Amount::new(entry.1, units_currency))
            }
        }
      }
  }
  weights
}

///|
fn negate_weights(weights : Array[@ast.Amount]) -> Array[@ast.Amount] {
  let result = []
  for weight in weights {
    result.push(@ast.Amount::new(weight.number.neg(), weight.currency))
  }
  result
}

///|
fn resolve_cost(
  spec : @ast.CostSpec,
  units : @ast.Amount,
  txn_date : @ast.Date,
) -> @ast.Cost? {
  let number = match spec.number_per {
    Some(number) => Some(number)
    None =>
      match spec.number_total {
        Some(total) =>
          if units.number.is_zero() {
            None
          } else {
            total.div(units.number.abs())
          }
        None => None
      }
  }
  match number {
    Some(number) =>
      match spec.currency {
        Some(currency) => {
          let date = match spec.date {
            Some(d) => d
            None => txn_date
          }
          Some(@ast.Cost::new(number, currency, Some(date), spec.label))
        }
        None => None
      }
    None => None
  }
}

///|
fn book_augmentation(
  state : BookingState,
  account : String,
  units : @ast.Amount,
  cost : @ast.Cost,
  price : @ast.PriceAnno?,
) -> Array[@ast.Amount] {
  let inv = state.inventory_for(account)
  inv.add({ units, cost: Some(cost), })
  if state.method_for(account, "") == "AVERAGE" {
    inv.merge_average(units.currency)
  }
  let weights = []
  add_weights(weights, cost_weight(units.number, cost))
  let _ = price
  weights
}

///|
pub fn book_augmentation_with_cost(
  state : BookingState,
  account : String,
  units : @ast.Amount,
  cost : @ast.Cost,
  price : @ast.PriceAnno?,
) -> Array[@ast.Amount] {
  book_augmentation(state, account, units, cost, price)
}

///|
fn reduce_with_average(
  inv : Inventory,
  units : @ast.Amount,
  posting : @ast.Posting,
) -> BookingOutcome {
  let need = units.number.abs()
  let average = inv.average_cost(units.currency)
  match average {
    None =>
      outcome_issue(
        "booking.no_match",
        "No position matches \"" +
        posting_label(posting) +
        " {}: " +
        inv.to_string(),
      )
    Some(cost) => {
      let candidates = []
      for i in 0.. BookingOutcome {
  let units = match posting.units {
    Some(units) => units
    None => return outcome_ok([])
  }
  let inv = state.inventory_for(account)
  match posting.cost {
    None => {
      inv.add({ units, cost: None, })
      let weights = []
      match posting.price {
        Some(price) => add_weights(weights, price_weight(units.number, price))
        None => add_weights(weights, units)
      }
      outcome_ok(weights)
    }
    Some(spec) =>
      if units.number.signum() >= 0 {
        let cost = resolve_cost(spec, units, txn_date)
        match cost {
          Some(cost) =>
            outcome_ok(
              book_augmentation(state, account, units, cost, posting.price),
              positions=[Position::new(units, Some(cost))],
            )
          None =>
            if spec_has_number(spec) && spec.currency is Some(_) {
              outcome_issue(
                "booking.cost",
                "could not resolve cost for " + posting_label(posting),
              )
            } else {
              needs_inference_outcome()
            }
        }
      } else {
        let booking = if spec.merge {
          // `{*}` merges every costed lot into one held at average cost and
          // then reduces that single averaged position.
          inv.merge_average(units.currency)
          "AVERAGE"
        } else {
          state.method_for(account, default_method)
        }
        if booking == "NONE" {
          let resolved = resolve_cost(spec, units, txn_date)
          match resolved {
            Some(cost) => {
              inv.add({ units, cost: Some(cost), })
              let weights = []
              add_weights(weights, cost_weight(units.number, cost))
              outcome_ok(weights, positions=[Position::new(units, Some(cost))])
            }
            None => {
              inv.add({ units, cost: None, })
              let weights = []
              match posting.price {
                Some(price) =>
                  add_weights(weights, price_weight(units.number, price))
                None => add_weights(weights, units)
              }
              outcome_ok(weights)
            }
          }
        } else if booking == "AVERAGE" {
          reduce_with_average(inv, units, posting)
        } else {
          let need = units.number.abs()
          let (plan, issue) = select_lots(
            inv,
            need,
            units.currency,
            spec,
            booking,
            posting,
          )
          match issue {
            Some(issue) =>
              {
                weights: [],
                positions: [],
                issue: Some(issue),
                needs_inference: false,
              }
            None => {
              let weights = plan_weights(
                inv,
                plan,
                units.currency,
                posting.price,
                None,
              )
              let positions = plan.map(fn(item) {
                Position::new(
                  @ast.Amount::new(item.1.neg(), units.currency),
                  inv.positions[item.0].cost,
                )
              })
              apply_plan(inv, plan)
              outcome_ok(negate_weights(weights), positions~)
            }
          }
        }
      }
  }
}