///|
/// A unit label resolved to a dimensional kind and an SI scale.
pub(all) struct UnitSemantic {
  label : String
  normalized : String
  kind : UnitKind
  to_si : Double
  recognized : Bool
} derive(Debug)

///|
/// One explicit conversion between two labels.
pub(all) struct UnitConversion {
  kind : UnitKind
  value : Double
  converted : Double
  from_unit : String
  to_unit : String
  factor : Double
  recognized : Bool
} derive(Debug)

///|
/// A warning emitted while checking a model's unit declarations.
pub(all) enum UnitAuditStatus {
  Consistent
  Missing
  Duplicate
  Unknown
  Incompatible
} derive(Debug, Eq)

///|
/// A normalized unit declaration from the model.
pub(all) struct UnitAuditRow {
  entity_id : Int
  kind : UnitKind
  name : String
  prefix : String?
  to_si : Double
  status : UnitAuditStatus
  message : String
} derive(Debug)

///|
/// Summary of the unit declarations and quantity conversions in a model.
pub(all) struct UnitAuditReport {
  rows : Array[UnitAuditRow]
  recognized_count : Int
  unknown_count : Int
  duplicate_kind_count : Int
  length_scale : Double
  area_scale : Double
  volume_scale : Double
} derive(Debug)

///|
/// A quantity normalized to the requested presentation unit.
pub(all) struct NormalizedQuantity {
  product_id : Int
  kind : QuantityKind
  value : Double
  unit : String
  source : String
  estimated : Bool
  recognized : Bool
} derive(Debug)

///|
fn semantic_trimmed(value : String) -> String {
  value.trim().to_owned().to_upper()
}

///|
fn semantic_compact(value : String) -> String {
  value
  .replace(old="_", new="")
  .replace(old=" ", new="")
  .replace(old="-", new="")
}

///|
/// Resolve the common labels used by IFC exports and quantity schedules.
pub fn unit_semantic(label : String) -> UnitSemantic {
  let normalized = semantic_compact(semantic_trimmed(label))
  let item : (UnitKind, Double)? = match normalized {
    "M" | "METRE" | "METER" | "METRES" | "METERS" => Some((Length, 1.0))
    "MM" | "MILLIMETRE" | "MILLIMETER" | "MILLIMETRES" | "MILLIMETERS" =>
      Some((Length, 0.001))
    "CM" | "CENTIMETRE" | "CENTIMETER" | "CENTIMETRES" | "CENTIMETERS" =>
      Some((Length, 0.01))
    "KM" | "KILOMETRE" | "KILOMETER" | "KILOMETRES" | "KILOMETERS" =>
      Some((Length, 1000.0))
    "IN" | "INCH" | "INCHES" => Some((Length, 0.0254))
    "FT" | "FOOT" | "FEET" => Some((Length, 0.3048))
    "YD" | "YARD" | "YARDS" => Some((Length, 0.9144))
    "M2" | "M²" | "SQUAREMETRE" | "SQUAREMETER" | "SQM" => Some((Area, 1.0))
    "MM2" | "MM²" | "SQUAREMILLIMETRE" | "SQUAREMILLIMETER" =>
      Some((Area, 0.000001))
    "CM2" | "CM²" | "SQUARECENTIMETRE" | "SQUARECENTIMETER" =>
      Some((Area, 0.0001))
    "FT2" | "FT²" | "SQUAREFOOT" | "SQUAREFEET" | "SQFT" =>
      Some((Area, 0.09290304))
    "M3" | "M³" | "CUBICMETRE" | "CUBICMETER" | "CUM" => Some((Volume, 1.0))
    "MM3" | "MM³" | "CUBICMILLIMETRE" | "CUBICMILLIMETER" =>
      Some((Volume, 0.000000001))
    "CM3" | "CM³" | "CUBICCENTIMETRE" | "CUBICCENTIMETER" =>
      Some((Volume, 0.000001))
    "FT3" | "FT³" | "CUBICFOOT" | "CUBICFEET" | "CUFT" =>
      Some((Volume, 0.028316846592))
    "KG" | "KILOGRAM" | "KILOGRAMS" => Some((Mass, 1.0))
    "G" | "GRAM" | "GRAMS" => Some((Mass, 0.001))
    "T" | "TONNE" | "TONNES" | "METRICTON" => Some((Mass, 1000.0))
    "S" | "SEC" | "SECOND" | "SECONDS" => Some((Time, 1.0))
    "MIN" | "MINUTE" | "MINUTES" => Some((Time, 60.0))
    "H" | "HR" | "HOUR" | "HOURS" => Some((Time, 3600.0))
    "RAD" | "RADIAN" | "RADIANS" => Some((PlaneAngle, 1.0))
    "DEG" | "DEGREE" | "DEGREES" => Some((PlaneAngle, 0.017453292519943295))
    _ => None
  }
  match item {
    Some((kind, to_si)) => { label, normalized, kind, to_si, recognized: true }
    None =>
      { label, normalized, kind: UnknownUnit, to_si: 1.0, recognized: false }
  }
}

