/**
 * LANE-RENDER — asset registry + MODELBEAST hot-swap.
 *
 * Every entity mesh keys off `MachineDef.asset` through here. No hard-coded ids.
 *
 * Default for every key is a procedural placeholder built to the style guide's
 * two-material rule: a grimy neutral body + exactly ONE emissive accent. Accent colour
 * precedence (contracts v2): `def.color` (LANE-DATA art-direction) > derived from what
 * the machine makes (recipes -> outputs -> ItemDef.color) > per-kind accent.
 *
 * If `public/assets/models/<key>.glb` exists, it replaces the placeholder factory.
 * We probe on init and re-probe every 10s in dev, so an asset dropped in mid-session
 * appears without a reload. GLBs are auto-normalised (scaled to footprint, sat on the
 * ground) so MODELBEAST can export at any scale and it just lands.
 */
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js';
import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js';
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import type { GameData, MachineDef } from '../contracts';
import { ACCENT_BY_KIND, ACCENT_MIN_L, BODY_BY_KIND, HEIGHT_BY_KIND, lightnessOf } from './palette';
import { DEFAULT_ERA, applyEraSkin, eraIndex, type Era } from './era';

const MODEL_DIR = 'assets/models';
const POLL_MS = 10_000;

/** Per-frame idle animation attached to a placeholder. `t` is seconds (phase-shifted). */
export type IdleFn = (t: number) => void;
/** Buffer tanks expose their fill level (0..1) to EntityLayer. */
export type FillFn = (fill: number) => void;

export interface Instanceable {
  geometry: THREE.BufferGeometry;
  material: THREE.Material | THREE.Material[];
}

export interface AssetEntry {
  key: string;
  /** Bumps when a GLB hot-swaps in; layers rebuild meshes whose version moved. */
  version: number;
  isGLB: boolean;
  /** A fresh instance, normalised to the machine footprint, origin at ground centre. */
  create(): THREE.Object3D;
  /** Single geo+mat for InstancedMesh use (belts). Null if the asset can't be instanced. */
  instanceable(): Instanceable | null;
}

export function idleOf(obj: THREE.Object3D): IdleFn | null {
  return (obj.userData.fktryIdle as IdleFn | undefined) ?? null;
}
export function fillOf(obj: THREE.Object3D): FillFn | null {
  return (obj.userData.fktrySetFill as FillFn | undefined) ?? null;
}
export function clipsOf(obj: THREE.Object3D): THREE.AnimationClip[] {
  return (obj.userData.fktryClips as THREE.AnimationClip[] | undefined) ?? [];
}

// ---------------------------------------------------------------- placeholders

function bodyMat(color: number): THREE.MeshStandardMaterial {
  return new THREE.MeshStandardMaterial({ color, roughness: 0.85, metalness: 0.15 });
}

function accentMat(color: number): THREE.MeshStandardMaterial {
  return new THREE.MeshStandardMaterial({
    color,
    emissive: color,
    emissiveIntensity: 1.35,
    roughness: 0.35,
    metalness: 0.1,
  });
}

/**
 * Procedural placeholder: footprint-sized bevelled box, one emissive accent, one tiny
 * idle motion. The body mesh is tagged `fktryBody` so EntityLayer knows what to heat —
 * a GLB has no such split, so there heat tints everything.
 */
