/**
 * Lane J — the workshop editor page.
 *
 * Three panels in a CSS grid, no framework: slots (left), the real course
 * (centre), fit controls (right). Written for an artist, not a programmer —
 * no slot ids, file paths or type names appear on screen, and every action has
 * exactly one obvious button.
 *
 * The whole page is visual: it never registers a system and never ticks the
 * sim (see stage.ts), so the hidden-tab fixed-step rule doesn't apply here.
 */
import { createEditorStage } from './stage'
import type { EditorStage } from './stage'
import { Workshop } from './workshop'
import type { DropResult } from './workshop'
import { SLOTS, SLOT_GROUPS, slotById } from './slots'
import type { SlotSpec } from './slots'
import { idbAvailable } from './idb'
import type { SlotEntry } from './manifest-io'

const el = <K extends keyof HTMLElementTagNameMap>(
  tag: K, cls?: string, text?: string,
): HTMLElementTagNameMap[K] => {
  const node = document.createElement(tag)
  if (cls) node.className = cls
  if (text !== undefined) node.textContent = text
  return node
}

const asTriple = (v: number | [number, number, number] | undefined): [number, number, number] =>
  v === undefined ? [0, 0, 0] : typeof v === 'number' ? [v, v, v] : v

const uniformScale = (v: SlotEntry['scale']): number =>
  v === undefined ? 1 : typeof v === 'number' ? v : v[0]

interface FitRow {
  label: string
  get: (e: SlotEntry) => number
  set: (e: SlotEntry, v: number) => Partial<SlotEntry>
  min: number
  max: number
  step: number
}

/** Shown when the browser will not keep anything (private windows, blocked storage). */
const READ_ONLY_MESSAGE =
  "This window won't let me keep anything, so nothing you do here will be remembered. " +
  'You can still try models out and download a manifest.json. Open the workshop in a ' +
  'normal window to save.'

const AXES = ['left / right', 'up / down', 'front / back'] as const

const FIT_ROWS: FitRow[] = [
  ...AXES.map((axis, i): FitRow => ({
    label: `Move ${axis}`,
    min: -6, max: 6, step: 0.05,
    get: (e) => asTriple(e.offset)[i],
    set: (e, v) => {
      const next = asTriple(e.offset)
      next[i] = v
      return { offset: next }
    },
  })),
  ...(['Tilt', 'Turn', 'Roll'] as const).map((name, i): FitRow => ({
    label: name,
    min: -180, max: 180, step: 1,
    get: (e) => asTriple(e.rotationDeg)[i],
    set: (e, v) => {
      const next = asTriple(e.rotationDeg)
      next[i] = v
      return { rotationDeg: next }
    },
  })),
  {
    label: 'Size', min: 0.05, max: 8, step: 0.05,
    get: (e) => uniformScale(e.scale),
    set: (_e, v) => ({ scale: v }),
  },
]

