///|
/// Counterclockwise floor-plan vertex in metres, without a repeated last vertex.
pub(all) struct Point2 {
  x : Double
  y : Double
}

///|
priv struct Plane {
  normal : Vec3
  offset : Double
}

///|
priv struct PrismImage {
  model : ImageSource
  sequence : Array[Int32]
  history : Array[Vec3]
}

///|
/// A physical path, with source-to-receiver ordered wall indices and hit points.
pub struct SpecularPath {
  path : Path
  walls : Array[Int32]
  hits : Array[Vec3]
}

///|
const GEOMETRY_EPS : Double = 1.0e-7

///|
fn dot(a : Vec3, b : Vec3) -> Double {
  a.x * b.x + a.y * b.y + a.z * b.z
}

///|
fn subtract(a : Vec3, b : Vec3) -> Vec3 {
  { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z, }
}

///|
fn interpolate(a : Vec3, b : Vec3, t : Double) -> Vec3 {
  {
    x: a.x + t * (b.x - a.x),
    y: a.y + t * (b.y - a.y),
    z: a.z + t * (b.z - a.z),
  }
}

///|
fn mirror(point : Vec3, plane : Plane) -> Vec3 {
  let scale = 2.0 * (dot(plane.normal, point) - plane.offset)
  {
    x: point.x - scale * plane.normal.x,
    y: point.y - scale * plane.normal.y,
    z: point.z - scale * plane.normal.z,
  }
}

///|
fn prism_planes(vertices : Array[Point2], height : Double) -> Array[Plane] {
  let planes : Array[Plane] = []
  for i in 0.. Bool {
  planes.all(fn(p) { dot(p.normal, point) < p.offset - GEOMETRY_EPS })
}

///|
/// Convex polygon extruded vertically. No holes, concave walls or obstacles.
/// Wall slots follow polygon edges, then floor, then ceiling. Orders 0..4.
pub fn prism_scene(
  footprint : Array[Point2],
  height : Double,
  source : Vec3,
  receivers : Array[Vec3],
  absorption : Array[Double],
  sample_rate : Int32,
  max_order : Int32,
  duration_ms : Int32,
  speed~ : Double = 343.0,
  half_width~ : Int32 = 16,
) -> Result[Scene, String] {
  if footprint.length() < 3 || footprint.length() > 8 {
    return Err("convex footprint needs 3..8 counterclockwise vertices")
  }
  if max_order < 0 || max_order > 4 {
    return Err("convex-prism reflection order must be 0..4")
  }
  if !(height >= 0.1 && height <= 1000.0) {
    return Err("height must be finite in 0.1..1000 metres")
  }
  let mut xmax = 0.0
  let mut ymax = 0.0
  for v in footprint {
    if !(v.x >= 0.0 && v.x <= 1000.0 && v.y >= 0.0 && v.y <= 1000.0) {
      return Err("footprint coordinates must be finite in 0..1000 metres")
    }
    xmax = if v.x > xmax { v.x } else { xmax }
    ymax = if v.y > ymax { v.y } else { ymax }
  }
  for i in 0..= 0.0 && a <= 1.0) {
      return Err("energy absorption must be finite in 0..1")
    }
  }
  let planes = prism_planes(footprint, height)
  if !strictly_inside(source, planes) ||
    !receivers.all(fn(p) { strictly_inside(p, planes) }) {
    return Err("source and receivers must be strictly inside the convex prism")
  }
  // Reuse the unchanged timing, allocation and source-distance checks.
  let s = match
    scene(
      { x: xmax, y: ymax, z: height, },
      source,
      receivers,
      [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
      sample_rate,
      max_order,
      duration_ms,
      speed~,
      half_width~,
    ) {
    Err(e) => return Err(e)
    Ok(s) => s
  }
  Ok({
    room: s.room,
    source: s.source,
    receivers: s.receivers,
    absorption: absorption.copy(),
    sample_rate: s.sample_rate,
    max_order: s.max_order,
    frames: s.frames,
    speed: s.speed,
    half_width: s.half_width,
    footprint: Some(footprint.copy()),
  })
}

///|
/// None means the original six-wall shoebox model.
pub fn Scene::floor_plan(self : Scene) -> Array[Point2]? {
  self.footprint.map(fn(vertices) { vertices.copy() })
}

///|
// Generate words, not deduplicated infinite planes. Consecutive equal walls
// cannot yield a physical reflection, so omit those words before tracing.
fn prism_images(s : Scene) -> Array[PrismImage] {
  let planes = prism_planes(s.footprint.unwrap(), s.room.z)
  let direct : PrismImage = {
    model: {
      image: s.source,
      bounces: Array::make(planes.length(), 0),
      order: 0,
      reflection: 1.0,
    },
    sequence: [],
    history: [s.source],
  }
  let out = [direct]
  let mut frontier = [direct]
  for order = 1; order <= s.max_order; order = order + 1 {
    let next : Array[PrismImage] = []
    for previous in frontier {
      for wall in 0.. Array[Vec3]? {
  let reversed : Array[Vec3] = []
  let mut current = receiver
  for k = im.sequence.length() - 1; k >= 0; k = k - 1 {
    let wall = im.sequence[k]
    let plane = planes[wall]
    let direction = subtract(im.history[k + 1], current)
    let denominator = dot(plane.normal, direction)
    if denominator.abs() < GEOMETRY_EPS {
      return None
    }
    let t = (plane.offset - dot(plane.normal, current)) / denominator
    if !(t > 0.0 && t < 1.0) {
      return None
    }
    let hit = interpolate(current, im.history[k + 1], t)
    // A face hit must lie strictly inside that finite face. Edge/corner hits
    // have ambiguous multiple-wall normals; exclude them, never double count.
    for j in 0..= planes[j].offset - GEOMETRY_EPS {
        return None
      }
    }
    if distance(current, hit) < GEOMETRY_EPS {
      return None
    }
    reversed.push(hit)
    current = hit
  }
  Some(
    Array::makei(reversed.length(), fn(i) {
      reversed[reversed.length() - 1 - i]
    }),
  )
}

///|
/// Enumerate and validate each finite-wall specular path. Convexity guarantees
/// the physical segments between validated hits lie inside the room.
pub fn trace_prism(s : Scene) -> Array[SpecularPath] {
  if s.footprint is None {
    return []
  }
  let planes = prism_planes(s.footprint.unwrap(), s.room.z)
  let candidates = prism_images(s)
  let out : Array[SpecularPath] = []
  for receiver in 0.. continue
        Some(hits) => hits
      }
      let d = distance(im.model.image, s.receivers[receiver])
      if d < GEOMETRY_EPS {
        continue
      }
      let path : Path = {
        receiver,
        image: im.model.image,
        bounces: im.model.bounces.copy(),
        order: im.model.order,
        distance: d,
        delay_samples: d / s.speed * s.sample_rate.to_double(),
        reflection: im.model.reflection,
        gain: im.model.reflection / (4.0 * PI * d),
      }
      out.push({ path, walls: im.sequence.copy(), hits, })
    }
  }
  out
}