///|
pub fn unit_semantic_kind(label : String) -> UnitKind {
  unit_semantic(label).kind
}

///|
pub fn unit_semantic_scale(label : String) -> Double? {
  let semantic = unit_semantic(label)
  if semantic.recognized {
    Some(semantic.to_si)
  } else {
    None
  }
}

///|
pub fn unit_semantic_is_recognized(label : String) -> Bool {
  unit_semantic(label).recognized
}

///|
pub fn convert_unit_value(
  value : Double,
  from_unit : String,
  to_unit : String,
) -> UnitConversion {
  let from = unit_semantic(from_unit)
  let to = unit_semantic(to_unit)
  let compatible = from.recognized && to.recognized && from.kind == to.kind
  if compatible {
    let converted = value * from.to_si / to.to_si
    {
      kind: from.kind,
      value,
      converted,
      from_unit: from.label,
      to_unit: to.label,
      factor: from.to_si / to.to_si,
      recognized: true,
    }
  } else {
    {
      kind: UnknownUnit,
      value,
      converted: value,
      from_unit: from.label,
      to_unit: to.label,
      factor: 1.0,
      recognized: false,
    }
  }
}

///|
pub fn UnitConversion::is_compatible(self : UnitConversion) -> Bool {
  self.recognized
}

///|
pub fn UnitConversion::headline(self : UnitConversion) -> String {
  self.value.to_string() +
  " " +
  self.from_unit +
  " -> " +
  self.converted.to_string() +
  " " +
  self.to_unit
}

///|
pub fn UnitConversion::to_json(self : UnitConversion) -> String {
  "{\"kind\":\"" +
  semantic_kind_name(self.kind) +
  "\",\"value\":" +
  self.value.to_string() +
  ",\"converted\":" +
  self.converted.to_string() +
  ",\"from\":\"" +
  self.from_unit +
  "\",\"to\":\"" +
  self.to_unit +
  "\",\"factor\":" +
  self.factor.to_string() +
  ",\"recognized\":" +
  self.recognized.to_string() +
  "}"
}

///|
fn semantic_kind_name(kind : UnitKind) -> String {
  match kind {
    Length => "length"
    Area => "area"
    Volume => "volume"
    Mass => "mass"
    Time => "time"
    PlaneAngle => "plane-angle"
    UnknownUnit => "unknown"
  }
}

///|
fn semantic_row_message(status : UnitAuditStatus, name : String) -> String {
  match status {
    Consistent => "recognized IFC unit " + name
    Missing => "no declaration for " + name
    Duplicate => "multiple declarations for " + name
    Unknown => "unit name is not in the semantic registry: " + name
    Incompatible => "unit declaration has an incompatible dimension: " + name
  }
}

///|
fn semantic_unit_status(
  kind : UnitKind,
  known : Map[String, Int],
) -> UnitAuditStatus {
  let key = semantic_kind_name(kind)
  if kind == UnknownUnit {
    Unknown
  } else if known.get(key).unwrap_or(0) > 1 {
    Duplicate
  } else {
    Consistent
  }
}

