///|
/// Projection mode used by a render-oriented visibility query.
pub(all) enum SceneProjection {
  Perspective
  Orthographic
} derive(Debug, Eq)

///|
/// Camera parameters shared by a WebAssembly viewer and native tools.
pub(all) struct SceneCamera {
  position : Point3
  forward : Vector3
  up : Vector3
  near_distance : Double
  far_distance : Double
  vertical_fov : Double
  aspect_ratio : Double
  projection : SceneProjection
} derive(Debug)

///|
/// Controls culling, LOD selection and the amount of render work returned.
pub(all) struct SceneQueryOptions {
  tolerance : Double
  minimum_distance : Double
  maximum_items : Int
  full_detail_distance : Double
  medium_detail_distance : Double
  include_empty : Bool
  include_back_facing : Bool
} derive(Debug)

///|
/// Render detail selected for one visible product.
pub(all) enum SceneLod {
  Full
  Medium
  Coarse
  BoundsOnly
} derive(Debug, Eq)

///|
/// A visible item with a conservative screen-independent LOD choice.
pub(all) struct SceneVisibleItem {
  product_id : Int
  ifc_type : String
  distance : Double
  depth : Double
  projected_radius : Double
  lod : SceneLod
  triangle_count : Int
  bounds : BoundingBox
} derive(Debug)

///|
/// Result of a visibility query over an indexed IFC model.
pub(all) struct SceneSelection {
  items : Array[SceneVisibleItem]
  tested_count : Int
  culled_count : Int
  full_count : Int
  medium_count : Int
  coarse_count : Int
  bounds_only_count : Int
  visible_bounds : BoundingBox
} derive(Debug)

///|
/// A picking ray and its closest hit.
pub(all) struct ScenePick {
  origin : Point3
  direction : Vector3
  product_id : Int?
  distance : Double?
  point : Point3?
} derive(Debug)

///|
/// Camera defaults are intentionally conservative for building-scale models.
pub fn default_scene_camera() -> SceneCamera {
  {
    position: { x: 0.0, y: -10.0, z: 5.0 },
    forward: Vector3::{ x: 0.0, y: 1.0, z: -0.15 }.normalized(),
    up: Vector3::{ x: 0.0, y: 0.0, z: 1.0 },
    near_distance: 0.01,
    far_distance: 10000.0,
    vertical_fov: 1.0471975511965976,
    aspect_ratio: 1.7777777777777777,
    projection: Perspective,
  }
}

///|
pub fn SceneCamera::looking_at(
  self : SceneCamera,
  target : Point3,
) -> SceneCamera {
  { ..self, forward: Vector3::from_points(self.position, target).normalized() }
}

///|
pub fn SceneCamera::with_position(
  self : SceneCamera,
  position : Point3,
) -> SceneCamera {
  { ..self, position, }
}

///|
pub fn SceneCamera::with_forward(
  self : SceneCamera,
  forward : Vector3,
) -> SceneCamera {
  { ..self, forward: forward.normalized() }
}

///|
pub fn SceneCamera::with_clip(
  self : SceneCamera,
  near_distance : Double,
  far_distance : Double,
) -> SceneCamera {
  {
    ..self,
    near_distance: if near_distance < 0.0 {
      0.0
    } else {
      near_distance
    },
    far_distance: if far_distance < near_distance {
      near_distance
    } else {
      far_distance
    },
  }
}

///|
pub fn SceneCamera::with_lens(
  self : SceneCamera,
  vertical_fov : Double,
  aspect_ratio : Double,
) -> SceneCamera {
  {
    ..self,
    vertical_fov: if vertical_fov <= 0.0 {
      1.0471975511965976
    } else {
      vertical_fov
    },
    aspect_ratio: if aspect_ratio <= 0.0 {
      1.0
    } else {
      aspect_ratio
    },
  }
}

///|
pub fn SceneCamera::with_projection(
  self : SceneCamera,
  projection : SceneProjection,
) -> SceneCamera {
  { ..self, projection, }
}

///|
pub fn SceneCamera::right(self : SceneCamera) -> Vector3 {
  self.forward.cross(self.up).normalized()
}

///|
pub fn SceneCamera::real_up(self : SceneCamera) -> Vector3 {
  self.right().cross(self.forward).normalized()
}

///|
pub fn SceneCamera::is_valid(self : SceneCamera) -> Bool {
  self.forward.length() > 0.0 &&
  self.up.length() > 0.0 &&
  self.near_distance >= 0.0 &&
  self.far_distance >= self.near_distance &&
  self.vertical_fov > 0.0 &&
  self.aspect_ratio > 0.0
}

