/**
 * PARTS — the Rube-Goldberg contraption toolkit (GDD §5.4).
 *
 * Every part is data-placeable: a factory takes `(world, config)` where config is
 * a plain `{ id, position, ... , onSignal?, emits? }` object, wires up its own
 * physics + visuals, and chains to other parts ONLY through `world.events`:
 *   - a part with `emits` fires   `machine:signal { id: emits }` when it activates
 *   - a part with `onSignal` listens for `machine:signal` and acts when the id matches
 * There are NO hardcoded references between part instances — the wiring is the
 * config strings, so the whole course is pure data.
 *
 * PILLAR: every part that applies an impulse or dumps paint plays a mandatory
 * ~0.5s telegraph windup first (see telegraph.ts). SpringBoot and BucketDump do.
 * PressurePlate is an input sensor (no force). ConveyorBelt / Fan / BubbleArch are
 * ambient continuous fields whose constant motion (sliding belt, spinning blades,
 * bubbling arch) is itself the readable, always-on telegraph.
 *
 * Communicates with paint (lane B) only via events: `paint:request-splat`,
 * `paint:request-cleanse`. Never imports src/paint or src/blob.
 */
import * as THREE from 'three'
import type RAPIER from '@dimforge/rapier3d-compat'
import type { World, PaintColor } from '../contracts'
import { PALETTE } from '../contracts'
import { telegraph, isTelegraphing } from './telegraph'
import { assets } from '../assets/registry'

/**
 * Slot hook shared by every part: park the custom model under the part's root
 * and hide the primitives it replaces. Only the primitives listed in `replaces`
 * go away — animated sub-parts (the plate cap, the belt chevrons, the fan
 * blades) stay procedural so they keep moving.
 */
function slotPart(slot: Parameters<ReturnType<typeof assets>['attachSlot']>[0],
                  root: THREE.Object3D, replaces: THREE.Object3D[]): void {
  assets().attachSlot(slot, root, {
    onSwap: () => { for (const o of replaces) o.visible = false },
  })
}

export type Vec3 = [number, number, number]

export interface MachinePart {
  id: string
  group: THREE.Group
}

const IDENTITY_ROT = { x: 0, y: 0, z: 0, w: 1 }

const v = (p: Vec3) => ({ x: p[0], y: p[1], z: p[2] })
const asVec3 = (p: Vec3) => new THREE.Vector3(p[0], p[1], p[2])

/** All *dynamic* rigid bodies whose colliders overlap an axis-aligned box. */
function dynamicBodiesInBox(
  world: World,
  center: { x: number; y: number; z: number },
  half: { x: number; y: number; z: number },
): Map<number, RAPIER.RigidBody> {
  const shape = new world.rapier.Cuboid(half.x, half.y, half.z)
  const found = new Map<number, RAPIER.RigidBody>()
  world.physics.intersectionsWithShape(center, IDENTITY_ROT, shape, (col) => {
    const b = col.parent()
    if (b && b.isDynamic()) found.set(b.handle, b)
    return true
  })
  return found
}

/** Axis-aligned box covering a beam of length `range` from `origin` along unit `dir`. */
function beamBox(origin: THREE.Vector3, dir: THREE.Vector3, range: number, cross: number) {
  const center = {
    x: origin.x + dir.x * range * 0.5,
    y: origin.y + dir.y * range * 0.5,
    z: origin.z + dir.z * range * 0.5,
  }
  const half = {
    x: Math.max(Math.abs(dir.x) * range * 0.5, cross),
    y: Math.max(Math.abs(dir.y) * range * 0.5, cross),
    z: Math.max(Math.abs(dir.z) * range * 0.5, cross),
  }
  return { center, half }
}

const lerp = (a: number, b: number, t: number) => a + (b - a) * t

// ---------------------------------------------------------------------------
// PressurePlate — depresses under real weight; trips only past massThreshold.
// ---------------------------------------------------------------------------
export interface PressurePlateConfig {
  id: string
  position: Vec3
  /** Trip only when total resting body mass ≥ this (uses body.mass()). */
  massThreshold: number
  /** Signal emitted on the untripped→tripped edge. */
  emits?: string
  /** [width, length] of the plate. Default [3, 3]. */
  size?: [number, number]
}

export function createPressurePlate(world: World, cfg: PressurePlateConfig): MachinePart {
  const { physics, rapier, scene } = world
  const [w, l] = cfg.size ?? [3, 3]
  const capH = 0.28
  const pos = asVec3(cfg.position)

  const group = new THREE.Group()
  group.position.copy(pos)
  scene.add(group)

  // static base frame (also catches the body so weight registers)
  const base = new THREE.Mesh(
    new THREE.BoxGeometry(w + 0.6, 0.4, l + 0.6),
    new THREE.MeshStandardMaterial({ color: '#4a4a52', roughness: 0.8 }),
  )
  base.position.y = -0.2
  base.receiveShadow = true
  group.add(base)

  // the moving cap the blob stands on
  const capMat = new THREE.MeshStandardMaterial({ color: '#8a8a98', roughness: 0.5 })
  const cap = new THREE.Mesh(new THREE.BoxGeometry(w, capH, l), capMat)
  cap.position.y = capH * 0.5 + 0.02
  cap.castShadow = true
  cap.receiveShadow = true
  group.add(cap)

  slotPart('machine.plate', group, [base])

  // physics: fixed platform the blob actually rests on
  const body = physics.createRigidBody(
    rapier.RigidBodyDesc.fixed().setTranslation(pos.x, pos.y + cap.position.y, pos.z),
  )
  physics.createCollider(
    rapier.ColliderDesc.cuboid(w * 0.5, capH * 0.5, l * 0.5).setFriction(1.0),
    body,
  )

  const detCenter = { x: pos.x, y: pos.y + cap.position.y + 0.5, z: pos.z }
  const detHalf = { x: w * 0.5, y: 0.55, z: l * 0.5 }

  let tripped = false
  const restY = cap.position.y
  const pressedY = restY - 0.16
  let targetY = restY

  world.addSystem({
    update() {
      const bodies = dynamicBodiesInBox(world, detCenter, detHalf)
      let load = 0
      for (const b of bodies.values()) load += b.mass()

      if (!tripped && load >= cfg.massThreshold) {
        tripped = true
        targetY = pressedY
        if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
      } else if (tripped && load < cfg.massThreshold * 0.5) {
        // hysteresis re-arm so a settled-then-departed weight can trip again
        tripped = false
        targetY = restY
      }
    },
  })

  world.onFrame((dt) => {
    cap.position.y = lerp(cap.position.y, targetY, Math.min(1, dt * 12))
    capMat.emissive.setHex(tripped ? 0x224400 : 0x000000)
    capMat.emissiveIntensity = tripped ? 0.6 : 0
  })

  return { id: cfg.id, group }
}

