import { useEffect, useRef, useState } from 'react'
import * as THREE from 'three'

// A diegetic 90s handset rendered as a small three.js scene inside a fixed 1:2
// portrait stage. The 3D layer owns the blocky body, the flip-open animation, and
// the pressable keys; the screen UI is real DOM positioned in PERCENT of the stage
// (the ortho camera is head-on, so the screen face maps to a constant rectangle).
//
// SPIKE STATUS: the directory + flag checks below are local stubs. In the game the
// phone is an always-available surface whose number->node directory and node-enable
// flag requirements come from the story graph (see the "mobile" gate discussion).
const SCREEN_RECT = { top: 9, left: 25, width: 50, height: 30 } // % of the stage

export const CLOSED_ANGLE = 3.12 // hinge rotation.x when shut (~179°: lid folds over the keypad)
const OPEN_ANGLE = 0 // lid stands up, coplanar with the keypad, facing camera

// ---- placeholder telephony audio (to be replaced by recorded assets) ----------
let pctx: AudioContext | null = null
function ac() {
  if (!pctx) { const AC = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (AC) pctx = new AC() }
  if (pctx?.state === 'suspended') void pctx.resume()
  return pctx
}
function tone(freqs: number[], dur: number, when = 0, gain = 0.05) {
  const ctx = ac(); if (!ctx) return
  const t = ctx.currentTime + when
  const g = ctx.createGain()
  g.gain.setValueAtTime(0.0001, t)
  g.gain.exponentialRampToValueAtTime(gain, t + 0.01)
  g.gain.exponentialRampToValueAtTime(0.0001, t + dur)
  g.connect(ctx.destination)
  for (const f of freqs) { const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = f; o.connect(g); o.start(t); o.stop(t + dur + 0.02) }
}
const DTMF: Record<string, [number, number]> = {
  '1': [697, 1209], '2': [697, 1336], '3': [697, 1477], '4': [770, 1209], '5': [770, 1336], '6': [770, 1477],
  '7': [852, 1209], '8': [852, 1336], '9': [852, 1477], '*': [941, 1209], '0': [941, 1336], '#': [941, 1477],
}
const sfx = {
  key(k: string) { const d = DTMF[k]; if (d) tone(d, 0.12, 0, 0.06) },
  ring() { tone([440, 480], 0.9, 0, 0.04) },
  unobtainable() { tone([950], 0.28, 0, 0.06); tone([1400], 0.28, 0.33, 0.06); tone([1800], 0.28, 0.66, 0.06) }, // SIT-ish
  voicemail() { tone([1000], 0.5, 0, 0.05) },
}

// ---- scene --------------------------------------------------------------------

type Built = { group: THREE.Group; hinge: THREE.Group; keys: THREE.Mesh[] }