///|
pub fn SceneCamera::headline(self : SceneCamera) -> String {
  "camera=(" +
  self.position.x.to_string() +
  "," +
  self.position.y.to_string() +
  "," +
  self.position.z.to_string() +
  ") near=" +
  self.near_distance.to_string() +
  " far=" +
  self.far_distance.to_string() +
  " fov=" +
  self.vertical_fov.to_string()
}

///|
pub fn default_scene_query_options() -> SceneQueryOptions {
  {
    tolerance: 0.001,
    minimum_distance: 0.0,
    maximum_items: 0,
    full_detail_distance: 30.0,
    medium_detail_distance: 120.0,
    include_empty: false,
    include_back_facing: false,
  }
}

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

///|
pub fn SceneQueryOptions::with_maximum_items(
  self : SceneQueryOptions,
  maximum_items : Int,
) -> SceneQueryOptions {
  { ..self, maximum_items: if maximum_items < 0 { 0 } else { maximum_items } }
}

///|
pub fn SceneQueryOptions::with_lod_distances(
  self : SceneQueryOptions,
  full_detail_distance : Double,
  medium_detail_distance : Double,
) -> SceneQueryOptions {
  let full = if full_detail_distance <= 0.0 {
    1.0
  } else {
    full_detail_distance
  }
  let medium = if medium_detail_distance < full {
    full
  } else {
    medium_detail_distance
  }
  { ..self, full_detail_distance: full, medium_detail_distance: medium }
}

///|
pub fn SceneQueryOptions::including_empty(
  self : SceneQueryOptions,
  include_empty : Bool,
) -> SceneQueryOptions {
  { ..self, include_empty, }
}

///|
pub fn SceneQueryOptions::including_back_facing(
  self : SceneQueryOptions,
  include_back_facing : Bool,
) -> SceneQueryOptions {
  { ..self, include_back_facing, }
}

///|
fn scene_lod_name(lod : SceneLod) -> String {
  match lod {
    Full => "full"
    Medium => "medium"
    Coarse => "coarse"
    BoundsOnly => "bounds-only"
  }
}

///|
fn scene_item_radius(bounds : BoundingBox) -> Double {
  Vector3::from_points(bounds.min, bounds.max).length() / 2.0
}

///|
fn scene_item_depth(camera : SceneCamera, center : Point3) -> (Double, Double) {
  let delta = Vector3::from_points(camera.position, center)
  let distance = delta.length()
  let depth = delta.dot(camera.forward)
  (distance, depth)
}

///|
fn scene_item_is_visible(
  camera : SceneCamera,
  bounds : BoundingBox,
  options : SceneQueryOptions,
) -> (Bool, Double, Double, Double) {
  if bounds.empty {
    return (options.include_empty, 0.0, 0.0, 0.0)
  }
  let center = bounds.center()
  let (distance, depth) = scene_item_depth(camera, center)
  let radius = scene_item_radius(bounds)
  if distance < options.minimum_distance ||
    distance - radius > camera.far_distance ||
    depth + radius < camera.near_distance {
    return (false, distance, depth, radius)
  }
  if options.include_back_facing || depth >= 0.0 {
    let direction = Vector3::from_points(camera.position, center).normalized()
    let alignment = direction.dot(camera.forward)
    let horizontal = alignment >= @math.cos(camera.vertical_fov / 2.0)
    let side_limit = @math.cos(camera.vertical_fov / 2.0)
    let side = direction.dot(camera.right()).abs()
    let vertical = direction.dot(camera.real_up()).abs()
    let angular_radius = if distance == 0.0 { 1.0 } else { radius / distance }
    let visible = horizontal ||
      side <= side_limit + angular_radius ||
      vertical <= side_limit + angular_radius
    (visible, distance, depth, radius)
  } else {
    (false, distance, depth, radius)
  }
}

///|
fn scene_item_lod(
  distance : Double,
  radius : Double,
  triangles : Int,
  options : SceneQueryOptions,
) -> SceneLod {
  if triangles == 0 {
    BoundsOnly
  } else if distance <= options.full_detail_distance + radius {
    Full
  } else if distance <= options.medium_detail_distance + radius {
    Medium
  } else if triangles > 5000 {
    Coarse
  } else {
    Medium
  }
}

