///|
/// Defaults for coordination checks: report volumetric intersections but not
/// merely touching faces.
pub fn default_clash_options() -> ClashOptions {
  {
    tolerance: 0.0001,
    minimum_overlap_volume: 0.000001,
    include_touching: false,
    include_same_container: true,
    ignored_types: [],
    maximum_pairs: 0,
  }
}

///|
pub fn ClashOptions::with_tolerance(
  self : ClashOptions,
  tolerance : Double,
) -> ClashOptions {
  { ..self, tolerance: if tolerance < 0.0 { 0.0 } else { tolerance } }
}

///|
pub fn ClashOptions::with_minimum_overlap(
  self : ClashOptions,
  volume : Double,
) -> ClashOptions {
  { ..self, minimum_overlap_volume: if volume < 0.0 { 0.0 } else { volume } }
}

///|
pub fn ClashOptions::including_touching(
  self : ClashOptions,
  value : Bool,
) -> ClashOptions {
  { ..self, include_touching: value }
}

///|
pub fn ClashOptions::including_same_container(
  self : ClashOptions,
  value : Bool,
) -> ClashOptions {
  { ..self, include_same_container: value }
}

///|
pub fn ClashOptions::ignoring_type(
  self : ClashOptions,
  name : String,
) -> ClashOptions {
  let result = self.ignored_types.copy()
  let normalized = name.to_upper()
  if normalized.length() > 0 && !result.contains(normalized) {
    result.push(normalized)
  }
  { ..self, ignored_types: result }
}

///|
pub fn ClashOptions::with_maximum_pairs(
  self : ClashOptions,
  count : Int,
) -> ClashOptions {
  { ..self, maximum_pairs: if count < 0 { 0 } else { count } }
}

///|
fn clash_type_ignored(options : ClashOptions, entry : SpatialEntry) -> Bool {
  options.ignored_types.contains(entry.ifc_type.to_upper())
}

///|
fn clash_same_container(left : SpatialEntry, right : SpatialEntry) -> Bool {
  match (left.container, right.container) {
    (Some(left_id), Some(right_id)) => left_id == right_id
    _ => false
  }
}

///|
fn clash_pair_key(left : SpatialEntry, right : SpatialEntry) -> String {
  let first = if left.product_id < right.product_id {
    left.product_id
  } else {
    right.product_id
  }
  let second = if left.product_id < right.product_id {
    right.product_id
  } else {
    left.product_id
  }
  first.to_string() + ":" + second.to_string()
}

///|
fn clash_expanded_bounds(bounds : BoundingBox, amount : Double) -> BoundingBox {
  let margin = if amount < 0.0 { 0.0 } else { amount }
  if bounds.empty {
    bounds
  } else {
    {
      min: {
        x: bounds.min.x - margin,
        y: bounds.min.y - margin,
        z: bounds.min.z - margin,
      },
      max: {
        x: bounds.max.x + margin,
        y: bounds.max.y + margin,
        z: bounds.max.z + margin,
      },
      empty: false,
    }
  }
}

///|
fn clash_overlap_box(
  left : SpatialEntry,
  right : SpatialEntry,
  tolerance : Double,
) -> BoundingBox {
  box_intersection(
    clash_expanded_bounds(left.bounds, tolerance),
    clash_expanded_bounds(right.bounds, tolerance),
  )
}

///|
fn clash_clearance(left : SpatialEntry, right : SpatialEntry) -> Double {
  box_distance(left.bounds, right.bounds)
}

///|
fn clash_pair_for(
  left : SpatialEntry,
  right : SpatialEntry,
  options : ClashOptions,
) -> ClashPair? {
  let relation = box_relation(left.bounds, right.bounds, options.tolerance)
  let touching = relation == Touching
  let overlap = clash_overlap_box(left, right, options.tolerance)
  let volume = overlap.volume()
  if relation == Disjoint ||
    (touching && !options.include_touching) ||
    volume < options.minimum_overlap_volume {
    None
  } else {
    Some({
      left,
      right,
      relation,
      overlap,
      overlap_volume: volume,
      clearance: clash_clearance(left, right),
    })
  }
}

///|
fn clash_pair_precedes(left : ClashPair, right : ClashPair) -> Bool {
  if left.overlap_volume == right.overlap_volume {
    clash_pair_key(left.left, left.right) <
    clash_pair_key(right.left, right.right)
  } else {
    left.overlap_volume > right.overlap_volume
  }
}

