/**
 * PaintCannon — a placed emitter that lobs paint-glob projectiles on a gravity
 * arc. On hitting the blob it stamps a splat exactly where it struck (the magic
 * moment) and emits `paint:splatted`. Fires on an interval AND on
 * `machine:signal {id}` matching its trigger id (Lane C wires the signals).
 *
 * Projectiles are simulated by a manual sweep (no Rapier collider churn): each
 * step advances position under gravity and does a swept sphere-vs-sphere test
 * against the target so fast shots can't tunnel through.
 */
import * as THREE from 'three'
import type { PaintColor, System, World } from '../contracts'
import { PALETTE } from '../contracts'
import type { PaintSkin } from './skin'
import { assets } from '../assets/registry'

export interface PaintCannonConfig {
  world: World
  position: THREE.Vector3
  color: PaintColor
  /** Aim target (usually the blob mesh) — sampled at fire time for a lead shot. */
  target: THREE.Object3D
  /** Skin to stamp on a blob hit. */
  paint: PaintSkin
  /** Target bounding radius (world units) for the hit test. */
  targetRadius: number
  /** Fire on `machine:signal {id}` when id === triggerId. */
  triggerId?: string
  /** Seconds between auto-fires. 0/undefined disables the auto cadence. */
  interval?: number
  muzzleSpeed?: number
  projRadius?: number
  /** World-space splat radius handed to the skin on hit. */
  splatRadius?: number
  /** Gravity magnitude for the arc (default 14, matching world.ts). */
  gravity?: number
  /** y below which a projectile is considered to have hit the ground. */
  groundY?: number
  /** Lead a moving target using its estimated velocity (default true). */
  lead?: boolean
}

interface Projectile {
  mesh: THREE.Mesh
  vel: THREE.Vector3
  prev: THREE.Vector3
  life: number
}

const MAX_LIFE = 6 // seconds before a stray glob is culled

export class PaintCannon implements System {
  readonly color: PaintColor
  private readonly cfg: Required<
    Omit<PaintCannonConfig, 'triggerId'>
  > & { triggerId?: string }
  private readonly scene: THREE.Scene
  private readonly projectiles: Projectile[] = []
  private readonly projGeo: THREE.SphereGeometry
  private readonly projMat: THREE.MeshStandardMaterial
  private readonly barrel: THREE.Group
  private acc = 0
  private readonly unsub: () => void

  // target velocity estimate (for leading a moving blob)
  private readonly _lastTarget = new THREE.Vector3()
  private readonly _targetVel = new THREE.Vector3()
  private hasLast = false

  // scratch
  private readonly _tpos = new THREE.Vector3()
  private readonly _aim = new THREE.Vector3()
  private readonly _hit = new THREE.Vector3()
  private readonly _vtmp = new THREE.Vector3()

  constructor(config: PaintCannonConfig) {
    this.color = config.color
    this.cfg = {
      interval: 0,
      muzzleSpeed: 16,
      projRadius: 0.12,
      splatRadius: 0.28,
      gravity: 14,
      groundY: 0,
      lead: true,
      ...config,
    }
    this.scene = config.world.scene

    const hex = PALETTE[config.color]
    this.projGeo = new THREE.SphereGeometry(this.cfg.projRadius, 12, 10)
    this.projMat = new THREE.MeshStandardMaterial({
      color: hex,
      emissive: hex,
      emissiveIntensity: 0.35,
      roughness: 0.4,
    })

    this.barrel = this.buildBarrel(hex)
    this.barrel.position.copy(config.position)
    this.scene.add(this.barrel)

    this.unsub = config.world.events.on('machine:signal', (p: { id: string }) => {
      if (this.cfg.triggerId && p?.id === this.cfg.triggerId) this.fire()
    })
  }

  /** Fire one glob now, leading the target with a ballistic solve. */
  fire(): void {
    this.target(this._tpos)
    const from = this.muzzleWorld()

    // Lead: aim where the target will be after the glob's ~time-of-flight.
    this._aim.copy(this._tpos)
    if (this.cfg.lead && this.hasLast) {
      const flight = from.distanceTo(this._tpos) / this.cfg.muzzleSpeed
      this._aim.addScaledVector(this._targetVel, flight)
    }

    const vel =
      solveBallisticVelocity(from, this._aim, this.cfg.muzzleSpeed, this.cfg.gravity) ??
      this._aim.clone().sub(from).normalize().multiplyScalar(this.cfg.muzzleSpeed)

    // Point the barrel along the launch direction.
    this.aimBarrel(vel)

    const mesh = new THREE.Mesh(this.projGeo, this.projMat)
    mesh.castShadow = true
    mesh.position.copy(from)
    this.scene.add(mesh)
    this.projectiles.push({
      mesh,
      vel: vel.clone(),
      prev: from.clone(),
      life: 0,
    })
  }