function makePlaceholder(def: MachineDef, accent: number, body_: number, era: Era): THREE.Object3D {
  const g = new THREE.Group();
  const fw = Math.max(0.4, def.footprint.x * 0.92);
  const fd = Math.max(0.4, def.footprint.y * 0.92);
  const h = HEIGHT_BY_KIND[def.kind] ?? 0.8;

  const chassis = bodyMat(body_);
  applyEraSkin(chassis, era); // chassis only — the accent is the resource, not the decade
  const body = new THREE.Mesh(
    new RoundedBoxGeometry(fw, h, fd, 2, Math.min(0.08, h * 0.25)),
    chassis,
  );
  body.position.y = h / 2;
  body.castShadow = body.receiveShadow = true;
  body.userData.fktryBody = true;
  g.add(body);

  const am = accentMat(accent);

  switch (def.kind) {
    case 'extractor': {
      // CRT monitor for a head + a drill bit that spins (codex §4).
      const crt = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.5, h * 0.3, 0.06), am);
      crt.position.set(0, h * 0.72, -fd / 2 - 0.01);
      g.add(crt);
      const drill = new THREE.Mesh(new THREE.ConeGeometry(fw * 0.16, h * 0.5, 6), am.clone());
      drill.position.set(0, h * 0.22, fd * 0.22);
      drill.rotation.x = Math.PI;
      g.add(drill);
      g.userData.fktryIdle = ((t: number) => {
        drill.rotation.y = t * 4;
        (crt.material as THREE.MeshStandardMaterial).emissiveIntensity =
          1.2 + Math.sin(t * 9) * 0.25; // CRT flicker
      }) satisfies IdleFn;
      break;
    }
    case 'crafter': {
      const jaw = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.62, h * 0.14, fd * 0.62), am);
      jaw.position.y = h + 0.02;
      g.add(jaw);
      g.userData.fktryIdle = ((t: number) => {
        jaw.position.y = h + 0.02 + Math.abs(Math.sin(t * 1.6)) * 0.09;
      }) satisfies IdleFn;
      break;
    }
    case 'power': {
      const led = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.7, 0.05, fd * 0.12), am);
      led.position.y = h + 0.02;
      g.add(led);
      g.userData.fktryIdle = ((t: number) => {
        am.emissiveIntensity = 1.0 + Math.sin(t * 2.2) * 0.5; // serene LED breath
      }) satisfies IdleFn;
      break;
    }
    case 'shipper': {
      const dish = new THREE.Mesh(new THREE.ConeGeometry(fw * 0.3, h * 0.42, 12, 1, true), am);
      dish.position.y = h + h * 0.2;
      g.add(dish);
      g.userData.fktryIdle = ((t: number) => {
        dish.rotation.y = t * 1.1;
        am.emissiveIntensity = 1.2 + Math.sin(t * 6) * 0.35;
      }) satisfies IdleFn;
      break;
    }
    case 'buffer': {
      // A tank with a window: the fill column is the machine's whole tell, so it reads
      // as a level even at iso distance. Driven from bandwidth.stored by EntityLayer.
      const fillH = h * 0.82;
      const fill = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.52, fillH, fd * 0.52), am);
      fill.position.y = 0;
      fill.scale.y = 0.001;
      g.add(fill);
      const rim = new THREE.Mesh(new THREE.TorusGeometry(fw * 0.3, 0.03, 8, 20), am.clone());
      rim.position.y = h + 0.03;
      rim.rotation.x = Math.PI / 2;
      g.add(rim);
      g.userData.fktrySetFill = ((f: number) => {
        const v = THREE.MathUtils.clamp(f, 0, 1);
        fill.scale.y = Math.max(0.001, v);
        fill.position.y = (fillH * v) / 2; // grow up from the tank floor
      }) satisfies FillFn;
      g.userData.fktryIdle = ((t: number) => {
        rim.rotation.z = t * 0.8;
      }) satisfies IdleFn;
      break;
    }
    case 'lab': {
      // A research bench: low slab, and the "bottled artifact triptych" (§2) standing on
      // it. Three bottles is the whole silhouette — nothing else in the hall is a row of
      // little glowing cylinders, which is the point of a placeholder.
      const bottles: THREE.Mesh[] = [];
      for (let i = 0; i < 3; i++) {
        const b = new THREE.Mesh(
          new THREE.CylinderGeometry(fw * 0.07, fw * 0.07, h * 0.3, 8),
          i === 0 ? am : am.clone(),
        );
        b.position.set((i - 1) * fw * 0.2, h + h * 0.15, 0);
        g.add(b);
        bottles.push(b);
      }
      g.userData.fktryIdle = ((t: number) => {
        // They pulse out of phase — a bench mid-assay, not a machine mid-cycle.
        bottles.forEach((b, i) => {
          (b.material as THREE.MeshStandardMaterial).emissiveIntensity =
            1.0 + Math.sin(t * 2.4 + i * 2.1) * 0.45;
        });
      }) satisfies IdleFn;
      break;
    }
    case 'splitter': {
      // Output-cycling tell: a lamp that sweeps across the outputs (codex: round-robin).
      const bar = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.66, 0.05, fd * 0.16), am);
      bar.position.y = h + 0.02;
      g.add(bar);
      const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), am.clone());
      lamp.position.y = h + 0.09;
      g.add(lamp);
      g.userData.fktryIdle = ((t: number) => {
        // Steps between output lanes rather than sliding — it's round-robin, not a slider.
        const step = Math.floor(t * 2) % 2 === 0 ? -1 : 1;
        lamp.position.x = step * fw * 0.24;
        (lamp.material as THREE.MeshStandardMaterial).emissiveIntensity =
          1.1 + Math.sin(t * 12) * 0.3;
      }) satisfies IdleFn;
      break;
    }
    default: {
      const brow = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.55, 0.05, fd * 0.2), am);
      brow.position.y = h + 0.02;
      g.add(brow);
      g.userData.fktryIdle = ((t: number) => {
        am.emissiveIntensity = 1.1 + Math.sin(t * 3) * 0.25;
      }) satisfies IdleFn;
    }
  }
  return g;
}

