/**
 * Unit checks for the editor's pure core. No test runner / no deps — run directly:
 *   node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
 *        src/editor/manifest-io.test.ts
 * (from the repo root; the --import hook teaches node the repo's extension-less
 * imports, which its ESM resolver rejects on its own). Same shape as
 * paint/coverage-math.test.ts: console + throw only, never bundled.
 */
import {
  IDB_README, SHIPPABLE_README,
  cleanManifest, composeFit, idbKey, identityTransform, isIdbUrl, isNeutralFit,
  manifestsEqual, parseEntry, parseManifest, serializeManifest,
} from './manifest-io'
import type { Manifest } from './manifest-io'
import { buildTinyGlb, isGlb } from './tiny-glb'
import { SLOTS, SLOT_IDS, slotById } from './slots'

// node:fs, reached through a computed specifier so tsc (whose lib is DOM-only,
// and whose config this lane may not edit) never tries to resolve it. Only this
// file — which never ships — touches it.
const { readFileSync } = await import('node' + ':fs')

let passed = 0
function ok(cond: boolean, msg: string): void {
  if (!cond) throw new Error('FAIL: ' + msg)
  passed++
}
function eq(a: unknown, b: unknown, msg: string): void {
  ok(JSON.stringify(a) === JSON.stringify(b), `${msg} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`)
}

