/**
 * Lane J — the swap itself, as plain scene-graph surgery.
 *
 * Split out of stage.ts so it can be driven headlessly (no WebGL, no canvas):
 * everything here is THREE object maths. The rule it encodes is the one the
 * runtime has to follow too — a custom asset never replaces the procedural node,
 * it goes into a NEW sibling "fit" node and the procedural node is hidden. That
 * keeps reset instant, keeps every reference other systems captured alive, and
 * keeps the manifest's offset/rotation/scale on a node nobody else writes to
 * (feel.ts, telegraph.ts and the machine parts all own transforms of their own).
 *
 * ONE FIT NODE PER PROCEDURAL OBJECT. Several slots are backed by more than one
 * object — nine paint puddles under `fx.puddle`, one barrel per cannon under
 * `cannon.barrel`. Hiding all of them and adding a single replacement made the
 * rest of the course silently disappear. The runtime calls `attachSlot` once per
 * instance (see paint/puddles.ts and paint/cannon.ts), so the editor clones the
 * asset per instance at that instance's own resting pose, and what the editor
 * shows is what the game builds.
 */
import * as THREE from 'three'
import { composeFit } from './manifest-io'
import type { BaseTransform, SlotEntry } from './manifest-io'

export const DEG = Math.PI / 180

/** How a slot clones its asset. Injected so this module never imports GLTF. */
export type Cloner = (object: THREE.Object3D) => THREE.Object3D

const plainClone: Cloner = (object) => object.clone(true)

export interface SlotStageEntry {
  id: string
  /** What the game builds today. Hidden — never removed — while a swap is in. */
  procedural: THREE.Object3D[]
  /** Parent the fit nodes hang off: each procedural object's own parent. */
  parent: THREE.Object3D
  /** Resting pose of the FIRST procedural object, in manifest units. */
  base: BaseTransform
  /** Resting pose of every procedural object, index-aligned with `procedural`. */
  bases: BaseTransform[]
  /**
   * Per-instance extra scale, index-aligned. The runtime stretches a puddle
   * decal to its own strip footprint (`fit:` in paint/puddles.ts); without the
   * same multiplier the editor preview would be a different size to the game.
   */
  extraScale: [number, number, number][]
  /** One wrapper per procedural object, or empty when the slot is procedural. */
  fitNodes: THREE.Group[]
  /** The first wrapper, or null. Kept so single-instance callers read naturally. */
  fitNode: THREE.Group | null
}

/** Read an object's pose in manifest units (degrees, not radians). */
export function readBase(object: THREE.Object3D): BaseTransform {
  const e = new THREE.Euler().setFromQuaternion(object.quaternion, 'XYZ')
  return {
    position: [object.position.x, object.position.y, object.position.z],
    rotationDeg: [e.x / DEG, e.y / DEG, e.z / DEG],
    scale: [object.scale.x, object.scale.y, object.scale.z],
  }
}

/**
 * Farm and Blender exports arrive with shadow flags off, which reads as a
 * floating prop rather than a missing checkbox — so turn them on for everything
 * that lands in a slot.
 */
export function dressAsset(object: THREE.Object3D): void {
  object.traverse((o) => {
    const m = o as THREE.Mesh
    if (!m.isMesh) return
    m.castShadow = true
    m.receiveShadow = true
  })
}

export interface RegisterOptions {
  /** Per-object extra scale (puddle footprints). Index-aligned with `objects`. */
  extraScale?: [number, number, number][]
}

export class SlotSwap {
  readonly slots = new Map<string, SlotStageEntry>()
  private readonly clone: Cloner

  // Plain assignment rather than a parameter property: node's type-stripping
  // mode (how the .test.ts files run) rejects parameter properties outright.
  constructor(clone: Cloner = plainClone) {
    this.clone = clone
  }

  /** Called once per slot as the stage builds. First object defines the pose. */
  register(
    id: string,
    objects: THREE.Object3D[],
    fallbackParent: THREE.Object3D,
    opts: RegisterOptions = {},
  ): void {
    const first = objects[0]
    if (!first) return
    const bases = objects.map(readBase)
    this.slots.set(id, {
      id,
      procedural: objects,
      parent: first.parent ?? fallbackParent,
      base: bases[0]!,
      bases,
      extraScale: objects.map((_, i) => opts.extraScale?.[i] ?? [1, 1, 1]),
      fitNodes: [],
      fitNode: null,
    })
  }

  has(id: string): boolean {
    return this.slots.has(id)
  }

  /** How many of this thing the course actually holds. */
  instanceCount(id: string): number {
    return this.slots.get(id)?.procedural.length ?? 0
  }

  setCustom(id: string, asset: THREE.Object3D, entry: SlotEntry): void {
    const s = this.slots.get(id)
    if (!s) return
    this.clearCustom(id)
    s.procedural.forEach((original, i) => {
      const fitNode = new THREE.Group()
      fitNode.name = s.procedural.length > 1 ? `fit:${id}#${i}` : `fit:${id}`
      // Always a copy, never `asset` itself: the caller caches parsed scenes and
      // re-uses them across slots, and a scene graph node can only have one
      // parent — adopting the original would tear it out of wherever it was.
      const instance = this.clone(asset)
      dressAsset(instance)
      fitNode.add(instance)
      ;(original.parent ?? s.parent).add(fitNode)
      s.fitNodes.push(fitNode)
      original.visible = false
    })
    s.fitNode = s.fitNodes[0] ?? null
    this.applyFit(id, entry)
  }

  clearCustom(id: string): void {
    const s = this.slots.get(id)
    if (!s) return
    for (const node of s.fitNodes) node.parent?.remove(node)
    s.fitNodes = []
    s.fitNode = null
    for (const p of s.procedural) p.visible = true
  }

  applyFit(id: string, entry: SlotEntry): void {
    const s = this.slots.get(id)
    if (!s) return
    s.fitNodes.forEach((node, i) => {
      const base = s.bases[i] ?? s.base
      const extra = s.extraScale[i] ?? [1, 1, 1]
      const fit = composeFit(base, entry)
      node.position.set(fit.position[0], fit.position[1], fit.position[2])
      node.rotation.set(
        fit.rotationDeg[0] * DEG, fit.rotationDeg[1] * DEG, fit.rotationDeg[2] * DEG)
      node.scale.set(
        fit.scale[0] * extra[0], fit.scale[1] * extra[1], fit.scale[2] * extra[2])
    })
  }

  /** What the fit panel is acting on: the custom asset if any, else the original. */
  currentObject(id: string): THREE.Object3D | null {
    const s = this.slots.get(id)
    if (!s) return null
    return s.fitNodes[0] ?? s.procedural[0] ?? null
  }

  /** True when this slot is showing a custom asset. */
  isCustom(id: string): boolean {
    return (this.slots.get(id)?.fitNodes.length ?? 0) > 0
  }
}