  update(dt: number): void {
    // maintain a smoothed target-velocity estimate for leading
    if (dt > 0) {
      this.target(this._tpos)
      if (this.hasLast) {
        const inv = 1 / dt
        this._vtmp.copy(this._tpos).sub(this._lastTarget).multiplyScalar(inv)
        this._targetVel.lerp(this._vtmp, 0.4)
      }
      this._lastTarget.copy(this._tpos)
      this.hasLast = true
    }

    // auto cadence
    if (this.cfg.interval > 0) {
      this.acc += dt
      while (this.acc >= this.cfg.interval) {
        this.acc -= this.cfg.interval
        this.fire()
      }
    }

    if (this.projectiles.length === 0) return
    this.target(this._tpos)
    const hitDist = this.cfg.targetRadius + this.cfg.projRadius

    for (let i = this.projectiles.length - 1; i >= 0; i--) {
      const p = this.projectiles[i]
      p.prev.copy(p.mesh.position)
      p.vel.y -= this.cfg.gravity * dt
      p.mesh.position.addScaledVector(p.vel, dt)
      p.life += dt

      // swept hit test against the target sphere (prev -> current segment)
      const d = closestDistToSegment(this._tpos, p.prev, p.mesh.position, this._hit)
      if (d <= hitDist) {
        this.cfg.paint.splatAtPoint(this._hit, this.color, this.cfg.splatRadius)
        this.cfg.world.events.emit('paint:splatted', {
          color: this.color,
          target: 'blob',
        })
        this.despawn(i)
        continue
      }

      if (p.mesh.position.y <= this.cfg.groundY || p.life > MAX_LIFE) {
        // Ground/world decals are V1; just despawn (protocol-complete signal).
        this.cfg.world.events.emit('paint:splatted', {
          color: this.color,
          target: 'world',
        })
        this.despawn(i)
      }
    }
  }

  dispose(): void {
    this.unsub()
    for (let i = this.projectiles.length - 1; i >= 0; i--) this.despawn(i)
    this.scene.remove(this.barrel)
    this.projGeo.dispose()
    this.projMat.dispose()
  }

  // ---- internals ----------------------------------------------------------

  private target(out: THREE.Vector3): THREE.Vector3 {
    return this.cfg.target.getWorldPosition(out)
  }

  private muzzleWorld(): THREE.Vector3 {
    // muzzle a little in front of / above the barrel base
    return this.cfg.position.clone().add(new THREE.Vector3(0, 0.55, 0))
  }

  private despawn(i: number): void {
    const p = this.projectiles[i]
    this.scene.remove(p.mesh)
    this.projectiles.splice(i, 1)
  }

  private buildBarrel(hex: string): THREE.Group {
    const g = new THREE.Group()
    // Slot `cannon.barrel` decorates the base; the named `pivot` child and its
    // local +Z stay procedural because aimBarrel() steers them every step.
    const base = new THREE.Mesh(
      new THREE.CylinderGeometry(0.45, 0.55, 0.5, 16),
      new THREE.MeshStandardMaterial({ color: '#3a3a44', roughness: 0.7 }),
    )
    base.castShadow = true
    g.add(base)
    const tube = new THREE.Mesh(
      new THREE.CylinderGeometry(0.22, 0.28, 1.1, 16),
      new THREE.MeshStandardMaterial({ color: hex, roughness: 0.5 }),
    )
    tube.castShadow = true
    // pivot the tube from the base and tilt it up; child group lets us aim it
    const pivot = new THREE.Group()
    pivot.position.y = 0.45
    tube.position.y = 0.4
    tube.rotation.x = Math.PI / 2 // point +Z by default
    pivot.add(tube)
    pivot.name = 'pivot'
    g.add(pivot)
    assets().attachSlot('cannon.barrel', g, {
      onSwap: () => {
        base.visible = false
        tube.visible = false
      },
    })
    return g
  }

  private aimBarrel(vel: THREE.Vector3): void {
    const pivot = this.barrel.getObjectByName('pivot')
    if (!pivot) return
    const dir = vel.clone().normalize()
    const m = new THREE.Matrix4().lookAt(
      new THREE.Vector3(0, 0, 0),
      dir,
      new THREE.Vector3(0, 1, 0),
    )
    pivot.quaternion.setFromRotationMatrix(m)
  }
}

/**
 * Ballistic launch velocity to hit `to` from `from` at fixed `speed` under
 * gravity magnitude `g` (down -y). Returns the LOW-arc solution, or null if the
 * target is out of range. Exported for potential reuse/testing.
 */
export function solveBallisticVelocity(
  from: THREE.Vector3,
  to: THREE.Vector3,
  speed: number,
  g: number,
): THREE.Vector3 | null {
  const dx = to.x - from.x
  const dz = to.z - from.z
  const dy = to.y - from.y
  const x = Math.hypot(dx, dz)
  if (x < 1e-3) {
    return new THREE.Vector3(0, Math.sign(dy || 1) * speed, 0)
  }
  const s2 = speed * speed
  const disc = s2 * s2 - g * (g * x * x + 2 * dy * s2)
  if (disc < 0) return null
  const root = Math.sqrt(disc)
  const tanTheta = (s2 - root) / (g * x) // low arc
  const theta = Math.atan(tanTheta)
  const hx = dx / x
  const hz = dz / x
  const vh = speed * Math.cos(theta)
  const vy = speed * Math.sin(theta)
  return new THREE.Vector3(hx * vh, vy, hz * vh)
}

/** Closest distance from point `p` to segment `a`->`b`; writes the point to `out`. */
function closestDistToSegment(
  p: THREE.Vector3,
  a: THREE.Vector3,
  b: THREE.Vector3,
  out: THREE.Vector3,
): number {
  const abx = b.x - a.x
  const aby = b.y - a.y
  const abz = b.z - a.z
  const len2 = abx * abx + aby * aby + abz * abz
  let t = 0
  if (len2 > 1e-12) {
    t = ((p.x - a.x) * abx + (p.y - a.y) * aby + (p.z - a.z) * abz) / len2
    t = t < 0 ? 0 : t > 1 ? 1 : t
  }
  out.set(a.x + abx * t, a.y + aby * t, a.z + abz * t)
  return out.distanceTo(p)
}