export function buildPhone(): Built {
  const group = new THREE.Group()
  const keys: THREE.Mesh[] = []

  const bodyMat = new THREE.MeshStandardMaterial({ color: 0x181a1c, roughness: 0.85, metalness: 0.05, flatShading: true })
  const trimMat = new THREE.MeshStandardMaterial({ color: 0x101214, roughness: 0.9, flatShading: true })
  const glassMat = new THREE.MeshStandardMaterial({ color: 0x0a1206, emissive: 0x101d09, emissiveIntensity: 0.6, roughness: 0.4, flatShading: true })
  const keyMat = new THREE.MeshStandardMaterial({ color: 0x9fb23a, emissive: 0x8fa522, emissiveIntensity: 0.55, roughness: 0.55, flatShading: true })

  const keypad = new THREE.Mesh(new THREE.BoxGeometry(0.66, 0.92, 0.16), bodyMat)
  keypad.position.set(0, -0.47, 0)
  keypad.castShadow = true
  keypad.receiveShadow = true
  group.add(keypad)

  // Hinge at the top-FRONT edge (ahead of the ~0.145 button tops); the lid child
  // cancels the hinge z so OPEN (rotation 0) is coplanar and SCREEN_RECT holds.
  const hinge = new THREE.Group()
  hinge.position.set(0, 0, 0.10)
  group.add(hinge)

  const lid = new THREE.Mesh(new THREE.BoxGeometry(0.66, 0.92, 0.10), bodyMat)
  lid.position.set(0, 0.47, -0.10)
  lid.castShadow = true
  lid.receiveShadow = true
  hinge.add(lid)

  const bezel = new THREE.Mesh(new THREE.BoxGeometry(0.54, 0.66, 0.02), trimMat)
  bezel.position.set(0, 0.05, 0.052)
  lid.add(bezel)
  const glass = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.6, 0.02), glassMat)
  glass.position.set(0, 0.05, 0.062)
  lid.add(glass)

  // Chunky protruding keys (LOCAL to the keypad, whose body spans local y [-0.46,0.46]).
  const key = (w: number, h: number, x: number, y: number, id: string) => {
    const k = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.09), keyMat)
    k.position.set(x, y, 0.10)
    k.userData = { key: id, baseZ: 0.10 }
    k.castShadow = true
    keypad.add(k)
    keys.push(k)
  }
  key(0.16, 0.10, -0.20, 0.40, 'call') // left soft key
  key(0.16, 0.10, 0.20, 0.40, 'end') // right soft key
  key(0.20, 0.16, 0, 0.22, 'nav') // nav pad
  const rows = [0.04, -0.10, -0.24, -0.38]
  const cols = [-0.20, 0, 0.20]
  const digits = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['*', '0', '#']]
  rows.forEach((y, r) => cols.forEach((x, c) => key(0.15, 0.10, x, y, digits[r][c])))

  // Hinge barrel: parented to the hinge group at its local origin, so it sits
  // EXACTLY on the rotation axis (the pivot passes through the cylinder centre).
  // A cylinder spinning about its own axis is invisible, so it stays put as the
  // lid swings. Pins stick out the sides to read as the pivot.
  const hingeMat = new THREE.MeshStandardMaterial({ color: 0x26292c, roughness: 0.5, metalness: 0.35, flatShading: true })
  const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.055, 0.74, 12), hingeMat)
  barrel.rotation.z = Math.PI / 2 // lay the cylinder along X (the hinge axis)
  barrel.castShadow = true
  hinge.add(barrel)

  // Chubby stub antenna on the top-right of the body, with a rounded cap.
  const antMat = new THREE.MeshStandardMaterial({ color: 0x2a2d30, roughness: 0.6, metalness: 0.2, flatShading: true })
  const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.042, 0.052, 0.22, 8), antMat)
  antenna.position.set(0.25, 0.10, -0.01)
  antenna.castShadow = true
  group.add(antenna)
  const tip = new THREE.Mesh(new THREE.SphereGeometry(0.055, 10, 8), antMat)
  tip.position.set(0.25, 0.24, -0.01)
  tip.castShadow = true
  group.add(tip)

  return { group, hinge, keys }
}

