///|
fn coordinate_frame_depth(
  index : ModelIndex,
  id : Int,
  path : Array[Int],
) -> Int {
  if path.contains(id) {
    return path.length()
  }
  match index.entity(id) {
    Some(entity) if entity.name.to_upper() == "IFCLOCALPLACEMENT" =>
      match optional_reference_argument(entity, 0) {
        Some(parent) => coordinate_frame_depth(index, parent, path + [id])
        None => path.length() + 1
      }
    _ => path.length() + 1
  }
}

///|
fn coordinate_frame_name(entity : Entity) -> String {
  entity.name + " #" + entity.id.to_string()
}

///|
fn coordinate_frames_from_index(index : ModelIndex) -> Array[CoordinateFrame] {
  index
  .all()
  .fold(init=Array::new(), (result, entity) => {
    if entity.name.to_upper() == "IFCLOCALPLACEMENT" {
      let transform = placement_transform(index, entity.id).unwrap_or(
        Transform3::identity(),
      )
      result.push({
        entity_id: entity.id,
        name: coordinate_frame_name(entity),
        parent_id: optional_reference_argument(entity, 0),
        transform,
        depth: coordinate_frame_depth(index, entity.id, []),
      })
    }
    result
  })
}

///|
fn coordinate_has_map_conversion(index : ModelIndex) -> Bool {
  index.ids_named("IFCMAPCONVERSION").length() > 0
}

///|
fn coordinate_unit_scale(index : ModelIndex) -> Double {
  unit_system(index).length_to_meters(1.0)
}

///|
/// Collect placement frames and world bounds for coordinate-aware consumers.
pub fn coordinate_report(model : Model) -> CoordinateReport raise ParseError {
  let index = model.index()
  let spatial = SpatialIndex::new(model)
  let map_count = index.ids_named("IFCMAPCONVERSION").length()
  {
    frames: coordinate_frames_from_index(index),
    world_bounds: spatial.bounds(),
    unit_scale: coordinate_unit_scale(index),
    has_map_conversion: coordinate_has_map_conversion(index),
    warning_count: if map_count > 1 {
      map_count - 1
    } else {
      0
    },
  }
}

///|
pub fn CoordinateFrame::is_root(self : CoordinateFrame) -> Bool {
  self.parent_id is None
}

///|
pub fn CoordinateFrame::origin(self : CoordinateFrame) -> Point3 {
  self.transform.apply({ x: 0.0, y: 0.0, z: 0.0 })
}

///|
pub fn CoordinateFrame::apply(self : CoordinateFrame, point : Point3) -> Point3 {
  self.transform.apply(point)
}

///|
pub fn CoordinateFrame::headline(self : CoordinateFrame) -> String {
  self.name +
  " depth=" +
  self.depth.to_string() +
  " origin=" +
  self.origin().x.to_string() +
  "," +
  self.origin().y.to_string() +
  "," +
  self.origin().z.to_string()
}

///|
fn coordinate_transform_determinant(transform : Transform3) -> Double {
  transform.m00 *
  (transform.m11 * transform.m22 - transform.m12 * transform.m21) -
  transform.m01 *
  (transform.m10 * transform.m22 - transform.m12 * transform.m20) +
  transform.m02 *
  (transform.m10 * transform.m21 - transform.m11 * transform.m20)
}

///|
/// Invert a general affine transform, including non-uniform scaling.
pub fn inverse_transform(transform : Transform3) -> Transform3? {
  let determinant = coordinate_transform_determinant(transform)
  if determinant.abs() <= 0.000000000001 {
    return None
  }
  let inverse = 1.0 / determinant
  let m00 = (transform.m11 * transform.m22 - transform.m12 * transform.m21) *
    inverse
  let m01 = (transform.m02 * transform.m21 - transform.m01 * transform.m22) *
    inverse
  let m02 = (transform.m01 * transform.m12 - transform.m02 * transform.m11) *
    inverse
  let m10 = (transform.m12 * transform.m20 - transform.m10 * transform.m22) *
    inverse
  let m11 = (transform.m00 * transform.m22 - transform.m02 * transform.m20) *
    inverse
  let m12 = (transform.m02 * transform.m10 - transform.m00 * transform.m12) *
    inverse
  let m20 = (transform.m10 * transform.m21 - transform.m11 * transform.m20) *
    inverse
  let m21 = (transform.m01 * transform.m20 - transform.m00 * transform.m21) *
    inverse
  let m22 = (transform.m00 * transform.m11 - transform.m01 * transform.m10) *
    inverse
  Some({
    m00,
    m01,
    m02,
    m10,
    m11,
    m12,
    m20,
    m21,
    m22,
    tx: -(m00 * transform.tx + m01 * transform.ty + m02 * transform.tz),
    ty: -(m10 * transform.tx + m11 * transform.ty + m12 * transform.tz),
    tz: -(m20 * transform.tx + m21 * transform.ty + m22 * transform.tz),
  })
}

