/**
 * Lane J — the THREE-free, DOM-free core of the workshop editor.
 *
 * Everything here is pure so it can be exercised by `manifest-io.test.ts` under
 * plain node (see coverage-math.test.ts for the house pattern). The types mirror
 * Lane I's `src/assets/manifest.ts` spec verbatim (lanes/LANE-I §Deliverables 1)
 * because Lane I lives in a parallel worktree — integration re-points these
 * imports at the real module and deletes the duplicates.
 */

/** Mirrors Lane I `SlotEntry`. */
export interface SlotEntry {
  url: string
  offset?: [number, number, number]
  rotationDeg?: [number, number, number]
  scale?: number | [number, number, number]
  idleClip?: string
}

/** Mirrors Lane I `Manifest` (slot id → entry). */
export type Manifest = Record<string, SlotEntry>

/** The note stamped into an exported manifest.json that still holds idb: urls. */
export const IDB_README =
  'Some urls start with "idb:" — those GLB files live only in this browser. ' +
  'To make them permanent: copy the .glb files into assets/live/ in the repo, ' +
  'change each "idb:..." url to "assets/live/<filename>.glb", then redeploy.'

/** The note stamped into an exported manifest.json whose urls are all shippable. */
export const SHIPPABLE_README =
  'Drop this file in the repo as assets/manifest.json, put the .glb files in ' +
  'assets/live/, then redeploy. No code changes needed.'

export const isIdbUrl = (url: string): boolean => url.startsWith('idb:')

/** The IndexedDB blob key behind an `idb:<key>` url ('' if not an idb url). */
export const idbKey = (url: string): string => (isIdbUrl(url) ? url.slice(4) : '')

// ---- fit maths -------------------------------------------------------------

/** A slot object's resting pose, in the units the manifest speaks. */
export interface BaseTransform {
  position: [number, number, number]
  rotationDeg: [number, number, number]
  scale: [number, number, number]
}

export const identityTransform = (): BaseTransform => ({
  position: [0, 0, 0], rotationDeg: [0, 0, 0], scale: [1, 1, 1],
})

const asTriple = (s: number | [number, number, number]): [number, number, number] =>
  typeof s === 'number' ? [s, s, s] : [s[0], s[1], s[2]]

/**
 * Where a dropped asset actually sits: the slot's procedural pose plus the
 * manifest's offset/rotation/scale. Offsets ADD to the base position and
 * rotations ADD (degrees, per axis) to the base rotation, so a manifest of all
 * zeros/one lands the asset exactly where the procedural part stood — which is
 * what makes "reset to procedural" and "empty manifest = today's game" agree.
 */
export function composeFit(base: BaseTransform, entry: SlotEntry): BaseTransform {
  const off = entry.offset ?? [0, 0, 0]
  const rot = entry.rotationDeg ?? [0, 0, 0]
  const sc = asTriple(entry.scale ?? 1)
  return {
    position: [base.position[0] + off[0], base.position[1] + off[1], base.position[2] + off[2]],
    rotationDeg: [
      base.rotationDeg[0] + rot[0],
      base.rotationDeg[1] + rot[1],
      base.rotationDeg[2] + rot[2],
    ],
    scale: [base.scale[0] * sc[0], base.scale[1] * sc[1], base.scale[2] * sc[2]],
  }
}

/** True when an entry carries no fit at all (asset sits exactly on the base pose). */
export function isNeutralFit(entry: SlotEntry): boolean {
  const off = entry.offset ?? [0, 0, 0]
  const rot = entry.rotationDeg ?? [0, 0, 0]
  const sc = asTriple(entry.scale ?? 1)
  return off.every((v) => v === 0) && rot.every((v) => v === 0) && sc.every((v) => v === 1)
}

// ---- serialisation ---------------------------------------------------------

const num3 = (v: unknown): [number, number, number] | undefined => {
  if (!Array.isArray(v) || v.length !== 3) return undefined
  const out = v.map((n) => (typeof n === 'number' && Number.isFinite(n) ? n : NaN))
  return out.some(Number.isNaN) ? undefined : (out as [number, number, number])
}

/**
 * Tolerant single-entry parse. Anything malformed is dropped rather than
 * thrown — a half-typed manifest must never take the editor (or the game) down.
 */
export function parseEntry(raw: unknown): SlotEntry | null {
  if (!raw || typeof raw !== 'object') return null
  const o = raw as Record<string, unknown>
  if (typeof o.url !== 'string' || o.url === '') return null
  const entry: SlotEntry = { url: o.url }
  const offset = num3(o.offset)
  if (offset) entry.offset = offset
  const rotationDeg = num3(o.rotationDeg)
  if (rotationDeg) entry.rotationDeg = rotationDeg
  if (typeof o.scale === 'number' && Number.isFinite(o.scale)) entry.scale = o.scale
  else {
    const s3 = num3(o.scale)
    if (s3) entry.scale = s3
  }
  if (typeof o.idleClip === 'string' && o.idleClip !== '') entry.idleClip = o.idleClip
  return entry
}

/** Keys that are documentation, not slots — stripped on the way back in. */
const META_KEYS = new Set(['_readme', '_generatedBy'])

/** Tolerant whole-manifest parse. Bad JSON or a bad shape yields `{}`. */
export function parseManifest(text: string): Manifest {
  let raw: unknown
  try {
    raw = JSON.parse(text)
  } catch {
    return {}
  }
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
  const out: Manifest = {}
  for (const [slot, value] of Object.entries(raw as Record<string, unknown>)) {
    if (META_KEYS.has(slot)) continue
    const entry = parseEntry(value)
    if (entry) out[slot] = entry
  }
  return out
}

/** Drop keys the editor never wrote so exports stay diff-friendly. */
export function cleanManifest(manifest: Manifest): Manifest {
  const out: Manifest = {}
  for (const slot of Object.keys(manifest).sort()) {
    const entry = parseEntry(manifest[slot])
    if (entry) out[slot] = entry
  }
  return out
}

/**
 * The exact bytes the download button hands over: sorted slots plus a plain-
 * language `_readme`. `_readme` is metadata, and `parseManifest` strips it, so
 * `parseManifest(serializeManifest(m))` deep-equals `cleanManifest(m)`.
 */
export function serializeManifest(manifest: Manifest): string {
  const clean = cleanManifest(manifest)
  const anyIdb = Object.values(clean).some((e) => isIdbUrl(e.url))
  const doc: Record<string, unknown> = {
    _readme: anyIdb ? IDB_README : SHIPPABLE_README,
    ...clean,
  }
  return JSON.stringify(doc, null, 2) + '\n'
}

/** Structural equality for manifests (used by the demo's round-trip assertion). */
export function manifestsEqual(a: Manifest, b: Manifest): boolean {
  return JSON.stringify(cleanManifest(a)) === JSON.stringify(cleanManifest(b))
}