// ---------------------------------------------------------------------------
// SpringBoot — on signal: TELEGRAPH → kick everything in its strike volume.
// ---------------------------------------------------------------------------
export interface SpringBootConfig {
  id: string
  position: Vec3
  /** Impulse magnitude applied to each struck body. */
  impulse: number
  /** Launch direction (auto-normalised). Default straight up. */
  direction?: Vec3
  /** Signal that triggers the kick. */
  onSignal: string
  /** Optional signal emitted right after the kick fires (for further chaining). */
  emits?: string
  /** Strike volume half-extents above the boot. Default [1, 0.8, 1]. */
  strikeSize?: Vec3
  /** Build its own landing pad collider (true) or kick whatever rests on an
   *  existing surface, e.g. when mounted on a PressurePlate (false). Default true. */
  pad?: boolean
}

export function createSpringBoot(world: World, cfg: SpringBootConfig): MachinePart {
  const { physics, rapier, scene } = world
  const pos = asVec3(cfg.position)
  const dir = (cfg.direction ? asVec3(cfg.direction) : new THREE.Vector3(0, 1, 0)).normalize()
  const strike = cfg.strikeSize ?? [1, 0.8, 1]

  const group = new THREE.Group()
  group.position.copy(pos)
  scene.add(group)

  // landing pad the blob sits on before being kicked
  const padMat = new THREE.MeshStandardMaterial({ color: '#c0392b', roughness: 0.4, metalness: 0.1 })
  const pad = new THREE.Mesh(new THREE.CylinderGeometry(1.1, 1.1, 0.3, 20), padMat)
  pad.position.y = 0.15
  pad.castShadow = true
  pad.receiveShadow = true
  group.add(pad)

  // coil spring under the pad (telegraph target scales/shakes the whole group)
  const coilMat = new THREE.MeshStandardMaterial({ color: '#7f8c8d', metalness: 0.6, roughness: 0.3 })
  const coils: THREE.Mesh[] = []
  for (let i = 0; i < 3; i++) {
    const ring = new THREE.Mesh(new THREE.TorusGeometry(0.7, 0.09, 8, 20), coilMat)
    ring.rotation.x = Math.PI / 2
    ring.position.y = -0.15 - i * 0.22
    group.add(ring)
    coils.push(ring)
  }

  slotPart('machine.boot', group, [pad, ...coils])

  // physics pad so the ball can rest here between signal and kick
  if (cfg.pad !== false) {
    const body = physics.createRigidBody(
      rapier.RigidBodyDesc.fixed().setTranslation(pos.x, pos.y + 0.15, pos.z),
    )
    physics.createCollider(
      rapier.ColliderDesc.cylinder(0.15, 1.1).setFriction(0.9), body,
    )
  }

  const strikeCenter = { x: pos.x, y: pos.y + 0.3 + strike[1], z: pos.z }
  const strikeHalf = { x: strike[0], y: strike[1], z: strike[2] }

  world.events.on('machine:signal', ({ id }: { id: string }) => {
    if (id !== cfg.onSignal || isTelegraphing(world, group)) return
    telegraph(world, group, {
      duration: 0.5,
      flashColor: '#ff5533',
      scalePulse: 0.28,
      shake: 0.06,
      onFire: () => {
        const bodies = dynamicBodiesInBox(world, strikeCenter, strikeHalf)
        for (const b of bodies.values()) {
          b.applyImpulse({ x: dir.x * cfg.impulse, y: dir.y * cfg.impulse, z: dir.z * cfg.impulse }, true)
        }
        if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
      },
    })
  })

  return { id: cfg.id, group }
}

// ---------------------------------------------------------------------------
// SeeSaw — plank on a real Rapier revolute joint. Pure physics: the heavier
// side wins. No telegraph (it applies no force of its own; it just tips).
// ---------------------------------------------------------------------------
export interface SeeSawConfig {
  id: string
  position: Vec3
  /** Plank [length, thickness, width]. Length runs along the crossing axis
   *  (see tipAxis); width is the other horizontal. Default [8, 0.4, 2.4]. */
  plankSize?: Vec3
  /** Which horizontal axis you CROSS along: 'x' (default, tips left↔right) or
   *  'z' (tips fore↔aft — for a course that runs along Z). */
  tipAxis?: 'x' | 'z'
  /** Max tilt each way, radians. Default 0.5 (~29°). */
  maxTilt?: number
  /** Initial tilt (rad). Sign follows the tip axis; 0 = level. Default 0. */
  restTilt?: number
}

export function createSeeSaw(world: World, cfg: SeeSawConfig): MachinePart {
  const { physics, rapier, scene } = world
  const pos = asVec3(cfg.position)
  const [len, py, wid] = cfg.plankSize ?? [8, 0.4, 2.4]
  const maxTilt = cfg.maxTilt ?? 0.5
  // Crossing axis → plank footprint + the axis it rocks about. 'z' runs the long
  // side along Z and tips about X, so you roll across it in a Z-flowing course.
  const tipZ = cfg.tipAxis === 'z'
  const hx = (tipZ ? wid : len) * 0.5
  const hz = (tipZ ? len : wid) * 0.5
  const tiltAxis = tipZ ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 0, 1)
  const jointAxis = tipZ ? { x: 1, y: 0, z: 0 } : { x: 0, y: 0, z: 1 }

  const group = new THREE.Group()
  scene.add(group)

  // visual fulcrum wedge (fixed)
  const fulcrum = new THREE.Mesh(
    new THREE.CylinderGeometry(0.05, 1.0, 1.2, 3),
    new THREE.MeshStandardMaterial({ color: '#5d4037', roughness: 0.9 }),
  )
  fulcrum.position.set(pos.x, pos.y - 0.6, pos.z)
  fulcrum.castShadow = true
  group.add(fulcrum)

  // fixed anchor body at the pivot
  const anchor = physics.createRigidBody(
    rapier.RigidBodyDesc.fixed().setTranslation(pos.x, pos.y, pos.z),
  )

  // dynamic plank (optionally pre-tilted so delivery direction is deterministic)
  const restTilt = cfg.restTilt ?? 0
  const q0 = new THREE.Quaternion().setFromAxisAngle(tiltAxis, restTilt)
  const plankBody = physics.createRigidBody(
    rapier.RigidBodyDesc.dynamic()
      .setTranslation(pos.x, pos.y, pos.z)
      .setRotation({ x: q0.x, y: q0.y, z: q0.z, w: q0.w })
      .setAngularDamping(0.6),
  )
  physics.createCollider(
    rapier.ColliderDesc.cuboid(hx, py * 0.5, hz)
      .setDensity(0.4)
      .setFriction(1.0),
    plankBody,
  )

  // revolute joint about the tip axis → the plank rocks so you roll across it
  const jd = rapier.JointData.revolute(
    { x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }, jointAxis,
  )
  jd.limitsEnabled = true
  jd.limits = [-maxTilt, maxTilt]
  physics.createImpulseJoint(jd, anchor, plankBody, true)

  // A tilting plank has no restoring force, so after a crossing it stays wherever
  // it settled (neutral equilibrium). Snap it back to its rest tilt each run so a
  // pre-tilted plank is boardable + teetery every time, not just the first.
  world.events.on('race:respawn', () => {
    plankBody.setRotation({ x: q0.x, y: q0.y, z: q0.z, w: q0.w }, true)
    plankBody.setAngvel({ x: 0, y: 0, z: 0 }, true)
    plankBody.setLinvel({ x: 0, y: 0, z: 0 }, true)
  })

  const plank = new THREE.Mesh(
    new THREE.BoxGeometry(hx * 2, py, hz * 2),
    new THREE.MeshStandardMaterial({ color: '#a1887f', roughness: 0.7 }),
  )
  plank.castShadow = true
  plank.receiveShadow = true
  group.add(plank)

  // The plank's world pose is copied off the rigid body every frame, so the
  // custom model rides as its CHILD and the primitive is hidden by turning its
  // material off — hiding the plank itself would hide the child too.
  assets().attachSlot('machine.seesaw', plank, {
    onSwap: () => { (plank.material as THREE.Material).visible = false },
  })

  world.onFrame(() => {
    const t = plankBody.translation()
    const r = plankBody.rotation()
    plank.position.set(t.x, t.y, t.z)
    plank.quaternion.set(r.x, r.y, r.z, r.w)
  })

  return { id: cfg.id, group }
}

