///|
fn schedule_node_products(root : SpatialNode) -> Array[Int] {
  root.all_product_ids()
}

///|
fn schedule_count_kind(products : Array[Product], kind : ProductKind) -> Int {
  products.fold(init=0, (result, product) => {
    if product.kind == kind {
      result + 1
    } else {
      result
    }
  })
}

///|
fn schedule_product_by_id(products : Array[Product], id : Int) -> Product? {
  for product in products {
    if product.id == id {
      return Some(product)
    }
  }
  None
}

///|
fn schedule_row_for_node(
  model : Model,
  index : ModelIndex,
  node : SpatialNode,
) -> SpatialScheduleRow raise ParseError {
  let products = model.products()
  let product_ids = schedule_node_products(node)
  let selected = product_ids.fold(init=Array::new(), (result, id) => {
    match schedule_product_by_id(products, id) {
      Some(product) => result.push(product)
      None => ()
    }
    result
  })
  let rows = selected.map(product => {
    quantity_for_product(index, product, default_takeoff_options())
  })
  let bounds = rows.fold(init=BoundingBox::empty(), (result, row) => {
    merge_bounds(result, row.bounds)
  })
  {
    id: node.id,
    name: node.name.unwrap_or(""),
    ifc_type: node.ifc_type,
    path: node.path_to(node.id).unwrap_or([node.id]),
    product_count: selected.length(),
    door_count: schedule_count_kind(selected, Door),
    window_count: schedule_count_kind(selected, Window),
    wall_count: schedule_count_kind(selected, Wall),
    area: rows.fold(init=0.0, (result, row) => result + row.value(Area)),
    volume: rows.fold(init=0.0, (result, row) => result + row.value(Volume)),
    bounds,
  }
}

///|
fn schedule_nodes(root : SpatialNode) -> Array[SpatialNode] {
  flatten_spatial(root).filter(node => {
    let name = node.ifc_type.to_upper()
    name == "IFCSPACE" || name == "IFCBUILDINGSTOREY" || name == "IFCBUILDING"
  })
}

///|
/// Produce room/storey rows for schedules, dashboards and downstream reports.
pub fn spatial_schedule(model : Model) -> SpatialSchedule raise ParseError {
  let index = model.index()
  let rows : Array[SpatialScheduleRow] = []
  match spatial_roots(index) {
    roots =>
      for root in roots {
        for node in schedule_nodes(root) {
          rows.push(schedule_row_for_node(model, index, node))
        }
      }
  }
  {
    rows,
    total_spaces: rows
    .filter(row => row.ifc_type.to_upper() == "IFCSPACE")
    .length(),
    total_products: rows.fold(init=0, (result, row) => {
      result + row.product_count
    }),
    total_area: rows.fold(init=0.0, (result, row) => result + row.area),
    total_volume: rows.fold(init=0.0, (result, row) => result + row.volume),
  }
}

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

///|
pub fn SpatialSchedule::spaces(
  self : SpatialSchedule,
) -> Array[SpatialScheduleRow] {
  self.rows.filter(row => row.ifc_type.to_upper() == "IFCSPACE")
}

///|
pub fn SpatialSchedule::storeys(
  self : SpatialSchedule,
) -> Array[SpatialScheduleRow] {
  self.rows.filter(row => row.ifc_type.to_upper() == "IFCBUILDINGSTOREY")
}

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

///|
pub fn SpatialSchedule::named(
  self : SpatialSchedule,
  fragment : String,
) -> Array[SpatialScheduleRow] {
  let wanted = fragment.to_lower()
  self.rows.filter(row => row.name.to_lower().contains(wanted))
}

///|
pub fn SpatialSchedule::total_doors(self : SpatialSchedule) -> Int {
  self.rows.fold(init=0, (result, row) => result + row.door_count)
}

///|
pub fn SpatialSchedule::total_windows(self : SpatialSchedule) -> Int {
  self.rows.fold(init=0, (result, row) => result + row.window_count)
}

///|
pub fn SpatialSchedule::total_walls(self : SpatialSchedule) -> Int {
  self.rows.fold(init=0, (result, row) => result + row.wall_count)
}

