/**
 * Headless end-to-end check of the drop → store → swap → export → restore path.
 *
 * The real SlotSwap does the scene surgery (it is pure THREE, no WebGL), the
 * real Workshop drives it, and a small in-memory stand-in plays IndexedDB so the
 * persistence path is exercised rather than mocked away. Only the WebGL stage
 * shell and the DOM panels are absent — those are the parts this cannot reach
 * without a browser.
 *
 *   node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
 *        src/editor/workshop.test.ts
 */
import * as THREE from 'three'

// ---- in-memory IndexedDB (installed before anything imports the store) -----
type Store = Map<string, unknown>
const dbs = new Map<string, Map<string, Store>>()

function fireLater<T>(req: { onsuccess: (() => void) | null; result: T }): void {
  queueMicrotask(() => req.onsuccess?.())
}

const fakeIndexedDb = {
  open(name: string) {
    const req: Record<string, unknown> = { result: null, onsuccess: null, onerror: null, onupgradeneeded: null }
    const stores = dbs.get(name) ?? new Map<string, Store>()
    dbs.set(name, stores)
    const db = {
      objectStoreNames: { contains: (s: string) => stores.has(s) },
      createObjectStore: (s: string) => void stores.set(s, new Map()),
      transaction: (s: string) => ({
        objectStore: (_n: string) => {
          const store = stores.get(s)!
          const wrap = <T>(result: T): Record<string, unknown> => {
            const r: Record<string, unknown> = { result, onsuccess: null, onerror: null }
            fireLater(r as never)
            return r
          }
          return {
            get: (k: string) => wrap(store.get(k)),
            put: (v: unknown, k: string) => { store.set(k, v); return wrap(undefined) },
            delete: (k: string) => { store.delete(k); return wrap(undefined) },
            getAllKeys: () => wrap([...store.keys()]),
            clear: () => { store.clear(); return wrap(undefined) },
          }
        },
      }),
    }
    req.result = db
    queueMicrotask(() => {
      ;(req.onupgradeneeded as (() => void) | null)?.()
      ;(req.onsuccess as (() => void) | null)?.()
    })
    return req
  },
}
;(globalThis as unknown as { indexedDB: unknown }).indexedDB = fakeIndexedDb

const { Workshop } = await import('./workshop')
const { SlotSwap } = await import('./slot-swap')
const { buildTinyGlb } = await import('./tiny-glb')
const { manifestsEqual, parseManifest, isIdbUrl, idbKey } = await import('./manifest-io')
const { getGlb, loadOverrideManifest } = await import('./store')
type EditorStage = import('./stage').EditorStage

let passed = 0
function ok(cond: boolean, msg: string): void {
  if (!cond) throw new Error('FAIL: ' + msg)
  passed++
}

// ---- a stage stand-in whose swap logic is the REAL SlotSwap ----------------
const scene = new THREE.Scene()
const swap = new SlotSwap()

const makeProcedural = (name: string, pos: [number, number, number]): THREE.Mesh => {
  const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
  mesh.name = name
  mesh.position.set(...pos)
  scene.add(mesh)
  return mesh
}
swap.register('machine.boot', [makeProcedural('boot', [-6, 0.2, -49.5])], scene)
swap.register('blob.body', [makeProcedural('body', [0, 3.5, 30])], scene)

type Entry = import('./manifest-io').SlotEntry
const stage = {
  slots: swap.slots,
  setCustom: (s: string, a: THREE.Object3D, e: Entry) => swap.setCustom(s, a, e),
  clearCustom: (s: string) => swap.clearCustom(s),
  applyFit: (s: string, e: Entry) => swap.applyFit(s, e),
  currentObject: (s: string) => swap.currentObject(s),
  highlight: () => {},
  focus: () => {},
  dispose: () => {},
} as unknown as EditorStage

const workshop = new Workshop(stage)
const BOOT = 'machine.boot'
const bootEntry = swap.slots.get(BOOT)!

// ---- clean session ---------------------------------------------------------
ok(!workshop.hasCustom(BOOT), 'nothing is swapped on a fresh session')
ok(bootEntry.fitNode === null, 'no fit wrapper exists yet')
ok(JSON.stringify(workshop.getManifest()) === '{}', 'a fresh session exports an empty manifest')

// ---- the drop --------------------------------------------------------------
const bytes = buildTinyGlb({ half: 1.1 })
const dropped = await workshop.dropFile(BOOT, 'prop-spring-boot.glb', bytes)
ok(!dropped.rejected, `the drop was accepted (${dropped.message})`)
ok(bootEntry.fitNode !== null, 'the swap created a fit wrapper')
ok(bootEntry.fitNode!.children.length === 1, 'the dropped model is inside the wrapper')
ok(bootEntry.procedural.every((p) => !p.visible), 'the procedural boot was hidden')
ok(bootEntry.procedural[0]!.parent === scene, 'the procedural boot is still in the scene, just hidden')
ok(isIdbUrl(dropped.entry.url), 'the manifest entry uses an idb: url')
const stored = await getGlb(idbKey(dropped.entry.url))
ok(stored !== undefined && stored.bytes.byteLength === bytes.byteLength,
  'the GLB bytes reached storage intact')