function PhoneDevice({ open, onKey }: { open: boolean; onKey: (k: string) => void }) {
  const stageRef = useRef<HTMLDivElement>(null)
  const targetRef = useRef(open ? 1 : 0)
  const onKeyRef = useRef(onKey)
  onKeyRef.current = onKey

  // Open at once; on close, hold a beat so the screen fades out before the lid swings.
  useEffect(() => {
    if (open) { targetRef.current = 1; return }
    const t = setTimeout(() => { targetRef.current = 0 }, 170)
    return () => clearTimeout(t)
  }, [open])

  useEffect(() => {
    const stage = stageRef.current
    if (!stage) return

    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
    renderer.shadowMap.enabled = true
    renderer.shadowMap.type = THREE.PCFSoftShadowMap
    stage.appendChild(renderer.domElement)
    renderer.domElement.style.cssText = 'position:absolute;inset:0;width:100%;height:100%'

    const scene = new THREE.Scene()
    const camera = new THREE.OrthographicCamera(-0.5, 0.5, 1, -1, 0.1, 100)
    camera.position.set(0, 0, 6)
    camera.lookAt(0, 0, 0)

    scene.add(new THREE.AmbientLight(0x3a4030, 0.75))
    const keyLight = new THREE.DirectionalLight(0xfff4e0, 1.05)
    keyLight.position.set(1.4, 2.2, 2.4)
    keyLight.castShadow = true
    keyLight.shadow.mapSize.set(1024, 1024)
    const sc = keyLight.shadow.camera as THREE.OrthographicCamera
    sc.left = -1; sc.right = 1; sc.top = 1.3; sc.bottom = -1.3; sc.near = 0.1; sc.far = 12
    scene.add(keyLight)
    // Cool fill lifts the shadow side; warm rim from behind-top catches the top edges
    // so the closed clamshell reads as a 3D object rather than a flat slab.
    const fill = new THREE.DirectionalLight(0x8fa6c0, 0.4)
    fill.position.set(-1.8, 0.5, 2.0)
    scene.add(fill)
    const rim = new THREE.DirectionalLight(0xffd7a0, 0.55)
    rim.position.set(-0.3, 1.6, -2.6)
    scene.add(rim)
    const glow = new THREE.PointLight(0x9fd020, 0.5, 4)
    glow.position.set(0, -0.5, 0.9)
    scene.add(glow)

    const { group, hinge, keys } = buildPhone()
    scene.add(group)

    // Pressable keys via raycasting on the canvas.
    const ray = new THREE.Raycaster()
    const ndc = new THREE.Vector2()
    const pressedAt = new Map<THREE.Mesh, number>()
    const onPointer = (e: PointerEvent) => {
      if (targetRef.current < 0.5) return // only when open
      const r = renderer.domElement.getBoundingClientRect()
      ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1)
      ray.setFromCamera(ndc, camera)
      const hit = ray.intersectObjects(keys, false)[0]
      if (hit) { const m = hit.object as THREE.Mesh; pressedAt.set(m, performance.now()); onKeyRef.current(m.userData.key) }
    }
    renderer.domElement.addEventListener('pointerdown', onPointer)

    const resize = () => { const w = stage.clientWidth, h = stage.clientHeight; if (w && h) renderer.setSize(w, h, false) }
    resize()
    const ro = new ResizeObserver(resize)
    ro.observe(stage)

    const R = 6
    let raf = 0
    let progress = 0
    const tick = () => {
      progress += (targetRef.current - progress) * 0.08
      hinge.rotation.x = CLOSED_ANGLE + (OPEN_ANGLE - CLOSED_ANGLE) * progress
      // Intro orbit: start angled (blocky form + seam visible) and rotate to exactly
      // head-on. Finishes by 80% open, so the DOM screen only ever appears aligned.
      const cam = Math.max(0, 1 - progress / 0.8)
      const az = 0.95 * cam, el = 0.28 * cam
      camera.position.set(Math.sin(az) * Math.cos(el) * R, Math.sin(el) * R, Math.cos(az) * Math.cos(el) * R)
      camera.lookAt(0, 0, 0)
      const now = performance.now()
      for (const k of keys) {
        const t0 = pressedAt.get(k)
        const base = k.userData.baseZ as number
        k.position.z = t0 ? base - 0.04 * Math.max(0, Math.sin(Math.min(1, (now - t0) / 130) * Math.PI)) : base
      }
      renderer.render(scene, camera)
      raf = requestAnimationFrame(tick)
    }
    tick()

    return () => {
      cancelAnimationFrame(raf)
      ro.disconnect()
      renderer.domElement.removeEventListener('pointerdown', onPointer)
      renderer.dispose()
      renderer.domElement.remove()
      scene.traverse(obj => { if (obj instanceof THREE.Mesh) { obj.geometry.dispose(); (obj.material as THREE.Material).dispose() } })
    }
  }, [])

  return <div ref={stageRef} className="phone-canvas" />
}

// ---- directory ----------------------------------------------------------------
// The number->node directory stays authored config for now; `requires` are the
// target node's flag requirements, checked live against the player's ACHIEVEMENTS
// (the real playthrough case-state). Wiring numbers to actual dialogue nodes and
// moving the directory server-side is the next slice.
type Contact = { name: string; requires: string[] }
const DIRECTORY: Record<string, Contact> = {
  '55501': { name: 'Elias Board', requires: ['elias_number_callable'] }, // dev-grant unlocks -> connects
  '55502': { name: 'Voss Antiquities', requires: ['voss_number_known'] }, // not earned -> voicemail
}

