/**
 * Lane I — manifest schema, validation and load order.
 *
 * The manifest is the whole "no code changes" promise: a JSON map of slot id ->
 * where the GLB is and how to sit it in place. It is hand-editable by a
 * non-programmer, so validation is forgiving by design — a broken ENTRY is
 * dropped with a warning and the rest of the pack still loads; only a manifest
 * that isn't an object at all collapses to `{}`.
 *
 * This module deliberately imports neither `three` nor anything DOM-only at the
 * top level, so `validateManifest`/`mergeManifests` can be node-tested.
 */
import { isSlotId, type SlotId } from './slots'

export interface SlotEntry {
  /** GLB url. `idb:<key>` resolves to a blob stored by the editor. */
  url: string
  /** Local offset in metres, applied before rotation/scale. */
  offset?: [number, number, number]
  /** Local rotation in DEGREES (artist units), XYZ order. */
  rotationDeg?: [number, number, number]
  /** Uniform number or per-axis. Ignored for `blob.body` (auto-fit). */
  scale?: number | [number, number, number]
  /** Name of an animation clip to loop (SkinnedMesh assets). */
  idleClip?: string
}

export type Manifest = Partial<Record<SlotId, SlotEntry>>

/** Reported by the validator so the editor can show what it threw away. */
export interface ManifestProblem {
  slot: string
  reason: string
}

export interface ValidationResult {
  manifest: Manifest
  problems: ManifestProblem[]
}

function isTriple(v: unknown): v is [number, number, number] {
  return (
    Array.isArray(v) && v.length === 3 &&
    v.every((n) => typeof n === 'number' && Number.isFinite(n))
  )
}

function isScale(v: unknown): v is number | [number, number, number] {
  if (typeof v === 'number') return Number.isFinite(v) && v !== 0
  return isTriple(v)
}

/**
 * Pure. Never throws — a non-object input yields an empty manifest, a bad entry
 * is skipped. `problems` is advisory (editor surface + one console.warn).
 */
export function validateManifest(raw: unknown): ValidationResult {
  const problems: ManifestProblem[] = []
  const manifest: Manifest = {}
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
    if (raw !== undefined) problems.push({ slot: '(root)', reason: 'not a JSON object' })
    return { manifest, problems }
  }

  for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
    if (!isSlotId(key)) {
      problems.push({ slot: key, reason: 'unknown slot id' })
      continue
    }
    if (value === null || typeof value !== 'object' || Array.isArray(value)) {
      problems.push({ slot: key, reason: 'entry is not an object' })
      continue
    }
    const v = value as Record<string, unknown>
    if (typeof v.url !== 'string' || v.url.length === 0) {
      problems.push({ slot: key, reason: 'missing "url"' })
      continue
    }
    const entry: SlotEntry = { url: v.url }
    if (v.offset !== undefined) {
      if (isTriple(v.offset)) entry.offset = v.offset
      else problems.push({ slot: key, reason: 'offset must be [x,y,z] numbers — ignored' })
    }
    if (v.rotationDeg !== undefined) {
      if (isTriple(v.rotationDeg)) entry.rotationDeg = v.rotationDeg
      else problems.push({ slot: key, reason: 'rotationDeg must be [x,y,z] numbers — ignored' })
    }
    if (v.scale !== undefined) {
      if (isScale(v.scale)) entry.scale = v.scale
      else problems.push({ slot: key, reason: 'scale must be a non-zero number or [x,y,z] — ignored' })
    }
    if (v.idleClip !== undefined) {
      if (typeof v.idleClip === 'string') entry.idleClip = v.idleClip
      else problems.push({ slot: key, reason: 'idleClip must be a string — ignored' })
    }
    manifest[key] = entry
  }
  return { manifest, problems }
}

/** Later sources win, per whole entry (not per field). Pure. */
export function mergeManifests(...sources: Manifest[]): Manifest {
  const out: Manifest = {}
  for (const src of sources) {
    for (const [k, v] of Object.entries(src) as [SlotId, SlotEntry][]) {
      if (v) out[k] = v
    }
  }
  return out
}

/**
 * The one correct way to address a shipped asset in this repo. `/assets/...`
 * drops the `/blobbo/` deploy prefix and a bare `assets/...` resolves against
 * the PAGE directory, which breaks on the demo pages (one level down).
 */
export function assetUrl(relative: string): string {
  const base = import.meta.env.BASE_URL || '/'
  return base + relative.replace(/^\/+/, '')
}

export const MANIFEST_URL_PATH = 'assets/manifest.json'

/** Fetch the shipped pack. A 404 is a normal, expected outcome (no pack). */
export async function fetchShippedManifest(): Promise<Manifest> {
  let raw: unknown
  try {
    const res = await fetch(assetUrl(MANIFEST_URL_PATH), { cache: 'no-cache' })
    if (!res.ok) return {}
    raw = await res.json()
  } catch {
    return {}
  }
  const { manifest, problems } = validateManifest(raw)
  if (problems.length) {
    console.warn('[assets] manifest.json problems:', problems)
  }
  return manifest
}