///|
pub fn SpatialSchedule::average_space_area(self : SpatialSchedule) -> Double {
  let spaces = self.spaces()
  if spaces.length() == 0 {
    0.0
  } else {
    spaces.fold(init=0.0, (result, row) => result + row.area) /
    spaces.length().to_double()
  }
}

///|
pub fn SpatialSchedule::headline(self : SpatialSchedule) -> String {
  "rows=" +
  self.rows.length().to_string() +
  " spaces=" +
  self.total_spaces.to_string() +
  " products=" +
  self.total_products.to_string() +
  " area=" +
  self.total_area.to_string() +
  " volume=" +
  self.total_volume.to_string()
}

///|
pub fn SpatialScheduleRow::headline(self : SpatialScheduleRow) -> String {
  self.ifc_type +
  " #" +
  self.id.to_string() +
  " " +
  self.name +
  " products=" +
  self.product_count.to_string() +
  " area=" +
  self.area.to_string() +
  " volume=" +
  self.volume.to_string()
}

///|
pub fn SpatialScheduleRow::opening_count(self : SpatialScheduleRow) -> Int {
  self.door_count + self.window_count
}

///|
pub fn SpatialScheduleRow::path_text(self : SpatialScheduleRow) -> String {
  self.path.map(id => "#" + id.to_string()).join("/")
}

///|
pub fn SpatialSchedule::to_csv(self : SpatialSchedule) -> String {
  let builder = StringBuilder()
  builder <+ "id,type,name,path,products,doors,windows,walls,area,volume\n"
  for row in self.rows {
    builder <+
      "\{row.id},\{row.ifc_type},\{row.name},\{row.path_text()},\{row.product_count},\{row.door_count},\{row.window_count},\{row.wall_count},\{row.area},\{row.volume}\n"
  }
  builder.to_string()
}

///|
fn schedule_row_json(row : SpatialScheduleRow) -> String {
  "{\"id\":" +
  row.id.to_string() +
  ",\"type\":\"" +
  row.ifc_type +
  "\",\"name\":\"" +
  row.name +
  "\",\"products\":" +
  row.product_count.to_string() +
  ",\"doors\":" +
  row.door_count.to_string() +
  ",\"windows\":" +
  row.window_count.to_string() +
  ",\"walls\":" +
  row.wall_count.to_string() +
  ",\"area\":" +
  row.area.to_string() +
  ",\"volume\":" +
  row.volume.to_string() +
  "}"
}

///|
pub fn SpatialSchedule::to_json(self : SpatialSchedule) -> String {
  "{\"total_spaces\":" +
  self.total_spaces.to_string() +
  ",\"total_products\":" +
  self.total_products.to_string() +
  ",\"total_area\":" +
  self.total_area.to_string() +
  ",\"total_volume\":" +
  self.total_volume.to_string() +
  ",\"rows\":[" +
  self.rows.map(row => schedule_row_json(row)).join(",") +
  "]}"
}

///|
pub fn SpatialSchedule::to_markdown(self : SpatialSchedule) -> String {
  let builder = StringBuilder()
  builder <+ "# Spatial schedule\n\n"
  builder <+ "- Spaces: \{self.total_spaces}\n"
  builder <+ "- Products: \{self.total_products}\n"
  builder <+ "- Doors: \{self.total_doors()}\n"
  builder <+ "- Windows: \{self.total_windows()}\n"
  builder <+ "- Area: \{self.total_area}\n"
  builder <+ "- Volume: \{self.total_volume}\n\n"
  builder <+
    "| ID | Type | Name | Products | Doors | Windows | Area | Volume |\n"
  builder <+ "| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: |\n"
  for row in self.rows {
    builder <+
      "| \{row.id} | \{row.ifc_type} | \{row.name} | \{row.product_count} | \{row.door_count} | \{row.window_count} | \{row.area} | \{row.volume} |\n"
  }
  builder.to_string()
}

///|
pub fn schedule_area_by_name(
  schedule : SpatialSchedule,
  name : String,
) -> Double {
  schedule.named(name).fold(init=0.0, (result, row) => result + row.area)
}

///|
pub fn schedule_product_count_by_type(
  schedule : SpatialSchedule,
  ifc_type : String,
) -> Int {
  let wanted = ifc_type.to_upper()
  schedule.rows
  .filter(row => row.ifc_type.to_upper() == wanted)
  .fold(init=0, (result, row) => result + row.product_count)
}