/**
 * Lane J — the editor stage: the REAL course, built from the game's own builders.
 *
 * Fits are only truthful if you fit against the actual thing, so this imports
 * buildGreybox / createBlob / installZones / the machine factories rather than
 * re-modelling anything. Physics is created (the builders need it) but never
 * stepped: `world.tick()` is never called and no system is registered, so the
 * stage is a frozen tableau. Rendering is a plain rAF calling `world.renderOnce()`
 * — deliberately NOT `world.start()`, which would advance the sim.
 *
 * Slot objects are captured two ways: handles where a builder returns one, and a
 * scene-children diff where it doesn't (installZones and PaintCannon both just
 * `scene.add` internally). The diff is why capture() exists.
 */
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { cloneAsset } from './glb'
import { createWorld } from '../world'
import { buildGreybox } from '../course/greybox'
import { createBlob } from '../blob/createBlob'
import { installZones } from '../course/zones'
import { PaintSkin } from '../paint/skin'
import { PaintCannon } from '../paint/cannon'
import { createBucketDump, createBubbleArch, createConveyorBelt } from '../machine/index'
import type { World } from '../contracts'
import { identityTransform } from './manifest-io'
import type { SlotEntry } from './manifest-io'
import { SlotSwap } from './slot-swap'
import type { SlotStageEntry } from './slot-swap'
import { SLOTS } from './slots'

// Some ids below (`cannon.base`, `course.tramp`, `course.tunnel`,
// `course.finish`) are registered whether or not the runtime knows them yet.
// Registering costs nothing, the left panel only lists ids the runtime does
// support (see slots.ts), and they light up by themselves the day it does.

export type { SlotStageEntry } from './slot-swap'

export interface EditorStage {
  world: World
  controls: OrbitControls
  slots: Map<string, SlotStageEntry>
  /**
   * Swap a slot's visual for a loaded GLB scene. The scene is CLONED, once per
   * procedural instance the slot holds, so the caller keeps its own copy intact
   * and a nine-puddle slot ends up with nine puddles.
   */
  setCustom(slot: string, asset: THREE.Object3D, entry: SlotEntry): void
  /** Drop the custom asset and show today's procedural build again. */
  clearCustom(slot: string): void
  /** Re-apply offset/rotation/scale without reloading the asset. */
  applyFit(slot: string, entry: SlotEntry): void
  /** The object the fit panel is currently acting on (custom asset or procedural). */
  currentObject(slot: string): THREE.Object3D | null
  highlight(slot: string | null): void
  focus(slot: string): void
  dispose(): void
}

export { cloneAsset, loadGlb } from './glb'