// ---------------------------------------------------------------------------
// BucketDump — on trigger (body in its catch zone) or signal: TELEGRAPH (teeter)
// → tips over and pours, emitting `paint:request-splat` under the spout.
// ---------------------------------------------------------------------------
export interface BucketDumpConfig {
  id: string
  position: Vec3
  color: PaintColor
  /** Splat radius requested from the paint lane. */
  radius: number
  /** Signal that triggers a dump (optional; a catch trigger works too). */
  onSignal?: string
  /** Optional signal emitted after the dump (further chaining). */
  emits?: string
  /** Self-contained catch zone: a body entering it triggers the dump. */
  trigger?: { size: Vec3; offset?: Vec3 }
}

export function createBucketDump(world: World, cfg: BucketDumpConfig): MachinePart {
  const { physics, scene } = world
  const pos = asVec3(cfg.position)
  const paintHex = PALETTE[cfg.color]

  // pivot group tips about its edge to pour
  const group = new THREE.Group()
  group.position.copy(pos)
  scene.add(group)

  const shell = new THREE.Mesh(
    new THREE.CylinderGeometry(1.3, 1.0, 2.0, 20, 1, true),
    new THREE.MeshStandardMaterial({ color: '#455a64', metalness: 0.5, roughness: 0.4, side: THREE.DoubleSide }),
  )
  shell.castShadow = true
  group.add(shell)

  // paint fill (colour reads even before it pours)
  const fillMat = new THREE.MeshStandardMaterial({ color: paintHex, roughness: 0.3 })
  const fill = new THREE.Mesh(new THREE.CylinderGeometry(1.15, 0.9, 1.4, 20), fillMat)
  fill.position.y = -0.15
  group.add(fill)

  // Fill stays procedural: it is the colour read, and it is re-tinted at runtime.
  slotPart('machine.bucket', group, [shell])

  let dumping = false
  let tip = 0        // current tip angle
  let tipTarget = 0
  let splatted = false

  const doDump = () => {
    if (dumping) return
    dumping = true
    splatted = false
    tipTarget = Math.PI * 0.85
  }

  const startDump = () => {
    if (dumping || isTelegraphing(world, group)) return
    telegraph(world, group, {
      duration: 0.5,
      flashColor: paintHex,
      scalePulse: 0.12,
      shake: 0.1,     // the teeter
      onFire: doDump,
    })
  }

  if (cfg.onSignal) {
    world.events.on('machine:signal', ({ id }: { id: string }) => {
      if (id === cfg.onSignal) startDump()
    })
  }

  // optional self-contained catch trigger
  let triggerCenter: { x: number; y: number; z: number } | null = null
  let triggerHalf: { x: number; y: number; z: number } | null = null
  let armed = true
  if (cfg.trigger) {
    const off = cfg.trigger.offset ?? [0, -1.5, 0]
    triggerCenter = { x: pos.x + off[0], y: pos.y + off[1], z: pos.z + off[2] }
    triggerHalf = { x: cfg.trigger.size[0] * 0.5, y: cfg.trigger.size[1] * 0.5, z: cfg.trigger.size[2] * 0.5 }
  }

  const spawnSlosh = () => {
    const blob = new THREE.Mesh(
      new THREE.SphereGeometry(cfg.radius * 0.5, 12, 10),
      new THREE.MeshStandardMaterial({ color: paintHex, transparent: true, opacity: 0.9, roughness: 0.2 }),
    )
    const spoutWorld = new THREE.Vector3(pos.x + 1.1, pos.y - 0.2, pos.z)
    blob.position.copy(spoutWorld)
    scene.add(blob)
    let vy = -1
    let life = 0
    const step = (dt: number) => {
      life += dt
      vy -= 14 * dt
      blob.position.y += vy * dt
      blob.scale.setScalar(1 + life * 1.5)
      ;(blob.material as THREE.MeshStandardMaterial).opacity = Math.max(0, 0.9 - life * 0.8)
      if (life > 1.2) {
        scene.remove(blob)
        blob.geometry.dispose()
        ;(blob.material as THREE.Material).dispose()
        const i = sloshers.indexOf(step)
        if (i >= 0) sloshers.splice(i, 1)
      }
    }
    sloshers.push(step)
  }
  const sloshers: Array<(dt: number) => void> = []

  world.addSystem({
    update() {
      if (!triggerCenter || !triggerHalf) return
      const bodies = dynamicBodiesInBox(world, triggerCenter, triggerHalf)
      const occupied = bodies.size > 0
      if (occupied && armed && !dumping) {
        armed = false
        startDump()
      } else if (!occupied && !dumping) {
        armed = true
      }
    },
  })

  // Visual-only sloshers stay on the render frame…
  world.onFrame((dt) => {
    for (const s of [...sloshers]) s(dt)
  })
  // …but the tip drives the SPLAT — gameplay, so fixed-step (onFrame stalls in
  // hidden tabs and would silence every dump; found at integration).
  world.addSystem({
    update(dt) {
    tip = lerp(tip, tipTarget, Math.min(1, dt * 4))
    group.rotation.z = -tip

    if (dumping && !splatted && tip > Math.PI * 0.4) {
      splatted = true
      const groundPoint = new THREE.Vector3(pos.x + 1.4, 0, pos.z)
      const ray = new world.rapier.Ray(
        { x: groundPoint.x, y: pos.y, z: groundPoint.z }, { x: 0, y: -1, z: 0 })
      const hit = world.physics.castRay(ray, 20, true)
      if (hit) groundPoint.y = pos.y - hit.timeOfImpact
      spawnSlosh()
      // ---- paint-lane wire (lane B renders; here we log the request) ----
      world.events.emit('paint:request-splat', {
        point: groundPoint, color: cfg.color, radius: cfg.radius,
      })
      console.log(
        `[machine] BucketDump "${cfg.id}" → paint:request-splat`,
        { color: cfg.color, radius: cfg.radius, point: groundPoint.toArray().map((n) => +n.toFixed(2)) },
      )
      if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
    }

    // right the bucket back once poured, then re-arm
    if (dumping && tip > Math.PI * 0.8) tipTarget = 0
    if (dumping && tipTarget === 0 && tip < 0.05) dumping = false
    },
  })

  return { id: cfg.id, group }
}