/** The flat belt plate. Instanced by belts.ts; chevron scroll lives in its material. */
export function beltPlateGeometry(): THREE.BufferGeometry {
  const geo = new THREE.BoxGeometry(0.94, HEIGHT_BY_KIND.belt, 0.94);
  geo.translate(0, HEIGHT_BY_KIND.belt / 2, 0);
  return geo;
}

// ---------------------------------------------------------------- GLB handling

/** Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane. */
function normaliseGLB(src: THREE.Object3D, def: MachineDef, clips: THREE.AnimationClip[]): THREE.Object3D {
  // SkeletonUtils.clone (not Object3D.clone) — rigged meshes need their skeleton rebound
  // per instance or every copy animates off the first one's bones.
  const obj = cloneSkinned(src);
  const box = new THREE.Box3().setFromObject(obj);
  const size = box.getSize(new THREE.Vector3());
  const targetW = Math.max(0.4, def.footprint.x * 0.92);
  const targetD = Math.max(0.4, def.footprint.y * 0.92);
  const s = Math.min(
    size.x > 1e-6 ? targetW / size.x : 1,
    size.z > 1e-6 ? targetD / size.z : 1,
  );
  if (Number.isFinite(s) && s > 0) obj.scale.setScalar(s);

  // Re-measure after scaling, then centre on X/Z and sit the base at y=0.
  const box2 = new THREE.Box3().setFromObject(obj);
  const c = box2.getCenter(new THREE.Vector3());
  obj.position.x -= c.x;
  obj.position.z -= c.z;
  obj.position.y -= box2.min.y;
  obj.traverse((o) => {
    if ((o as THREE.Mesh).isMesh) o.castShadow = o.receiveShadow = true;
  });
  // A scaled/offset root would fight the mixer's root-motion tracks, so animated GLBs
  // get wrapped: the mixer drives the inner node, the wrapper does the normalising.
  if (clips.length) {
    const wrap = new THREE.Group();
    wrap.add(obj);
    wrap.userData.fktryClips = clips;
    return wrap;
  }
  obj.userData.fktryClips = clips;
  return obj;
}

/**
 * Collapse a GLB into one instanceable geometry. Multi-mesh assets are merged with
 * groups so a fancy belt segment can't half-render (round 1 only took the first mesh).
 * Merging needs matching attributes; if they don't line up we keep the first mesh and
 * say so rather than silently dropping geometry.
 */
function toInstanceable(root: THREE.Object3D, key: string): Instanceable | null {
  const geos: THREE.BufferGeometry[] = [];
  const mats: THREE.Material[] = [];
  root.updateWorldMatrix(true, true);
  root.traverse((o) => {
    const m = o as THREE.Mesh;
    if (!m.isMesh || !m.geometry) return;
    geos.push(m.geometry.clone().applyMatrix4(m.matrixWorld));
    mats.push(Array.isArray(m.material) ? m.material[0] : m.material);
  });
  if (!geos.length) return null;
  if (geos.length === 1) return { geometry: geos[0], material: mats[0] };

  const merged = mergeGeometries(geos, true); // useGroups: keeps one material per mesh
  if (merged) return { geometry: merged, material: mats };
  console.warn(
    `[render] "${key}": multi-mesh GLB has mismatched attributes and could not be ` +
      `merged; instancing the first mesh only (${geos.length} meshes seen)`,
  );
  return { geometry: geos[0], material: mats[0] };
}

/**
 * True only if the URL is really a GLB. A dev server's SPA fallback happily answers
 * 200 + text/html for a missing file, so check the content-type before we trust it.
 */
async function glbExists(url: string): Promise<boolean> {
  try {
    const res = await fetch(url, { method: 'HEAD', cache: 'no-store' });
    if (!res.ok) return false;
    const ct = res.headers.get('content-type') ?? '';
    return !ct.includes('text/html');
  } catch {
    return false;
  }
}

// ---------------------------------------------------------------- registry

export class AssetRegistry {
  /** Bumps on any hot-swap. Layers compare against it to know a rebuild is due. */
  version = 0;

  private entries = new Map<string, AssetEntry>();
  private defs = new Map<string, MachineDef>();
  private eras = new Map<string, Era>();
  private loader = new GLTFLoader();
  private timer: number | null = null;

  /** Which era a machine is skinned as (derived from tech unlocks; stream by default). */
  eraOf(machineId: string): Era {
    return this.eras.get(machineId) ?? DEFAULT_ERA;
  }

  async init(data: GameData): Promise<void> {
    this.eras = eraIndex(data);
    for (const def of data.machines) {
      this.defs.set(def.asset, def);
      const entry = this.placeholderEntry(def, accentFor(def, data), bodyFor(def), this.eraOf(def.id));
      this.entries.set(def.asset, entry);
    }
    await this.probeAll();
    if (import.meta.env.DEV) {
      this.timer = setInterval(() => void this.probeAll(), POLL_MS) as unknown as number;
    }
  }