export async function createEditorStage(container: HTMLElement): Promise<EditorStage> {
  const world = await createWorld(container)
  world.scene.fog = null // an editor wants to see the far end of the course

  const swap = new SlotSwap(cloneAsset)
  const slots = swap.slots
  const register = (
    id: string, objects: THREE.Object3D[], extraScale?: [number, number, number][],
  ): void => swap.register(id, objects, world.scene, extraScale ? { extraScale } : {})

  /** Everything a builder adds straight to the scene, recovered by diffing. */
  const capture = <T>(fn: () => T): { result: T; added: THREE.Object3D[] } => {
    const before = new Set(world.scene.children)
    const result = fn()
    return { result, added: world.scene.children.filter((c) => !before.has(c)) }
  }

  // ---- the course ----------------------------------------------------------
  const course = buildGreybox(world)
  const meshAt = (x: number, y: number, z: number): THREE.Mesh | undefined =>
    course.meshes.find((m) =>
      Math.abs(m.position.x - x) < 0.01 &&
      Math.abs(m.position.y - y) < 0.01 &&
      Math.abs(m.position.z - z) < 0.01)

  const cereal = meshAt(-20, 7, 26)
  if (cereal) register('course.scenery.cereal', [cereal])
  const block = meshAt(20, 5, 12)
  if (block) register('course.scenery.block', [block])
  const finish = meshAt(0, 0.6, -64)
  if (finish) register('course.finish', [finish])

  // ---- blob ----------------------------------------------------------------
  const blob = createBlob(world, { position: course.spawn })
  const skin = new PaintSkin(blob.mesh, { size: 64 }) // small: nothing paints here
  blob.paint = skin
  world.blob = blob
  register('blob.body', [blob.mesh])
  register('blob.face', [blob.face])

  // Ghost preview. The shipped GhostPlayer only builds itself when a saved run
  // exists, so the editor shows the same thing it would derive: a see-through
  // copy of whatever is in the body slot, parked beside the blob.
  const ghostRoot = new THREE.Group()
  ghostRoot.position.set(course.spawn.x + 2.2, course.spawn.y, course.spawn.z)
  world.scene.add(ghostRoot)
  const rebuildGhost = (source: THREE.Object3D): void => {
    ghostRoot.clear()
    const copy = cloneAsset(source)
    copy.position.set(0, 0, 0)
    copy.rotation.set(0, 0, 0)
    copy.traverse((o) => {
      const m = o as THREE.Mesh
      if (!m.isMesh) return
      // Clone the material: the source may share it with the real blob (or with
      // other instances via the asset cache) and we must not make those sheer.
      const src = Array.isArray(m.material) ? m.material[0] : m.material
      const ghostMat = (src as THREE.Material).clone() as THREE.MeshStandardMaterial
      ghostMat.transparent = true
      ghostMat.opacity = 0.35
      ghostMat.depthWrite = false
      m.material = ghostMat
    })
    copy.renderOrder = 2
    ghostRoot.add(copy)
  }
  rebuildGhost(blob.mesh)
  register('ghost.body', [ghostRoot.children[0]!])

  // ---- zones: puddles, the purple fork's plate + boot, the MINI tunnel ------
  // installZones registers systems, but nothing ever ticks them here.
  const zoned = capture(() => installZones(world, blob, skin))
  const zones = zoned.result
  // The plate and boot are the only Groups zones adds; the tunnel's roof/bar/
  // posts arrive as loose Meshes. Order is stable (plate, then boot, then tunnel).
  const zoneGroups = zoned.added.filter((o) => o instanceof THREE.Group)
  if (zoneGroups[0]) register('machine.plate', [zoneGroups[0]])
  if (zoneGroups[1]) register('machine.boot', [zoneGroups[1]])
  // Each puddle is a BoxGeometry(w, 0.05, l) at scale 1 and the runtime stretches
  // a custom decal to that footprint (paint/puddles.ts passes `fit: (w,1,l)`).
  // Reading the same numbers back off the geometry keeps the preview honest.
  if (zones.puddles.length) {
    const puddleMeshList = zones.puddles.map((p) => p.mesh)
    const footprints = puddleMeshList.map((m): [number, number, number] => {
      const params = (m.geometry as THREE.BoxGeometry).parameters as
        { width?: number; depth?: number } | undefined
      return [params?.width ?? 1, 1, params?.depth ?? 1]
    })
    register('fx.puddle', puddleMeshList, footprints)
  }
  // The tunnel is whatever loose Meshes are left once the puddles are accounted
  // for — installPuddles runs FIRST and also adds bare Meshes to the scene, so
  // filtering on "not a Group" alone would swallow all ten puddles.
  const puddleMeshes = new Set<THREE.Object3D>(zones.puddles.map((p) => p.mesh))
  const tunnelParts = zoned.added.filter(
    (o) => !(o instanceof THREE.Group) && !puddleMeshes.has(o))
  if (tunnelParts.length) register('course.tunnel', tunnelParts)

  // ---- centre-lane machines (same configs as the shipped game) -------------
  const bucket = createBucketDump(world, {
    id: 'bucket-1', position: [-1.4, 6, -50], color: 'purple', radius: 0.8,
  })
  register('machine.bucket', [bucket.group])
  const belt = createConveyorBelt(world, {
    id: 'belt-1', position: [0, 0.0, -50], size: [3, 0.5, 10], velocity: [0, 0, -3],
  })
  register('machine.belt', [belt.group])
  const arch = createBubbleArch(world, {
    id: 'arch-1', position: [-4.5, 0.12, -56], fraction: 0.6,
  })
  register('machine.arch', [arch.group])

  // ---- cannons: built for their look only; never added as systems ----------
  const cannonCaptures = zones.cannonConfigs.map((spec) =>
    capture(() => new PaintCannon({
      world,
      position: new THREE.Vector3(...spec.position),
      color: spec.color,
      target: blob.mesh,
      paint: skin,
      targetRadius: blob.radius,
    })))
  const barrels = cannonCaptures.flatMap((c) => c.added)
  // A barrel group is [base mesh, 'pivot' group] — the plan splits those into
  // two slots, and PaintCannon.aimBarrel looks the pivot up BY NAME, so the
  // pivot node must survive any swap (a custom barrel goes inside it).
  const bases = barrels.map((b) => b.children[0]).filter(Boolean) as THREE.Object3D[]
  const pivots = barrels
    .map((b) => b.children.find((c) => c.name === 'pivot'))
    .filter(Boolean) as THREE.Object3D[]
  if (bases.length) register('cannon.base', bases)
  if (pivots.length) register('cannon.barrel', pivots)

  // ---- gap launcher --------------------------------------------------------
  // game.ts builds this inline and game.ts is frozen, so the stage mirrors its
  // geometry here. Reported as friction: the editor can preview a fit for this
  // slot but nothing reads it until game.ts asks the registry.
  const tramp = new THREE.Mesh(
    new THREE.BoxGeometry(12, 0.5, 4),
    new THREE.MeshStandardMaterial({ color: '#FF9500', roughness: 0.6 }))
  tramp.position.set(0, 0.25, 0)
  tramp.receiveShadow = true
  world.scene.add(tramp)
  register('course.tramp', [tramp])

  // ---- camera + orbit ------------------------------------------------------
  world.camera.position.set(14, 12, 34)
  const controls = new OrbitControls(world.camera, world.renderer.domElement)
  controls.target.set(0, 2, 22)
  controls.enableDamping = true
  controls.maxPolarAngle = Math.PI * 0.49 // never orbit under the floor
  controls.update()

  // ---- selection highlight -------------------------------------------------
  const marker = new THREE.Mesh(
    new THREE.SphereGeometry(1, 20, 14),
    new THREE.MeshBasicMaterial({
      color: '#FFD60A', wireframe: true, transparent: true, opacity: 0.85,
    }))
  marker.visible = false
  marker.renderOrder = 3
  world.scene.add(marker)
  const markerBox = new THREE.Box3()
  const markerCenter = new THREE.Vector3()
  const markerSize = new THREE.Vector3()
  let markerPulse = 0

  const highlight = (slot: string | null): void => {
    const entry = slot ? slots.get(slot) : undefined
    const object = entry ? (entry.fitNode ?? entry.procedural[0]) : undefined
    if (!object) {
      marker.visible = false
      return
    }
    object.updateWorldMatrix(true, true)
    markerBox.setFromObject(object)
    if (markerBox.isEmpty()) {
      marker.visible = false
      return
    }
    markerBox.getCenter(markerCenter)
    markerBox.getSize(markerSize)
    marker.position.copy(markerCenter)
    marker.scale.setScalar(Math.max(0.6, markerSize.length() * 0.55))
    marker.visible = true
  }

  let selected: string | null = null

  // ---- render loop: frame hooks + render only. Physics never advances. -----
  let raf = 0
  let disposed = false
  const loop = (): void => {
    if (disposed) return
    raf = requestAnimationFrame(loop)
    controls.update()
    markerPulse += 0.03
    if (marker.visible) {
      const s = 1 + Math.sin(markerPulse) * 0.04
      marker.scale.multiplyScalar(s / (marker.userData.lastPulse ?? 1))
      marker.userData.lastPulse = s
    }
    world.renderOnce()
  }
  raf = requestAnimationFrame(loop)

  const stage: EditorStage = {
    world,
    controls,
    slots,

    setCustom(slot, asset, entry) {
      swap.setCustom(slot, asset, entry)
      if (slot === 'blob.body') rebuildGhost(asset)
      if (selected === slot) highlight(slot)
    },

    clearCustom(slot) {
      swap.clearCustom(slot)
      if (slot === 'blob.body') rebuildGhost(blob.mesh)
      if (selected === slot) highlight(slot)
    },

    applyFit(slot, entry) {
      swap.applyFit(slot, entry)
      if (selected === slot) highlight(slot)
    },

    currentObject(slot) {
      return swap.currentObject(slot)
    },

    highlight(slot) {
      selected = slot
      highlight(slot)
    },

    focus(slot) {
      const spec = SLOTS.find((x) => x.id === slot)
      const s = slots.get(slot)
      const target = new THREE.Vector3()
      if (s) {
        const object = s.fitNode ?? s.procedural[0]!
        object.updateWorldMatrix(true, true)
        const box = new THREE.Box3().setFromObject(object)
        if (!box.isEmpty()) box.getCenter(target)
        else object.getWorldPosition(target)
      } else if (spec) {
        target.set(...spec.focus.target)
      }
      const distance = spec?.focus.distance ?? 10
      controls.target.copy(target)
      world.camera.position.set(
        target.x + distance * 0.55, target.y + distance * 0.5, target.z + distance * 0.8)
      controls.update()
    },

    dispose() {
      disposed = true
      cancelAnimationFrame(raf)
      controls.dispose()
      world.renderer.dispose()
      world.renderer.domElement.remove()
    },
  }

  // Nothing is selected on boot; keep the marker off so an untouched session is
  // visually identical to the game's own course.
  return stage
}

export const stageSlotIds = (stage: EditorStage): string[] => [...stage.slots.keys()]

export { identityTransform }
