///|
fn material_name_for_optional(index : ModelIndex, id : Int?) -> String? {
  match id {
    Some(value) => material_entity_name(index, value)
    None => None
  }
}

///|
fn optional_value_double(value : Value?) -> Double? {
  match value {
    Some(item) => item.as_double()
    None => None
  }
}

///|
fn optional_value_int(value : Value?) -> Int? {
  match value {
    Some(item) => item.as_int()
    None => None
  }
}

///|
fn material_entity_name(index : ModelIndex, id : Int) -> String? {
  match index.entity(id) {
    Some(entity) if entity.name.to_upper() == "IFCMATERIAL" =>
      optional_string_argument(entity, 0)
    Some(entity) if entity.name.to_upper() == "IFCMATERIALDEFINITION" =>
      optional_string_argument(entity, 0)
    _ => None
  }
}

///|
fn material_layer_from_entity(
  index : ModelIndex,
  entity : Entity,
) -> MaterialLayer {
  let material_id = optional_reference_argument(entity, 0)
  {
    entity_id: entity.id,
    material_id,
    material_name: material_name_for_optional(index, material_id),
    thickness: optional_value_double(entity_value(entity, 1)),
    priority: optional_value_int(entity_value(entity, 2)),
    is_unbounded: entity.name.to_upper() == "IFCMATERIALCONSTITUENT",
  }
}

///|
fn material_layer_ids(index : ModelIndex, id : Int) -> Array[Int] {
  match index.entity(id) {
    Some(entity) if entity.name.to_upper() == "IFCMATERIALLAYERSET" =>
      entity_value(entity, 0).map(value => references(value)).unwrap_or([])
    Some(entity) if entity.name.to_upper() == "IFCMATERIALLAYERSETUSAGE" =>
      optional_reference_argument(entity, 0)
      .map(layer_id => material_layer_ids(index, layer_id))
      .unwrap_or([])
    _ => []
  }
}

///|
fn material_constituent_names(index : ModelIndex, id : Int) -> Array[String] {
  match index.entity(id) {
    Some(entity) if entity.name.to_upper() == "IFCMATERIALCONSTITUENTSET" =>
      entity_value(entity, 0)
      .map(value => {
        references(value).fold(init=Array::new(), (result, constituent_id) => {
          match index.entity(constituent_id) {
            Some(constituent) =>
              match
                material_name_for_optional(
                  index,
                  optional_reference_argument(constituent, 0),
                ) {
                Some(name) => result.push(name)
                None => ()
              }
            None => ()
          }
          result
        })
      })
      .unwrap_or([])
    _ => []
  }
}

///|
fn material_assignment_for(
  index : ModelIndex,
  product_id : Int,
  relation : Entity,
) -> MaterialAssignment {
  let material_id = optional_reference_argument(relation, 5)
  let material_name = material_name_for_optional(index, material_id)
  let layers : Array[MaterialLayer] = match material_id {
    Some(id) =>
      match index.entity(id) {
        Some(_) => {
          let layer_ids = material_layer_ids(index, id)
          layer_ids.fold(init=Array::new(), (result, layer_id) => {
            match index.entity(layer_id) {
              Some(layer) =>
                result.push(material_layer_from_entity(index, layer))
              None => ()
            }
            result
          })
        }
        None => []
      }
    None => []
  }
  let constituents = material_id
    .map(id => material_constituent_names(index, id))
    .unwrap_or([])
  {
    product_id,
    association_id: relation.id,
    material_id,
    material_name,
    layers,
    constituents,
  }
}

///|
/// Decode material associations for one product.
pub fn material_assignments(
  index : ModelIndex,
  product : Product,
) -> Array[MaterialAssignment] {
  index
  .all()
  .fold(init=Array::new(), (result, relation) => {
    if relation.name.to_upper() == "IFCRELASSOCIATESMATERIAL" &&
      relation_objects(relation, 4).contains(product.id) {
      result.push(material_assignment_for(index, product.id, relation))
    }
    result
  })
}