///|
fn semantic_unit_rows(index : ModelIndex) -> Array[UnitAuditRow] {
  let known : Map[String, Int] = Map([])
  let decoded : Array[(Int, UnitScale)] = []
  for id in index.ids_named("IFCSIUNIT") {
    match index.entity(id) {
      Some(entity) => {
        let kind = unit_semantic_kind(
          optional_string_argument(entity, 3).unwrap_or(""),
        )
        known.update_or_default(semantic_kind_name(kind), 1, count => count + 1)
        let name = optional_string_argument(entity, 3).unwrap_or("UNKNOWN")
        let prefix = match entity_value(entity, 2) {
          Some(Enumeration(value)) => Some(value)
          _ => None
        }
        let scale = match unit_semantic_scale(name) {
          Some(value) => value
          None => 1.0
        }
        decoded.push((id, { kind, name, prefix, to_si: scale }))
      }
      None => ()
    }
  }
  let rows : Array[UnitAuditRow] = []
  for item in decoded {
    let (id, scale) = item
    let status = semantic_unit_status(scale.kind, known)
    rows.push({
      entity_id: id,
      kind: scale.kind,
      name: scale.name,
      prefix: scale.prefix,
      to_si: scale.to_si,
      status,
      message: semantic_row_message(status, scale.name),
    })
  }
  rows
}

///|
fn semantic_count_status(
  rows : Array[UnitAuditRow],
  status : UnitAuditStatus,
) -> Int {
  rows.fold(init=0, (count, row) => {
    if row.status == status {
      count + 1
    } else {
      count
    }
  })
}

///|
fn semantic_first_scale(rows : Array[UnitAuditRow], kind : UnitKind) -> Double {
  for row in rows {
    if row.kind == kind && row.status != Unknown {
      return row.to_si
    }
  }
  1.0
}

///|
/// Audit the IFC unit assignment while retaining unknown declarations.
pub fn unit_audit(model : Model) -> UnitAuditReport {
  let rows = semantic_unit_rows(model.index())
  {
    rows,
    recognized_count: semantic_count_status(rows, Consistent),
    unknown_count: semantic_count_status(rows, Unknown),
    duplicate_kind_count: semantic_count_status(rows, Duplicate),
    length_scale: semantic_first_scale(rows, Length),
    area_scale: semantic_first_scale(rows, Area),
    volume_scale: semantic_first_scale(rows, Volume),
  }
}

///|
pub fn UnitAuditReport::is_consistent(self : UnitAuditReport) -> Bool {
  self.unknown_count == 0 && self.duplicate_kind_count == 0
}

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

///|
pub fn UnitAuditReport::rows_for(
  self : UnitAuditReport,
  kind : UnitKind,
) -> Array[UnitAuditRow] {
  self.rows.filter(row => row.kind == kind)
}

///|
pub fn UnitAuditReport::scale(
  self : UnitAuditReport,
  kind : UnitKind,
) -> Double {
  match kind {
    Length => self.length_scale
    Area => self.area_scale
    Volume => self.volume_scale
    _ => 1.0
  }
}

///|
pub fn UnitAuditReport::headline(self : UnitAuditReport) -> String {
  "units=" +
  self.row_count().to_string() +
  " recognized=" +
  self.recognized_count.to_string() +
  " unknown=" +
  self.unknown_count.to_string() +
  " duplicate=" +
  self.duplicate_kind_count.to_string()
}

