///|
pub fn default_takeoff_options() -> TakeoffOptions {
  {
    grouping: ProductType,
    include_empty: false,
    include_unknown: true,
    use_mesh_measurements: true,
    include_surface_area: true,
    minimum_value: 0.0,
  }
}

///|
pub fn TakeoffOptions::group_by(
  self : TakeoffOptions,
  grouping : QuantityGrouping,
) -> TakeoffOptions {
  { ..self, grouping, }
}

///|
pub fn TakeoffOptions::including_empty(
  self : TakeoffOptions,
  value : Bool,
) -> TakeoffOptions {
  { ..self, include_empty: value }
}

///|
pub fn TakeoffOptions::including_unknown(
  self : TakeoffOptions,
  value : Bool,
) -> TakeoffOptions {
  { ..self, include_unknown: value }
}

///|
pub fn TakeoffOptions::using_mesh_measurements(
  self : TakeoffOptions,
  value : Bool,
) -> TakeoffOptions {
  { ..self, use_mesh_measurements: value }
}

///|
pub fn TakeoffOptions::including_surface_area(
  self : TakeoffOptions,
  value : Bool,
) -> TakeoffOptions {
  { ..self, include_surface_area: value }
}

///|
pub fn TakeoffOptions::with_minimum_value(
  self : TakeoffOptions,
  value : Double,
) -> TakeoffOptions {
  { ..self, minimum_value: if value < 0.0 { 0.0 } else { value } }
}

///|
pub fn QuantityFormula::new(
  kind : QuantityKind,
  unit? : String = "",
) -> QuantityFormula {
  {
    kind,
    property_names: [],
    fallback_to_geometry: true,
    multiplier: 1.0,
    unit,
  }
}

///|
pub fn QuantityFormula::from_properties(
  self : QuantityFormula,
  names : Array[String],
) -> QuantityFormula {
  { ..self, property_names: names, fallback_to_geometry: false }
}

///|
pub fn QuantityFormula::with_fallback(
  self : QuantityFormula,
  value : Bool,
) -> QuantityFormula {
  { ..self, fallback_to_geometry: value }
}

///|
pub fn QuantityFormula::scaled(
  self : QuantityFormula,
  multiplier : Double,
) -> QuantityFormula {
  { ..self, multiplier, }
}

///|
fn quantity_unit(kind : QuantityKind) -> String {
  match kind {
    Count => "count"
    Length => "model-length"
    Area => "model-area"
    Volume => "model-volume"
    SurfaceArea => "model-area"
    Height => "model-length"
    Width => "model-length"
    Depth => "model-length"
  }
}

///|
fn quantity_kind_text(kind : QuantityKind) -> String {
  match kind {
    Count => "count"
    Length => "length"
    Area => "area"
    Volume => "volume"
    SurfaceArea => "surface_area"
    Height => "height"
    Width => "width"
    Depth => "depth"
  }
}

///|
pub fn quantity_kind_name(kind : QuantityKind) -> String {
  quantity_kind_text(kind)
}

///|
fn quantity_value(
  kind : QuantityKind,
  value : Double,
  source : String,
  estimated : Bool,
  unit : String,
) -> QuantityValue {
  {
    kind,
    value,
    unit: if unit.length() == 0 {
      quantity_unit(kind)
    } else {
      unit
    },
    source,
    estimated,
  }
}

///|
fn quantity_mesh_for_product(
  index : ModelIndex,
  product : Product,
) -> Mesh raise ParseError {
  mesh_for_product_advanced(index, product)
}

///|
fn quantity_curve_length(index : ModelIndex, product : Product) -> Double {
  geometries_for_product(index, product).fold(init=0.0, (result, geometry) => {
    match geometry.kind {
      Polyline => result + polyline_length(geometry.points, closed=false)
      _ => result
    }
  })
}

///|
fn quantity_product_bounds(
  index : ModelIndex,
  product : Product,
  mesh : Mesh,
) -> BoundingBox {
  if mesh.points.length() > 0 {
    bounds_of_mesh(mesh)
  } else {
    geometries_for_product(index, product).fold(init=BoundingBox::empty(), (
      result,
      geometry,
    ) => {
      merge_bounds(
        result,
        BoundingBox::from_points(
          transform_points(index, product, geometry.points),
        ),
      )
    })
  }
}

///|
fn quantity_geometry_volume(
  index : ModelIndex,
  product : Product,
  mesh : Mesh,
) -> (Double, Bool) {
  if mesh.triangles.length() > 0 {
    let stats = mesh_stats(mesh)
    (stats.volume().abs(), false)
  } else {
    let estimates = geometries_for_product(index, product).fold(init=0.0, (
      result,
      geometry,
    ) => result + estimated_volume(geometry).unwrap_or(0.0))
    (estimates, true)
  }
}

