/**
 * Lane J — the editor's brain, with no DOM in it.
 *
 * Holds the working manifest, owns the drop → store → swap pipeline, and is the
 * single code path both the real drag-drop and the demo's scripted drop go
 * through (so a green demo means the real button works).
 *
 * TWO INVARIANTS, both learned the hard way:
 *
 *  1. NOTHING LEAVES THIS CLASS AS A REJECTED PROMISE. Every caller is a DOM
 *     handler that can only `void` the result, so a rejection used to vanish
 *     into an unhandled-rejection with the status line frozen mid-sentence.
 *     Storage failures come back as a `rejected` result carrying a sentence a
 *     person can read, and the slot stays procedural.
 *
 *  2. A SAVED SWAP IS NEVER DESTROYED BY THE EDITOR. An entry whose file will
 *     not re-load stays in the manifest, flagged unloadable, so the next save
 *     cannot prune its bytes away. Losing a file the user cannot regenerate is
 *     worse than showing them a broken row.
 */
import * as THREE from 'three'
import type { EditorStage } from './stage'
import { loadGlb } from './glb'
import { firstMesh, inspectPaintability } from './paintability'
import type { PaintableInfo } from './paintability'
import {
  cleanManifest, idbKey, isIdbUrl, serializeManifest,
} from './manifest-io'
import type { Manifest, SlotEntry } from './manifest-io'
import {
  blobKeyFor, clearOverrides, getGlb, loadOverrideManifest, pruneOrphans,
  putGlb, saveOverrideManifest,
} from './store'
import { slotById } from './slots'

export interface DropResult {
  slot: string
  entry: SlotEntry
  /** Only produced for the paintable body slot. */
  paint?: PaintableInfo
  /** True when the asset was rejected and the procedural build was kept. */
  rejected: boolean
  /** Plain-language outcome for the status line. */
  message: string
}

export interface SaveResult {
  /** How many swaps are now saved. */
  saved: number
  /** False when the browser refused to keep them. */
  ok: boolean
  /** Plain-language outcome for the status line. */
  message: string
}

export interface RestoreResult {
  /** Slots whose model came back and is on the stage. */
  restored: string[]
  /** Slots still in the manifest whose file could not be read back. */
  unloadable: string[]
}

/** What the user is told when the browser will not keep a file. */
const NO_ROOM =
  "There wasn't room in this browser to keep that file, so the original is still there. " +
  'Try a smaller model, or clear some swaps you no longer want.'

const NO_MESH = "There's no model inside this file — the original is still there."

export class Workshop {
  private manifest: Manifest = {}
  /** Parsed GLB scenes by url, so a slot switch never re-parses. */
  private readonly cache = new Map<string, THREE.Object3D>()
  private readonly paintReports = new Map<string, PaintableInfo>()
  /** Slots whose stored file would not load: kept, shown, never pruned. */
  private readonly unloadable = new Set<string>()
  private readonly stage: EditorStage

  // Plain assignment rather than a parameter property: node's type-stripping
  // mode (how the .test.ts files run) rejects parameter properties outright.
  constructor(stage: EditorStage) {
    this.stage = stage
  }

  getManifest(): Manifest {
    return cleanManifest(this.manifest)
  }

  entry(slot: string): SlotEntry | undefined {
    return this.manifest[slot]
  }

  paintReport(slot: string): PaintableInfo | undefined {
    return this.paintReports.get(slot)
  }

  hasCustom(slot: string): boolean {
    return this.manifest[slot] !== undefined
  }

  /** True when this slot's saved file is missing or unreadable. */
  isUnloadable(slot: string): boolean {
    return this.unloadable.has(slot)
  }

  unloadableSlots(): string[] {
    return [...this.unloadable]
  }