///|
fn clash_sort_pairs(pairs : Array[ClashPair]) -> Array[ClashPair] {
  let result = pairs.copy()
  for i in 0.. String {
  let left = pair.left.ifc_type.to_upper()
  let right = pair.right.ifc_type.to_upper()
  if left < right {
    left + " / " + right
  } else {
    right + " / " + left
  }
}

///|
fn clash_by_type(pairs : Array[ClashPair]) -> Map[String, Int] {
  pairs.fold(init=Map([]), (result, pair) => {
    result.update_or_default(clash_type_key(pair), 1, count => count + 1)
    result
  })
}

///|
/// Detect product-level clashes using the spatial grid as the broad phase.
pub fn detect_clashes(
  spatial : SpatialIndex,
  options? : ClashOptions = default_clash_options(),
) -> ClashReport {
  let pairs : Array[ClashPair] = []
  let seen : Map[String, Bool] = Map([])
  let mut candidates = 0
  let mut ignored = 0
  for left in spatial.entries() {
    if clash_type_ignored(options, left) {
      ignored += 1
      continue
    }
    let query = clash_expanded_bounds(left.bounds, options.tolerance)
    for hit in spatial.query_box(query, tolerance=options.tolerance) {
      if hit.product_id <= left.product_id {
        continue
      }
      match spatial.entry(hit.product_id) {
        Some(right) => {
          let key = clash_pair_key(left, right)
          if seen.contains(key) {
            continue
          }
          seen[key] = true
          candidates += 1
          if clash_type_ignored(options, right) {
            ignored += 1
            continue
          }
          if !options.include_same_container &&
            clash_same_container(left, right) {
            ignored += 1
            continue
          }
          match clash_pair_for(left, right, options) {
            Some(pair) => pairs.push(pair)
            None => ()
          }
        }
        None => ()
      }
    }
  }
  let sorted = clash_sort_pairs(pairs)
  let limited = if options.maximum_pairs > 0 &&
    sorted.length() > options.maximum_pairs {
    sorted[:options.maximum_pairs].to_owned()
  } else {
    sorted
  }
  {
    pairs: limited,
    candidate_count: candidates,
    ignored_count: ignored,
    overlap_volume: limited.fold(init=0.0, (result, pair) => {
      result + pair.overlap_volume
    }),
    by_type: clash_by_type(limited),
  }
}

///|
/// Construct a spatial index and run a clash report in one call.
pub fn clash_report(
  model : Model,
  options? : ClashOptions = default_clash_options(),
) -> ClashReport raise ParseError {
  detect_clashes(SpatialIndex::new(model), options~)
}

///|
pub fn ClashReport::is_empty(self : ClashReport) -> Bool {
  self.pairs.length() == 0
}

///|
pub fn ClashReport::count(self : ClashReport) -> Int {
  self.pairs.length()
}

///|
pub fn ClashReport::highest_overlap(self : ClashReport) -> ClashPair? {
  self.pairs.get(0)
}

///|
pub fn ClashReport::pairs_for_type(
  self : ClashReport,
  left_type : String,
  right_type : String,
) -> Array[ClashPair] {
  let left = left_type.to_upper()
  let right = right_type.to_upper()
  self.pairs.filter(pair => {
    (
      pair.left.ifc_type.to_upper() == left &&
      pair.right.ifc_type.to_upper() == right
    ) ||
    (
      pair.left.ifc_type.to_upper() == right &&
      pair.right.ifc_type.to_upper() == left
    )
  })
}

///|
pub fn ClashReport::pairs_for_product(
  self : ClashReport,
  product_id : Int,
) -> Array[ClashPair] {
  self.pairs.filter(pair => {
    pair.left.product_id == product_id || pair.right.product_id == product_id
  })
}

///|
pub fn ClashReport::product_ids(self : ClashReport) -> Array[Int] {
  let result : Array[Int] = []
  for pair in self.pairs {
    if !result.contains(pair.left.product_id) {
      result.push(pair.left.product_id)
    }
    if !result.contains(pair.right.product_id) {
      result.push(pair.right.product_id)
    }
  }
  result
}

///|
pub fn ClashReport::severity(self : ClashReport) -> String {
  if self.pairs.length() == 0 {
    "clear"
  } else if self.overlap_volume > 100.0 {
    "high"
  } else if self.overlap_volume > 1.0 {
    "medium"
  } else {
    "low"
  }
}

///|
pub fn ClashReport::headline(self : ClashReport) -> String {
  "clashes=" +
  self.pairs.length().to_string() +
  " candidates=" +
  self.candidate_count.to_string() +
  " overlap_volume=" +
  self.overlap_volume.to_string() +
  " severity=" +
  self.severity()
}

///|
pub fn ClashPair::headline(self : ClashPair) -> String {
  "#" +
  self.left.product_id.to_string() +
  " <-> #" +
  self.right.product_id.to_string() +
  " " +
  box_relation_text(self.relation) +
  " volume=" +
  self.overlap_volume.to_string()
}

///|
pub fn ClashPair::type_key(self : ClashPair) -> String {
  clash_type_key(self)
}

///|
pub fn ClashPair::is_touching(self : ClashPair) -> Bool {
  self.relation == Touching
}

///|
pub fn ClashPair::has_volume(self : ClashPair) -> Bool {
  self.overlap_volume > 0.0
}

///|
pub fn ClashPair::intersection_center(self : ClashPair) -> Point3 {
  self.overlap.center()
}

///|
pub fn ClashPair::minimum_separation(self : ClashPair) -> Double {
  self.clearance
}

///|
fn clash_pair_json(pair : ClashPair) -> String {
  "{\"left\":" +
  pair.left.product_id.to_string() +
  ",\"right\":" +
  pair.right.product_id.to_string() +
  ",\"left_type\":\"" +
  pair.left.ifc_type +
  "\"" +
  ",\"right_type\":\"" +
  pair.right.ifc_type +
  "\"" +
  ",\"relation\":\"" +
  box_relation_text(pair.relation) +
  "\"" +
  ",\"overlap_volume\":" +
  pair.overlap_volume.to_string() +
  ",\"clearance\":" +
  pair.clearance.to_string() +
  "}"
}

///|
pub fn ClashReport::to_json(self : ClashReport) -> String {
  let values = self.pairs.map(pair => clash_pair_json(pair))
  "{\"candidate_count\":" +
  self.candidate_count.to_string() +
  ",\"ignored_count\":" +
  self.ignored_count.to_string() +
  ",\"overlap_volume\":" +
  self.overlap_volume.to_string() +
  ",\"severity\":\"" +
  self.severity() +
  "\"" +
  ",\"pairs\":[" +
  values.join(",") +
  "]}"
}

///|
pub fn ClashReport::to_markdown(self : ClashReport) -> String {
  let builder = StringBuilder()
  builder <+ "# Clash report\n\n"
  builder <+ "- Pairs: \{self.pairs.length()}\n"
  builder <+ "- Candidates: \{self.candidate_count}\n"
  builder <+ "- Ignored: \{self.ignored_count}\n"
  builder <+ "- Overlap volume: \{self.overlap_volume}\n"
  builder <+ "- Severity: \{self.severity()}\n\n"
  if self.pairs.length() > 0 {
    builder <+ "| Left | Right | Relation | Volume | Clearance |\n"
    builder <+ "| ---: | ---: | --- | ---: | ---: |\n"
    for pair in self.pairs {
      builder <+
        "| \{pair.left.product_id} | \{pair.right.product_id} | \{box_relation_text(pair.relation)} | \{pair.overlap_volume} | \{pair.clearance} |\n"
    }
  }
  builder.to_string()
}

///|
pub fn clash_pairs_between(
  spatial : SpatialIndex,
  left_ids : Array[Int],
  right_ids : Array[Int],
  options? : ClashOptions = default_clash_options(),
) -> Array[ClashPair] {
  let result : Array[ClashPair] = []
  for left_id in left_ids {
    match spatial.entry(left_id) {
      Some(left) =>
        for right_id in right_ids {
          if left_id == right_id {
            continue
          }
          match spatial.entry(right_id) {
            Some(right) =>
              match clash_pair_for(left, right, options) {
                Some(pair) if !result.any(item => {
                    clash_pair_key(item.left, item.right) ==
                    clash_pair_key(left, right)
                  }) => result.push(pair)
                _ => ()
              }
            None => ()
          }
        }
      None => ()
    }
  }
  clash_sort_pairs(result)
}

///|
pub fn clearances_below(
  spatial : SpatialIndex,
  clearance : Double,
  options? : ClashOptions = default_clash_options(),
) -> Array[ClashPair] {
  let limit = if clearance < 0.0 { 0.0 } else { clearance }
  let relaxed = {
    ..options,
    include_touching: true,
    minimum_overlap_volume: 0.0,
    tolerance: limit,
  }
  let report = detect_clashes(spatial, options=relaxed)
  report.pairs.filter(pair => pair.clearance <= limit)
}

///|
pub fn clash_report_summary(report : ClashReport) -> Array[String] {
  [
    report.headline(),
    "pair_count=" + report.pairs.length().to_string(),
    "product_count=" + report.product_ids().length().to_string(),
    "type_groups=" + report.by_type.length().to_string(),
  ]
}

///|
pub fn clash_type_histogram(report : ClashReport) -> Map[String, Int] {
  report.by_type
}