///|
fn formula_property_value(
  sets : Array[PropertySet],
  formula : QuantityFormula,
) -> Double? {
  for name in formula.property_names {
    match property_number(sets, name) {
      Some(value) => return Some(value * formula.multiplier)
      None => ()
    }
  }
  None
}

///|
fn formula_base_value(row : ProductTakeoff, kind : QuantityKind) -> Double? {
  match row.quantity(kind) {
    Some(value) => Some(value.value)
    None => None
  }
}

///|
fn formula_value_for_row(
  index : ModelIndex,
  row : ProductTakeoff,
  formula : QuantityFormula,
) -> (QuantityValue, QuantityNote?) raise ParseError {
  let sets = property_sets_for_product(index, row.product)
  match formula_property_value(sets, formula) {
    Some(value) =>
      (
        quantity_value(formula.kind, value, "property", false, formula.unit),
        None,
      )
    None =>
      if formula.fallback_to_geometry {
        match formula_base_value(row, formula.kind) {
          Some(value) =>
            (
              quantity_value(
                formula.kind,
                value,
                "geometry-fallback",
                true,
                formula.unit,
              ),
              Some(
                quantity_note(
                  row.product.id,
                  formula.kind,
                  "property formula not populated; geometry fallback used",
                ),
              ),
            )
          None =>
            (
              quantity_value(formula.kind, 0.0, "missing", true, formula.unit),
              Some(
                quantity_note(
                  row.product.id,
                  formula.kind,
                  "property formula and geometry quantity are missing",
                  severity=Warning,
                ),
              ),
            )
        }
      } else {
        (
          quantity_value(formula.kind, 0.0, "missing", true, formula.unit),
          Some(
            quantity_note(
              row.product.id,
              formula.kind,
              "property formula is not populated",
              severity=Warning,
            ),
          ),
        )
      }
  }
}

///|
fn formula_replace_quantity(
  values : Array[QuantityValue],
  replacement : QuantityValue,
) -> Array[QuantityValue] {
  let result = values.copy()
  for index in 0.. FormulaTakeoff raise ParseError {
  let index = model.index()
  let base = quantity_takeoff(
    model,
    options=default_takeoff_options().including_empty(true),
  )
  let rows : Array[ProductTakeoff] = []
  let notes : Array[QuantityNote] = []
  let mut missing = 0
  for row in base.products {
    let mut quantities = row.quantities
    for formula in formulas {
      let (value, note) = formula_value_for_row(index, row, formula)
      quantities = formula_replace_quantity(quantities, value)
      match note {
        Some(item) => {
          notes.push(item)
          missing += 1
        }
        None => ()
      }
    }
    rows.push({ ..row, quantities, })
  }
  {
    rows,
    notes,
    formulas,
    product_count: rows.length(),
    missing_formula_count: missing,
  }
}

///|
pub fn FormulaTakeoff::row_count(self : FormulaTakeoff) -> Int {
  self.rows.length()
}

///|
pub fn FormulaTakeoff::find(
  self : FormulaTakeoff,
  product_id : Int,
) -> ProductTakeoff? {
  for row in self.rows {
    if row.product.id == product_id {
      return Some(row)
    }
  }
  None
}

///|
pub fn FormulaTakeoff::quantity(
  self : FormulaTakeoff,
  product_id : Int,
  kind : QuantityKind,
) -> QuantityValue? {
  match self.find(product_id) {
    Some(row) => row.quantity(kind)
    None => None
  }
}

///|
pub fn FormulaTakeoff::note_count(self : FormulaTakeoff) -> Int {
  self.notes.length()
}

///|
pub fn FormulaTakeoff::warnings(self : FormulaTakeoff) -> Array[QuantityNote] {
  self.notes.filter(note => note.severity == Warning)
}

///|
pub fn FormulaTakeoff::total(
  self : FormulaTakeoff,
  kind : QuantityKind,
) -> Double {
  self.rows.fold(init=0.0, (result, row) => result + row.value(kind))
}

///|
pub fn FormulaTakeoff::headline(self : FormulaTakeoff) -> String {
  "rows=" +
  self.rows.length().to_string() +
  " formulas=" +
  self.formulas.length().to_string() +
  " notes=" +
  self.notes.length().to_string() +
  " missing=" +
  self.missing_formula_count.to_string()
}

///|
pub fn FormulaTakeoff::formula_names(self : FormulaTakeoff) -> Array[String] {
  self.formulas.map(formula => quantity_kind_name(formula.kind))
}

///|
pub fn FormulaTakeoff::to_json(self : FormulaTakeoff) -> String {
  let rows = self.rows.map(row => row.to_json()).join(",")
  let notes = self.notes
    .map(note => {
      "{\"product_id\":" +
      note.product_id.to_string() +
      ",\"kind\":\"" +
      quantity_kind_name(note.kind) +
      "\",\"message\":\"" +
      note.message +
      "\"}"
    })
    .join(",")
  "{\"product_count\":" +
  self.product_count.to_string() +
  ",\"missing_formula_count\":" +
  self.missing_formula_count.to_string() +
  ",\"rows\":[" +
  rows +
  "],\"notes\":[" +
  notes +
  "]}"
}

///|
pub fn FormulaTakeoff::to_csv(self : FormulaTakeoff) -> String {
  let builder = StringBuilder()
  builder <+ "product_id,type,name"
  for formula in self.formulas {
    builder <+ ",\{quantity_kind_name(formula.kind)}"
  }
  builder <+ "\n"
  for row in self.rows {
    let name = row.product.name.unwrap_or("")
    builder <+ "\{row.product.id},\{row.product.ifc_type},\{name}"
    for formula in self.formulas {
      builder <+ ",\{row.value(formula.kind)}"
    }
    builder <+ "\n"
  }
  builder.to_string()
}

///|
pub fn FormulaTakeoff::to_markdown(self : FormulaTakeoff) -> String {
  let builder = StringBuilder()
  builder <+ "# Formula takeoff\n\n"
  builder <+ "- Rows: \{self.rows.length()}\n"
  builder <+ "- Formulas: \{self.formulas.length()}\n"
  builder <+ "- Notes: \{self.notes.length()}\n\n"
  for note in self.notes {
    builder <+ "- \{note.headline()}\n"
  }
  builder.to_string()
}

///|
pub fn formula_takeoff_for_kind(
  model : Model,
  kind : ProductKind,
  formulas : Array[QuantityFormula],
) -> FormulaTakeoff raise ParseError {
  let takeoff = formula_takeoff(model, formulas)
  { ..takeoff, rows: takeoff.rows.filter(row => row.product.kind == kind) }
}

///|
pub fn formula_quantity(
  model : Model,
  product_id : Int,
  formula : QuantityFormula,
) -> QuantityValue? raise ParseError {
  formula_takeoff(model, [formula]).quantity(product_id, formula.kind)
}