type Mode = 'home' | 'dial' | 'calling' | 'unknown' | 'voicemail' | 'connected'

// ---- screen -------------------------------------------------------------------
function PhoneScreen({ visible, mode, dialed, callee }: { visible: boolean; mode: Mode; dialed: string; callee: string }) {
  const hhmm = new Date().toTimeString().slice(0, 5)
  return <div className={`phone-screen${visible ? ' on' : ''}`}
    style={{ top: `${SCREEN_RECT.top}%`, left: `${SCREEN_RECT.left}%`, width: `${SCREEN_RECT.width}%`, height: `${SCREEN_RECT.height}%` }}>
    <div className="pscr-status"><span>▮▮▮</span><span>GU-NET</span><span>▚▚</span></div>
    {mode === 'home' && <>
      <div className="pscr-clock">{hhmm}</div>
      <div className="pscr-date">17 AUG</div>
      <div className="pscr-dir">55501 · 55502 · 55503</div>
      <div className="pscr-soft"><span>Menu</span><span>Names</span></div>
    </>}
    {mode === 'dial' && <>
      <div className="pscr-num">{dialed || '_'}</div>
      <div className="pscr-soft"><span>▸ Call</span><span>Clr ◂</span></div>
    </>}
    {mode === 'calling' && <><div className="pscr-big">CALLING<span className="pscr-dots" /></div><div className="pscr-num sm">{dialed}</div></>}
    {mode === 'unknown' && <><div className="pscr-big warn">NUMBER NOT</div><div className="pscr-big warn">IN SERVICE</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
    {mode === 'voicemail' && <><div className="pscr-big">VOICEMAIL</div><div className="pscr-callee">{callee}</div><div className="pscr-line">leave a message…</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
    {mode === 'connected' && <><div className="pscr-big ok">CONNECTED</div><div className="pscr-callee">{callee}</div><div className="pscr-line">[connected]</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
  </div>
}

// In-game the phone runs against a live playthrough: it dials the connected phone
// node's directory and, on connect, hands back to the campaign runtime.
export type PhoneSession = { playthroughId: string; onConnect: () => void }

export function PhonePreview({ session }: { session?: PhoneSession } = {}) {
  const [open, setOpen] = useState(Boolean(session)) // in-game the handset opens on its own
  const [screenOn, setScreenOn] = useState(false)
  const [mode, setMode] = useState<Mode>('home')
  const [dialed, setDialed] = useState('')
  const [callee, setCallee] = useState('')
  const [playthroughId, setPlaythroughId] = useState<string | null>(session?.playthroughId ?? null)
  const [achieved, setAchieved] = useState<Set<string>>(new Set())
  const [directory, setDirectory] = useState<{ number: string; name: string }[]>([])
  const [called, setCalled] = useState<Set<string>>(new Set())
  const callTimer = useRef<number | undefined>(undefined)
  const digits = (value: string) => value.replace(/\D/g, '')

  useEffect(() => {
    if (open) { const t = setTimeout(() => setScreenOn(true), 560); return () => clearTimeout(t) }
    setScreenOn(false); setMode('home'); setDialed('')
  }, [open])

  const refreshAchievements = async (id: string) => {
    const res = await fetch(`/api/playthroughs/${id}/achievements`)
    if (res.ok) setAchieved(new Set(await res.json() as string[]))
  }
  // Game session -> load the connected directory. Spike -> a demo playthrough + case-state.
  useEffect(() => {
    let cancelled = false
    ;(async () => {
      if (session) {
        const res = await fetch(`/api/playthroughs/${session.playthroughId}/phone`)
        if (!cancelled && res.ok) setDirectory((await res.json()).numbers || [])
        return
      }
      let id: string | null = null
      const cur = await fetch('/api/playthroughs/current')
      if (cur.ok && cur.status !== 204) id = (await cur.json())?.playthrough?.id ?? null
      if (!id) {
        const made = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: 'glass-harbor' }) })
        if (made.ok) id = (await made.json())?.playthrough?.id ?? null
      }
      if (cancelled || !id) return
      setPlaythroughId(id)
      await refreshAchievements(id)
    })()
    return () => { cancelled = true }
  }, [session])

  const connectable = (num: string) => session ? directory.some(d => digits(d.number) === num) : (DIRECTORY[num] ? DIRECTORY[num].requires.every(f => achieved.has(f)) : false)
  // Glow the handset when a callable, not-yet-called number is waiting.
  const glow = session ? directory.some(d => !called.has(digits(d.number))) : Object.keys(DIRECTORY).some(num => connectable(num) && !called.has(num))

  const grantElias = async () => {
    if (!playthroughId) return
    await fetch(`/api/playthroughs/${playthroughId}/achievements`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ flagKey: 'elias_number_callable' }) })
    await refreshAchievements(playthroughId)
  }

  const resolveCall = (num: string) => {
    sfx.ring()
    setMode('calling')
    window.clearTimeout(callTimer.current)
    callTimer.current = window.setTimeout(async () => {
      setCalled(prev => new Set(prev).add(num))
      if (session) {
        const res = await fetch(`/api/playthroughs/${session.playthroughId}/dial`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ number: num }) })
        const result = res.ok ? await res.json() : { outcome: 'unknown' }
        if (result.outcome === 'connect') { setCallee(result.name || ''); setMode('connected'); window.setTimeout(() => session.onConnect(), 950) }
        else if (result.outcome === 'voicemail') { setCallee(result.name || ''); sfx.voicemail(); setMode('voicemail') }
        else { sfx.unobtainable(); setMode('unknown') }
        return
      }
      const contact = DIRECTORY[num]
      if (!contact) { sfx.unobtainable(); setMode('unknown'); return }
      setCallee(contact.name)
      if (connectable(num)) setMode('connected')
      else { sfx.voicemail(); setMode('voicemail') }
    }, 950)
  }

  const press = (k: string) => {
    // In a result screen, any key hangs up back to the dialer.
    if (mode === 'calling' || mode === 'unknown' || mode === 'voicemail' || mode === 'connected') {
      window.clearTimeout(callTimer.current); setMode(dialed ? 'dial' : 'home'); if (k === 'end') setDialed(''); return
    }
    if (k in DTMF) { sfx.key(k); setDialed(d => (d + k).slice(0, 14)); setMode('dial'); return }
    if (k === 'call' && dialed) { resolveCall(dialed); return }
    if (k === 'end') { setDialed(d => d.slice(0, -1)); if (dialed.length <= 1) setMode('home') }
  }

  // Hardware keyboard convenience: digits, Enter = call, Backspace = delete.
  useEffect(() => {
    if (!open) return
    const onKey = (e: KeyboardEvent) => {
      if (e.key >= '0' && e.key <= '9') press(e.key)
      else if (e.key === '*' || e.key === '#') press(e.key)
      else if (e.key === 'Enter') press('call')
      else if (e.key === 'Backspace') { e.preventDefault(); press('end') }
    }
    window.addEventListener('keydown', onKey)
    return () => window.removeEventListener('keydown', onKey)
  }) // re-bind each render so `press` closes over fresh state

  return <div className="phone-backdrop">
    <div className="phone-stage">
      <PhoneDevice open={open} onKey={press} />
      <PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} />
    </div>
    <button className={`phone-open-btn${glow && !open ? ' glow' : ''}`} onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
    {session
      ? <p className="phone-hint">Key in a number, then press Call.</p>
      : <div className="phone-dev">
        <button className="phone-dev-btn" disabled={!playthroughId || achieved.has('elias_number_callable')} onClick={grantElias}>
          {achieved.has('elias_number_callable') ? '✓ elias_number_callable' : '▸ grant elias_number_callable'}
        </button>
        <p className="phone-hint">dial <code>55501</code> Elias (voicemail → connect once granted) · <code>55502</code> Voss (voicemail) · else unobtainable</p>
      </div>}
  </div>
}
