/**
 * Lane J demo — the workshop editor booted and then driven by a script.
 *
 * The scripted drop goes through `bootEditor().dropFile`, which is the exact
 * function the real drag-drop handler calls, so a green run here means the
 * button works too. Checks, in order: the stage object actually swapped, the
 * fit controls move it, the export round-trips, reset restores the procedural
 * build, and Clear leaves nothing behind.
 *
 * THE DEMO IS A GUEST, NOT AN OWNER. It used to run against the same IndexedDB
 * database as the real editor, which meant opening this page deleted whatever
 * the user had saved (`pruneOrphans` treats every blob key the demo's manifest
 * does not mention as garbage), and closing the tab mid-run left a fake
 * `machine.boot` override that the LIVE game would then load. So:
 *
 *   - everything runs against a throwaway database, DEMO_DB;
 *   - the real database is fingerprinted before and after and the run FAILS if
 *     one byte of it moved;
 *   - the throwaway database is deleted in a `finally`, so a crash or a reload
 *     mid-run leaves nothing behind either.
 *
 * Asset note: `assets/meshes/*.glb` is NOT in the vite build graph today
 * (nothing copies `assets/` into dist/), so the real boot GLB is fetched
 * best-effort and a generated stand-in GLB is used when it 404s. Which one ran
 * is printed — the pipeline is identical either way.
 */
import { bootEditor } from '../editor/main'
import {
  manifestsEqual, parseManifest, isIdbUrl, idbKey,
} from '../editor/manifest-io'
import { buildTinyGlb } from '../editor/tiny-glb'
import { getGlb, loadOverrideManifest } from '../editor/store'
import {
  BLOB_STORE, DEFAULT_DB_NAME, deleteDb, idbKeys, setDbName,
} from '../editor/idb'

const SLOT = 'machine.boot'
const BOOT_URL = import.meta.env.BASE_URL + 'assets/meshes/prop-spring-boot.glb'
/** Never 'blobbo-workshop'. That one belongs to the user. */
const DEMO_DB = 'blobbo-workshop-demo'

const out = document.getElementById('results')!
let passed = 0
let failed = 0

function check(cond: boolean, msg: string, detail = ''): void {
  const line = document.createElement('div')
  line.className = cond ? 'pass' : 'fail'
  line.textContent = `${cond ? '✓' : '✗'} ${msg}${detail ? ` — ${detail}` : ''}`
  out.append(line)
  if (cond) passed++
  else failed++
  console[cond ? 'log' : 'error'](`${cond ? 'PASS' : 'FAIL'}: ${msg}`, detail)
}

function note(msg: string): void {
  const line = document.createElement('div')
  line.className = 'note'
  line.textContent = msg
  out.append(line)
  console.log(msg)
}

async function fetchBootGlb(): Promise<{ name: string; bytes: ArrayBuffer; real: boolean }> {
  try {
    const res = await fetch(BOOT_URL)
    if (res.ok) {
      const bytes = await res.arrayBuffer()
      if (bytes.byteLength > 0) {
        return { name: 'prop-spring-boot.glb', bytes, real: true }
      }
    }
    note(`Real farm GLB not served at ${BOOT_URL} (${res.status}) — using a generated stand-in.`)
  } catch {
    note(`Real farm GLB not reachable at ${BOOT_URL} — using a generated stand-in.`)
  }
  return { name: 'prop-spring-boot.glb', bytes: buildTinyGlb({ half: 1.1 }), real: false }
}

/**
 * Everything about the user's real store that this demo could possibly disturb:
 * the saved manifest and the list of stored files. Read-only. (Reading does open
 * the database, which creates it empty if absent — an empty store and a missing
 * store are the same thing to both the editor and the game.)
 */
async function fingerprintRealDb(): Promise<string> {
  setDbName(DEFAULT_DB_NAME)
  try {
    const manifest = await loadOverrideManifest()
    const keys = (await idbKeys(BLOB_STORE)).map(String).sort()
    return JSON.stringify({ manifest, keys })
  } catch (err) {
    return `unreadable:${String(err)}`
  }
}