///|
fn quantity_geometry_area(mesh : Mesh) -> (Double, Bool) {
  if mesh.triangles.length() > 0 {
    (mesh_stats(mesh).surface_area, false)
  } else {
    (0.0, true)
  }
}

///|
fn quantity_for_product(
  index : ModelIndex,
  product : Product,
  options : TakeoffOptions,
) -> ProductTakeoff raise ParseError {
  let geometries = geometries_for_product(index, product)
  let mesh = if options.use_mesh_measurements {
    quantity_mesh_for_product(index, product)
  } else {
    { points: [], triangles: [] }
  }
  let bounds = quantity_product_bounds(index, product, mesh)
  let (volume, volume_estimated) = quantity_geometry_volume(
    index, product, mesh,
  )
  let (surface_area, surface_estimated) = quantity_geometry_area(mesh)
  let length = quantity_curve_length(index, product)
  let quantities : Array[QuantityValue] = [
    quantity_value(Count, 1.0, "product", false, "count"),
    quantity_value(
      Width,
      bounds.width(),
      "bounds",
      bounds.empty,
      "model-length",
    ),
    quantity_value(
      Height,
      bounds.height(),
      "bounds",
      bounds.empty,
      "model-length",
    ),
    quantity_value(
      Depth,
      bounds.depth(),
      "bounds",
      bounds.empty,
      "model-length",
    ),
    quantity_value(
      Volume,
      volume,
      if volume_estimated {
        "extrusion-estimate"
      } else {
        "mesh"
      },
      volume_estimated,
      "model-volume",
    ),
    quantity_value(Length, length, "polyline", false, "model-length"),
  ]
  let with_area = if options.include_surface_area {
    quantities.push(
      quantity_value(
        SurfaceArea,
        surface_area,
        "mesh",
        surface_estimated,
        "model-area",
      ),
    )
    quantities
  } else {
    quantities
  }
  {
    product,
    quantities: with_area,
    geometry_count: geometries.length(),
    mesh_triangle_count: mesh.triangles.length(),
    bounds,
  }
}

///|
fn quantity_find(
  product : ProductTakeoff,
  kind : QuantityKind,
) -> QuantityValue? {
  for value in product.quantities {
    if value.kind == kind {
      return Some(value)
    }
  }
  None
}

///|
pub fn ProductTakeoff::quantity(
  self : ProductTakeoff,
  kind : QuantityKind,
) -> QuantityValue? {
  quantity_find(self, kind)
}

///|
pub fn ProductTakeoff::value(
  self : ProductTakeoff,
  kind : QuantityKind,
) -> Double {
  self.quantity(kind).map(value => value.value).unwrap_or(0.0)
}

///|
pub fn ProductTakeoff::has_geometry(self : ProductTakeoff) -> Bool {
  self.geometry_count > 0 && !self.bounds.empty
}

///|
pub fn ProductTakeoff::is_estimated(
  self : ProductTakeoff,
  kind : QuantityKind,
) -> Bool {
  self.quantity(kind).map(value => value.estimated).unwrap_or(false)
}

///|
pub fn ProductTakeoff::headline(self : ProductTakeoff) -> String {
  let label = self.product.name.unwrap_or(self.product.ifc_type)
  label +
  " #" +
  self.product.id.to_string() +
  " volume=" +
  self.value(Volume).to_string() +
  " area=" +
  self.value(SurfaceArea).to_string()
}

///|
fn quantity_group_key(
  index : ModelIndex,
  product : Product,
  grouping : QuantityGrouping,
) -> String raise ParseError {
  match grouping {
    ProductType => product.ifc_type.to_upper()
    ProductKind => product_kind_name(product.kind)
    Container =>
      product.container.map(id => "#" + id.to_string()).unwrap_or("uncontained")
    Property(name) => {
      let sets = property_sets_for_product(index, product)
      property_string(sets, name).unwrap_or("(missing)")
    }
  }
}