  /**
   * Re-hydrate everything the browser remembered from a previous session.
   * Never throws: no storage is a normal state, not a reason to lose the editor.
   */
  async restore(): Promise<RestoreResult> {
    let stored: Manifest = {}
    try {
      stored = await loadOverrideManifest()
    } catch (err) {
      console.warn('[workshop] could not read what was saved last time', err)
      return { restored: [], unloadable: [] }
    }
    const restored: string[] = []
    const unloadable: string[] = []
    for (const [slot, entry] of Object.entries(stored)) {
      if (!this.stage.slots.has(slot)) continue
      let ok = false
      try {
        ok = await this.applyEntry(slot, entry)
      } catch (err) {
        console.warn(`[workshop] stored asset for ${slot} could not be restored`, err)
      }
      // The entry is kept either way. Dropping it here is what used to make the
      // next save delete the only copy of the file.
      this.manifest[slot] = entry
      if (ok) {
        this.unloadable.delete(slot)
        restored.push(slot)
      } else {
        this.unloadable.add(slot)
        unloadable.push(slot)
      }
    }
    return { restored, unloadable }
  }

  /**
   * The one drop path. Bytes in, stage swapped, manifest updated.
   * A body asset that paint can't stick to is REJECTED and the procedural
   * sphere stays: broken paint is worse than a missing model.
   */
  async dropFile(slot: string, fileName: string, bytes: ArrayBuffer): Promise<DropResult> {
    const spec = slotById(slot)
    if (!spec) {
      return {
        slot, entry: { url: '' }, rejected: true,
        message: "That isn't something this game can swap out.",
      }
    }
    if (!this.stage.slots.has(slot)) {
      return {
        slot, entry: { url: '' }, rejected: true,
        message: `There isn't a ${spec.label.toLowerCase()} anywhere in the course at the ` +
          'moment, so there is nothing here to replace.',
      }
    }
    if (spec.derivedFrom) {
      return {
        slot, entry: { url: '' }, rejected: true,
        message: `${spec.label} copies "${slotById(spec.derivedFrom)?.label}" — drop your model there instead.`,
      }
    }

    let scene: THREE.Object3D
    try {
      scene = await loadGlb(bytes)
    } catch (err) {
      console.warn(`[workshop] ${fileName} could not be read as a .glb`, err)
      return {
        slot, entry: { url: '' }, rejected: true,
        message: `${fileName} isn't a .glb file this browser can read. Try exporting again from Blender.`,
      }
    }

    // EVERY slot, not just the paintable one: a file with no mesh in it used to
    // be accepted, hide the original and put nothing in its place — a cheerful
    // success message for an object that had just vanished from the course.
    if (!firstMesh(scene)) {
      return { slot, entry: { url: '' }, rejected: true, message: NO_MESH }
    }

    let paint: PaintableInfo | undefined
    if (spec.paintable) {
      paint = inspectPaintability(scene)
      this.paintReports.set(slot, paint)
      if (!paint.uvOk) {
        return {
          slot, entry: { url: '' }, rejected: true, paint,
          message: 'Paint would not stick to this model, so the plain blob is still in. ' +
            (paint.problems[0] ?? ''),
        }
      }
    }

    // Storage first: a swap the browser cannot keep must not appear on the
    // stage, or Save would silently drop it and the preview would be a lie.
    const key = blobKeyFor(slot, fileName)
    let url: string
    try {
      url = await putGlb(key, fileName, bytes)
    } catch (err) {
      console.warn(`[workshop] could not store ${fileName}`, err)
      this.paintReports.delete(slot)
      return { slot, entry: { url: '' }, rejected: true, paint, message: NO_ROOM }
    }
    this.cache.set(url, scene)

    const previous = this.manifest[slot]
    const entry: SlotEntry = {
      url,
      offset: previous?.offset ?? [0, 0, 0],
      rotationDeg: previous?.rotationDeg ?? [0, 0, 0],
      scale: previous?.scale ?? 1,
    }
    this.manifest[slot] = entry
    this.unloadable.delete(slot)
    this.stage.setCustom(slot, scene, entry)
    const count = this.stage.slots.get(slot)?.procedural.length ?? 1
    return {
      slot, entry, paint, rejected: false,
      message: count > 1
        ? `${fileName} is now all ${count} of the ${spec.label.toLowerCase()}s.`
        : `${fileName} is in the ${spec.label.toLowerCase()} slot.`,
    }
  }

