/**
 * GhostPlayer — if a saved ghost exists, spawns a translucent, physics-free
 * "past self" on `race:started` and replays the recorded track in race time
 * with linear interpolation, then despawns on `race:finished` (or the next
 * respawn, which re-fires race:started and resets it).
 *
 * Interpolation runs on the RENDER frame (onFrame) for smoothness; the track
 * itself was sampled deterministically at 15Hz. The ghost is deliberately
 * NOT-you: ghostly desaturated white, ~0.35 opacity, faint emissive, a slight
 * vertical bob.
 *
 * Consumes: race:started, race:finished.  Emits: nothing.
 */
import * as THREE from 'three'
import type { World } from '../contracts'
import { loadGhost, sampleAt, type GhostSample, type GhostTrack } from './format'
import { assets } from '../assets/registry'

const GHOST_OPACITY = 0.35
const BOB_AMPLITUDE = 0.06 // metres
const BOB_RATE = 4 // rad/s

export interface GhostPlayerOptions {
  /** Visual radius; match the blob so the overlap reads true. Default 0.5. */
  radius?: number
}

export class GhostPlayer {
  private group: THREE.Group | null = null
  private track: GhostTrack | null = null
  private playing = false
  private t = 0
  private bob = 0
  private readonly radius: number
  private readonly out: GhostSample = { x: 0, y: 0, z: 0, scale: 1 }
  private readonly mats: THREE.Material[] = []
  private readonly geos: THREE.BufferGeometry[] = []

  constructor(
    private readonly world: World,
    opts: GhostPlayerOptions = {},
  ) {
    this.radius = opts.radius ?? 0.5
    world.events.on('race:started', () => this.spawn())
    world.events.on('race:finished', () => this.despawn())
    world.onFrame((dt) => this.update(dt))
  }

  private build(): THREE.Group {
    const r = this.radius
    const g = new THREE.Group()

    const bodyGeo = new THREE.SphereGeometry(r, 32, 24)
    const bodyMat = new THREE.MeshStandardMaterial({
      color: '#eaf6ff',
      emissive: '#bfe6ff',
      emissiveIntensity: 0.5,
      roughness: 0.35,
      metalness: 0.0,
      transparent: true,
      opacity: GHOST_OPACITY,
      depthWrite: false, // translucent: don't occlude the real blob behind it
    })
    const body = new THREE.Mesh(bodyGeo, bodyMat)
    g.add(body)

    // Simple eyes so it reads as a blob (not a soap bubble), slightly less sheer.
    const eyeGeo = new THREE.SphereGeometry(r * 0.16, 12, 10)
    const eyeMat = new THREE.MeshStandardMaterial({
      color: '#2a3a48',
      transparent: true,
      opacity: Math.min(1, GHOST_OPACITY + 0.25),
      depthWrite: false,
    })
    for (const side of [-1, 1]) {
      const eye = new THREE.Mesh(eyeGeo, eyeMat)
      eye.position.set(side * r * 0.34, r * 0.32, r * 0.9)
      g.add(eye)
    }

    g.renderOrder = 2 // composite over the opaque scene
    this.mats.push(bodyMat, eyeMat)
    this.geos.push(bodyGeo, eyeGeo)

    // Slot `ghost.body`, falling back to whatever is in `blob.body` so a custom
    // blob automatically gets a matching ghost. Materials are cloned per
    // instance by the registry, so forcing them translucent here cannot make
    // the real blob see-through.
    const reg = assets()
    const slot = reg.has('ghost.body') ? 'ghost.body' : 'blob.body'
    reg.attachSlot(slot, g, {
      onSwap: (asset) => {
        body.visible = false
        asset.traverse((o) => {
          const m = (o as THREE.Mesh).material as THREE.MeshStandardMaterial | undefined
          if (!m) return
          m.transparent = true
          m.opacity = GHOST_OPACITY
          m.depthWrite = false
          if (m.isMeshStandardMaterial) {
            m.color.set('#eaf6ff')
            m.emissive.set('#bfe6ff')
            m.emissiveIntensity = 0.5
            m.map = null // the real blob's paint canvas is not the ghost's
          }
          this.mats.push(m)
        })
      },
    })
    return g
  }

  private spawn(): void {
    this.track = loadGhost()
    if (!this.track || this.track.count < 2) {
      this.track = null // no ghost yet (e.g. first run ever)
      return
    }
    if (!this.group) {
      this.group = this.build()
      this.world.scene.add(this.group)
    }
    this.group.visible = true
    this.t = 0
    this.bob = 0
    this.playing = true
    this.place() // snap to the start pose immediately, no one-frame pop
  }

  private despawn(): void {
    this.playing = false
    if (this.group) this.group.visible = false
  }

  private place(): void {
    if (!this.group || !this.track) return
    sampleAt(this.track, this.t, this.out)
    const bob = Math.sin(this.bob * BOB_RATE) * BOB_AMPLITUDE
    this.group.position.set(this.out.x, this.out.y + bob, this.out.z)
    this.group.scale.setScalar(this.out.scale || 1)
  }

  private update(dt: number): void {
    if (!this.playing) return
    this.t += dt
    this.bob += dt
    this.place()
  }

  /** Fully tear down (scene node + GPU resources). */
  dispose(): void {
    if (this.group) {
      this.world.scene.remove(this.group)
      this.group = null
    }
    for (const m of this.mats) m.dispose()
    for (const geo of this.geos) geo.dispose()
    this.mats.length = 0
    this.geos.length = 0
    this.playing = false
  }
}