// ---------------------------------------------------------------------------
// ConveyorBelt — ambient surface that carries bodies at a target velocity.
// (Its constantly-scrolling stripes are the always-on telegraph.)
// ---------------------------------------------------------------------------
export interface ConveyorBeltConfig {
  id: string
  position: Vec3
  /** [length(x), thickness(y), width(z)]. Default [10, 0.5, 3]. */
  size?: Vec3
  /** Target surface velocity carried to bodies on the belt. */
  velocity: Vec3
}

export function createConveyorBelt(world: World, cfg: ConveyorBeltConfig): MachinePart {
  const { physics, rapier, scene } = world
  const pos = asVec3(cfg.position)
  const [sx, sy, sz] = cfg.size ?? [10, 0.5, 3]
  const vel = asVec3(cfg.velocity)

  const group = new THREE.Group()
  group.position.copy(pos)
  scene.add(group)

  const belt = new THREE.Mesh(
    new THREE.BoxGeometry(sx, sy, sz),
    new THREE.MeshStandardMaterial({ color: '#2c3e50', roughness: 0.6 }),
  )
  belt.receiveShadow = true
  group.add(belt)

  // direction chevrons that scroll to advertise travel direction
  const dir = vel.clone().normalize()
  const chevronMat = new THREE.MeshStandardMaterial({ color: '#f1c40f', emissive: '#4a3b00', emissiveIntensity: 0.4 })
  const chevrons: THREE.Mesh[] = []
  const alongX = Math.abs(dir.x) >= Math.abs(dir.z)
  const span = alongX ? sx : sz
  const n = 6
  for (let i = 0; i < n; i++) {
    const c = new THREE.Mesh(new THREE.BoxGeometry(alongX ? 0.5 : sz * 0.6, 0.06, alongX ? sz * 0.6 : 0.5), chevronMat)
    c.position.y = sy * 0.5 + 0.03
    group.add(c)
    chevrons.push(c)
  }

  // Chevrons stay procedural — they scroll every frame to advertise direction.
  slotPart('machine.belt', group, [belt])
  const placeChevron = (c: THREE.Mesh, offset: number) => {
    const t = ((offset % span) + span) % span - span * 0.5
    if (alongX) c.position.x = t
    else c.position.z = t
  }

  const detCenter = { x: pos.x, y: pos.y + sy * 0.5 + 0.4, z: pos.z }
  const detHalf = { x: sx * 0.5, y: 0.5, z: sz * 0.5 }

  physics.createCollider(
    rapier.ColliderDesc.cuboid(sx * 0.5, sy * 0.5, sz * 0.5)
      .setTranslation(pos.x, pos.y, pos.z)
      .setFriction(1.2),
  )

  world.addSystem({
    update() {
      const bodies = dynamicBodiesInBox(world, detCenter, detHalf)
      for (const b of bodies.values()) {
        const lv = b.linvel()
        b.setLinvel({ x: lerp(lv.x, vel.x, 0.18), y: lv.y, z: lerp(lv.z, vel.z, 0.18) }, true)
      }
    },
  })

  let scroll = 0
  const speed = alongX ? vel.x : vel.z
  world.onFrame((dt) => {
    scroll += speed * dt
    chevrons.forEach((c, i) => placeChevron(c, scroll + (i / n) * span))
  })

  return { id: cfg.id, group }
}

// ---------------------------------------------------------------------------
// BubbleArch — walk-through cleanse zone. On blob entry: `paint:request-cleanse`
// + a bubble burst. Ambient bubbling is the telegraph; you opt in by entering.
// ---------------------------------------------------------------------------
export interface BubbleArchConfig {
  id: string
  position: Vec3
  /** Cleanse fraction requested from the paint lane (0..1). */
  fraction: number
  /** Arch [width(x), height(y), depth(z)]. Default [4, 4, 2]. */
  size?: Vec3
}

export function createBubbleArch(world: World, cfg: BubbleArchConfig): MachinePart {
  const { scene } = world
  const pos = asVec3(cfg.position)
  const [w, h, d] = cfg.size ?? [4, 4, 2]

  const group = new THREE.Group()
  group.position.copy(pos)
  scene.add(group)

  const postMat = new THREE.MeshStandardMaterial({ color: '#00bcd4', roughness: 0.3, metalness: 0.2 })
  const archParts: THREE.Mesh[] = []
  for (const sx of [-1, 1]) {
    const post = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, h, 12), postMat)
    post.position.set((sx * w) / 2, h / 2, 0)
    post.castShadow = true
    group.add(post)
    archParts.push(post)
  }
  const bar = new THREE.Mesh(new THREE.BoxGeometry(w + 0.4, 0.4, 0.4), postMat)
  bar.position.y = h
  group.add(bar)
  archParts.push(bar)

  // a few permanent decorative bubbles clinging to the arch (ambient telegraph)
  const bubbleMat = new THREE.MeshStandardMaterial({ color: '#e0f7ff', transparent: true, opacity: 0.5, roughness: 0.05 })
  for (let i = 0; i < 10; i++) {
    const b = new THREE.Mesh(new THREE.SphereGeometry(0.15 + Math.random() * 0.2, 10, 8), bubbleMat)
    b.position.set((Math.random() - 0.5) * w, Math.random() * h, (Math.random() - 0.5) * d)
    group.add(b)
    archParts.push(b)
  }

  slotPart('machine.arch', group, archParts)

  const detCenter = { x: pos.x, y: pos.y + h * 0.5, z: pos.z }
  const detHalf = { x: w * 0.5, y: h * 0.5, z: d * 0.5 }

  const inside = new Set<number>()
  const bursts: Array<(dt: number) => void> = []

  const burst = () => {
    const spheres: THREE.Mesh[] = []
    for (let i = 0; i < 14; i++) {
      const s = new THREE.Mesh(
        new THREE.SphereGeometry(0.12 + Math.random() * 0.18, 10, 8),
        new THREE.MeshStandardMaterial({ color: '#eafaff', transparent: true, opacity: 0.85, roughness: 0.05 }),
      )
      s.position.set(pos.x + (Math.random() - 0.5) * w, pos.y + 0.5, pos.z + (Math.random() - 0.5) * d)
      scene.add(s)
      spheres.push(s)
    }
    const vels = spheres.map(() => 1 + Math.random() * 1.5)
    let life = 0
    const step = (dt: number) => {
      life += dt
      spheres.forEach((s, i) => {
        s.position.y += vels[i] * dt
        ;(s.material as THREE.MeshStandardMaterial).opacity = Math.max(0, 0.85 - life * 0.7)
      })
      if (life > 1.2) {
        for (const s of spheres) {
          scene.remove(s)
          s.geometry.dispose()
          ;(s.material as THREE.Material).dispose()
        }
        const idx = bursts.indexOf(step)
        if (idx >= 0) bursts.splice(idx, 1)
      }
    }
    bursts.push(step)
  }

  world.addSystem({
    update() {
      const bodies = dynamicBodiesInBox(world, detCenter, detHalf)
      const now = new Set(bodies.keys())
      for (const h of now) {
        if (!inside.has(h)) {
          // rising edge — a blob just entered the arch
          world.events.emit('paint:request-cleanse', { fraction: cfg.fraction })
          console.log(`[machine] BubbleArch "${cfg.id}" → paint:request-cleanse`, { fraction: cfg.fraction })
          burst()
        }
      }
      inside.clear()
      for (const h of now) inside.add(h)
    },
  })

  world.onFrame((dt) => {
    for (const b of [...bursts]) b(dt)
  })

  return { id: cfg.id, group }
}