///|
fn quantity_accumulate(
  values : Array[QuantityValue],
  incoming : QuantityValue,
) -> Array[QuantityValue] {
  let result = values.copy()
  for index in 0.. Unit {
  groups.update_or_default(
    key,
    { key, product_count: 1, quantities: takeoff.quantities },
    group => {
      let mut quantities = group.quantities
      for value in takeoff.quantities {
        quantities = quantity_accumulate(quantities, value)
      }
      { ..group, product_count: group.product_count + 1, quantities }
    },
  )
}

///|
fn quantity_group_array(
  groups : Map[String, TakeoffGroup],
) -> Array[TakeoffGroup] {
  let result : Array[TakeoffGroup] = []
  for _, group in groups {
    result.push(group)
  }
  let sorted = result.copy()
  for i in 0.. TakeoffReport raise ParseError {
  let index = model.index()
  let products = model.products()
  let rows : Array[ProductTakeoff] = []
  let groups : Map[String, TakeoffGroup] = Map([])
  let mut skipped = 0
  for product in products {
    let takeoff = quantity_for_product(index, product, options)
    if (!options.include_empty && takeoff.bounds.empty) ||
      (!options.include_unknown && product.kind == Generic) {
      skipped += 1
      continue
    }
    if takeoff.quantities.any(value => value.value >= options.minimum_value) {
      rows.push(takeoff)
      let key = quantity_group_key(index, product, options.grouping)
      quantity_group_add(groups, key, takeoff)
    } else {
      skipped += 1
    }
  }
  {
    products: rows,
    groups: quantity_group_array(groups),
    unit_system: unit_system(index),
    source_schema: model.schema(),
    skipped_products: skipped,
  }
}

///|
pub fn TakeoffReport::product_count(self : TakeoffReport) -> Int {
  self.products.length()
}

///|
pub fn TakeoffReport::group_count(self : TakeoffReport) -> Int {
  self.groups.length()
}

///|
pub fn TakeoffReport::find_product(
  self : TakeoffReport,
  id : Int,
) -> ProductTakeoff? {
  for row in self.products {
    if row.product.id == id {
      return Some(row)
    }
  }
  None
}

///|
pub fn TakeoffReport::find_group(
  self : TakeoffReport,
  key : String,
) -> TakeoffGroup? {
  for group in self.groups {
    if group.key == key {
      return Some(group)
    }
  }
  None
}

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

///|
pub fn TakeoffReport::estimated_count(
  self : TakeoffReport,
  kind : QuantityKind,
) -> Int {
  self.products.fold(init=0, (result, row) => {
    if row.is_estimated(kind) {
      result + 1
    } else {
      result
    }
  })
}

///|
pub fn TakeoffReport::headline(self : TakeoffReport) -> String {
  "products=" +
  self.products.length().to_string() +
  " groups=" +
  self.groups.length().to_string() +
  " volume=" +
  self.total(Volume).to_string() +
  " area=" +
  self.total(SurfaceArea).to_string()
}

///|
fn quantity_text(value : QuantityValue) -> String {
  value.value.to_string() + " " + value.unit
}

///|
pub fn QuantityValue::headline(self : QuantityValue) -> String {
  let suffix = if self.estimated { " (estimated)" } else { "" }
  quantity_kind_text(self.kind) + "=" + quantity_text(self) + suffix
}

///|
pub fn TakeoffGroup::value(self : TakeoffGroup, kind : QuantityKind) -> Double {
  for value in self.quantities {
    if value.kind == kind {
      return value.value
    }
  }
  0.0
}

///|
pub fn TakeoffGroup::quantity(
  self : TakeoffGroup,
  kind : QuantityKind,
) -> QuantityValue? {
  for value in self.quantities {
    if value.kind == kind {
      return Some(value)
    }
  }
  None
}

///|
pub fn TakeoffGroup::headline(self : TakeoffGroup) -> String {
  self.key +
  " count=" +
  self.product_count.to_string() +
  " volume=" +
  self.value(Volume).to_string()
}

///|
fn quantity_csv_escape(text : String) -> String {
  if text.contains(",") || text.contains("\"") || text.contains("\n") {
    "\"" + text.replace(old="\"", new="\"\"") + "\""
  } else {
    text
  }
}

///|
pub fn TakeoffReport::to_csv(self : TakeoffReport) -> String {
  let builder = StringBuilder()
  builder <+
    "product_id,type,name,count,width,height,depth,length,area,surface_area,volume\n"
  for row in self.products {
    let product_name = row.product.name.unwrap_or("")
    builder <+
      "\{row.product.id},\{quantity_csv_escape(row.product.ifc_type)},\{quantity_csv_escape(product_name)},\{row.value(Count)},\{row.value(Width)},\{row.value(Height)},\{row.value(Depth)},\{row.value(Length)},\{row.value(Area)},\{row.value(SurfaceArea)},\{row.value(Volume)}\n"
  }
  builder.to_string()
}

///|
fn quantity_json_value(value : QuantityValue) -> String {
  "{\"kind\":\"" +
  quantity_kind_text(value.kind) +
  "\",\"value\":" +
  value.value.to_string() +
  ",\"unit\":\"" +
  value.unit +
  "\",\"source\":\"" +
  value.source +
  "\",\"estimated\":" +
  value.estimated.to_string() +
  "}"
}

///|
pub fn ProductTakeoff::to_json(self : ProductTakeoff) -> String {
  "{\"product_id\":" +
  self.product.id.to_string() +
  ",\"type\":\"" +
  self.product.ifc_type +
  "\",\"quantities\":[" +
  self.quantities.map(value => quantity_json_value(value)).join(",") +
  "]}"
}

///|
pub fn TakeoffReport::to_json(self : TakeoffReport) -> String {
  "{\"schema\":\"" +
  self.source_schema +
  "\",\"skipped_products\":" +
  self.skipped_products.to_string() +
  ",\"products\":[" +
  self.products.map(row => row.to_json()).join(",") +
  "],\"groups\":[" +
  self.groups
  .map(group => {
    "{\"key\":\"" +
    group.key +
    "\",\"product_count\":" +
    group.product_count.to_string() +
    ",\"quantities\":[" +
    group.quantities.map(value => quantity_json_value(value)).join(",") +
    "]}"
  })
  .join(",") +
  "]}"
}

///|
pub fn TakeoffReport::to_markdown(self : TakeoffReport) -> String {
  let builder = StringBuilder()
  builder <+ "# Quantity takeoff\n\n"
  builder <+ "- Schema: \{self.source_schema}\n"
  builder <+ "- Products: \{self.products.length()}\n"
  builder <+ "- Groups: \{self.groups.length()}\n"
  builder <+ "- Skipped: \{self.skipped_products}\n\n"
  builder <+ "| Group | Products | Volume | Surface area |\n"
  builder <+ "| --- | ---: | ---: | ---: |\n"
  for group in self.groups {
    builder <+
      "| \{group.key} | \{group.product_count} | \{group.value(Volume)} | \{group.value(SurfaceArea)} |\n"
  }
  builder.to_string()
}

///|
pub fn TakeoffReport::quantity_kinds(self : TakeoffReport) -> Array[String] {
  let result : Array[String] = []
  for row in self.products {
    for value in row.quantities {
      let name = quantity_kind_text(value.kind)
      if !result.contains(name) {
        result.push(name)
      }
    }
  }
  result
}

///|
pub fn TakeoffReport::group_keys(self : TakeoffReport) -> Array[String] {
  self.groups.map(group => group.key)
}

///|
pub fn quantity_value_in_si(
  units : UnitSystem,
  value : QuantityValue,
) -> Double {
  match value.kind {
    Length | Width | Height | Depth => units.length_to_meters(value.value)
    Area | SurfaceArea => units.area_to_square_meters(value.value)
    Volume => units.volume_to_cubic_meters(value.value)
    Count => value.value
  }
}

///|
pub fn TakeoffReport::total_in_si(
  self : TakeoffReport,
  kind : QuantityKind,
) -> Double {
  self.products.fold(init=0.0, (result, row) => {
    match row.quantity(kind) {
      Some(value) => result + quantity_value_in_si(self.unit_system, value)
      None => result
    }
  })
}

///|
pub fn QuantityNote::headline(self : QuantityNote) -> String {
  "#" +
  self.product_id.to_string() +
  " " +
  quantity_kind_text(self.kind) +
  ": " +
  self.message
}

///|
pub fn quantity_note(
  product_id : Int,
  kind : QuantityKind,
  message : String,
  severity? : DiagnosticSeverity = Note,
) -> QuantityNote {
  { product_id, kind, message, severity }
}

///|
pub fn takeoff_for_product(
  model : Model,
  product_id : Int,
  options? : TakeoffOptions = default_takeoff_options(),
) -> ProductTakeoff? raise ParseError {
  let index = model.index()
  match product_by_id(model.products(), product_id) {
    Some(product) => Some(quantity_for_product(index, product, options))
    None => None
  }
}

///|
pub fn takeoff_by_kind(
  report : TakeoffReport,
  kind : ProductKind,
) -> Array[ProductTakeoff] {
  report.products.filter(row => row.product.kind == kind)
}

///|
pub fn takeoff_by_type(
  report : TakeoffReport,
  ifc_type : String,
) -> Array[ProductTakeoff] {
  let wanted = ifc_type.to_upper()
  report.products.filter(row => row.product.ifc_type.to_upper() == wanted)
}

///|
pub fn takeoff_area_per_volume(report : TakeoffReport) -> Double {
  let volume = report.total(Volume)
  if volume == 0.0 {
    0.0
  } else {
    report.total(SurfaceArea) / volume
  }
}