ok(workshop.hasCustom(BOOT), 'the slot now reports a custom asset')

// shadows: a farm export arrives with them off and must not float
let anyShadow = false
bootEntry.fitNode!.traverse((o) => { if ((o as THREE.Mesh).isMesh && o.castShadow) anyShadow = true })
ok(anyShadow, 'the dropped model casts a shadow (not left floating)')

// the swap lands exactly on the procedural pose with no fit applied
ok(bootEntry.fitNode!.position.distanceTo(bootEntry.procedural[0]!.position) < 1e-9,
  'with no nudging, the custom model sits exactly where the original stood')

// ---- fit -------------------------------------------------------------------
workshop.setFit(BOOT, { offset: [0, 1.5, 0], rotationDeg: [0, 90, 0], scale: 2 })
ok(Math.abs(bootEntry.fitNode!.position.y - (0.2 + 1.5)) < 1e-9, 'raising it moves it exactly that far')
ok(Math.abs(bootEntry.fitNode!.rotation.y - Math.PI / 2) < 1e-9, 'turning it rotates it')
ok(Math.abs(bootEntry.fitNode!.scale.x - 2) < 1e-9, 'resizing it scales it')

// ---- export round-trip -----------------------------------------------------
const text = workshop.exportText()
const back = parseManifest(text)
ok(manifestsEqual(workshop.getManifest(), back), 'the exported manifest round-trips exactly')
ok(back[BOOT]!.offset?.[1] === 1.5, 'the fit survives export and re-import')

// ---- values survive a slot switch -----------------------------------------
await workshop.dropFile('blob.body', 'tiny.glb', buildTinyGlb())
ok(workshop.entry(BOOT)?.offset?.[1] === 1.5, "another slot's drop does not disturb the boot's fit")
ok(workshop.hasCustom('blob.body'), 'the second slot swapped too')

// ---- a body that paint cannot stick to is refused --------------------------
workshop.reset('blob.body')
const bad = await workshop.dropFile('blob.body', 'no-uvs.glb', buildTinyGlb({ withUv: false }))
ok(bad.rejected, 'a body model with no UV map is refused')
ok(!workshop.hasCustom('blob.body'), 'the refused model is not written into the manifest')
ok(swap.slots.get('blob.body')!.procedural.every((p) => p.visible),
  'the plain blob is still on screen after a refusal')
ok(bad.paint !== undefined && !bad.paint.uvOk, 'the refusal carries the paint report')
ok(/paint would not stick/i.test(bad.message), 'the refusal is explained in plain words')

// ---- junk bytes are refused, not thrown ------------------------------------
const junk = await workshop.dropFile(BOOT, 'notes.txt', new TextEncoder().encode('hello').buffer)
ok(junk.rejected, 'a non-GLB file is refused')
ok(bootEntry.fitNode !== null, 'a refused drop leaves the previous good swap alone')
ok(workshop.entry(BOOT)?.url === dropped.entry.url, 'a refused drop does not overwrite the manifest entry')

// ---- derived slots refuse drops with an explanation ------------------------
swap.register('ghost.body', [makeProcedural('ghost', [2, 3.5, 30])], scene)
const ghost = await workshop.dropFile('ghost.body', 'x.glb', buildTinyGlb())
ok(ghost.rejected, 'the ghost slot refuses its own drop')
ok(/blob body/i.test(ghost.message), 'it points you at the blob body slot instead')

// ---- save + restore --------------------------------------------------------
const saveResult = await workshop.saveLocally()
ok(saveResult.ok, 'saving succeeded')
ok(saveResult.saved === 1, `saving wrote the one live swap (got ${saveResult.saved})`)
const persisted = await loadOverrideManifest()
ok(manifestsEqual(workshop.getManifest(), persisted),
  'what was saved is exactly what the live game reads back')

const reopened = new Workshop(stage)
swap.clearCustom(BOOT)
const restored = await reopened.restore()
ok(restored.restored.includes(BOOT), 'reopening the editor brings the swap back')
ok(restored.unloadable.length === 0, 'nothing was reported as missing')
ok(bootEntry.fitNode !== null, 'the restored swap is on the stage again')
ok(Math.abs(bootEntry.fitNode!.position.y - 1.7) < 1e-9, 'the restored swap keeps its fit')

// ---- reset -----------------------------------------------------------------
reopened.reset(BOOT)
ok(bootEntry.fitNode === null, 'reset removes the custom model')
ok(bootEntry.procedural.every((p) => p.visible), 'reset shows the original again')
ok(!reopened.hasCustom(BOOT), 'reset clears the manifest entry')

// ---- clear everything ------------------------------------------------------
await workshop.clearEverything()
const afterClear = await loadOverrideManifest()
ok(Object.keys(afterClear).length === 0, 'Clear wipes the saved manifest')
ok(JSON.stringify(workshop.getManifest()) === '{}', 'Clear empties the working manifest')
ok(swap.slots.get(BOOT)!.procedural.every((p) => p.visible), 'Clear puts every original back on screen')

console.log(`editor workshop.test: ${passed} assertions passed ✓`)
