import { RenderConfig } from "../engine/render/RenderConfig"
import { Camera } from "../engine/scene/Camera"
import type { Mesh } from "../engine/scene/Mesh"
import { Mob, MOB_KINDS, type MobKind } from "../game/actors/Mob"
import type { Vec3 } from "../engine/math/Vec3"
import { loadTextures } from "./assets"
import { buildLevel, type Level } from "../game/level"
import { EYE_HEIGHT, Player } from "../game/player"
import { createRenderer } from "./renderer"
import { chunkFar, visibleChunks, visibleMobs, type Scene } from "../game/renderScene"

const FOV_DEGREES = 75
const FOV = (FOV_DEGREES * Math.PI) / 180
/** How close (world units) a mob must be to the player to get a live collider.
 *  Mobs farther than this can't be touched this frame, so skip them -- keeps the
 *  per-frame collider list (and the player's collision loop) short. */
const MOB_COLLIDE_RANGE = 3

const screen = document.querySelector<HTMLCanvasElement>("#screen")!
const ctx = screen.getContext("2d")!
const fpsEl = document.querySelector<HTMLDivElement>("#fps")!

/** Deterministic flythrough (virtual time from frame index), so the st and mt
 *  bench runs render the exact same work. Deliberately stands *inside* the dense
 *  tree ring (radius ~40–100) and sweeps a full 360° yaw so the frame is filled
 *  with forest -- the heavy case that quantizes to 30fps, not the empty clearing. */
function benchCamera(tv: number): Camera {
  const drift = tv * 0.12
  const radius = 65 + 30 * Math.sin(tv * 0.25)
  return {
    position: { x: Math.cos(drift) * radius, y: 3, z: Math.sin(drift) * radius },
    yaw: tv * 0.7,
    pitch: 0.05 * Math.sin(tv * 0.5),
    fov: FOV,
  }
}

function round2(n: number): number {
  return Math.round(n * 100) / 100
}

function benchStats(a: number[]): { median: number; p95: number; max: number; mean: number } {
  const s = a.toSorted((x, y) => x - y)
  const q = (p: number): number => s[Math.min(s.length - 1, Math.floor(p * s.length))]
  return { median: round2(q(0.5)), p95: round2(q(0.95)), max: round2(s[s.length - 1]), mean: round2(a.reduce((x, y) => x + y, 0) / a.length) }
}