// ---------------------------------------------------------------------------
// Fan — constant directional force volume. Same force on every body, so light
// blobs get blown hard and heavy blobs barely budge (mass does the work).
// ---------------------------------------------------------------------------
export interface FanConfig {
  id: string
  position: Vec3
  /** Force magnitude applied to each body in the beam, per step. */
  force: number
  /** Blow direction (auto-normalised). Default +x. */
  direction?: Vec3
  /** Beam length. Default 10. */
  range?: number
  /** Beam half-cross-section. Default 1.6. */
  spread?: number
}

export function createFan(world: World, cfg: FanConfig): MachinePart {
  const { scene } = world
  const pos = asVec3(cfg.position)
  const dir = (cfg.direction ? asVec3(cfg.direction) : new THREE.Vector3(1, 0, 0)).normalize()
  const range = cfg.range ?? 10
  const spread = cfg.spread ?? 1.6

  const group = new THREE.Group()
  group.position.copy(pos)
  // orient housing so blades face the blow direction
  group.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), dir)
  scene.add(group)

  const housing = new THREE.Mesh(
    new THREE.CylinderGeometry(1.5, 1.5, 0.6, 24),
    new THREE.MeshStandardMaterial({ color: '#34495e', metalness: 0.4, roughness: 0.5 }),
  )
  housing.rotation.x = Math.PI / 2
  housing.castShadow = true
  group.add(housing)

  const blades = new THREE.Group()
  const bladeMat = new THREE.MeshStandardMaterial({ color: '#95a5a6', metalness: 0.6, roughness: 0.3 })
  for (let i = 0; i < 4; i++) {
    const blade = new THREE.Mesh(new THREE.BoxGeometry(0.3, 2.4, 0.08), bladeMat)
    blade.rotation.z = (i / 4) * Math.PI * 2
    blades.add(blade)
  }
  blades.position.z = 0.05
  group.add(blades)

  // Blades stay procedural — the constant spin IS this part's telegraph.
  slotPart('machine.fan', group, [housing])

  const beam = beamBox(pos, dir, range, spread)

  world.addSystem({
    update(dt) {
      // Per-step impulse (force·dt) rather than addForce: Rapier's force
      // accumulator persists across steps, so re-adding a force every tick would
      // compound into a runaway. An impulse each step models a constant force
      // cleanly — same force on every body, so light blobs get flung and heavy
      // blobs barely move (mass does the work).
      const bodies = dynamicBodiesInBox(world, beam.center, beam.half)
      const k = cfg.force * dt
      for (const b of bodies.values()) {
        b.applyImpulse({ x: dir.x * k, y: dir.y * k, z: dir.z * k }, true)
      }
    },
  })

  world.onFrame((dt) => {
    blades.rotation.z += dt * 12 // constant spin = always-on telegraph
  })

  return { id: cfg.id, group }
}

// ---------------------------------------------------------------------------
// PaintMine — a floor panel that arms, and when a body rolls onto it TELEGRAPHs
// (~0.35s snap windup) then bursts: it coats whoever is on it (paint:request-
// splat aimed at each struck body, so installPaint lands it on that blob) and
// pops them with an impulse. Reward vs hazard is pure config: a fat splat of a
// GOOD colour + an upward launch is a reward; a heavy/unwanted colour + a
// sideways shove is a hazard.
//
// `once` (default true) is the first-through-only trap John asked for: after it
// fires it reads as SPENT and won't re-fire — so in a pack the leader trips it
// clean and everyone behind sees a used panel (GDD §5.4 persistent course
// state). It re-arms on `race:respawn` so every fresh run gets a live panel.
// `once:false` re-arms the instant the panel clears (a repeatable hazard).
// ---------------------------------------------------------------------------
export interface PaintMineConfig {
  id: string
  position: Vec3
  color: PaintColor
  /** Splat radius requested from the paint lane — bigger = more coverage. */
  radius: number
  /** How many splats to douse the body with, spread around it. Default 1.
   *  A single disc only wraps one side; 4-5 reliably crosses the buff threshold. */
  splats?: number
  /** Pop impulse applied to each struck body. Default 6. */
  impulse?: number
  /** Pop direction (auto-normalised). Default straight up. */
  direction?: Vec3
  /** Panel footprint [x, z]. Default [3, 3]. */
  size?: [number, number]
  /** First-through-only: fire once, stay spent until race:respawn. Default true. */
  once?: boolean
  /** Also fire on this machine:signal (chaining), on top of step-on. */
  onSignal?: string
  /** Signal emitted right after it bursts (further chaining). */
  emits?: string
}

