/**
 * LANE-RENDER — belt cargo. One InstancedMesh per item type; sim owns the truth,
 * we own the smoothness.
 *
 * Interpolation: main.ts hands us the post-tick snapshot plus `alpha` (fraction into
 * the next tick), so we render lerp(prevTick, thisTick, alpha) — the classic
 * fixed-timestep interpolation. One tick of latency, and no extrapolation jitter when
 * an item is blocked by back-pressure (prev == cur -> it simply sits still).
 *
 * Identity: contracts v3 makes `BeltItem.id` required, so interpolation is EXACT and
 * the old order-matching fallback (and its per-frame sort of every item) is gone.
 *
 * Pathing: cargo follows the belt's topology shape, so on a corner it enters at the
 * feeding edge and sweeps the arc rather than teleporting to the back edge and cutting
 * across. Still pure presentation — `t` remains the sim's progress along the tile.
 */
import * as THREE from 'three';
import type { GameData, ItemDef, SimSnapshot } from '../contracts';
import { HEIGHT_BY_KIND, readable } from './palette';
import { BeltTopology, localPath, toWorld } from './topology';

const ITEM_SIZE = 0.3;
const RIDE_Y = HEIGHT_BY_KIND.belt + ITEM_SIZE * 0.5;

interface ItemMesh {
  mesh: THREE.InstancedMesh;
  capacity: number;
}

/**
 * Products glow; raw ore doesn't — saturation belongs to media (style guide §8).
 * The base colour goes through `readable()` because MDAT ORE's authored #1a1a22 is
 * invisible against the belt; ore still reads as the darkest thing on the line, just
 * not as a hole in the world.
 */
export function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial {
  return new THREE.MeshStandardMaterial({
    color: readable(def.color),
    emissive: new THREE.Color(def.color),
    emissiveIntensity: def.tier >= 2 ? 1.4 : def.tier * 0.35,
    roughness: 0.5,
    metalness: 0.2,
  });
}

export function itemGeometry(def: ItemDef): THREE.BufferGeometry {
  // Products read as faceted crystals; raw/refined as crate-ish boxes.
  return def.tier >= 2
    ? new THREE.OctahedronGeometry(ITEM_SIZE * 0.7)
    : new THREE.BoxGeometry(ITEM_SIZE, ITEM_SIZE * 0.8, ITEM_SIZE);
}

export class BeltItemLayer {
  readonly group = new THREE.Group();
  private byItem = new Map<string, ItemMesh>();
  private items = new Map<string, ItemDef>();
  /** Keyed by BeltItem.id — stable across the belt-to-belt handoff. */
  private prev = new Map<number, number>();
  private cur = new Map<number, number>();
  private lastTick = -1;
  private dummy = new THREE.Object3D();
  private counts = new Map<string, number>();
  private written = new Map<string, number>();
  private lp = { x: 0, z: 0 };
  private wp = { x: 0, z: 0 };

  constructor(data: GameData, private topo: BeltTopology) {
    this.group.name = 'beltItems';
    for (const i of data.items) this.items.set(i.id, i);
  }

  private ensure(def: ItemDef, needed: number): ItemMesh {
    const existing = this.byItem.get(def.id);
    if (existing && needed <= existing.capacity) return existing;
    const capacity = Math.max(128, 1 << Math.ceil(Math.log2(Math.max(1, needed))));
    // Re-use geo/mat across growth; only the InstancedMesh itself is replaced.
    const geometry = existing?.mesh.geometry ?? itemGeometry(def);
    const material = (existing?.mesh.material as THREE.Material) ?? itemMaterial(def);
    if (existing) {
      this.group.remove(existing.mesh);
      existing.mesh.dispose();
    }
    const mesh = new THREE.InstancedMesh(geometry, material, capacity);
    mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
    mesh.frustumCulled = false;
    mesh.castShadow = true;
    mesh.count = 0;
    this.group.add(mesh);
    const im: ItemMesh = { mesh, capacity };
    this.byItem.set(def.id, im);
    return im;
  }

  sync(snap: SimSnapshot, alpha: number, timeSec: number): void {
    // Roll the tick window: cur becomes prev the moment the sim advances.
    const advanced = snap.tick !== this.lastTick;
    if (advanced) {
      const swap = this.prev;
      this.prev = this.cur;
      this.cur = swap;
      this.cur.clear();
      this.lastTick = snap.tick;
    }

    // Pass 1 — count per item type, and record this tick's positions for next frame.
    this.counts.clear();
    for (const bi of snap.beltItems) {
      if (!this.topo.get(bi.entity)) continue; // cargo on a belt we haven't seen yet
      this.counts.set(bi.item, (this.counts.get(bi.item) ?? 0) + 1);
      if (advanced) this.cur.set(bi.id, bi.t);
    }
    for (const [itemId, count] of this.counts) {
      const def = this.items.get(itemId);
      if (def) this.ensure(def, count);
    }

    // Pass 2 — write instance matrices.
    this.written.clear();
    for (const bi of snap.beltItems) {
      const node = this.topo.get(bi.entity);
      if (!node) continue;
      const def = this.items.get(bi.item);
      const im = this.byItem.get(bi.item);
      if (!def || !im) continue;

      const from = this.prev.get(bi.id);
      const t = from === undefined ? bi.t : from + (bi.t - from) * alpha;

      localPath(node.shape, t, this.lp);
      toWorld(this.lp.x, this.lp.z, node.x, node.y, node.dir, this.wp);
      this.dummy.position.set(this.wp.x, RIDE_Y, this.wp.z);
      this.dummy.rotation.set(0, 0, 0);
      if (def.tier >= 2) {
        this.dummy.rotation.y = timeSec * 1.6; // products tumble; ore just rides
        this.dummy.position.y += Math.sin(timeSec * 3 + bi.entity) * 0.02;
      }
      this.dummy.updateMatrix();

      const n = this.written.get(bi.item) ?? 0;
      if (n < im.capacity) {
        im.mesh.setMatrixAt(n, this.dummy.matrix);
        this.written.set(bi.item, n + 1);
      }
    }

    for (const [id, im] of this.byItem) {
      im.mesh.count = this.written.get(id) ?? 0;
      im.mesh.instanceMatrix.needsUpdate = true;
    }
  }
}