///|
pub fn UnitAuditReport::to_json(self : UnitAuditReport) -> String {
  let rows = self.rows
    .map(row => {
      "{\"entity_id\":" +
      row.entity_id.to_string() +
      ",\"kind\":\"" +
      semantic_kind_name(row.kind) +
      "\",\"name\":\"" +
      row.name +
      "\",\"to_si\":" +
      row.to_si.to_string() +
      ",\"status\":\"" +
      semantic_status_name(row.status) +
      "\"}"
    })
    .join(",")
  "{\"recognized\":" +
  self.recognized_count.to_string() +
  ",\"unknown\":" +
  self.unknown_count.to_string() +
  ",\"duplicate\":" +
  self.duplicate_kind_count.to_string() +
  ",\"length_scale\":" +
  self.length_scale.to_string() +
  ",\"area_scale\":" +
  self.area_scale.to_string() +
  ",\"volume_scale\":" +
  self.volume_scale.to_string() +
  ",\"rows\":[" +
  rows +
  "]}"
}

///|
pub fn UnitAuditReport::to_markdown(self : UnitAuditReport) -> String {
  let builder = StringBuilder()
  builder <+ "# Unit audit\n\n"
  builder <+ "- \{self.headline()}\n"
  builder <+ "- Length scale: \{self.length_scale}\n"
  builder <+ "- Area scale: \{self.area_scale}\n"
  builder <+ "- Volume scale: \{self.volume_scale}\n\n"
  builder <+
    "| Entity | Kind | Name | SI scale | Status |\n| ---: | --- | --- | ---: | --- |\n"
  for row in self.rows {
    builder <+
      "|\{row.entity_id}| \{semantic_kind_name(row.kind)} | \{row.name} | \{row.to_si} | \{semantic_status_name(row.status)} |\n"
  }
  builder.to_string()
}

///|
fn normalized_quantity_kind(kind : QuantityKind) -> UnitKind {
  match kind {
    Count => UnknownUnit
    Length | Width | Height | Depth => Length
    Area | SurfaceArea => Area
    Volume => Volume
  }
}

///|
fn normalized_default_unit(kind : QuantityKind) -> String {
  match kind {
    Count => "count"
    Length | Width | Height | Depth => "m"
    Area | SurfaceArea => "m2"
    Volume => "m3"
  }
}

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

///|
fn semantic_status_name(status : UnitAuditStatus) -> String {
  match status {
    Consistent => "consistent"
    Missing => "missing"
    Duplicate => "duplicate"
    Unknown => "unknown"
    Incompatible => "incompatible"
  }
}

///|
fn normalized_quantity_value(
  product_id : Int,
  quantity : QuantityValue,
  target_unit : String,
) -> NormalizedQuantity {
  if quantity.kind == Count {
    {
      product_id,
      kind: quantity.kind,
      value: quantity.value,
      unit: target_unit,
      source: quantity.source,
      estimated: quantity.estimated,
      recognized: true,
    }
  } else {
    let conversion = convert_unit_value(
      quantity.value,
      quantity.unit,
      target_unit,
    )
    {
      product_id,
      kind: quantity.kind,
      value: conversion.converted,
      unit: target_unit,
      source: quantity.source,
      estimated: quantity.estimated,
      recognized: conversion.recognized,
    }
  }
}

///|
pub fn normalize_quantity(
  product_id : Int,
  quantity : QuantityValue,
  target_unit~ : String?,
) -> NormalizedQuantity {
  let target = target_unit.unwrap_or(normalized_default_unit(quantity.kind))
  normalized_quantity_value(product_id, quantity, target)
}

///|
pub fn normalize_takeoff(
  report : TakeoffReport,
  target_length_unit? : String = "m",
  target_area_unit? : String = "m2",
  target_volume_unit? : String = "m3",
) -> Array[NormalizedQuantity] {
  let result : Array[NormalizedQuantity] = []
  for row in report.products {
    for quantity in row.quantities {
      let target = match normalized_quantity_kind(quantity.kind) {
        Length => target_length_unit
        Area => target_area_unit
        Volume => target_volume_unit
        _ => quantity.unit
      }
      result.push(normalized_quantity_value(row.product.id, quantity, target))
    }
  }
  result
}

///|
pub fn NormalizedQuantity::headline(self : NormalizedQuantity) -> String {
  let suffix = if self.estimated { " (estimated)" } else { "" }
  "#" +
  self.product_id.to_string() +
  " " +
  normalized_kind_name(self.kind) +
  "=" +
  self.value.to_string() +
  " " +
  self.unit +
  suffix
}