  /** Nudge the fit. Re-applies to the stage immediately; nothing is re-parsed. */
  setFit(slot: string, patch: Partial<SlotEntry>): SlotEntry | undefined {
    const current = this.manifest[slot]
    if (!current) return undefined
    const next: SlotEntry = { ...current, ...patch }
    this.manifest[slot] = next
    this.stage.applyFit(slot, next)
    return next
  }

  /** Back to what the game builds today. */
  reset(slot: string): void {
    delete this.manifest[slot]
    this.paintReports.delete(slot)
    this.unloadable.delete(slot)
    this.stage.clearCustom(slot)
  }

  /**
   * Write the working manifest where the live game will find it.
   * Pruning happens only after the write succeeds, and only against a manifest
   * that still holds every entry — including the ones that would not load.
   */
  async saveLocally(): Promise<SaveResult> {
    const clean = cleanManifest(this.manifest)
    const saved = Object.keys(clean).length
    try {
      await saveOverrideManifest(clean)
    } catch (err) {
      console.warn('[workshop] could not save the manifest', err)
      return { saved: 0, ok: false, message: NO_ROOM }
    }
    try {
      await pruneOrphans(clean)
    } catch (err) {
      // Leftover bytes waste space but break nothing — never fail a save for it.
      console.warn('[workshop] could not tidy up unused files', err)
    }
    return {
      saved, ok: true,
      message: saved === 0
        ? 'Saved — no swaps, so the game looks like it always did.'
        : `Saved ${saved} swap${saved === 1 ? '' : 's'}. Press "Test drive" to play with them.`,
    }
  }

  /** Wipe every override — the game goes back to its shipped look. */
  async clearEverything(): Promise<boolean> {
    for (const slot of Object.keys(this.manifest)) this.stage.clearCustom(slot)
    this.manifest = {}
    this.paintReports.clear()
    this.unloadable.clear()
    this.cache.clear()
    try {
      await clearOverrides()
      return true
    } catch (err) {
      console.warn('[workshop] could not clear stored swaps', err)
      return false
    }
  }

  /** The exact text the download button hands over. */
  exportText(): string {
    return serializeManifest(this.manifest)
  }

  /** Re-apply a stored entry to the stage (used by restore and by the demo). */
  private async applyEntry(slot: string, entry: SlotEntry): Promise<boolean> {
    let scene = this.cache.get(entry.url)
    if (!scene) {
      const bytes = await this.readBytes(entry.url)
      if (!bytes) return false
      try {
        scene = await loadGlb(bytes)
      } catch (err) {
        console.warn(`[workshop] stored asset for ${slot} could not be read`, err)
        return false
      }
      if (!firstMesh(scene)) {
        console.warn(`[workshop] stored asset for ${slot} has no model in it`)
        return false
      }
      this.cache.set(entry.url, scene)
      const spec = slotById(slot)
      if (spec?.paintable) this.paintReports.set(slot, inspectPaintability(scene))
    }
    this.stage.setCustom(slot, scene, entry)
    return true
  }

  private async readBytes(url: string): Promise<ArrayBuffer | null> {
    if (isIdbUrl(url)) {
      try {
        const record = await getGlb(idbKey(url))
        return record?.bytes ?? null
      } catch (err) {
        console.warn(`[workshop] could not read the stored file for ${url}`, err)
        return null
      }
    }
    try {
      const res = await fetch(url)
      if (!res.ok) {
        console.warn(`[workshop] ${url} answered ${res.status} — keeping the original.`)
        return null
      }
      return await res.arrayBuffer()
    } catch (err) {
      console.warn(`[workshop] could not fetch ${url}`, err)
      return null
    }
  }
}