async function run(): Promise<void> {
  const before = await fingerprintRealDb()
  note(`Real store fingerprinted before the run (${before.length} chars). ` +
    `Everything below happens in "${DEMO_DB}".`)
  setDbName(DEMO_DB)

  try {
    await runChecks()
  } finally {
    // Runs on the crash path too. Without it, an assertion failure between the
    // save and the clear left a persisted override behind.
    setDbName(DEMO_DB)
    await deleteDb(DEMO_DB)
    const after = await fingerprintRealDb()
    check(after === before,
      "the user's own saved swaps were not touched by this demo",
      after === before ? 'byte-identical before and after' : 'THE REAL STORE CHANGED')
  }
}

async function runChecks(): Promise<void> {
  const editor = await bootEditor(document.getElementById('workshop')!)
  const { stage, workshop } = editor

  check(editor.canSave(), 'this browser can store models (otherwise the demo is read-only)')

  // ---- a clean session must be all-procedural ------------------------------
  check(stage.slots.has(SLOT), 'the spring boot slot exists on the stage')
  check(!workshop.hasCustom(SLOT), 'nothing is swapped before the drop')
  const entryRef = stage.slots.get(SLOT)!
  check(entryRef.fitNode === null, 'the slot has no custom wrapper yet')
  check(entryRef.procedural.every((p) => p.visible), 'the procedural boot is visible')
  const proceduralChildCount = entryRef.procedural[0]!.children.length

  editor.select(SLOT)

  // ---- the scripted drop ---------------------------------------------------
  const file = await fetchBootGlb()
  note(`Dropping ${file.name} (${file.bytes.byteLength.toLocaleString()} bytes, ` +
    `${file.real ? 'real farm asset' : 'generated stand-in'}) onto the spring boot slot.`)
  const result = await editor.dropFile(SLOT, file.name, file.bytes)

  check(!result.rejected, 'the drop was accepted', result.message)
  check(entryRef.fitNode !== null, 'a custom wrapper now holds the dropped model')
  check(entryRef.fitNode !== null && entryRef.fitNode.children.length > 0,
    'the dropped model is actually inside the wrapper')
  check(entryRef.procedural.every((p) => !p.visible),
    'the procedural boot is hidden (swapped, not stacked)')
  check(entryRef.fitNodes.length === entryRef.procedural.length,
    'every original this slot holds got its own replacement',
    `${entryRef.procedural.length} original(s) → ${entryRef.fitNodes.length} replacement(s)`)
  check(entryRef.procedural[0]!.children.length === proceduralChildCount,
    'the procedural boot was hidden, not destroyed (reset can restore it)')
  check(isIdbUrl(result.entry.url), 'the manifest entry points at browser storage',
    result.entry.url)
  const stored = await getGlb(idbKey(result.entry.url))
  check(stored !== undefined && stored.bytes.byteLength === file.bytes.byteLength,
    'the .glb bytes went into IndexedDB intact')
  check(stage.currentObject(SLOT) === entryRef.fitNode,
    'the fit panel now acts on the dropped model')

  // ---- a file with no model in it is refused, on a slot that isn't paintable
  const empty = await editor.dropFile(SLOT, 'empty.glb', buildTinyGlb({ withMesh: false }))
  check(empty.rejected, 'a .glb with no model inside is refused', empty.message)
  check(/no model inside/i.test(empty.message), 'the refusal is explained in plain words')
  check(entryRef.fitNode !== null && workshop.entry(SLOT)?.url === result.entry.url,
    'the refused file left the previous good swap exactly as it was')

  // Where the swapped boot sits before any nudging.
  const basePos = entryRef.fitNode!.position.clone()

  // ---- fit controls actually move it ---------------------------------------
  workshop.setFit(SLOT, { offset: [0, 1.5, 0], rotationDeg: [0, 90, 0], scale: 2 })
  const moved = entryRef.fitNode!
  check(Math.abs(moved.position.y - (basePos.y + 1.5)) < 1e-6,
    'raising the model moves it up by exactly that much',
    `y ${basePos.y.toFixed(3)} → ${moved.position.y.toFixed(3)}`)
  check(Math.abs(moved.rotation.y - Math.PI / 2) < 1e-6, 'turning it applies the rotation')
  check(Math.abs(moved.scale.x - 2) < 1e-6, 'resizing it applies the scale')

  // ---- multi-instance slots keep every object ------------------------------
  // fx.puddle is nine separate patches. Replacing one and hiding all nine is
  // what used to happen; the count is asserted rather than assumed.
  const puddles = stage.slots.get('fx.puddle')
  if (puddles && puddles.procedural.length > 1) {
    const n = puddles.procedural.length
    const puddleDrop = await editor.dropFile('fx.puddle', 'puddle.glb', buildTinyGlb())
    check(!puddleDrop.rejected, `dropping onto the ${n} paint puddles was accepted`,
      puddleDrop.message)
    check(puddles.fitNodes.length === n,
      `all ${n} puddles were replaced, not just one`,
      `${puddles.fitNodes.length} replacements for ${n} puddles`)
    const distinct = new Set(puddles.fitNodes.map((f) =>
      `${f.position.x.toFixed(3)},${f.position.z.toFixed(3)}`))
    check(distinct.size === n, 'each replacement sits where its own puddle was',
      `${distinct.size} distinct positions`)
    workshop.reset('fx.puddle')
    check(puddles.procedural.every((p) => p.visible) && puddles.fitNodes.length === 0,
      'resetting the puddles brings all of them back')
  } else {
    note('No multi-instance puddle slot on this stage — skipped the multi-instance check.')
  }

  // ---- export round-trips --------------------------------------------------
  const text = editor.exportManifest()
  const reparsed = parseManifest(text)
  check(manifestsEqual(workshop.getManifest(), reparsed),
    'the downloaded manifest.json parses back to exactly what the editor holds')
  check(reparsed[SLOT] !== undefined && reparsed[SLOT]!.offset?.[1] === 1.5,
    'the fit survives the round trip')
  check(typeof (JSON.parse(text) as { _readme?: string })._readme === 'string',
    'the download carries a plain-language note about shipping it')

  // Same trip again through a real Blob/File, i.e. the bytes a download produces.
  const downloaded = new Blob([text], { type: 'application/json' })
  const roundTripped = parseManifest(await downloaded.text())
  check(manifestsEqual(reparsed, roundTripped),
    'a real downloaded file round-trips identically')

  // ---- save + reload persistence ------------------------------------------
  await editor.save()
  const persisted = await loadOverrideManifest()
  check(manifestsEqual(workshop.getManifest(), persisted),
    'saving writes the same manifest the live game will read back')

  // ---- reset restores the procedural build ---------------------------------
  workshop.reset(SLOT)
  check(entryRef.fitNode === null, 'reset removes the custom wrapper')
  check(entryRef.procedural.every((p) => p.visible), 'reset shows the original boot again')
  check(!workshop.hasCustom(SLOT), 'reset drops the manifest entry')

  // ---- clear leaves nothing behind ----------------------------------------
  await editor.clearAll()
  const afterClear = await loadOverrideManifest()
  check(Object.keys(afterClear).length === 0, 'Clear removes the saved override manifest')
  ;(window as unknown as { LANE_J: unknown }).LANE_J = { editor, passed, failed }
}

function summarise(): void {
  const summary = document.createElement('div')
  summary.className = failed === 0 ? 'summary pass' : 'summary fail'
  summary.textContent = failed === 0
    ? `All ${passed} checks passed ✓`
    : `${failed} of ${passed + failed} checks FAILED`
  out.append(summary)
}

void run()
  .catch((err: unknown) => {
    console.error(err)
    check(false, 'the demo crashed', String(err))
  })
  .finally(summarise)