///|
pub fn materials_for_product(
  index : ModelIndex,
  product : Product,
) -> Array[String] {
  let result : Array[String] = []
  for assignment in material_assignments(index, product) {
    match assignment.material_name {
      Some(name) if !result.contains(name) => result.push(name)
      _ => ()
    }
    for layer in assignment.layers {
      match layer.material_name {
        Some(name) if !result.contains(name) => result.push(name)
        _ => ()
      }
    }
    for name in assignment.constituents {
      if !result.contains(name) {
        result.push(name)
      }
    }
  }
  result
}

///|
pub fn material_layers_for_product(
  index : ModelIndex,
  product : Product,
) -> Array[MaterialLayer] {
  material_assignments(index, product).fold(init=Array::new(), (
    result,
    assignment,
  ) => {
    result.append(assignment.layers)
    result
  })
}

///|
pub fn material_thickness(index : ModelIndex, product : Product) -> Double {
  material_layers_for_product(index, product).fold(init=0.0, (result, layer) => {
    result + layer.thickness.unwrap_or(0.0)
  })
}

///|
pub fn MaterialLayer::headline(self : MaterialLayer) -> String {
  self.material_name.unwrap_or("(unknown)") +
  " thickness=" +
  self.thickness.map(value => value.to_string()).unwrap_or("-")
}

///|
pub fn MaterialAssignment::material_names(
  self : MaterialAssignment,
) -> Array[String] {
  let result : Array[String] = []
  match self.material_name {
    Some(name) => result.push(name)
    None => ()
  }
  for layer in self.layers {
    match layer.material_name {
      Some(name) if !result.contains(name) => result.push(name)
      _ => ()
    }
  }
  for name in self.constituents {
    if !result.contains(name) {
      result.push(name)
    }
  }
  result
}

///|
pub fn MaterialAssignment::headline(self : MaterialAssignment) -> String {
  "product=#" +
  self.product_id.to_string() +
  " association=#" +
  self.association_id.to_string() +
  " materials=" +
  self.material_names().join(", ") +
  " layers=" +
  self.layers.length().to_string()
}

///|
fn material_inventory_materials(
  assignments : Array[MaterialAssignment],
) -> Array[String] {
  let result : Array[String] = []
  for assignment in assignments {
    for name in assignment.material_names() {
      if !result.contains(name) {
        result.push(name)
      }
    }
  }
  result
}

///|
/// Build an inventory used by material schedules and specification checks.
pub fn material_inventory(model : Model) -> MaterialInventory {
  let index = model.index()
  let assignments : Array[MaterialAssignment] = []
  let mut layered = 0
  let mut unassigned = 0
  for product in model.products() {
    let product_assignments = material_assignments(index, product)
    if product_assignments.length() == 0 {
      unassigned += 1
    } else {
      for assignment in product_assignments {
        if assignment.layers.length() > 0 {
          layered += 1
        }
        assignments.push(assignment)
      }
    }
  }
  {
    assignments,
    distinct_materials: material_inventory_materials(assignments),
    layered_product_count: layered,
    unassigned_product_count: unassigned,
  }
}

///|
pub fn MaterialInventory::assignment_count(self : MaterialInventory) -> Int {
  self.assignments.length()
}

///|
pub fn MaterialInventory::material_count(self : MaterialInventory) -> Int {
  self.distinct_materials.length()
}

///|
pub fn MaterialInventory::products_with_material(
  self : MaterialInventory,
  name : String,
) -> Array[Int] {
  let wanted = name.to_lower()
  let result : Array[Int] = []
  for assignment in self.assignments {
    if assignment.material_names().any(value => value.to_lower() == wanted) &&
      !result.contains(assignment.product_id) {
      result.push(assignment.product_id)
    }
  }
  result
}