export function createPaintMine(world: World, cfg: PaintMineConfig): MachinePart {
  const { scene } = world
  const pos = asVec3(cfg.position)
  const [w, l] = cfg.size ?? [3, 3]
  const paintHex = PALETTE[cfg.color]
  const dir = (cfg.direction ? asVec3(cfg.direction) : new THREE.Vector3(0, 1, 0)).normalize()
  const impulse = cfg.impulse ?? 6
  const once = cfg.once !== false

  const group = new THREE.Group()
  group.position.copy(pos)
  scene.add(group)

  // dark warning rim under a live colour panel proud of it. NO collider: the
  // course floor already carries the blob, so a panel can never open a hole or
  // grow an invisible wall — it's a decal + a trigger volume just above it.
  const rimMat = new THREE.MeshStandardMaterial({ color: '#1a1a1f', roughness: 0.85 })
  const rim = new THREE.Mesh(new THREE.BoxGeometry(w + 0.28, 0.16, l + 0.28), rimMat)
  rim.position.y = 0.05
  rim.receiveShadow = true
  group.add(rim)

  const panelMat = new THREE.MeshStandardMaterial({
    color: paintHex, roughness: 0.5, metalness: 0.1,
    emissive: paintHex, emissiveIntensity: 0.35,
  })
  const panel = new THREE.Mesh(new THREE.BoxGeometry(w, 0.12, l), panelMat)
  panel.position.y = 0.13
  panel.receiveShadow = true
  group.add(panel)

  const detCenter = { x: pos.x, y: pos.y + 0.6, z: pos.z }
  const detHalf = { x: w * 0.5, y: 0.6, z: l * 0.5 }

  // paint-coloured particle burst (same shape as the bubble arch's burst)
  const bursts: Array<(dt: number) => void> = []
  const burst = () => {
    const parts: THREE.Mesh[] = []
    const vels: THREE.Vector3[] = []
    for (let i = 0; i < 16; i++) {
      const s = new THREE.Mesh(
        new THREE.SphereGeometry(0.1 + Math.random() * 0.16, 8, 6),
        new THREE.MeshStandardMaterial({ color: paintHex, transparent: true, opacity: 0.95, roughness: 0.3 }),
      )
      s.position.set(pos.x + (Math.random() - 0.5) * w, pos.y + 0.3, pos.z + (Math.random() - 0.5) * l)
      scene.add(s)
      parts.push(s)
      vels.push(new THREE.Vector3((Math.random() - 0.5) * 3, 2 + Math.random() * 3, (Math.random() - 0.5) * 3))
    }
    let life = 0
    const step = (dt: number) => {
      life += dt
      parts.forEach((s, i) => {
        vels[i].y -= 9 * dt
        s.position.addScaledVector(vels[i], dt)
        ;(s.material as THREE.MeshStandardMaterial).opacity = Math.max(0, 0.95 - life * 0.9)
      })
      if (life > 1.1) {
        for (const s of parts) {
          scene.remove(s)
          s.geometry.dispose()
          ;(s.material as THREE.Material).dispose()
        }
        const idx = bursts.indexOf(step)
        if (idx >= 0) bursts.splice(idx, 1)
      }
    }
    bursts.push(step)
  }

  let fired = false
  let armed = true

  const markLive = () => { panelMat.color.set(paintHex); panelMat.emissive.set(paintHex); panelMat.emissiveIntensity = 0.35 }
  const markSpent = () => { panelMat.color.set('#6b6b6b'); panelMat.emissive.setHex(0x000000); panelMat.emissiveIntensity = 0 }

  const fire = () => {
    if ((once && fired) || isTelegraphing(world, group)) return
    // Capture who's on the panel NOW (windup start): a fast roller has left the
    // trigger box by the time the ~0.35s telegraph completes, so we pop/coat the
    // bodies that tripped it (at their live position) rather than re-scanning an
    // empty box at fire time.
    const struck = dynamicBodiesInBox(world, detCenter, detHalf)
    telegraph(world, group, {
      duration: 0.35,
      flashColor: paintHex,
      scalePulse: 0.2,
      shake: 0.06,
      onFire: () => {
        // Douse offsets: centre + around the body, so a burst wraps more than one
        // UV face. splatAtPoint stamps where the world point meets the body, so
        // offsetting the point hits different sides (installPaint's proximity gate
        // still passes — offsets are well inside worldRadius + splat radius).
        const offsets = [[0, 0.35, 0], [0.4, 0, 0], [-0.4, 0, 0], [0, 0, 0.4], [0, 0, -0.4]]
        const n = Math.max(1, cfg.splats ?? 1)
        for (const b of struck.values()) {
          const t = b.translation()
          b.applyImpulse({ x: dir.x * impulse, y: dir.y * impulse, z: dir.z * impulse }, true)
          for (let i = 0; i < n; i++) {
            const [ox, oy, oz] = offsets[i % offsets.length]
            world.events.emit('paint:request-splat', {
              point: new THREE.Vector3(t.x + ox, t.y + oy, t.z + oz), color: cfg.color, radius: cfg.radius,
            })
          }
        }
        burst()
        if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
        if (once) { fired = true; markSpent() }
      },
    })
  }

  if (cfg.onSignal) {
    world.events.on('machine:signal', ({ id }: { id: string }) => {
      if (id === cfg.onSignal) fire()
    })
  }

  // Fresh panel every run: a spent one-shot re-arms when the race restarts.
  world.events.on('race:respawn', () => { fired = false; armed = true; markLive() })

  world.addSystem({
    update() {
      const occupied = dynamicBodiesInBox(world, detCenter, detHalf).size > 0
      if (occupied && armed) {
        armed = false
        fire()
      } else if (!occupied && !once) {
        armed = true // repeatable hazard re-arms on clear; a one-shot never does
      }
    },
  })

  world.onFrame((dt) => {
    for (const b of [...bursts]) b(dt)
  })

  return { id: cfg.id, group }
}

// ---------------------------------------------------------------------------
// ColorGate — a portcullis that stays SHUT unless an approaching blob carries
// enough of a keyed colour, then slides into the floor to let it through and
// closes behind. This is the GDD §5.4 colour-keyed shortcut: the course paints
// you, and being painted opens your route. The door colour IS the key (a green
// door needs green), matching the pink-tunnel signage language.
//
// The part never imports the paint lane — integration injects `qualifies()`
// (e.g. `() => skin.coverage().byColor.green >= 0.4`), so the seam stays clean
// and this works for whatever "enough colour" the course wants.
// ---------------------------------------------------------------------------
export interface ColorGateConfig {
  id: string
  position: Vec3
  /** Which colour keys the gate — sets the door tint (the visible signage). */
  color: PaintColor
  /** Door slab [width, height, depth]. Default [4, 3, 0.6]. */
  size?: Vec3
  /** Approach-zone half-extents on the +Z (racer) side. Default derived from size. */
  detectHalf?: Vec3
  /** True when the approaching blob carries enough of `color`. Injected by
   *  integration to keep this part decoupled from the paint lane. */
  qualifies: () => boolean
  /** Signal emitted on the shut→open edge (further chaining). */
  emits?: string
}

