///|
/// A complete pricing rule without any ignored usage quantity.
pub(all) enum Tariff {
  MeteredTariff(Plan, String, ZeroUsageMinimum)
  PackageTariff(PackagePlan)
}

///|
pub(all) struct CatalogEntry {
  starts_on : String
  ends_before : String?
  tariff : Tariff
}

///|
priv struct TariffInfo {
  id : String
  version : String
  metric : String
  unit : String
  currency : String
}

///|
fn tariff_info(tariff : Tariff) -> TariffInfo raise PricingError {
  match tariff {
    MeteredTariff(p, minimum, _) => {
      ignore(decimal(minimum, "tariff.minimum"))
      {
        id: p.id,
        version: p.version,
        metric: p.metric,
        unit: p.unit,
        currency: p.currency,
      }
    }
    PackageTariff(p) =>
      {
        id: p.id,
        version: p.version,
        metric: p.metric,
        unit: p.unit,
        currency: p.currency,
      }
  }
}

///|
priv struct DatedTariff {
  start : Int32
  end : Int32?
  entry : CatalogEntry
}

///|
/// Detached timelines for all required metrics of one product, in one currency.
pub struct Catalog {
  priv entries : Array[DatedTariff]
  priv metrics : Array[String]
}

///|
pub(all) struct CatalogBill {
  on : String
  bill : Bill
} derive(Debug, Eq)

///|
pub extend CatalogBill with Debug::{to_repr}

///|
pub extend CatalogBill with Eq::{equal, not_equal}

///|
pub fn compile_catalog(
  entries : ArrayView[CatalogEntry],
) -> Catalog raise PricingError {
  if entries.length() == 0 || entries.length() > 512 {
    invalid("size", "catalog.entries", "provide 1 to 512 dated rules")
  }
  let metadata : Map[String, TariffInfo] = Map([])
  let versions : Map[String, Map[String, Unit]] = Map([])
  let previous : Map[String, DatedTariff] = Map([])
  let metrics : Array[String] = []
  let compiled : Array[DatedTariff] = []
  let currency = tariff_info(entries[0].tariff).currency
  for i, entry in entries {
    let path = "catalog.entries[\{i}]"
    let info = tariff_info(entry.tariff)
    let start = date_key(entry.starts_on, path + ".starts_on")
    let end = entry.ends_before.map(s => date_key(s, path + ".ends_before"))
    if end is Some(n) && n <= start {
      invalid("interval", path, "end must be strictly after start")
    }
    if info.currency != currency {
      invalid("currency", path, "every catalog metric must use one currency")
    }
    match metadata.get(info.metric) {
      None => {
        if metrics.length() == 64 {
          invalid("size", path, "maximum 64 catalog metrics")
        }
        metadata[info.metric] = info
        versions[info.metric] = Map([])
        metrics.push(info.metric)
      }
      Some(first) => {
        if info.id != first.id || info.unit != first.unit {
          invalid(
            "identity", path, "versions of each metric must preserve plan id and unit",
          )
        }
        let prev = previous[info.metric]
        if start <= prev.start ||
          prev.end is None ||
          (prev.end is Some(n) && start < n) {
          invalid(
            "overlap", path, "each metric timeline must be ordered and disjoint; only its final end may be null",
          )
        }
      }
    }
    let seen = versions[info.metric]
    if seen.contains(info.version) {
      invalid(
        "duplicate", path, "versions must be unique within each metric timeline",
      )
    }
    if seen.length() == 128 {
      invalid("size", path, "maximum 128 versions per metric")
    }
    seen[info.version] = ()
    let dated : DatedTariff = { start, end, entry, }
    previous[info.metric] = dated
    compiled.push(dated)
  }
  { entries: compiled, metrics, }
}

///|
fn bind_tariff(tariff : Tariff, usage : Usage) -> BillItem {
  match tariff {
    MeteredTariff(plan, minimum, zero_usage) =>
      Metered({ plan, usage, minimum, zero_usage, })
    PackageTariff(plan) => Packaged(plan, usage)
  }
}

