/**
 * Lane H demo — ghost replay + title screen.
 *
 * Self-contained (no game.ts): a scripted "run" drives a ball around a 20s
 * circle. The title card gates the start (and emits ui:play). Run 1 records the
 * path; every later run replays the saved ghost racing the live ball around the
 * SAME deterministic circle, so the translucent ghost should sit right on top
 * of the solid ball. The recorder persists the best to localStorage, so a page
 * reload comes back with the ghost already armed on the title.
 *
 * Debug keys (browser):
 *   G — wipe the saved ghost from localStorage (blobbo:ghost)
 *   R — restart the run immediately
 *   P — print a one-line state snapshot to the console
 *
 * A live "drift" readout compares the replayed ghost against the true circle in
 * real time; the rigorous numeric check is verify-drift.ts (bundle + node).
 */
import * as THREE from 'three'
import type { Blob, World } from '../contracts'
import { defaultModifiers } from '../contracts'
import { installTitle } from '../ui/title'
import { installGhost } from '../ghost/install'
import { clearGhost, hasGhost, loadGhost, sampleAt, type GhostSample, type GhostTrack } from '../ghost/format'

// ---- the deterministic scripted path (a ball on a circle) -----------------
const R = 8 // circle radius (m)
const CY = 1.0 // ride height (m)
const RUN_SECONDS = 20
const OMEGA = (2 * Math.PI) / RUN_SECONDS
const BLOB_RADIUS = 0.5

function pathAt(t: number, out: GhostSample): GhostSample {
  const th = OMEGA * t - Math.PI / 2 // start near (0,·,-R)
  out.x = R * Math.cos(th)
  out.y = CY
  out.z = R * Math.sin(th)
  out.scale = 1 + 0.15 * Math.sin(2 * OMEGA * t) // gentle pulse to exercise scale replay
  return out
}