export function createColorGate(world: World, cfg: ColorGateConfig): MachinePart {
  const { physics, rapier, scene } = world
  const pos = asVec3(cfg.position)
  const [w, h, d] = cfg.size ?? [4, 3, 0.6]
  const hex = PALETTE[cfg.color]

  const group = new THREE.Group()
  group.position.copy(pos)
  scene.add(group)

  // cosmetic frame: two posts + a coloured lintel so the gate reads as a gate.
  const postMat = new THREE.MeshStandardMaterial({ color: '#37474f', metalness: 0.4, roughness: 0.5 })
  for (const sx of [-1, 1]) {
    const post = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.22, h + 0.8, 12), postMat)
    post.position.set(sx * (w / 2 + 0.3), (h + 0.8) / 2, 0)
    post.castShadow = true
    group.add(post)
  }
  const lintel = new THREE.Mesh(
    new THREE.BoxGeometry(w + 1.0, 0.4, d + 0.3),
    new THREE.MeshStandardMaterial({ color: hex, emissive: hex, emissiveIntensity: 0.35, roughness: 0.5 }),
  )
  lintel.position.y = h + 0.6
  group.add(lintel)

  // the door — colour is the key. Fixed body + collider we slide into the floor.
  const doorMat = new THREE.MeshStandardMaterial({
    color: hex, roughness: 0.45, metalness: 0.1,
    emissive: hex, emissiveIntensity: 0.25, transparent: true, opacity: 0.92,
  })
  const door = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), doorMat)
  door.castShadow = true
  door.receiveShadow = true
  group.add(door)

  const closedY = pos.y + h / 2
  const openY = closedY - (h + 0.3) // sink fully into the ground
  const doorBody = physics.createRigidBody(
    rapier.RigidBodyDesc.fixed().setTranslation(pos.x, closedY, pos.z),
  )
  physics.createCollider(rapier.ColliderDesc.cuboid(w / 2, h / 2, d / 2), doorBody)

  const dh = cfg.detectHalf ?? [w / 2 + 1, h / 2 + 0.6, 3]
  // detector on the +Z approach side (racers run toward -Z through the course)
  const detCenter = { x: pos.x, y: pos.y + h / 2, z: pos.z + dh[2] }
  const detHalf = { x: dh[0], y: dh[1], z: dh[2] }

  let open = false
  let curY = closedY
  let targetY = closedY

  // Everything runs in the FIXED step, including the door's slide. The door is a
  // COLLIDER (gameplay) — if its motion lived in onFrame it would freeze in a
  // hidden tab and leave a half-open gate anyone could walk through (the lesson
  // telegraph.ts and BucketDump already learned). Mesh + glow ride along here at
  // 60Hz, which is smooth enough for a door.
  world.addSystem({
    update(dt) {
      const near = dynamicBodiesInBox(world, detCenter, detHalf).size > 0
      const shouldOpen = near && cfg.qualifies()
      if (shouldOpen && !open) {
        open = true
        targetY = openY
        if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
      } else if (!near && open) {
        open = false
        targetY = closedY
      }
      curY = lerp(curY, targetY, Math.min(1, dt * 6))
      door.position.y = curY - pos.y // mesh is a child of the group at pos
      doorBody.setTranslation({ x: pos.x, y: curY, z: pos.z }, true)
      doorMat.emissiveIntensity = open ? 0.7 : 0.25
    },
  })

  return { id: cfg.id, group }
}

// ---------------------------------------------------------------------------
// PaintBubbles — a field of drifting soap bubbles that each hold a dose of one
// colour. Touch one and it POPS: a burst of droplets, a paint douse on whoever
// popped it, and a soft upward boop. The slow ambient drift is the always-on
// telegraph (like the belt/fan), and the tint is the signage — a green bubble
// gives green, matching the gate/tunnel colour language. Popped bubbles respawn
// in the field after a beat, so the supply is steady but not spammy.
// ---------------------------------------------------------------------------
export interface PaintBubblesConfig {
  id: string
  /** Centre of the spawn field. */
  position: Vec3
  color: PaintColor
  /** Splat radius per douse splat (the mine's scale: ~0.9 is a real dose). */
  radius: number
  /** Splats per pop, spread around the body. Default 3. */
  splats?: number
  /** Bubbles alive at once. Default 6. */
  count?: number
  /** Field half-extents the bubbles wander in. Default [3, 1.5, 3]. */
  area?: Vec3
  /** Base drift velocity. Default [0, 0.35, 0] — a lazy rise. */
  drift?: Vec3
  /** Seconds a bubble lives before quietly re-forming elsewhere. Default 7. */
  lifetime?: number
  /** Bubble radius (visual + pop-trigger). Default 0.55. */
  bubbleRadius?: number
  /** Upward boop applied to the popper. Default 3. */
  impulse?: number
}

export function createPaintBubbles(world: World, cfg: PaintBubblesConfig): MachinePart {
  const { scene } = world
  const pos = asVec3(cfg.position)
  const hex = PALETTE[cfg.color]
  const count = cfg.count ?? 6
  const area = cfg.area ?? [3, 1.5, 3]
  const drift = cfg.drift ?? [0, 0.35, 0]
  const lifetime = cfg.lifetime ?? 7
  const bubbleR = cfg.bubbleRadius ?? 0.55
  const nSplats = Math.max(1, cfg.splats ?? 3)
  const impulse = cfg.impulse ?? 3

  const group = new THREE.Group()
  group.position.copy(pos)
  scene.add(group)

  // Shared translucent soap-film material, tinted with the payload colour.
  const filmMat = new THREE.MeshStandardMaterial({
    color: hex, transparent: true, opacity: 0.42, roughness: 0.08, metalness: 0.1,
    emissive: hex, emissiveIntensity: 0.25, depthWrite: false,
  })

  interface Bubble {
    mesh: THREE.Mesh
    age: number
    /** seconds until respawn after a pop (0 = alive) */
    dead: number
    phase: number
  }

  const spawnPoint = () => new THREE.Vector3(
    pos.x + (Math.random() - 0.5) * 2 * area[0],
    pos.y + (Math.random() - 0.5) * 2 * area[1],
    pos.z + (Math.random() - 0.5) * 2 * area[2],
  )

  const bubbles: Bubble[] = []
  for (let i = 0; i < count; i++) {
    const mesh = new THREE.Mesh(new THREE.SphereGeometry(bubbleR, 16, 12), filmMat)
    mesh.position.copy(spawnPoint())
    mesh.renderOrder = 1
    scene.add(mesh)
    // stagger ages so the field never pops/expires in sync
    bubbles.push({ mesh, age: Math.random() * lifetime, dead: 0, phase: Math.random() * Math.PI * 2 })
  }

  // droplet burst on pop (same recipe as the arch/mine bursts)
  const bursts: Array<(dt: number) => void> = []
  const burstAt = (at: THREE.Vector3) => {
    const parts: THREE.Mesh[] = []
    const vels: THREE.Vector3[] = []
    for (let i = 0; i < 10; i++) {
      const s = new THREE.Mesh(
        new THREE.SphereGeometry(0.07 + Math.random() * 0.12, 8, 6),
        new THREE.MeshStandardMaterial({ color: hex, transparent: true, opacity: 0.9, roughness: 0.2 }),
      )
      s.position.copy(at)
      scene.add(s)
      parts.push(s)
      vels.push(new THREE.Vector3((Math.random() - 0.5) * 3, 1 + Math.random() * 2.5, (Math.random() - 0.5) * 3))
    }
    let life = 0
    const step = (dt: number) => {
      life += dt
      parts.forEach((s, i) => {
        vels[i].y -= 8 * dt
        s.position.addScaledVector(vels[i], dt)
        ;(s.material as THREE.MeshStandardMaterial).opacity = Math.max(0, 0.9 - life * 1.1)
      })
      if (life > 0.9) {
        for (const s of parts) {
          scene.remove(s)
          s.geometry.dispose()
          ;(s.material as THREE.Material).dispose()
        }
        const i = bursts.indexOf(step)
        if (i >= 0) bursts.splice(i, 1)
      }
    }
    bursts.push(step)
  }

  let clock = 0
  world.addSystem({
    update(dt) {
      clock += dt
      for (const b of bubbles) {
        if (b.dead > 0) {
          b.dead -= dt
          if (b.dead <= 0) {
            b.mesh.position.copy(spawnPoint())
            b.mesh.visible = true
            b.age = 0
          }
          continue
        }
        // drift + a lazy sideways wander so the field feels alive
        b.age += dt
        b.mesh.position.x += (drift[0] + Math.sin(clock * 0.9 + b.phase) * 0.25) * dt
        b.mesh.position.y += drift[1] * dt
        b.mesh.position.z += (drift[2] + Math.cos(clock * 0.7 + b.phase) * 0.25) * dt
        if (b.age > lifetime) {
          // quiet expiry: re-form elsewhere, no pop, no paint
          b.mesh.position.copy(spawnPoint())
          b.age = 0
          continue
        }
        // pop check: any dynamic body inside the bubble
        const hit = dynamicBodiesInBox(world,
          { x: b.mesh.position.x, y: b.mesh.position.y, z: b.mesh.position.z },
          { x: bubbleR, y: bubbleR, z: bubbleR })
        if (hit.size > 0) {
          burstAt(b.mesh.position)
          const offsets = [[0, 0.35, 0], [0.4, 0, 0], [-0.4, 0, 0], [0, 0, 0.4], [0, 0, -0.4]]
          for (const body of hit.values()) {
            const t = body.translation()
            body.applyImpulse({ x: 0, y: impulse, z: 0 }, true)
            for (let i = 0; i < nSplats; i++) {
              const [ox, oy, oz] = offsets[i % offsets.length]
              world.events.emit('paint:request-splat', {
                point: new THREE.Vector3(t.x + ox, t.y + oy, t.z + oz), color: cfg.color, radius: cfg.radius,
              })
            }
          }
          b.mesh.visible = false
          b.dead = 2.5 + Math.random() * 2 // respawn after a beat
        }
      }
    },
  })

  world.onFrame((dt) => {
    for (const s of [...bursts]) s(dt)
  })

  return { id: cfg.id, group }
}