///|
pub fn NormalizedQuantity::to_json(self : NormalizedQuantity) -> String {
  "{\"product_id\":" +
  self.product_id.to_string() +
  ",\"kind\":\"" +
  normalized_kind_name(self.kind) +
  "\",\"value\":" +
  self.value.to_string() +
  ",\"unit\":\"" +
  self.unit +
  "\",\"source\":\"" +
  self.source +
  "\",\"estimated\":" +
  self.estimated.to_string() +
  ",\"recognized\":" +
  self.recognized.to_string() +
  "}"
}

///|
pub fn normalized_quantities_to_csv(rows : Array[NormalizedQuantity]) -> String {
  let builder = StringBuilder()
  builder <+ "product_id,kind,value,unit,source,estimated,recognized\n"
  for row in rows {
    builder <+
      "\{row.product_id},\{normalized_kind_name(row.kind)},\{row.value},\{row.unit},\{row.source},\{row.estimated},\{row.recognized}\n"
  }
  builder.to_string()
}

///|
pub fn normalized_quantities_to_json(
  rows : Array[NormalizedQuantity],
) -> String {
  "[" + rows.map(row => row.to_json()).join(",") + "]"
}

///|
pub fn normalized_quantities_total(
  rows : Array[NormalizedQuantity],
  kind : QuantityKind,
) -> Double {
  rows.fold(init=0.0, (sum, row) => {
    if row.kind == kind {
      sum + row.value
    } else {
      sum
    }
  })
}

///|
pub fn normalized_quantities_for_product(
  rows : Array[NormalizedQuantity],
  product_id : Int,
) -> Array[NormalizedQuantity] {
  rows.filter(row => row.product_id == product_id)
}

///|
pub fn normalized_quantities_unrecognized(
  rows : Array[NormalizedQuantity],
) -> Array[NormalizedQuantity] {
  rows.filter(row => !row.recognized)
}

///|
pub fn normalized_quantity_unit_histogram(
  rows : Array[NormalizedQuantity],
) -> Map[String, Int] {
  let result : Map[String, Int] = Map([])
  for row in rows {
    result.update_or_default(row.unit, 1, count => count + 1)
  }
  result
}

///|
pub fn normalized_quantity_summary(
  rows : Array[NormalizedQuantity],
) -> Array[String] {
  [
    "rows=" + rows.length().to_string(),
    "length=" + normalized_quantities_total(rows, Length).to_string(),
    "area=" + normalized_quantities_total(rows, Area).to_string(),
    "volume=" + normalized_quantities_total(rows, Volume).to_string(),
    "unrecognized=" +
    normalized_quantities_unrecognized(rows).length().to_string(),
  ]
}

///|
pub fn quantity_kind_unit(kind : QuantityKind) -> String {
  normalized_default_unit(kind)
}

///|
pub fn quantity_kind_dimension(kind : QuantityKind) -> UnitKind {
  normalized_quantity_kind(kind)
}

///|
pub fn unit_semantic_catalog() -> Array[UnitSemantic] {
  [
    unit_semantic("m"),
    unit_semantic("mm"),
    unit_semantic("cm"),
    unit_semantic("ft"),
    unit_semantic("m2"),
    unit_semantic("ft2"),
    unit_semantic("m3"),
    unit_semantic("ft3"),
    unit_semantic("kg"),
    unit_semantic("s"),
    unit_semantic("rad"),
  ]
}

///|
pub fn unit_semantic_catalog_json() -> String {
  "[" +
  unit_semantic_catalog()
  .map(item => {
    "{\"label\":\"" +
    item.label +
    "\",\"normalized\":\"" +
    item.normalized +
    "\",\"kind\":\"" +
    semantic_kind_name(item.kind) +
    "\",\"to_si\":" +
    item.to_si.to_string() +
    "}"
  })
  .join(",") +
  "]"
}