///|

///|
fn property_display_value(value : Value) -> String {
  match value {
    Null | Omitted => ""
    String(text) => text
    Enumeration(text) => text
    Integer(number) => number.to_string()
    Real(number) => number.to_string()
    Reference(id) => "#" + id.to_string()
    Binary(text) => text
    Typed(name~, value~) => name + "(" + property_display_value(value) + ")"
    List(values) =>
      "(" + values.map(item => property_display_value(item)).join(",") + ")"
  }
}

///|
fn property_is_numeric(text : String) -> Bool {
  let parsed = @string.parse_double(text) catch { _ => 0.0 }
  parsed != 0.0 || text == "0" || text == "0.0"
}

///|
fn property_names_for_product(
  index : ModelIndex,
  product : Product,
) -> Array[String] raise ParseError {
  property_sets_for_product(index, product).fold(init=Array::new(), (
    result,
    set,
  ) => {
    for property in set.properties {
      if !result.contains(property.name) {
        result.push(property.name)
      }
    }
    result
  })
}

///|
fn property_map_for_product(
  index : ModelIndex,
  product : Product,
) -> Map[String, String] raise ParseError {
  property_sets_for_product(index, product).fold(init=Map([]), (result, set) => {
    for property in set.properties {
      result[property.name] = property_display_value(property.value)
    }
    result
  })
}

///|
fn property_table_names(
  index : ModelIndex,
  products : Array[Product],
  requested : Array[String],
) -> Array[String] raise ParseError {
  if requested.length() > 0 {
    requested.copy()
  } else {
    products.fold(init=Array::new(), (result, product) => {
      for name in property_names_for_product(index, product) {
        if !result.contains(name) {
          result.push(name)
        }
      }
      result
    })
  }
}

///|
fn property_column_for(
  name : String,
  rows : Array[PropertyRow],
) -> PropertyColumn {
  let mut populated = 0
  let mut numeric = 0
  let distinct : Map[String, Bool] = Map([])
  let mut value_kind = "missing"
  for row in rows {
    match row.values.get(name) {
      Some(value) if value.length() > 0 => {
        populated += 1
        distinct[value] = true
        if property_is_numeric(value) {
          numeric += 1
          value_kind = "number"
        } else {
          value_kind = "string"
        }
      }
      _ => ()
    }
  }
  {
    name,
    value_type: value_kind,
    populated_count: populated,
    numeric_count: numeric,
    distinct_count: distinct.length(),
  }
}

///|
fn property_table_row(
  index : ModelIndex,
  product : Product,
  names : Array[String],
) -> PropertyRow raise ParseError {
  let values = property_map_for_product(index, product)
  let missing = names.fold(init=0, (result, name) => {
    if values.get(name) is Some(_) {
      result
    } else {
      result + 1
    }
  })
  {
    product_id: product.id,
    ifc_type: product.ifc_type,
    name: product.name,
    values,
    missing_count: missing,
  }
}

///|
/// Flatten selected IFC properties into a deterministic table.
pub fn property_table(
  model : Model,
  names? : Array[String] = [],
) -> PropertyTable raise ParseError {
  property_table_for_products(model, model.products(), names~)
}

///|
pub fn property_table_for_products(
  model : Model,
  products : Array[Product],
  names? : Array[String] = [],
) -> PropertyTable raise ParseError {
  let index = model.index()
  let columns_names = property_table_names(index, products, names)
  let rows = products.fold(init=Array::new(), (result, product) => {
    result.push(property_table_row(index, product, columns_names))
    result
  })
  {
    columns: columns_names.map(name => property_column_for(name, rows)),
    rows,
    product_count: products.length(),
    missing_value_count: rows.fold(init=0, (result, row) => {
      result + row.missing_count
    }),
  }
}

///|
pub fn property_table_for_kind(
  model : Model,
  kind : ProductKind,
  names? : Array[String] = [],
) -> PropertyTable raise ParseError {
  property_table_for_products(
    model,
    model.products().filter(product => product.kind == kind),
    names~,
  )
}

///|
pub fn PropertyColumn::coverage(
  self : PropertyColumn,
  row_count : Int,
) -> Double {
  if row_count <= 0 {
    0.0
  } else {
    self.populated_count.to_double() / row_count.to_double()
  }
}

///|
pub fn PropertyColumn::is_numeric(self : PropertyColumn) -> Bool {
  self.numeric_count > 0 && self.numeric_count == self.populated_count
}

///|
pub fn PropertyColumn::headline(self : PropertyColumn) -> String {
  self.name +
  " type=" +
  self.value_type +
  " populated=" +
  self.populated_count.to_string() +
  " distinct=" +
  self.distinct_count.to_string()
}

///|
pub fn PropertyRow::value(self : PropertyRow, name : String) -> String? {
  self.values.get(name)
}

///|
pub fn PropertyRow::has_value(self : PropertyRow, name : String) -> Bool {
  match self.values.get(name) {
    Some(value) => value.length() > 0
    None => false
  }
}

///|
pub fn PropertyRow::headline(self : PropertyRow) -> String {
  "#" +
  self.product_id.to_string() +
  " " +
  self.ifc_type +
  " missing=" +
  self.missing_count.to_string()
}