export async function bootEditor(root: HTMLElement): Promise<{
  stage: EditorStage
  workshop: Workshop
  /** Same code path a real drag-drop takes — the demo calls this. */
  dropFile: (slot: string, name: string, bytes: ArrayBuffer) => Promise<DropResult>
  select: (slot: string) => void
  save: () => Promise<void>
  exportManifest: () => string
  clearAll: () => Promise<void>
  status: () => string
  /** False when this browser refuses to store anything (private windows). */
  canSave: () => boolean
}> {
  root.classList.add('workshop')

  // ---- skeleton ------------------------------------------------------------
  const left = el('aside', 'panel panel-slots')
  const centre = el('main', 'panel panel-stage')
  const right = el('aside', 'panel panel-fit')
  const stageHost = el('div', 'stage-host')
  centre.append(stageHost)
  root.append(left, centre, right)

  const stage = await createEditorStage(stageHost)
  const workshop = new Workshop(stage)

  // ---- left panel ----------------------------------------------------------
  left.append(el('h1', 'brand', 'BLOBBO workshop'))
  left.append(el('p', 'lede', 'Pick a thing, drop your model on it, nudge it until it looks right.'))

  const slotButtons = new Map<string, HTMLButtonElement>()
  for (const group of SLOT_GROUPS) {
    const members = SLOTS.filter((s) => s.group === group)
    if (!members.length) continue
    left.append(el('h2', 'group', group))
    const list = el('div', 'slot-list')
    for (const spec of members) {
      const button = el('button', 'slot')
      button.append(el('span', 'slot-name', spec.label))
      button.append(el('span', 'slot-state', ''))
      button.title = spec.hint
      button.addEventListener('click', () => select(spec.id))
      list.append(button)
      slotButtons.set(spec.id, button)
    }
    left.append(list)
  }

  const bigActions = el('div', 'big-actions')
  const saveBtn = el('button', 'action primary', 'Save to this browser')
  const testBtn = el('button', 'action', 'Test drive the game')
  const exportBtn = el('button', 'action', 'Download manifest.json')
  const clearBtn = el('button', 'action danger', 'Clear all my swaps')
  bigActions.append(saveBtn, testBtn, exportBtn, clearBtn)
  left.append(bigActions)

  const statusLine = el('p', 'status', 'Nothing swapped yet — this is the game as it ships.')
  left.append(statusLine)
  const setStatus = (text: string, tone: 'ok' | 'warn' = 'ok'): void => {
    statusLine.textContent = text
    statusLine.dataset.tone = tone
  }

  // ---- centre panel overlay ------------------------------------------------
  const stageHint = el('div', 'stage-hint',
    'Drag to spin the view · scroll to zoom · right-drag to slide')
  centre.append(stageHint)
  const focusBtn = el('button', 'focus-btn', 'Show me the selected thing')
  focusBtn.addEventListener('click', () => { if (selected) stage.focus(selected) })
  centre.append(focusBtn)

  // ---- right panel ---------------------------------------------------------
  const fitTitle = el('h2', 'fit-title', 'Nothing selected')
  const fitHint = el('p', 'fit-hint', 'Pick something from the list on the left.')
  const dropZone = el('div', 'dropzone')
  const dropLabel = el('div', 'dropzone-label', 'Drop a .glb file here')
  const dropSub = el('div', 'dropzone-sub', 'or click to pick one from your computer')
  dropZone.append(dropLabel, dropSub)
  const filePicker = el('input')
  filePicker.type = 'file'
  filePicker.accept = '.glb,model/gltf-binary'
  filePicker.style.display = 'none'

  const paintCard = el('div', 'paint-card')
  const fitControls = el('div', 'fit-controls')
  const resetBtn = el('button', 'action', 'Put the original back')
  const helpCard = el('details', 'help-card')
  right.append(fitTitle, fitHint, dropZone, filePicker, paintCard, fitControls, resetBtn, helpCard)

  helpCard.append(el('summary', '', 'How to make models that fit (and how to ship them)'))
  const helpBody = el('div', 'help-body')
  helpBody.innerHTML = `
    <h3>In Blender, before you export</h3>
    <ul>
      <li>Work in <b>metres</b>, Y up, facing −Z. The blob is about <b>1 metre wide</b>.</li>
      <li>Put the origin in the <b>centre</b> for the blob, and at the <b>ground contact point</b> for props.</li>
      <li>One material per model if you can. Textures no bigger than 2048×2048.</li>
      <li>Export as <b>.glb</b> (embedded), not .gltf + files.</li>
    </ul>
    <h3>If it's the blob body</h3>
    <ul>
      <li>It has to be UV unwrapped, all in one 0–1 square, <b>no overlaps and no mirroring</b> —
          that's where the paint goes.</li>
      <li>Rigged is fine: one armature, the looping animation named <code>idle</code>, keep it under ~40 bones.</li>
    </ul>
    <h3>Ship it for real</h3>
    <ol>
      <li>Press <b>Save to this browser</b>, then <b>Test drive</b> to play with it.</li>
      <li>When you're happy, press <b>Download manifest.json</b>.</li>
      <li>Copy your .glb files into <code>assets/live/</code> in the repo and put the
          downloaded <code>manifest.json</code> next to them as <code>assets/manifest.json</code>.</li>
      <li>Change every url that starts with <code>idb:</code> to <code>assets/live/yourfile.glb</code>.</li>
      <li>Redeploy. Everyone sees it.</li>
    </ol>
    <p><b>Saved here stays here.</b> Swaps you save live in this browser only —
       nobody else sees them until you do the steps above.</p>`
  helpCard.append(helpBody)

  // ---- selection state -----------------------------------------------------
  let selected: string | null = null

  const refreshSlotStates = (): void => {
    for (const [id, button] of slotButtons) {
      const spec = slotById(id)!
      const state = button.querySelector('.slot-state') as HTMLElement
      if (!stage.slots.has(id)) {
        // The runtime has a slot for this, but this course never builds one.
        // Saying so beats a row that looks live and does nothing when clicked.
        state.textContent = 'not in this course'
        button.dataset.state = 'absent'
      } else if (spec.derivedFrom) {
        state.textContent = 'copies the blob'
        button.dataset.state = 'derived'
      } else if (workshop.isUnloadable(id)) {
        state.textContent = 'file missing'
        button.dataset.state = 'broken'
      } else if (workshop.hasCustom(id)) {
        state.textContent = 'yours'
        button.dataset.state = 'custom'
      } else {
        state.textContent = 'original'
        button.dataset.state = 'plain'
      }
      button.dataset.selected = String(id === selected)
    }
  }

  const renderPaintCard = (spec: SlotSpec): void => {
    paintCard.textContent = ''
    if (!spec.paintable) {
      paintCard.style.display = 'none'
      return
    }
    paintCard.style.display = 'block'
    const report = workshop.paintReport(spec.id)
    if (!report) {
      paintCard.dataset.tone = 'idle'
      paintCard.append(el('div', 'paint-head', 'Paint check'))
      paintCard.append(el('div', 'paint-note',
        'Drop a blob model and I will check the paint sticks to it.'))
      return
    }
    paintCard.dataset.tone = report.uvOk && report.problems.length === 0 ? 'ok' : 'bad'
    paintCard.append(el('div', 'paint-head',
      report.uvOk && report.problems.length === 0
        ? 'Paint check passed'
        : 'Paint problem'))
    const facts = el('div', 'paint-facts')
    facts.append(el('span', '', `${report.triCount.toLocaleString()} triangles`))
    facts.append(el('span', '', `${report.materialCount} material${report.materialCount === 1 ? '' : 's'}`))
    facts.append(el('span', '', report.uvOk ? 'UV map looks right' : 'UV map is wrong'))
    paintCard.append(facts)
    for (const problem of report.problems) paintCard.append(el('div', 'paint-note', problem))
  }

  const renderFitControls = (): void => {
    fitControls.textContent = ''
    const entry = selected ? workshop.entry(selected) : undefined
    if (!selected || !entry) {
      fitControls.style.display = 'none'
      resetBtn.style.display = 'none'
      return
    }
    fitControls.style.display = 'block'
    resetBtn.style.display = 'block'
    for (const row of FIT_ROWS) {
      const wrap = el('label', 'fit-row')
      wrap.append(el('span', 'fit-label', row.label))
      const slider = el('input')
      slider.type = 'range'
      slider.min = String(row.min)
      slider.max = String(row.max)
      slider.step = String(row.step)
      const number = el('input')
      number.type = 'number'
      number.step = String(row.step)
      const current = row.get(entry)
      slider.value = String(current)
      number.value = String(Number(current.toFixed(3)))
      const push = (raw: string): void => {
        const value = Number(raw)
        if (!Number.isFinite(value) || !selected) return
        const live = workshop.entry(selected)
        if (!live) return
        workshop.setFit(selected, row.set(live, value))
        slider.value = String(value)
        number.value = String(Number(value.toFixed(3)))
        setStatus('Nudged. Press "Save to this browser" when it looks right.')
      }
      slider.addEventListener('input', () => push(slider.value))
      number.addEventListener('change', () => push(number.value))
      wrap.append(slider, number)
      fitControls.append(wrap)
    }
  }

  const select = (slot: string): void => {
    selected = slot
    const spec = slotById(slot)
    if (!spec) return
    const absent = !stage.slots.has(slot)
    fitTitle.textContent = spec.label
    fitHint.textContent = absent
      ? `${spec.hint} There isn't one of these anywhere in the course at the moment, ` +
        'so there is nothing here to replace.'
      : spec.derivedFrom
        ? `${spec.hint} There is nothing to drop here.`
        : spec.hint
    dropZone.style.display = spec.derivedFrom || absent ? 'none' : 'flex'
    stage.highlight(slot)
    stage.focus(slot)
    renderPaintCard(spec)
    renderFitControls()
    refreshSlotStates()
  }

  // ---- drop plumbing -------------------------------------------------------
  const handleFile = async (file: File): Promise<void> => {
    if (!selected) {
      setStatus('Pick something on the left first, then drop your file.', 'warn')
      return
    }
    setStatus(`Reading ${file.name}…`)
    let bytes: ArrayBuffer
    try {
      // The file can be moved or renamed between picking it and reading it, and
      // then this throws — leaving the status stuck on "Reading…" forever.
      bytes = await file.arrayBuffer()
    } catch (err) {
      console.warn('[workshop] could not read the dropped file', err)
      setStatus(`I couldn't read ${file.name}. Is it still where it was?`, 'warn')
      return
    }
    const result = await workshop.dropFile(selected, file.name, bytes)
    setStatus(result.message, result.rejected ? 'warn' : 'ok')
    const spec = slotById(selected)
    if (spec) renderPaintCard(spec)
    renderFitControls()
    refreshSlotStates()
  }

  /** Every DOM handler funnels through here: nothing may end as a dead promise. */
  const guard = (label: string, work: () => Promise<void>): void => {
    void work().catch((err: unknown) => {
      console.error(`[workshop] ${label} failed`, err)
      setStatus('Something went wrong there and nothing was changed. Try again.', 'warn')
    })
  }

  const stop = (e: Event): void => { e.preventDefault(); e.stopPropagation() }
  for (const type of ['dragenter', 'dragover'] as const) {
    dropZone.addEventListener(type, (e) => { stop(e); dropZone.dataset.hot = 'true' })
  }
  for (const type of ['dragleave', 'drop'] as const) {
    dropZone.addEventListener(type, (e) => { stop(e); dropZone.dataset.hot = 'false' })
  }
  dropZone.addEventListener('drop', (e) => {
    const file = (e as DragEvent).dataTransfer?.files?.[0]
    if (file) guard('the drop', () => handleFile(file))
  })
  dropZone.addEventListener('click', () => filePicker.click())
  filePicker.addEventListener('change', () => {
    const file = filePicker.files?.[0]
    if (file) guard('the drop', () => handleFile(file))
    filePicker.value = ''
  })
  // Dropping anywhere else must not make the browser navigate to the file.
  addEventListener('dragover', (e) => e.preventDefault())
  addEventListener('drop', (e) => e.preventDefault())

  // ---- buttons -------------------------------------------------------------
  resetBtn.addEventListener('click', () => {
    if (!selected) return
    workshop.reset(selected)
    const spec = slotById(selected)
    if (spec) renderPaintCard(spec)
    renderFitControls()
    refreshSlotStates()
    setStatus('Back to the original. Save if you want that to stick.')
  })

  const save = async (): Promise<void> => {
    if (!canSave) {
      setStatus(READ_ONLY_MESSAGE, 'warn')
      return
    }
    const result = await workshop.saveLocally()
    setStatus(result.message, result.ok ? 'ok' : 'warn')
  }
  saveBtn.addEventListener('click', () => guard('saving', save))

  testBtn.addEventListener('click', () => guard('the test drive', async () => {
    if (!canSave) {
      setStatus(READ_ONLY_MESSAGE, 'warn')
      return
    }
    const result = await workshop.saveLocally()
    if (!result.ok) {
      // Opening the game now would show none of what is on screen here.
      setStatus(result.message, 'warn')
      return
    }
    setStatus('Opened the game in a new tab — your swaps are already in it.')
    open(import.meta.env.BASE_URL, '_blank')
  }))

  const exportManifest = (): string => workshop.exportText()
  exportBtn.addEventListener('click', () => {
    const text = exportManifest()
    const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }))
    const a = el('a')
    a.href = url
    a.download = 'manifest.json'
    a.click()
    setTimeout(() => URL.revokeObjectURL(url), 1000)
    setStatus('Downloaded manifest.json — open the help below to see where it goes.')
  })

  const clearAll = async (): Promise<void> => {
    const ok = await workshop.clearEverything()
    refreshSlotStates()
    renderFitControls()
    if (selected) {
      const spec = slotById(selected)
      if (spec) renderPaintCard(spec)
    }
    setStatus(ok
      ? 'All swaps cleared. The game is back to how it ships.'
      : "Everything is off the screen, but this browser wouldn't let me clear what it had stored.",
      ok ? 'ok' : 'warn')
  }
  clearBtn.addEventListener('click', () => {
    if (confirm('Throw away every model you dropped in? This cannot be undone.')) {
      guard('clearing', clearAll)
    }
  })

  // ---- can this browser keep anything? -------------------------------------
  // A real open(), not a `typeof indexedDB` guess: private windows expose the
  // object and then fail. Failing that probe means a look-but-don't-save
  // editor, NOT an error page over a stage that built perfectly well.
  const canSave = await idbAvailable()
  if (!canSave) {
    saveBtn.disabled = true
    clearBtn.disabled = true
    testBtn.disabled = true
    saveBtn.textContent = "Can't save in this window"
    const banner = el('p', 'readonly-banner', READ_ONLY_MESSAGE)
    left.insertBefore(banner, bigActions)
    setStatus(READ_ONLY_MESSAGE, 'warn')
  }

  // ---- restore a previous session -----------------------------------------
  refreshSlotStates()
  renderFitControls()
  if (canSave) {
    const { restored, unloadable } = await workshop.restore()
    if (restored.length || unloadable.length) refreshSlotStates()
    const names = unloadable.map((id) => slotById(id)?.label ?? id).join(', ')
    if (unloadable.length) {
      // Kept, not dropped: the entry stays in the manifest so saving again can
      // never delete the stored file it points at.
      setStatus(
        `Brought back ${restored.length} swap${restored.length === 1 ? '' : 's'}. ` +
        `I couldn't find the file for: ${names}. Those are still listed — drop the ` +
        'model in again, or press "Put the original back" on each one.', 'warn')
    } else if (restored.length) {
      setStatus(`Brought back ${restored.length} swap${restored.length === 1 ? '' : 's'} from last time.`)
    }
  }

  return {
    stage,
    workshop,
    dropFile: (slot, name, bytes) => workshop.dropFile(slot, name, bytes),
    select,
    save,
    exportManifest,
    clearAll,
    status: () => statusLine.textContent ?? '',
    canSave: () => canSave,
  }
}

// Auto-boot when loaded as the editor page. The demo page marks its host
// `data-manual` and calls bootEditor itself, so it never gets two editors.
const host = document.getElementById('workshop')
if (host && !host.hasAttribute('data-manual')) {
  void bootEditor(host).catch((err: unknown) => {
    // Storage problems no longer reach here — bootEditor degrades instead. What
    // is left is a genuinely dead page (no WebGL), so a plain sentence is right.
    console.error(err)
    host.textContent =
      "The workshop couldn't start in this browser. It needs 3D graphics turned on — " +
      'try a different browser, or check your browser settings for hardware acceleration.'
  })
}