///|
fn scene_sort_items(items : Array[SceneVisibleItem]) -> Array[SceneVisibleItem] {
  let result = items.copy()
  for i in 0.. SceneSelection {
  let items : Array[SceneVisibleItem] = []
  let mut tested = 0
  let mut culled = 0
  for entry in index.entries() {
    tested += 1
    let (visible, distance, depth, radius) = scene_item_is_visible(
      camera,
      entry.bounds,
      options,
    )
    if !visible {
      culled += 1
      continue
    }
    let lod = scene_item_lod(distance, radius, entry.triangle_count, options)
    items.push({
      product_id: entry.product_id,
      ifc_type: entry.ifc_type,
      distance,
      depth,
      projected_radius: if distance == 0.0 {
        radius
      } else {
        radius / distance
      },
      lod,
      triangle_count: entry.triangle_count,
      bounds: entry.bounds,
    })
  }
  let sorted = scene_sort_items(items)
  let limited = if options.maximum_items > 0 &&
    sorted.length() > options.maximum_items {
    sorted[:options.maximum_items].to_owned()
  } else {
    sorted
  }
  let full_count = limited.fold(init=0, (count, item) => {
    if item.lod == Full {
      count + 1
    } else {
      count
    }
  })
  let medium_count = limited.fold(init=0, (count, item) => {
    if item.lod == Medium {
      count + 1
    } else {
      count
    }
  })
  let coarse_count = limited.fold(init=0, (count, item) => {
    if item.lod == Coarse {
      count + 1
    } else {
      count
    }
  })
  let bounds_count = limited.fold(init=0, (count, item) => {
    if item.lod == BoundsOnly {
      count + 1
    } else {
      count
    }
  })
  {
    items: limited,
    tested_count: tested,
    culled_count: culled,
    full_count,
    medium_count,
    coarse_count,
    bounds_only_count: bounds_count,
    visible_bounds: limited.fold(init=BoundingBox::empty(), (result, item) => {
      merge_bounds(result, item.bounds)
    }),
  }
}

///|
pub fn scene_selection(
  model : Model,
  camera : SceneCamera,
  options? : SceneQueryOptions = default_scene_query_options(),
) -> SceneSelection raise ParseError {
  SceneSelection::from_index(SpatialIndex::new(model), camera, options~)
}

///|
pub fn SceneSelection::visible_count(self : SceneSelection) -> Int {
  self.items.length()
}

///|
pub fn SceneSelection::contains(
  self : SceneSelection,
  product_id : Int,
) -> Bool {
  self.items.any(item => item.product_id == product_id)
}

///|
pub fn SceneSelection::item(
  self : SceneSelection,
  product_id : Int,
) -> SceneVisibleItem? {
  for item in self.items {
    if item.product_id == product_id {
      return Some(item)
    }
  }
  None
}

///|
pub fn SceneSelection::ids(self : SceneSelection) -> Array[Int] {
  self.items.map(item => item.product_id)
}

///|
pub fn SceneSelection::by_lod(
  self : SceneSelection,
  lod : SceneLod,
) -> Array[SceneVisibleItem] {
  self.items.filter(item => item.lod == lod)
}

///|
pub fn SceneSelection::triangle_budget(self : SceneSelection) -> Int {
  self.items.fold(init=0, (sum, item) => {
    let factor = match item.lod {
      Full => 1
      Medium => 4
      Coarse => 16
      BoundsOnly => 0
    }
    sum + item.triangle_count / factor.max(1)
  })
}

///|
pub fn SceneSelection::lod_histogram(self : SceneSelection) -> Map[String, Int] {
  let result : Map[String, Int] = Map([])
  for item in self.items {
    result.update_or_default(scene_lod_name(item.lod), 1, count => count + 1)
  }
  result
}

///|
pub fn SceneSelection::headline(self : SceneSelection) -> String {
  "visible=" +
  self.visible_count().to_string() +
  " tested=" +
  self.tested_count.to_string() +
  " culled=" +
  self.culled_count.to_string() +
  " triangles=" +
  self.triangle_budget().to_string()
}

///|
pub fn SceneSelection::to_json(self : SceneSelection) -> String {
  let items = self.items
    .map(item => {
      "{\"product_id\":" +
      item.product_id.to_string() +
      ",\"type\":\"" +
      item.ifc_type +
      "\",\"distance\":" +
      item.distance.to_string() +
      ",\"depth\":" +
      item.depth.to_string() +
      ",\"lod\":\"" +
      scene_lod_name(item.lod) +
      "\",\"triangles\":" +
      item.triangle_count.to_string() +
      "}"
    })
    .join(",")
  "{\"tested\":" +
  self.tested_count.to_string() +
  ",\"culled\":" +
  self.culled_count.to_string() +
  ",\"visible\":" +
  self.visible_count().to_string() +
  ",\"full\":" +
  self.full_count.to_string() +
  ",\"medium\":" +
  self.medium_count.to_string() +
  ",\"coarse\":" +
  self.coarse_count.to_string() +
  ",\"bounds_only\":" +
  self.bounds_only_count.to_string() +
  ",\"items\":[" +
  items +
  "]}"
}

///|
pub fn SceneSelection::to_markdown(self : SceneSelection) -> String {
  let builder = StringBuilder()
  builder <+ "# Scene selection\n\n"
  builder <+ "- \{self.headline()}\n"
  builder <+ "- Full: \{self.full_count}\n"
  builder <+ "- Medium: \{self.medium_count}\n"
  builder <+ "- Coarse: \{self.coarse_count}\n"
  builder <+ "- Bounds only: \{self.bounds_only_count}\n\n"
  builder <+ "| Product | IFC type | Distance | LOD | Triangles |\n"
  builder <+ "| ---: | --- | ---: | --- | ---: |\n"
  for item in self.items {
    builder <+
      "| \{item.product_id} | \{item.ifc_type} | \{item.distance} | \{scene_lod_name(item.lod)} | \{item.triangle_count} |\n"
  }
  builder.to_string()
}

///|
fn scene_ray_point(
  origin : Point3,
  direction : Vector3,
  distance : Double,
) -> Point3 {
  {
    x: origin.x + direction.x * distance,
    y: origin.y + direction.y * distance,
    z: origin.z + direction.z * distance,
  }
}

///|
/// Pick the nearest indexed product along a normalized or non-normalized ray.
pub fn scene_pick(
  index : SpatialIndex,
  origin : Point3,
  direction : Vector3,
) -> ScenePick {
  let normalized = direction.normalized()
  let hits = index.raycast(origin, normalized)
  match hits.get(0) {
    Some(hit) => {
      let point = scene_ray_point(origin, normalized, hit.distance)
      {
        origin,
        direction: normalized,
        product_id: Some(hit.product_id),
        distance: Some(hit.distance),
        point: Some(point),
      }
    }
    None =>
      {
        origin,
        direction: normalized,
        product_id: None,
        distance: None,
        point: None,
      }
  }
}

///|
pub fn ScenePick::is_hit(self : ScenePick) -> Bool {
  self.product_id is Some(_)
}

///|
pub fn ScenePick::headline(self : ScenePick) -> String {
  match (self.product_id, self.distance) {
    (Some(id), Some(distance)) =>
      "hit=#" + id.to_string() + " distance=" + distance.to_string()
    _ => "miss"
  }
}

///|
pub fn ScenePick::to_json(self : ScenePick) -> String {
  match (self.product_id, self.distance, self.point) {
    (Some(id), Some(distance), Some(point)) =>
      "{\"product_id\":" +
      id.to_string() +
      ",\"distance\":" +
      distance.to_string() +
      ",\"point\":[" +
      point.x.to_string() +
      "," +
      point.y.to_string() +
      "," +
      point.z.to_string() +
      "]}"
    _ => "{\"product_id\":null,\"distance\":null,\"point\":null}"
  }
}

///|
pub fn scene_selection_ids_json(selection : SceneSelection) -> String {
  "[" + selection.ids().map(id => id.to_string()).join(",") + "]"
}

///|
pub fn scene_selection_lod_json(selection : SceneSelection) -> String {
  "[" +
  selection.items
  .map(item => {
    "{\"product_id\":" +
    item.product_id.to_string() +
    ",\"lod\":\"" +
    scene_lod_name(item.lod) +
    "\"}"
  })
  .join(",") +
  "]"
}

///|
pub fn scene_selection_distance_range(
  selection : SceneSelection,
) -> (Double, Double) {
  if selection.items.length() == 0 {
    return (0.0, 0.0)
  }
  let mut minimum = selection.items[0].distance
  let mut maximum = minimum
  for item in selection.items[1:] {
    if item.distance < minimum {
      minimum = item.distance
    }
    if item.distance > maximum {
      maximum = item.distance
    }
  }
  (minimum, maximum)
}

///|
pub fn scene_selection_type_histogram(
  selection : SceneSelection,
) -> Map[String, Int] {
  let result : Map[String, Int] = Map([])
  for item in selection.items {
    result.update_or_default(item.ifc_type, 1, count => count + 1)
  }
  result
}