async function main(): Promise<void> {
  const textures = await loadTextures()
  const level = buildLevel(textures)
  // Build each kind's canonical mesh once, shared by every instance (the sim
  // supplies each mob's per-frame transform). Registry-driven -- a new kind needs
  // no change here.
  const mobMesh = {} as Record<MobKind, Mesh>
  for (const kind of MOB_KINDS) {
    const m: Mesh = { verts: [], indices: [] }
    Mob.build(kind, m)
    mobMesh[kind] = m
  }
  const scene: Scene = {
    chunks: level.chunks,
    floor: level.floor,
    walls: level.walls,
    crate: level.crate,
    npc: { position: level.npcPosition, size: { x: 1.1, y: 1.5 } },
    mobMesh,
    mobCount: level.mobs.length,
    sky: level.sky,
    textures,
  }
  // `?bench=st` / `?bench=mt` runs a scripted flythrough and reports timings.
  const benchMode = new URLSearchParams(globalThis.location.search).get("bench")
  const forceWorkers = benchMode === "mt" ? true : benchMode === "st" ? false : undefined

  let config: RenderConfig = RenderConfig.standard
  const renderer = createRenderer(scene, config, forceWorkers)
  let image = new ImageData(renderer.fb.width, renderer.fb.height)
  let colorBytes = new Uint8ClampedArray(renderer.fb.color.buffer)

  // The canvas backing store IS the internal resolution; the browser/compositor
  // scales the element up (see `layout`). So `present` is one internal-res
  // putImageData with no per-frame window-sized blit on the main thread.
  function retarget(): void {
    const fb = renderer.fb
    screen.width = fb.width
    screen.height = fb.height
    image = new ImageData(fb.width, fb.height)
    colorBytes = new Uint8ClampedArray(fb.color.buffer)
    layout()
  }

  // Size + center the canvas to an integer multiple of the internal res (crisp
  // letterboxed upscale, done by the GPU). Recomputed only on resize/config.
  function layout(): void {
    const fb = renderer.fb
    const scale = Math.max(1, Math.floor(Math.min(globalThis.innerWidth / fb.width, globalThis.innerHeight / fb.height)))
    const w = fb.width * scale
    const h = fb.height * scale
    screen.style.width = `${w}px`
    screen.style.height = `${h}px`
    screen.style.left = `${(globalThis.innerWidth - w) >> 1}px`
    screen.style.top = `${(globalThis.innerHeight - h) >> 1}px`
    screen.style.imageRendering = config.upscaleFilter === "linear" ? "auto" : "pixelated"
  }
  retarget()

  function useConfig(next: RenderConfig): void {
    config = next
    renderer.reconfigure(next)
    retarget()
  }

  function present(): void {
    image.data.set(colorBytes)
    ctx.putImageData(image, 0, 0)
  }

  globalThis.addEventListener("resize", layout)

  if (benchMode) {
    runBench(renderer, level, present, benchMode)
    return
  }

  const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true }
  // Colliders past this index are the dynamic mob ones, rebuilt every frame.
  const staticColliderCount = level.colliders.length
  const keys = new Set<string>()
  globalThis.addEventListener("keydown", (e) => {
    keys.add(e.code)
    if (e.code === "Digit1") {
      useConfig(RenderConfig.standard)
    }
    if (e.code === "Digit2") {
      useConfig(RenderConfig.soft)
    }
    if (e.code === "Digit3") {
      useConfig(RenderConfig.clean)
    }
  })
  globalThis.addEventListener("keyup", (e) => {
    keys.delete(e.code)
  })
  // Losing focus (alt-tab, pointer-lock exit) drops keyup events, so clear held
  // keys or movement sticks on.
  globalThis.addEventListener("blur", () => {
    keys.clear()
  })
  screen.addEventListener("click", () => {
    screen.requestPointerLock()
  })
  globalThis.addEventListener("mousemove", (e) => {
    if (document.pointerLockElement !== screen) {
      return
    }
    player.yaw += e.movementX * 0.0025
    player.pitch = Math.max(-1.4, Math.min(1.4, player.pitch - e.movementY * 0.0025))
  })

  // Triangles drawn this frame (room + each visible chunk, LOD-aware) for the HUD.
  function frameTris(visible: number[], cam: Camera): number {
    let t = level.floor.indices.length + level.walls.indices.length + level.crate.indices.length
    for (const i of visible) {
      const c = level.chunks[i]
      const groups = chunkFar(c, cam.position, config.lodDistance) ? c.far : c.near
      for (const g of groups) {
        t += g.mesh.indices.length
      }
    }
    return (t / 3) | 0
  }

  // Poll-based pump: present the finished frame, dispatch the next; if workers
  // aren't done we skip this vsync (no async/rAF desync). The HUD reports the
  // critical-path budget (work + present) so the real bottleneck is visible.
  let inFlight = false
  let last = performance.now()
  let fpsLast = last
  let fpsFrames = 0
  let workMax = 0
  let presentMax = 0
  let vsyncMax = 0
  let lastPresent = performance.now()
  let lastVisible: number[] = []
  let lastCamera: Camera = { position: { x: 0, y: 0, z: 0 }, yaw: 0, pitch: 0, fov: FOV }

  function show(): void {
    const p0 = performance.now()
    present()
    const p1 = performance.now()
    presentMax = Math.max(presentMax, p1 - p0)
    vsyncMax = Math.max(vsyncMax, p1 - lastPresent)
    lastPresent = p1
    workMax = Math.max(workMax, renderer.workMs())
    fpsFrames++
  }

  function tick(): void {
    requestAnimationFrame(tick)
    if (inFlight) {
      if (!renderer.done()) {
        return
      }
      show()
      inFlight = false
    }
    const now = performance.now()
    const dt = Math.min(0.05, (now - last) / 1000)
    last = now
    if (now - fpsLast >= 250) {
      const fps = Math.round((fpsFrames * 1000) / (now - fpsLast))
      const tag = renderer.parallel ? "" : " 1core"
      fpsEl.textContent =
        `${fps} fps${tag}\n` +
        `work ${round2(workMax)} + present ${round2(presentMax)} = ${round2(workMax + presentMax)}ms\n` +
        `vsync ${round2(vsyncMax)}ms · ${lastVisible.length} ch · ${frameTris(lastVisible, lastCamera)} tris`
      fpsLast = now
      fpsFrames = 0
      workMax = 0
      presentMax = 0
      vsyncMax = 0
    }
    for (const m of level.mobs) {
      Mob.update(m, dt, level.terrain)
    }
    rebuildMobColliders(level, player.position, staticColliderCount)
    Player.update(player, keys, dt, level)
    const camera: Camera = {
      position: { x: player.position.x, y: player.position.y + EYE_HEIGHT, z: player.position.z },
      yaw: player.yaw,
      pitch: player.pitch,
      fov: FOV,
    }
    const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
    const visible = visibleChunks(level.chunks, viewProj)
    const mobDraws = visibleMobs(level.mobs, viewProj)
    lastVisible = visible
    lastCamera = camera
    renderer.dispatch(camera, viewProj, visible, now / 1000, mobDraws)
    inFlight = true
    if (renderer.done()) {
      show()
      inFlight = false
    }
  }
  requestAnimationFrame(tick)
}