///|
pub fn PropertyTable::column(
  self : PropertyTable,
  name : String,
) -> PropertyColumn? {
  for column in self.columns {
    if column.name.to_lower() == name.to_lower() {
      return Some(column)
    }
  }
  None
}

///|
pub fn PropertyTable::row(
  self : PropertyTable,
  product_id : Int,
) -> PropertyRow? {
  for row in self.rows {
    if row.product_id == product_id {
      return Some(row)
    }
  }
  None
}

///|
pub fn PropertyTable::rows_with_value(
  self : PropertyTable,
  name : String,
  value : String,
) -> Array[PropertyRow] {
  let wanted = value.to_lower()
  self.rows.filter(row => {
    row
    .value(name)
    .map(text => text.to_lower().contains(wanted))
    .unwrap_or(false)
  })
}

///|
pub fn PropertyTable::numeric_rows(
  self : PropertyTable,
  name : String,
  comparison : NumberComparison,
  expected : Double,
  upper? : Double? = None,
) -> Array[PropertyRow] {
  self.rows.filter(row => {
    match row.value(name) {
      Some(text) =>
        query_compare_number(
          @string.parse_double(text),
          comparison,
          expected,
          upper,
        ) catch {
          _ => false
        }
      None => false
    }
  })
}

///|
pub fn PropertyTable::headline(self : PropertyTable) -> String {
  "rows=" +
  self.rows.length().to_string() +
  " columns=" +
  self.columns.length().to_string() +
  " missing_values=" +
  self.missing_value_count.to_string()
}

///|
pub fn PropertyTable::coverage(self : PropertyTable, name : String) -> Double {
  self
  .column(name)
  .map(column => column.coverage(self.rows.length()))
  .unwrap_or(0.0)
}

///|
pub fn PropertyTable::complete_rows(self : PropertyTable) -> Array[PropertyRow] {
  self.rows.filter(row => row.missing_count == 0)
}

///|
pub fn PropertyTable::missing_rows(self : PropertyTable) -> Array[PropertyRow] {
  self.rows.filter(row => row.missing_count > 0)
}

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

///|
pub fn PropertyTable::to_csv(self : PropertyTable) -> String {
  let builder = StringBuilder()
  builder <+ "product_id,type,name"
  for column in self.columns {
    builder <+ ",\{property_table_csv_escape(column.name)}"
  }
  builder <+ "\n"
  for row in self.rows {
    let row_name = property_table_csv_escape(row.name.unwrap_or(""))
    builder <+
      "\{row.product_id},\{property_table_csv_escape(row.ifc_type)},\{row_name}"
    for column in self.columns {
      let cell = property_table_csv_escape(row.value(column.name).unwrap_or(""))
      builder <+ ",\{cell}"
    }
    builder <+ "\n"
  }
  builder.to_string()
}

///|
pub fn PropertyTable::to_json(self : PropertyTable) -> String {
  let rows = self.rows
    .map(row => {
      let values : Array[String] = []
      for key, value in row.values {
        values.push("\"" + key + "\":\"" + value + "\"")
      }
      "{\"product_id\":" +
      row.product_id.to_string() +
      ",\"type\":\"" +
      row.ifc_type +
      "\",\"name\":\"" +
      row.name.unwrap_or("") +
      "\",\"values\":{" +
      values.join(",") +
      "}}"
    })
    .join(",")
  "{\"product_count\":" +
  self.product_count.to_string() +
  ",\"missing_value_count\":" +
  self.missing_value_count.to_string() +
  ",\"columns\":[" +
  self.columns.map(column => "\"" + column.name + "\"").join(",") +
  "],\"rows\":[" +
  rows +
  "]}"
}

///|
pub fn PropertyTable::to_markdown(self : PropertyTable) -> String {
  let builder = StringBuilder()
  builder <+ "# Property table\n\n"
  builder <+ "- Rows: \{self.rows.length()}\n"
  builder <+ "- Columns: \{self.columns.length()}\n"
  builder <+ "- Missing values: \{self.missing_value_count}\n\n"
  for column in self.columns {
    builder <+ "- \{column.headline()}\n"
  }
  builder.to_string()
}

///|
pub fn PropertyTable::column_names(self : PropertyTable) -> Array[String] {
  self.columns.map(column => column.name)
}

///|
pub fn PropertyTable::product_ids(self : PropertyTable) -> Array[Int] {
  self.rows.map(row => row.product_id)
}

///|
pub fn property_values(
  model : Model,
  name : String,
) -> Map[String, Int] raise ParseError {
  let table = property_table(model, names=[name])
  table.rows.fold(init=Map([]), (result, row) => {
    match row.value(name) {
      Some(value) if value.length() > 0 => {
        result.update_or_default(value, 1, count => count + 1)
        result
      }
      _ => result
    }
  })
}

///|
pub fn property_coverage_report(
  model : Model,
  names : Array[String],
) -> Array[String] raise ParseError {
  let table = property_table(model, names~)
  table.columns.map(column => {
    column.name + "=" + column.coverage(table.rows.length()).to_string()
  })
}