///|
pub fn world_to_local(transform : Transform3, point : Point3) -> Point3? {
  inverse_transform(transform).map(inverse => inverse.apply(point))
}

///|
pub fn local_to_world(transform : Transform3, point : Point3) -> Point3 {
  transform.apply(point)
}

///|
pub fn transform_bounds(
  transform : Transform3,
  bounds : BoundingBox,
) -> BoundingBox {
  if bounds.empty {
    return bounds
  }
  BoundingBox::from_points([
    transform.apply({ x: bounds.min.x, y: bounds.min.y, z: bounds.min.z }),
    transform.apply({ x: bounds.min.x, y: bounds.min.y, z: bounds.max.z }),
    transform.apply({ x: bounds.min.x, y: bounds.max.y, z: bounds.min.z }),
    transform.apply({ x: bounds.min.x, y: bounds.max.y, z: bounds.max.z }),
    transform.apply({ x: bounds.max.x, y: bounds.min.y, z: bounds.min.z }),
    transform.apply({ x: bounds.max.x, y: bounds.min.y, z: bounds.max.z }),
    transform.apply({ x: bounds.max.x, y: bounds.max.y, z: bounds.min.z }),
    transform.apply({ x: bounds.max.x, y: bounds.max.y, z: bounds.max.z }),
  ])
}

///|
pub fn CoordinateReport::frame(
  self : CoordinateReport,
  id : Int,
) -> CoordinateFrame? {
  for frame in self.frames {
    if frame.entity_id == id {
      return Some(frame)
    }
  }
  None
}

///|
pub fn CoordinateReport::roots(
  self : CoordinateReport,
) -> Array[CoordinateFrame] {
  self.frames.filter(frame => frame.is_root())
}

///|
pub fn CoordinateReport::max_depth(self : CoordinateReport) -> Int {
  self.frames.fold(init=0, (result, frame) => {
    if frame.depth > result {
      frame.depth
    } else {
      result
    }
  })
}

///|
pub fn CoordinateReport::is_consistent(self : CoordinateReport) -> Bool {
  self.warning_count == 0 && self.frames.all(frame => frame.depth > 0)
}

///|
pub fn CoordinateReport::headline(self : CoordinateReport) -> String {
  "frames=" +
  self.frames.length().to_string() +
  " roots=" +
  self.roots().length().to_string() +
  " max_depth=" +
  self.max_depth().to_string() +
  " unit_scale=" +
  self.unit_scale.to_string() +
  " map_conversion=" +
  self.has_map_conversion.to_string()
}

///|
pub fn CoordinateReport::to_json(self : CoordinateReport) -> String {
  let frames = self.frames
    .map(frame => {
      let origin = frame.origin()
      "{\"id\":" +
      frame.entity_id.to_string() +
      ",\"name\":\"" +
      frame.name +
      "\",\"parent_id\":" +
      frame.parent_id.map(id => id.to_string()).unwrap_or("null") +
      ",\"depth\":" +
      frame.depth.to_string() +
      ",\"origin\":[" +
      origin.x.to_string() +
      "," +
      origin.y.to_string() +
      "," +
      origin.z.to_string() +
      "]}"
    })
    .join(",")
  "{\"unit_scale\":" +
  self.unit_scale.to_string() +
  ",\"has_map_conversion\":" +
  self.has_map_conversion.to_string() +
  ",\"warning_count\":" +
  self.warning_count.to_string() +
  ",\"frames\":[" +
  frames +
  "]}"
}

///|
pub fn CoordinateReport::to_markdown(self : CoordinateReport) -> String {
  let builder = StringBuilder()
  builder <+ "# Coordinate report\n\n"
  builder <+ "- Frames: \{self.frames.length()}\n"
  builder <+ "- Roots: \{self.roots().length()}\n"
  builder <+ "- Maximum depth: \{self.max_depth()}\n"
  builder <+ "- Unit scale: \{self.unit_scale}\n"
  builder <+ "- Map conversion: \{self.has_map_conversion}\n\n"
  for frame in self.frames {
    builder <+ "- \{frame.headline()}\n"
  }
  builder.to_string()
}

///|
pub fn coordinate_frames(model : Model) -> Array[CoordinateFrame] {
  coordinate_frames_from_index(model.index())
}

///|
pub fn coordinate_transform_for(
  model : Model,
  placement_id : Int,
) -> Transform3? {
  placement_transform(model.index(), placement_id)
}

///|
pub fn model_world_bounds(model : Model) -> BoundingBox raise ParseError {
  SpatialIndex::new(model).bounds()
}