/** Scripted flythrough that records critical-path work time and present-to-present
 *  interval, then reports the distributions (exposed on `window.__BENCH__`). */
function runBench(
  renderer: ReturnType<typeof createRenderer>,
  level: Level,
  present: () => void,
  mode: string,
): void {
  const WARM = 60
  const MEASURE = 300
  const work: number[] = []
  const frame: number[] = []
  let i = 0
  let inFlight = false
  let prev = performance.now()
  let finished = false

  function record(): boolean {
    present()
    const now = performance.now()
    if (i >= WARM) {
      work.push(renderer.workMs())
      frame.push(now - prev)
    }
    prev = now
    inFlight = false
    i++
    return i >= WARM + MEASURE
  }

  function report(): void {
    finished = true
    const result = {
      mode,
      parallel: renderer.parallel,
      cores: (globalThis.navigator as Navigator).hardwareConcurrency,
      coi: (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === true,
      res: `${renderer.fb.width}x${renderer.fb.height}`,
      workMs: benchStats(work),
      frameMs: benchStats(frame),
    }
    ;(globalThis as { __BENCH__?: unknown }).__BENCH__ = result
    console.log(`BENCH ${JSON.stringify(result)}`)
    fpsEl.textContent = `bench ${mode}: work ${result.workMs.median}ms (p95 ${result.workMs.p95})`
  }

  function tick(): void {
    if (finished) {
      return
    }
    requestAnimationFrame(tick)
    if (inFlight) {
      if (!renderer.done()) {
        return
      }
      if (record()) {
        report()
        return
      }
    }
    for (const m of level.mobs) {
      Mob.update(m, 1 / 60, level.terrain)
    }
    const camera = benchCamera(i / 60)
    const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
    const visible = visibleChunks(level.chunks, viewProj)
    const mobDraws = visibleMobs(level.mobs, viewProj)
    renderer.dispatch(camera, viewProj, visible, i / 60, mobDraws)
    inFlight = true
    if (renderer.done() && record()) {
      report()
    }
  }
  requestAnimationFrame(tick)
}

/** Rebuild the dynamic tail of `level.colliders`: keep the static prefix, then add
 *  a block/stand-on AABB for each mob near the player. Mobs move, so these can't be
 *  baked. Only mobs on the ground are `standable` (hop onto a resting frog/perched
 *  robin); bees and airborne birds still block but never make a mid-air platform.
 *  Only mobs within `MOB_COLLIDE_RANGE` are added -- the rest can't be reached this
 *  frame anyway. */
function rebuildMobColliders(level: Level, playerPos: Vec3, staticCount: number): void {
  level.colliders.length = staticCount
  for (const m of level.mobs) {
    const dx = m.position.x - playerPos.x
    const dz = m.position.z - playerPos.z
    if (dx * dx + dz * dz > MOB_COLLIDE_RANGE * MOB_COLLIDE_RANGE) {
      continue
    }
    const half = Mob.boundingRadius(m.kind) * m.scale * 0.7
    level.colliders.push({
      minX: m.position.x - half,
      maxX: m.position.x + half,
      minZ: m.position.z - half,
      maxZ: m.position.z + half,
      top: m.position.y + Mob.bodyHeight(m.kind) * m.scale,
      standable: m.kind !== "bee" && m.grounded,
    })
  }
}

main().catch((error) => {
  console.error(error)
})
