/**
 * Lane J — the failure paths, headlessly.
 *
 * workshop.test.ts covers the happy path. This one covers what the editor does
 * when things go wrong, because every case here was once a silent data loss or a
 * frozen UI:
 *
 *   [1] a .glb with no model in it, dropped on a slot that isn't the paintable
 *       body — used to hide the original and show nothing;
 *   [2] a slot the game builds nine of — used to hide all nine and add one;
 *   [3] a full/blocked IndexedDB — used to reject into an unhandled promise;
 *   [4] a saved swap whose stored bytes no longer load — used to be dropped from
 *       the manifest, after which the next save deleted the bytes for good;
 *   [5] the slot list agreeing with the runtime's, by construction.
 *
 *   node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
 *        src/editor/hardening.test.ts
 */
import * as THREE from 'three'

// ---- in-memory IndexedDB with a fault switch -------------------------------
type Store = Map<string, unknown>
const dbs = new Map<string, Map<string, Store>>()
/** Flip to make every write fail the way a full disk does. */
let failWrites = false

const fakeIndexedDb = {
  open(name: string) {
    const req: Record<string, unknown> = {
      result: null, onsuccess: null, onerror: null, onupgradeneeded: null, onblocked: 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()),
      close: () => {}, // the runtime's reader closes its handle; the editor's pools it
      transaction: (s: string) => ({
        objectStore: (_n: string) => {
          const store = stores.get(s)!
          const wrap = <T>(result: T, fail = false): Record<string, unknown> => {
            const r: Record<string, unknown> = {
              result, onsuccess: null, onerror: null,
              error: fail ? new Error('QuotaExceededError') : null,
            }
            queueMicrotask(() => {
              if (fail) (r.onerror as (() => void) | null)?.()
              else (r.onsuccess as (() => void) | null)?.()
            })
            return r
          }
          return {
            get: (k: string) => wrap(store.get(k)),
            put: (v: unknown, k: string) => {
              if (failWrites) return wrap(undefined, true)
              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
  },
  deleteDatabase(name: string) {
    const req: Record<string, unknown> = { onsuccess: null, onerror: null, onblocked: null }
    dbs.delete(name)
    queueMicrotask(() => void (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 { BLOB_STORE, idbKeys, idbPut } = await import('./idb')
const { saveOverrideManifest } = await import('./store')
const { SLOTS, SLOT_IDS } = await import('./slots')
const runtimeSlots = await import('../assets/slots')
type EditorStage = import('./stage').EditorStage
type Entry = import('./manifest-io').SlotEntry

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

const scene = new THREE.Scene()
const swap = new SlotSwap()
const makeMesh = (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
}
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

// ---- [1] a file with no model in it -----------------------------------------
const BOOT = 'machine.boot'
swap.register(BOOT, [makeMesh('boot', [-6, 0.2, -49.5])], scene)
const workshop = new Workshop(stage)
const bootSlot = swap.slots.get(BOOT)!

const empty = await workshop.dropFile(BOOT, 'empty.glb', buildTinyGlb({ withMesh: false }))
ok(empty.rejected, '[1] a .glb with no mesh is refused on a non-paintable slot')
ok(/no model inside this file/i.test(empty.message), '[1] the refusal says so in plain words')
ok(bootSlot.procedural.every((p) => p.visible), '[1] the original is still on screen')
ok(bootSlot.fitNodes.length === 0, '[1] nothing was put in its place')
ok(!workshop.hasCustom(BOOT), '[1] nothing was written to the manifest')
ok((await idbKeys(BLOB_STORE)).length === 0, '[1] nothing reached storage either')

// ---- [2] a slot the game builds several of ----------------------------------
const PUD = 'fx.puddle'
const puddleMeshes = [
  makeMesh('p1', [0, 0.05, 10]),
  makeMesh('p2', [4, 0.05, -12]),
  makeMesh('p3', [-3, 0.05, -40]),
]
swap.register(PUD, puddleMeshes, scene, { extraScale: [[2, 1, 3], [1, 1, 1], [4, 1, 2]] })

const puddleDrop = await workshop.dropFile(PUD, 'puddle.glb', buildTinyGlb())
const puddles = swap.slots.get(PUD)!
ok(!puddleDrop.rejected, '[2] the puddle drop was accepted')
ok(puddles.fitNodes.length === 3, `[2] all three puddles were replaced (got ${puddles.fitNodes.length})`)
ok(puddles.procedural.every((p) => !p.visible), '[2] all three originals are hidden')
ok(puddles.fitNodes.every((f, i) =>
  Math.abs(f.position.z - puddleMeshes[i]!.position.z) < 1e-9),
'[2] each replacement sits where its own puddle stood')
ok(puddles.fitNodes.every((f) => f.children.length === 1),
  '[2] each replacement holds its own copy of the model')
ok(new Set(puddles.fitNodes.map((f) => f.children[0])).size === 3,
  '[2] the three copies are three distinct objects, not one object moved about')
ok(Math.abs(puddles.fitNodes[2]!.scale.x - 4) < 1e-9,
  "[2] each replacement is stretched to its own puddle's footprint")
ok(/all 3/.test(puddleDrop.message), `[2] the message says how many changed: "${puddleDrop.message}"`)
workshop.reset(PUD)
ok(puddles.procedural.every((p) => p.visible) && puddles.fitNodes.length === 0,
  '[2] reset brings every puddle back')

// ---- [3] storage that refuses to keep anything ------------------------------
failWrites = true
const full = await workshop.dropFile(BOOT, 'huge.glb', buildTinyGlb())
ok(full.rejected, '[3] a drop the browser cannot store is refused, not thrown')
ok(/wasn't room in this browser/i.test(full.message),
  `[3] the refusal is plain English: "${full.message}"`)
ok(bootSlot.fitNodes.length === 0 && bootSlot.procedural.every((p) => p.visible),
  '[3] the slot stayed procedural')
ok(!workshop.hasCustom(BOOT), '[3] the manifest was not touched')

const failedSave = await workshop.saveLocally()
ok(failedSave.ok === false, '[3] a save the browser refuses comes back as a result, not a throw')
ok(/wasn't room in this browser/i.test(failedSave.message), '[3] and it says so in plain words')
failWrites = false

// ---- [4] a saved swap whose file will not load ------------------------------
// The exact shape of the loss: bytes ARE there but truncated, so the load fails.
await idbPut(BLOB_STORE, 'machine.boot-lost.glb', new TextEncoder().encode('trunc').buffer)
await saveOverrideManifest({ [BOOT]: { url: 'idb:machine.boot-lost.glb' } })

const reopened = new Workshop(stage)
const result = await reopened.restore()
ok(result.restored.length === 0, '[4] the broken swap did not come back on screen')
ok(result.unloadable.includes(BOOT), '[4] it is reported as unloadable rather than forgotten')
ok(reopened.hasCustom(BOOT), '[4] the entry is STILL in the working manifest')
ok(reopened.isUnloadable(BOOT), '[4] and is flagged so the UI can say the file is missing')

const saved = await reopened.saveLocally()
ok(saved.ok && saved.saved === 1, '[4] saving after that keeps the entry')
const keysAfter = (await idbKeys(BLOB_STORE)).map(String)
ok(keysAfter.includes('machine.boot-lost.glb'),
  `[4] and the stored file was NOT deleted (keys: ${JSON.stringify(keysAfter)})`)

// ---- [5] the slot list is the runtime's, not a copy --------------------------
ok(JSON.stringify(SLOT_IDS) === JSON.stringify([...runtimeSlots.SLOT_IDS]),
  '[5] the editor offers exactly the runtime slots, in the runtime order')
ok(SLOTS.every((s) => runtimeSlots.isSlotId(s.id)),
  '[5] every slot the editor describes is one the runtime accepts')
ok(SLOTS.every((s) => s.label.length > 0 && s.hint.length > 0),
  '[5] every slot has a name and a sentence, including ones with no bespoke text')
ok(SLOTS.every((s) => !/[a-z]+\.[a-z]/.test(s.label)),
  '[5] no slot id leaks into a label shown on screen')

// ---- [6] the game can read what the editor writes ---------------------------
// Cross-lane: src/assets/idb.ts `getBlob` feeds its result straight into
// `new Blob([buf])`. The editor used to store a `{name, bytes, savedAt}` wrapper
// there, which produces a Blob of the string "[object Object]" and a GLB the
// game silently fails to parse. Both sides must speak raw ArrayBuffer.
{
  const { putGlb } = await import('./store')
  const runtimeIdb = await import('../assets/idb')
  const bytes = buildTinyGlb({ half: 0.7 })
  const url = await putGlb('machine.arch-cross-lane.glb', 'cross-lane.glb', bytes)
  const readBack = await runtimeIdb.getBlob(runtimeIdb.idbKeyFromUrl(url))
  ok(readBack instanceof ArrayBuffer,
    `[6] the game's reader gets an ArrayBuffer back (got ${Object.prototype.toString.call(readBack)})`)
  ok(readBack!.byteLength === bytes.byteLength,
    '[6] byte-for-byte the same file the editor was handed')
}

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