// ---- the slot list, the stage and the runtime ------------------------------
// Static source check: the stage builds its objects inside WebGL-only code that
// can't run here, but the registration calls are greppable.
//
// The three lists are deliberately NOT required to be equal, because two honest
// gaps exist and hiding either one is how the editor started lying:
//
//   NOT_IN_THIS_COURSE — the runtime supports the slot but this course builds no
//     such object (nothing calls createFan or createSeeSaw). The editor still
//     LISTS these, marked unavailable, rather than pretending the game has no
//     fan slot at all. `stage.slots.has(id)` is what the UI tests at run time.
//   NOT_YET_IN_THE_RUNTIME — the plan's table has these and the stage already
//     builds them, but src/assets/slots.ts has not adopted the ids yet, so the
//     runtime would discard the manifest entry. The editor must NOT offer them
//     until it does; the registrations cost nothing and light up on the day.
{
  const stageSource = readFileSync(new URL('./stage.ts', import.meta.url), 'utf8')
  const registered = new Set(
    [...stageSource.matchAll(/register\('([^']+)'/g)].map((m) => m[1]!))
  const knownIds = new Set<string>(SLOT_IDS)
  const NOT_IN_THIS_COURSE = new Set(['machine.fan', 'machine.seesaw'])
  const NOT_YET_IN_THE_RUNTIME = new Set([
    'cannon.base', 'course.tramp', 'course.tunnel', 'course.finish',
  ])
  for (const id of SLOT_IDS) {
    ok(registered.has(id) || NOT_IN_THIS_COURSE.has(id),
      `the stage builds something for the "${id}" slot, or it is a known course gap`)
  }
  for (const id of registered) {
    ok(knownIds.has(id) || NOT_YET_IN_THE_RUNTIME.has(id),
      `stage.ts registers "${id}", which the runtime knows about (or is queued for it)`)
  }
  for (const id of NOT_YET_IN_THE_RUNTIME) {
    ok(!knownIds.has(id),
      `"${id}" is still absent from the runtime — move it out of this list once added`)
  }
}

// ---- slot catalogue --------------------------------------------------------
{
  ok(SLOT_IDS.length === new Set(SLOT_IDS).size, 'slot ids are unique')
  ok(SLOTS.every((s) => s.label.length > 0 && s.hint.length > 0), 'every slot is labelled')
  ok(SLOTS.every((s) => !/[._]/.test(s.label)), 'no slot id leaks into an on-screen label')
  const derived = SLOTS.filter((s) => s.derivedFrom)
  ok(derived.length > 0, 'at least one derived slot exists (the ghost)')
  ok(derived.every((s) => slotById(s.derivedFrom!) !== undefined), 'derived slots point at real slots')
  ok(SLOTS.filter((s) => s.paintable).length === 1, 'exactly one paintable slot (the body)')
}

// ---- entry parsing ---------------------------------------------------------
{
  ok(parseEntry(null) === null, 'null is not an entry')
  ok(parseEntry({}) === null, 'an entry without a url is dropped')
  ok(parseEntry({ url: '' }) === null, 'an empty url is dropped')
  eq(parseEntry({ url: 'a.glb' }), { url: 'a.glb' }, 'a bare url survives')
  eq(parseEntry({ url: 'a.glb', offset: [1, 2] }), { url: 'a.glb' }, 'a short offset is dropped')
  eq(parseEntry({ url: 'a.glb', offset: [1, 'x', 3] }), { url: 'a.glb' }, 'a non-numeric offset is dropped')
  eq(parseEntry({ url: 'a.glb', scale: 2 }), { url: 'a.glb', scale: 2 }, 'a uniform scale survives')
  eq(parseEntry({ url: 'a.glb', scale: [1, 2, 3] }), { url: 'a.glb', scale: [1, 2, 3] }, 'a triple scale survives')
  eq(parseEntry({ url: 'a.glb', idleClip: 'idle' }), { url: 'a.glb', idleClip: 'idle' }, 'idleClip survives')
}

// ---- manifest parsing is total --------------------------------------------
{
  eq(parseManifest('not json at all'), {}, 'bad JSON parses to an empty manifest')
  eq(parseManifest('[1,2,3]'), {}, 'an array parses to an empty manifest')
  eq(parseManifest('null'), {}, 'null parses to an empty manifest')
  eq(parseManifest('{}'), {}, 'an empty object parses to an empty manifest')
  eq(parseManifest('{"machine.boot":{"url":"a.glb"},"bad":7}'),
    { 'machine.boot': { url: 'a.glb' } }, 'malformed slots are skipped, good ones kept')
  eq(parseManifest(`{"_readme":"hi","machine.boot":{"url":"a.glb"}}`),
    { 'machine.boot': { url: 'a.glb' } }, '_readme is metadata, not a slot')
}

// ---- round trip ------------------------------------------------------------
{
  const m: Manifest = {
    'machine.boot': { url: 'idb:machine.boot-prop-spring-boot.glb', offset: [0, 0.2, 0], rotationDeg: [0, 90, 0], scale: 1.4 },
    'blob.body': { url: 'assets/live/blobbo.glb', scale: [1, 1.2, 1], idleClip: 'idle' },
  }
  const text = serializeManifest(m)
  const back = parseManifest(text)
  ok(manifestsEqual(m, back), 'serialize → parse round-trips exactly')
  eq(back, cleanManifest(m), 'the round trip deep-equals the cleaned manifest')
  ok(JSON.parse(text)._readme === IDB_README, 'an export holding idb: urls carries the commit-it note')
  ok(Object.keys(JSON.parse(text)).indexOf('_readme') === 0, '_readme comes first so it reads like a header')
  ok(text.endsWith('\n'), 'the export ends with a newline (diff-friendly)')

  const shippable: Manifest = { 'machine.boot': { url: 'assets/live/boot.glb' } }
  const shippableDoc = JSON.parse(serializeManifest(shippable))
  ok(shippableDoc._readme === SHIPPABLE_README, 'a shippable export gets the ship-it note')
  ok(shippableDoc._readme !== IDB_README, 'a shippable export does NOT nag about idb urls')

  // key order must not change equality
  const reordered: Manifest = { 'blob.body': m['blob.body']!, 'machine.boot': m['machine.boot']! }
  ok(manifestsEqual(m, reordered), 'slot order does not affect manifest equality')
  ok(Object.keys(cleanManifest(reordered))[0] === 'blob.body', 'exports are sorted by slot')
}

// ---- idb urls --------------------------------------------------------------
{
  ok(isIdbUrl('idb:x'), 'idb: prefix detected')
  ok(!isIdbUrl('assets/live/x.glb'), 'a normal path is not an idb url')
  ok(idbKey('idb:machine.boot-a.glb') === 'machine.boot-a.glb', 'the key is everything after idb:')
  ok(idbKey('assets/live/x.glb') === '', 'a non-idb url has no key')
}

// ---- fit maths -------------------------------------------------------------
{
  const base = { position: [1, 2, 3], rotationDeg: [0, 45, 0], scale: [1, 1, 1] } as const
  const neutral = composeFit({ ...base, position: [...base.position], rotationDeg: [...base.rotationDeg], scale: [...base.scale] } as never, { url: 'a.glb' })
  eq(neutral.position, [1, 2, 3], 'an empty fit leaves the asset exactly on the procedural pose')
  eq(neutral.rotationDeg, [0, 45, 0], 'an empty fit keeps the procedural rotation')
  eq(neutral.scale, [1, 1, 1], 'an empty fit keeps the procedural scale')

  const fitted = composeFit(
    { position: [1, 2, 3], rotationDeg: [0, 45, 0], scale: [2, 2, 2] },
    { url: 'a.glb', offset: [0, 0.5, -1], rotationDeg: [0, 45, 10], scale: 3 })
  eq(fitted.position, [1, 2.5, 2], 'offsets add to the base position')
  eq(fitted.rotationDeg, [0, 90, 10], 'rotations add to the base rotation')
  eq(fitted.scale, [6, 6, 6], 'scale multiplies the base scale')

  const nonUniform = composeFit(identityTransform(), { url: 'a.glb', scale: [1, 2, 3] })
  eq(nonUniform.scale, [1, 2, 3], 'a triple scale is applied per axis')

  ok(isNeutralFit({ url: 'a.glb' }), 'a bare entry is a neutral fit')
  ok(isNeutralFit({ url: 'a.glb', offset: [0, 0, 0], rotationDeg: [0, 0, 0], scale: 1 }), 'explicit zeros are neutral')
  ok(!isNeutralFit({ url: 'a.glb', offset: [0, 0.1, 0] }), 'any offset is not neutral')
  ok(!isNeutralFit({ url: 'a.glb', scale: 1.0001 }), 'any scale change is not neutral')
}

// ---- tiny glb --------------------------------------------------------------
{
  const glb = buildTinyGlb()
  ok(isGlb(glb), 'the generated buffer has a valid GLB header and length')
  ok(glb.byteLength % 4 === 0, 'the GLB total length is 4-byte aligned')
  const view = new DataView(glb)
  const jsonLen = view.getUint32(12, true)
  ok(jsonLen % 4 === 0, 'the JSON chunk is padded to 4 bytes')
  const json = JSON.parse(new TextDecoder().decode(new Uint8Array(glb, 20, jsonLen)).trim())
  ok(json.asset.version === '2.0', 'the glTF asset version is 2.0')
  ok(json.meshes[0].primitives[0].attributes.TEXCOORD_0 !== undefined, 'the quad ships UVs by default')
  ok(json.materials.length === 1, 'the quad has exactly one material')
  const binStart = 20 + jsonLen
  ok(view.getUint32(binStart + 4, true) === 0x004e4942, 'the second chunk is the BIN chunk')
  ok(binStart + 8 + view.getUint32(binStart, true) === glb.byteLength, 'the BIN chunk fills the rest of the file')

  const noUv = buildTinyGlb({ withUv: false })
  ok(isGlb(noUv), 'the UV-less variant is still a valid GLB')
  const noUvLen = new DataView(noUv).getUint32(12, true)
  const noUvJson = JSON.parse(new TextDecoder().decode(new Uint8Array(noUv, 20, noUvLen)).trim())
  ok(noUvJson.meshes[0].primitives[0].attributes.TEXCOORD_0 === undefined, 'the UV-less variant really has no UVs')

  ok(!isGlb(new ArrayBuffer(8)), 'a stub buffer is not a GLB')
}

console.log(`editor manifest-io.test: ${passed} assertions passed ✓`)