// ---------------------------------------------------------------------------
// DominoChain — a run of real dynamic slabs standing in a line. Roll into the
// first and physics topples the rest down the row; when the LAST tile tips
// past ~45° the chain emits its `emits` signal exactly once — so a domino run
// is a wire: knock it here, something fires over there (a cannon volley, a
// bucket, a boot). Pure passive physics like the see-saw, so no telegraph —
// the visible slow topple IS the telegraph. Re-arms upright on race:respawn.
// ---------------------------------------------------------------------------
export interface DominoChainConfig {
  id: string
  /** Base of the FIRST domino (its feet). */
  position: Vec3
  /** Direction the row runs (auto-normalised, horizontal). Default [0,0,-1]. */
  direction?: Vec3
  /** How many tiles. Default 8. */
  count?: number
  /** Gap between tile centres. Keep < height so they chain. Default 1.1. */
  spacing?: number
  /** Tile [width, height, thickness]. Default [1.2, 1.6, 0.18]. */
  size?: Vec3
  /** Tile colour. Default cream. */
  color?: string
  /** Signal emitted once when the last tile has toppled. */
  emits?: string
}

export function createDominoChain(world: World, cfg: DominoChainConfig): MachinePart {
  const { physics, rapier, scene } = world
  const base = asVec3(cfg.position)
  const dir = (cfg.direction ? asVec3(cfg.direction) : new THREE.Vector3(0, 0, -1))
  dir.y = 0
  dir.normalize()
  const count = cfg.count ?? 8
  const spacing = cfg.spacing ?? 1.1
  const [w, h, d] = cfg.size ?? [1.2, 1.6, 0.18]

  const group = new THREE.Group()
  scene.add(group)

  // face the slabs perpendicular to the run: thin axis (d) along `dir`
  const yaw = Math.atan2(dir.x, dir.z)
  const q0 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yaw)

  const mat = new THREE.MeshStandardMaterial({ color: cfg.color ?? '#f5f0e6', roughness: 0.6 })
  const edgeMat = new THREE.MeshStandardMaterial({ color: '#d8506e', roughness: 0.6 })

  interface Tile {
    body: RAPIER.RigidBody
    mesh: THREE.Mesh
    home: { x: number; y: number; z: number }
  }
  const tiles: Tile[] = []

  for (let i = 0; i < count; i++) {
    const home = {
      x: base.x + dir.x * spacing * i,
      y: base.y + h / 2,
      z: base.z + dir.z * spacing * i,
    }
    const body = physics.createRigidBody(
      rapier.RigidBodyDesc.dynamic()
        .setTranslation(home.x, home.y, home.z)
        .setRotation({ x: q0.x, y: q0.y, z: q0.z, w: q0.w })
        .setAngularDamping(0.15),
    )
    physics.createCollider(
      rapier.ColliderDesc.cuboid(w / 2, h / 2, d / 2).setDensity(0.3).setFriction(0.6),
      body,
    )
    // last tile gets a marker stripe so the payoff end reads at a glance
    const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), i === count - 1 ? edgeMat : mat)
    mesh.castShadow = true
    mesh.receiveShadow = true
    group.add(mesh)
    tiles.push({ body, mesh, home })
  }

  let fired = false

  world.addSystem({
    update() {
      if (fired || !cfg.emits) return
      const last = tiles[tiles.length - 1]
      // y-component of the tile's local +Y in world space: 1 - 2(qx² + qz²).
      // Below cos(45°) the last tile has toppled → the chain completes.
      const r = last.body.rotation()
      const upY = 1 - 2 * (r.x * r.x + r.z * r.z)
      if (upY < 0.707) {
        fired = true
        world.events.emit('machine:signal', { id: cfg.emits })
      }
    },
  })

  // stand every tile back up for the next run
  world.events.on('race:respawn', () => {
    fired = false
    for (const t of tiles) {
      t.body.setTranslation(t.home, true)
      t.body.setRotation({ x: q0.x, y: q0.y, z: q0.z, w: q0.w }, true)
      t.body.setLinvel({ x: 0, y: 0, z: 0 }, true)
      t.body.setAngvel({ x: 0, y: 0, z: 0 }, true)
    }
  })

  world.onFrame(() => {
    for (const t of tiles) {
      const p = t.body.translation()
      const r = t.body.rotation()
      t.mesh.position.set(p.x, p.y, p.z)
      t.mesh.quaternion.set(r.x, r.y, r.z, r.w)
    }
  })

  return { id: cfg.id, group }
}