///|
fn check_usage_bundle(usages : ArrayView[Usage]) -> Unit raise PricingError {
  if usages.length() == 0 || usages.length() > 64 {
    invalid("size", "usages", "provide 1 to 64 aggregated metric snapshots")
  }
  let seen : Map[String, Unit] = Map([])
  for i, u in usages {
    check_usage(u)
    if u.customer != usages[0].customer || u.period != usages[0].period {
      invalid(
        "identity",
        "usages[\{i}]",
        "bundle must share customer and period",
      )
    }
    if seen.contains(u.metric) {
      invalid(
        "duplicate",
        "usages[\{i}].metric",
        "provide exactly one aggregated snapshot per metric",
      )
    }
    seen[u.metric] = ()
  }
}

///|
fn catalog_items(
  catalog : Catalog,
  on : String,
  usages : ArrayView[Usage],
) -> Array[BillItem] raise PricingError {
  let day = date_key(on, "catalog.on")
  check_usage_bundle(usages)
  let by_metric : Map[String, Usage] = Map([])
  for u in usages {
    if !catalog.metrics.contains(u.metric) {
      invalid(
        "unknown",
        "usages.metric",
        "metric '\{u.metric}' is not in this catalog",
      )
    }
    by_metric[u.metric] = u
  }
  let items : Array[BillItem] = []
  for metric in catalog.metrics {
    guard by_metric.get(metric) is Some(usage) else {
      invalid(
        "missing",
        "usages",
        "missing required metric '\{metric}'; supply an explicit zero snapshot if unused",
      )
      return []
    }
    let mut selected : Tariff? = None
    for dated in catalog.entries {
      if tariff_info(dated.entry.tariff).metric == metric &&
        day >= dated.start &&
        (dated.end is None || (dated.end is Some(n) && day < n)) {
        selected = Some(dated.entry.tariff)
        break
      }
    }
    guard selected is Some(tariff) else {
      invalid(
        "uncovered",
        "catalog.on",
        "no version covers '\{on}' for metric '\{metric}'",
      )
      return []
    }
    items.push(bind_tariff(tariff, usage))
  }
  items
}

///|
/// Select all required metric rules on one explicit civil date, then produce a
/// mixed bill. Input usage order does not change the catalog's output order.
///
/// ```mbt check
/// test {
///   let p = @jieheng.compile_package(
///     id="sms",
///     version="v1",
///     metric="messages",
///     unit="message",
///     currency="USD",
///     package_size="1000",
///     package_price="0.85",
///   )
///   let c = @jieheng.compile_catalog([
///     { starts_on: "2026-10-01", ends_before: None, tariff: PackageTariff(p), },
///   ])
///   let r = @jieheng.rate_catalog(
///     c,
///     "2026-10-22",
///     [
///       {
///         customer: "acme",
///         metric: "messages",
///         period: "October",
///         quantity: "2501",
///       },
///     ],
///     scale=2,
///     rounding=HalfEven,
///   )
///   inspect(r.bill.totals.amount, content="2.55")
/// }
/// ```
pub fn rate_catalog(
  catalog : Catalog,
  on : String,
  usages : ArrayView[Usage],
  scale~ : Int32,
  rounding~ : Rounding,
) -> CatalogBill raise PricingError {
  {
    on,
    bill: rate_bill(catalog_items(catalog, on, usages)[:], scale~, rounding~),
  }
}

///|
pub fn verify_catalog(
  catalog : Catalog,
  on : String,
  usages : ArrayView[Usage],
  record : CatalogBill,
  scale~ : Int32,
  rounding~ : Rounding,
) -> Unit raise PricingError {
  if record.on != on {
    invalid(
      "identity", "catalog.on", "record date must match the trusted pricing date",
    )
  }
  verify_bill(
    catalog_items(catalog, on, usages)[:],
    record.bill,
    scale~,
    rounding~,
  )
}