  dispose(): void {
    if (this.timer !== null) clearInterval(this.timer);
  }

  get(key: string): AssetEntry | null {
    return this.entries.get(key) ?? null;
  }

  private placeholderEntry(def: MachineDef, accent: number, body: number, era: Era): AssetEntry {
    const prev = this.entries.get(def.asset);
    return {
      key: def.asset,
      version: prev ? prev.version : 0,
      isGLB: false,
      create: () => makePlaceholder(def, accent, body, era),
      // Belts are instanced by belts.ts, which supplies the chevron material itself —
      // this material is only a fallback and is normally discarded.
      instanceable: () =>
        def.kind === 'belt'
          ? { geometry: beltPlateGeometry(), material: bodyMat(body) }
          : null,
    };
  }

  /** Probe every key that hasn't got a GLB yet. Cheap: one HEAD each, 10s apart. */
  private async probeAll(): Promise<void> {
    const pending = [...this.defs.values()].filter((d) => !this.entries.get(d.asset)?.isGLB);
    await Promise.all(pending.map((d) => this.tryLoad(d)));
  }

  private async tryLoad(def: MachineDef): Promise<void> {
    const url = `${import.meta.env.BASE_URL}${MODEL_DIR}/${def.asset}.glb`;
    if (!(await glbExists(url))) return;
    try {
      const gltf = await this.loader.loadAsync(url);
      const prev = this.entries.get(def.asset);
      const version = (prev?.version ?? 0) + 1;
      const template = gltf.scene;
      const clips = gltf.animations ?? [];
      this.entries.set(def.asset, {
        key: def.asset,
        version,
        isGLB: true,
        create: () => normaliseGLB(template, def, clips),
        instanceable: () => toInstanceable(normaliseGLB(template, def, []), def.asset),
      });
      this.version++;
      console.info(
        `[render] hot-swapped GLB for "${def.asset}"${clips.length ? ` (+${clips.length} clip(s))` : ''}`,
      );
    } catch (err) {
      // Corrupt/half-written file (MODELBEAST may still be copying). Keep the
      // placeholder and let the next poll retry.
      console.warn(`[render] GLB for "${def.asset}" failed to parse, keeping placeholder`, err);
    }
  }
}

/**
 * The machine's ONE emissive accent: `MachineDef.accent` (v4) when art-directed,
 * otherwise the colour of what it makes (recipes -> outputs -> ItemDef.color), otherwise
 * a per-kind accent.
 *
 * `MachineDef.color` is NOT read here — v3 ruled it the chassis; see `bodyFor` below.
 *
 * The derived path has a guard: raw products are near-black by design (MDAT ORE is
 * #1a1a22), and an accent that dark isn't an accent — the seam extractor's CRT head
 * would be a black panel on a black chassis. Anything below the legibility floor falls
 * through to the kind accent (for the extractor, CRT blue, as the codex describes it).
 */
function accentFor(def: MachineDef, data: GameData): number {
  // v4: explicit art-direction wins outright, with no legibility floor. DATA choosing a
  // dark accent is a decision; the floor below exists for DERIVED colours, which are
  // incidental (item colours are authored for chips, not for machine accents). Same
  // reasoning as `bodyFor` and `def.color`.
  if (def.accent) return new THREE.Color(def.accent).getHex();
  for (const rid of def.recipes) {
    const recipe = data.recipes.find((r) => r.id === rid);
    const outId = recipe && Object.keys(recipe.outputs)[0];
    const item = outId ? data.items.find((i) => i.id === outId) : undefined;
    if (item && lightnessOf(item.color) >= ACCENT_MIN_L) {
      return new THREE.Color(item.color).getHex();
    }
  }
  return ACCENT_BY_KIND[def.kind] ?? 0x3fffe0;
}

/**
 * The grimy industrial body. `MachineDef.color` lands here rather than on the accent.
 *
 * The v2 field is commented "art-direction accent", but every one of the 21 values
 * LANE-DATA authored is a desaturated industrial neutral matching a codex CHASSIS
 * description — "grimy yellow chassis" is #8a7a32, the ASIC's "server-blade monolith"
 * is #b9bec4, the mosh reactor's pressure vessel is #52333e. Style guide §8 is explicit
 * that ALL saturation belongs to media/artifacts and that each machine gets exactly ONE
 * impossible element; wiring these to the emissive makes all 21 glow muddy neutral and
 * deletes that element. Read as bodies they are exactly right, and they art-direct 21
 * machines that were previously 7 per-kind greys. Flagged for the orchestrator.
 */
function bodyFor(def: MachineDef): number {
  if (def.color) return new THREE.Color(def.color).getHex();
  return BODY_BY_KIND[def.kind] ?? 0x3b3944;
}