export function buildLaneH(world: World): {
  blob: Blob
  beginRun: () => void
  wipeGhost: () => void
} {
  const { scene, physics, rapier } = world

  // ---------------------------------------------------------------- scenery
  const floor = new THREE.Mesh(
    new THREE.CircleGeometry(R + 4, 64),
    new THREE.MeshStandardMaterial({ color: '#3a4a63', roughness: 0.95 }),
  )
  floor.rotation.x = -Math.PI / 2
  floor.receiveShadow = true
  scene.add(floor)

  // the racing line, drawn on the floor so the circle is obvious
  const ring = new THREE.Mesh(
    new THREE.RingGeometry(R - 0.12, R + 0.12, 96),
    new THREE.MeshBasicMaterial({ color: '#ffd60a', side: THREE.DoubleSide }),
  )
  ring.rotation.x = -Math.PI / 2
  ring.position.y = 0.02
  scene.add(ring)

  // start marker
  const startMark = new THREE.Mesh(
    new THREE.BoxGeometry(0.4, 0.05, 2.4),
    new THREE.MeshBasicMaterial({ color: '#FF6EB4' }),
  )
  startMark.position.set(0, 0.03, -R)
  scene.add(startMark)

  // ---------------------------------------------------------------- the "blob"
  // A Lane-H-owned stand-in shaped like the frozen Blob: the recorder reads the
  // root group's position + scale. A kinematic body is created only to satisfy
  // the Blob type (never stepped; the group is driven directly).
  const group = new THREE.Group()
  group.position.set(0, CY, -R)
  scene.add(group)

  const mesh = new THREE.Mesh(
    new THREE.SphereGeometry(BLOB_RADIUS, 32, 24),
    new THREE.MeshStandardMaterial({ color: '#F5F5F7', roughness: 0.4, emissive: '#ffcaa8', emissiveIntensity: 0.15 }),
  )
  mesh.castShadow = true
  group.add(mesh)

  // little eyes so the live ball reads as Blobbo, for contrast with the ghost
  const eyeMat = new THREE.MeshStandardMaterial({ color: '#1a1a22', roughness: 0.5 })
  const eyeGeo = new THREE.SphereGeometry(BLOB_RADIUS * 0.16, 12, 10)
  for (const side of [-1, 1]) {
    const eye = new THREE.Mesh(eyeGeo, eyeMat)
    eye.position.set(side * BLOB_RADIUS * 0.34, BLOB_RADIUS * 0.32, BLOB_RADIUS * 0.9)
    group.add(eye)
  }

  const body = physics.createRigidBody(
    rapier.RigidBodyDesc.kinematicPositionBased().setTranslation(0, CY, -R),
  )
  const blob: Blob = { group, mesh, body, modifiers: defaultModifiers() }

  // ---------------------------------------------------------------- run driver
  let running = false
  let runT = 0
  let runNo = 0
  let best: number | null = null
  let ghostThisRun = false
  let liveTrack: GhostTrack | null = null
  let maxDrift = 0
  const scratchA: GhostSample = { x: 0, y: 0, z: 0, scale: 1 }
  const scratchB: GhostSample = { x: 0, y: 0, z: 0, scale: 1 }
  let nextTimer = 0

  // Driver runs as a fixed-step System added BEFORE installGhost, so the
  // recorder samples this frame's updated group position (insertion order).
  world.addSystem({
    update(dt: number): void {
      if (!running) return
      runT += dt
      const tt = Math.min(runT, RUN_SECONDS)
      pathAt(tt, scratchA)
      group.position.set(scratchA.x, scratchA.y, scratchA.z)
      group.scale.setScalar(scratchA.scale)

      if (runT >= RUN_SECONDS) {
        running = false
        const time = RUN_SECONDS
        const isBest = best == null || time < best
        if (isBest) best = time
        world.events.emit('race:finished', { time, best })
        toast(`🏁 run ${runNo} — ${time.toFixed(2)}s${ghostThisRun ? `  ·  ghost drift ${maxDrift.toFixed(3)}m` : ' (recording)'}`)
        // auto-loop into the next run
        window.clearTimeout(nextTimer)
        nextTimer = window.setTimeout(beginRun, 2200)
      }
    },
  })

  // ---------------------------------------------------------------- ghost wiring
  installGhost(world, blob, { radius: BLOB_RADIUS })

  // Live drift readout: replayed ghost vs the true circle at the same race time.
  // Registered AFTER installGhost so the ghost has advanced this frame.
  world.onFrame(() => {
    if (!running || !ghostThisRun || !liveTrack) return
    sampleAt(liveTrack, runT, scratchB) // what the ghost replays
    pathAt(runT, scratchA) // ground truth
    const d = Math.hypot(scratchA.x - scratchB.x, scratchA.y - scratchB.y, scratchA.z - scratchB.z)
    if (d > maxDrift) maxDrift = d
  })

  function beginRun(): void {
    window.clearTimeout(nextTimer)
    runT = 0
    running = true
    runNo++
    maxDrift = 0
    ghostThisRun = hasGhost()
    liveTrack = ghostThisRun ? loadGhost() : null
    // snap live ball to the start so recording starts clean
    pathAt(0, scratchA)
    group.position.set(scratchA.x, scratchA.y, scratchA.z)
    group.scale.setScalar(scratchA.scale)
    world.events.emit('race:started', {})
    toast(ghostThisRun ? `run ${runNo} — 👻 racing your ghost` : `run ${runNo} — recording…`)
  }

  function wipeGhost(): void {
    clearGhost()
    toast('👻 ghost wiped')
    updateHud()
  }

  // ---------------------------------------------------------------- HUD + toast
  const hud = document.createElement('div')
  hud.style.cssText =
    'position:fixed;top:12px;left:14px;z-index:20;pointer-events:none;' +
    'font:600 14px ui-monospace,Menlo,monospace;color:#eaf2ff;background:rgba(16,20,34,.7);' +
    'padding:10px 13px;border-radius:10px;line-height:1.55'
  document.body.appendChild(hud)

  function updateHud(): void {
    hud.innerHTML =
      `<b style="color:#ffd60a">BLOBBO — Lane H ghost demo</b><br>` +
      `run ${runNo}${running ? `  ⏱ ${Math.min(runT, RUN_SECONDS).toFixed(2)}s` : ''}<br>` +
      `ghost: ${hasGhost() ? `saved ✓${ghostThisRun ? ' · replaying' : ''}` : 'none'}<br>` +
      `${ghostThisRun ? `live drift: ${maxDrift.toFixed(3)}m  (< ${BLOB_RADIUS}m radius)<br>` : ''}` +
      `<span style="opacity:.7">keys: G wipe ghost · R restart · P snapshot</span>`
  }
  world.onFrame(updateHud)

  let toastEl: HTMLDivElement | null = null
  let toastTimer = 0
  function toast(msg: string): void {
    if (!toastEl) {
      toastEl = document.createElement('div')
      toastEl.style.cssText =
        'position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:20;pointer-events:none;' +
        'font:700 15px ui-monospace,Menlo,monospace;color:#16264a;background:#ffd60a;' +
        'padding:9px 16px;border-radius:10px;box-shadow:0 6px 18px rgba(0,0,0,.4)'
      document.body.appendChild(toastEl)
    }
    toastEl.textContent = msg
    toastEl.style.opacity = '1'
    window.clearTimeout(toastTimer)
    toastTimer = window.setTimeout(() => {
      if (toastEl) toastEl.style.opacity = '0'
    }, 2600)
  }

  function snapshot(): Record<string, unknown> {
    return {
      run: runNo,
      running,
      t: +runT.toFixed(2),
      hasGhost: hasGhost(),
      replaying: ghostThisRun,
      maxDriftM: +maxDrift.toFixed(4),
      pos: [+group.position.x.toFixed(2), +group.position.y.toFixed(2), +group.position.z.toFixed(2)],
    }
  }

  addEventListener('keydown', (e) => {
    if (e.code === 'KeyG') wipeGhost()
    else if (e.code === 'KeyR') beginRun()
    else if (e.code === 'KeyP') console.log('[lane-h]', JSON.stringify(snapshot()))
  })

  return { blob, beginRun, wipeGhost }
}

// --------------------------------------------------------------- bootstrap
if (typeof document !== 'undefined') {
  const { createWorld } = await import('../world')
  const world = await createWorld(document.getElementById('app')!)
  world.camera.position.set(0, 16, 20)
  world.onFrame(() => world.camera.lookAt(0, 0.5, 0))

  const h = buildLaneH(world)

  // Title in front. Hand it a short boot promise to show the loading blob dot
  // before PLAY (the real world is already booted; this just exercises the
  // loading state). ui:play kicks off the first run exactly once.
  const boot = new Promise<void>((res) => setTimeout(res, 700))
  const title = installTitle(world, { ready: boot })
  title.done.then(() => {
    console.log('[lane-h] ui:play received — starting run 1')
    h.beginRun()
  })

  world.start()
  console.log('[lane-h] ready — click PLAY, then keys: G wipe ghost · R restart · P snapshot')
}