///|
pub fn MaterialInventory::thickness_for_product(
  self : MaterialInventory,
  product_id : Int,
) -> Double {
  self.assignments.fold(init=0.0, (result, assignment) => {
    if assignment.product_id == product_id {
      result +
      assignment.layers.fold(init=0.0, (value, layer) => {
        value + layer.thickness.unwrap_or(0.0)
      })
    } else {
      result
    }
  })
}

///|
pub fn MaterialInventory::headline(self : MaterialInventory) -> String {
  "materials=" +
  self.distinct_materials.length().to_string() +
  " assignments=" +
  self.assignments.length().to_string() +
  " layered_products=" +
  self.layered_product_count.to_string() +
  " unassigned_products=" +
  self.unassigned_product_count.to_string()
}

///|
pub fn MaterialInventory::to_json(self : MaterialInventory) -> String {
  "{\"material_count\":" +
  self.distinct_materials.length().to_string() +
  ",\"layered_product_count\":" +
  self.layered_product_count.to_string() +
  ",\"unassigned_product_count\":" +
  self.unassigned_product_count.to_string() +
  ",\"materials\":[" +
  self.distinct_materials.map(name => "\"" + name + "\"").join(",") +
  "]}"
}

///|
pub fn MaterialInventory::to_csv(self : MaterialInventory) -> String {
  let builder = StringBuilder()
  builder <+ "product_id,association_id,material,layers,thickness\n"
  for assignment in self.assignments {
    let names = assignment.material_names().join(";")
    let thickness = assignment.layers.fold(init=0.0, (result, layer) => {
      result + layer.thickness.unwrap_or(0.0)
    })
    builder <+
      "\{assignment.product_id},\{assignment.association_id},\{names},\{assignment.layers.length()},\{thickness}\n"
  }
  builder.to_string()
}

///|
fn classification_reference_from_entity(
  index : ModelIndex,
  entity : Entity,
  product_id : Int,
) -> ClassificationReference {
  let source_id = optional_reference_argument(entity, 0)
  let source_name = match source_id {
    Some(id) =>
      match index.entity(id) {
        Some(source) => optional_string_argument(source, 0)
        None => None
      }
    None => None
  }
  {
    entity_id: entity.id,
    source: source_name,
    identification: optional_string_argument(entity, 1),
    name: optional_string_argument(entity, 2),
    referenced_entity: product_id,
  }
}

///|
/// Decode classification associations for one product.
pub fn classification_references(
  index : ModelIndex,
  product : Product,
) -> Array[ClassificationReference] {
  let result : Array[ClassificationReference] = []
  for relation in index.all() {
    if relation.name.to_upper() == "IFCRELASSOCIATESCLASSIFICATION" &&
      relation_objects(relation, 4).contains(product.id) {
      match optional_reference_argument(relation, 5) {
        Some(id) =>
          match index.entity(id) {
            Some(reference) =>
              result.push(
                classification_reference_from_entity(
                  index,
                  reference,
                  product.id,
                ),
              )
            None => ()
          }
        None => ()
      }
    }
  }
  result
}

///|
pub fn classification_codes(
  index : ModelIndex,
  product : Product,
) -> Array[String] {
  classification_references(index, product).fold(init=Array::new(), (
    result,
    reference,
  ) => {
    match reference.identification {
      Some(value) => result.push(value)
      None => ()
    }
    result
  })
}

///|
pub fn classification_name(reference : ClassificationReference) -> String {
  reference.name.unwrap_or(reference.identification.unwrap_or("(unnamed)"))
}

///|
pub fn ClassificationReference::headline(
  self : ClassificationReference,
) -> String {
  self.source.unwrap_or("(local)") +
  ":" +
  self.identification.unwrap_or("(no-code)") +
  " " +
  classification_name(self)
}

///|
pub fn material_report_lines(inventory : MaterialInventory) -> Array[String] {
  [
    inventory.headline(),
    "distinct=" + inventory.distinct_materials.join(", "),
    "layered=" + inventory.layered_product_count.to_string(),
  ]
}