/**
 * 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(x), thickness(y), width(z)]. Default [8, 0.4, 2.4]. */
  plankSize?: Vec3
  /** Max tilt each way, radians. Default 0.5 (~29°). */
  maxTilt?: number
  /** Initial tilt (rad, about Z). Negative = +X end starts low. Default 0 (level). */
  restTilt?: number
}

export function createSeeSaw(world: World, cfg: SeeSawConfig): MachinePart {
  const { physics, rapier, scene } = world
  const pos = asVec3(cfg.position)
  const [px, py, pz] = cfg.plankSize ?? [8, 0.4, 2.4]
  const maxTilt = cfg.maxTilt ?? 0.5

  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(new THREE.Vector3(0, 0, 1), 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(px * 0.5, py * 0.5, pz * 0.5)
      .setDensity(0.4)
      .setFriction(1.0),
    plankBody,
  )

  // revolute joint about Z → the plank tips in the X/Y plane (blob rolls along X)
  const jd = rapier.JointData.revolute(
    { x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 1 },
  )
  jd.limitsEnabled = true
  jd.limits = [-maxTilt, maxTilt]
  physics.createImpulseJoint(jd, anchor, plankBody, true)

  const plank = new THREE.Mesh(
    new THREE.BoxGeometry(px, py, pz),
    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 }
}
