/**
 * LANE-RENDER — `?showroom`: the MODELBEAST asset QA page.
 *
 * Every machine and every item, on labelled plinths, under a fixed camera. Drop a GLB
 * into `public/assets/models/<asset>.glb` and it appears here within ~10s with no
 * reload — this is the eyeball step for an asset drop: right scale? sits on the ground?
 * silhouette reads at iso distance? clip loops?
 *
 * The machines are fabricated as a normal SimSnapshot rather than hand-built meshes, so
 * they go through the SAME EntityLayer path as the real game — hot-swap, GLB
 * normalisation, AnimationMixer, era skin and all. A bespoke display path would be a
 * QA page that tests itself instead of the renderer.
 *
 * Items aren't entities (they have no `asset` key and never hot-swap), so those are
 * built directly — but from beltitems.ts's own geometry/material factories, so what you
 * see here is literally what rides the belts.
 *
 * Dev-only, like the rest of the scaffold: `import.meta.env.DEV` plus the query flag.
 */
import * as THREE from 'three';
import type { Dir, EntityState, GameData, SimSnapshot } from '../contracts';
import { AssetRegistry } from './registry';
import { itemGeometry, itemMaterial } from './beltitems';

export const showroomEnabled = (): boolean =>
  import.meta.env.DEV && new URLSearchParams(location.search).has('showroom');

/** Machines face the camera (dir 2 = S = +z), which is where the iso rig looks from. */
const FACING: Dir = 2;
/**
 * Machine hall to the north, item hall to the south, both inside the ±32 world.
 * Both catalogs grow every round (9 items -> 60; 7 machines -> 29), so nothing here is
 * a fixed row/column count: the halls are packed into the world box at build time.
 */
const M_COLS = 8;
const M_SPACING = 6.5;
const M_Z0 = -24;
const I_SPACING = 3.5;
const I_GAP = 7; // between the two halls
const I_Z_LIMIT = 30; // keep the item hall inside the world (camera clamps at ±32)
const X_LIMIT = 30;
const PLINTH_H = 0.05;

interface Slot {
  label: string;
  sub: string;
  x: number;
  z: number;
  /** Cached label size, measured once — re-reading offsetWidth every frame thrashes layout. */
  w: number;
  h: number;
}

export class Showroom {
  readonly decor = new THREE.Group();
  private entities: EntityState[] = [];
  private slots: Slot[] = [];
  private labelHost: HTMLDivElement | null = null;
  private labelEls: HTMLDivElement[] = [];
  private v = new THREE.Vector3();
  /** Camera preset framing the whole hall. */
  readonly view = { x: 0, z: 2, zoom: 21 };

  constructor(private data: GameData, private registry: AssetRegistry) {
    this.decor.name = 'showroom';
    this.build();
  }

  private build(): void {
    let id = 1;
    const mCentre = ((M_COLS - 1) * M_SPACING) / 2;

    // --- machines: fabricated entities, rendered by the real EntityLayer -------------
    this.data.machines.forEach((def, i) => {
      const cx = -mCentre + (i % M_COLS) * M_SPACING;
      const cz = M_Z0 + Math.floor(i / M_COLS) * M_SPACING;
      // Centre each footprint on its slot: pos is the min corner.
      const x = Math.round(cx - def.footprint.x / 2);
      const z = Math.round(cz - def.footprint.y / 2);
      this.entities.push({
        id: id++, def: def.id, pos: { x, y: z }, dir: FACING, recipe: null,
        progress: 0, inputBuf: {}, outputBuf: {}, jammed: null, heat: 0, scrammed: false,
      });
      this.slots.push({
        label: def.name,
        sub: `${def.asset}.glb · ${this.registry.eraOf(def.id)}`,
        x: cx, z: cz, w: 0, h: 0,
      });
      this.decor.add(plinth(def.footprint.x + 0.8, def.footprint.y + 0.8, cx, cz));
    });

    const itemZ0 = M_Z0 + Math.ceil(this.data.machines.length / M_COLS) * M_SPACING + I_GAP;

    // Fit the item hall to the world box — BOTH axes. Budgeting only depth is what put
    // all 60 items on one 206-tile-wide row the moment the machine hall grew to 5 rows
    // and ate the remaining depth: rows collapsed to 1, so columns took the overflow and
    // walked straight out of the world.
    const iRows = Math.max(1, Math.floor((I_Z_LIMIT - itemZ0) / I_SPACING) + 1);
    const maxCols = Math.max(1, Math.floor((X_LIMIT * 2) / I_SPACING) + 1);
    const iCols = Math.min(maxCols, Math.max(1, Math.ceil(this.data.items.length / iRows)));
    const iCentre = ((iCols - 1) * I_SPACING) / 2;
    // If both axes are full the catalog has outgrown the hall — say so rather than
    // quietly stacking items on top of each other off-world.
    const capacity = iRows * iCols;
    if (this.data.items.length > capacity) {
      console.warn(
        `[render] showroom: ${this.data.items.length} items exceed the hall's ${capacity} ` +
          `slots (${iCols}x${iRows}); the overflow will sit past the world edge. Time to page.`,
      );
    }

    // --- items: built from the belt layer's own factories ----------------------------
    this.data.items.forEach((def, i) => {
      const cx = -iCentre + (i % iCols) * I_SPACING;
      const cz = itemZ0 + Math.floor(i / iCols) * I_SPACING;
      const mesh = new THREE.Mesh(itemGeometry(def), itemMaterial(def));
      mesh.position.set(cx, PLINTH_H + 0.35, cz);
      mesh.castShadow = true;
      mesh.userData.spin = true;
      this.decor.add(mesh);
      this.decor.add(plinth(1.4, 1.4, cx, cz));
      this.slots.push({
        label: def.name, sub: `tier ${def.tier} · ${def.color}`, x: cx, z: cz, w: 0, h: 0,
      });
    });

    // Preset frames the MACHINE hall: assets are what this page exists to inspect, and a
    // view wide enough to include every item renders each machine too small to judge.
    // The item hall is one pan south (WASD / drag).
    const mRows = Math.ceil(this.data.machines.length / M_COLS);
    this.view.z = M_Z0 + ((mRows - 1) * M_SPACING) / 2;
    this.view.zoom = 22;
  }

  /** All machines, standing still. Items live in `decor`, not the snapshot. */
  snapshot(tick: number): SimSnapshot {
    return {
      tick,
      paused: false,
      entities: this.entities,
      beltItems: [],
      bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false },
      shippedTotal: {},
      activeCommission: null,
      commissionProgress: {},
    };
  }

  frame(timeSec: number): void {
    for (const o of this.decor.children) {
      if (o.userData.spin) o.rotation.y = timeSec * 0.9;
    }
  }

  // ---------------------------------------------------------------- labels

  /** DOM labels: crisp at any zoom, and no font atlas to maintain for a QA page. */
  mountLabels(container: HTMLElement): void {
    const host = document.createElement('div');
    host.style.cssText = 'position:absolute;inset:0;pointer-events:none;overflow:hidden';
    this.labelEls = this.slots.map((s) => {
      const el = document.createElement('div');
      el.style.cssText = [
        'position:absolute',
        'transform:translate(-50%,0)',
        'font:10px/1.35 "Courier New",ui-monospace,monospace',
        'color:#cfe6ff',
        'text-align:center',
        'text-shadow:0 1px 3px #000,0 0 8px #000',
        'white-space:nowrap',
      ].join(';');
      el.innerHTML = `<div>${esc(s.label)}</div><div style="color:#7d8aa8">${esc(s.sub)}</div>`;
      host.appendChild(el);
      return el;
    });
    container.appendChild(host);
    this.labelHost = host;
    // Measure once: sizes never change, and reading offsetWidth per frame would force
    // a layout for every one of ~74 labels.
    this.slots.forEach((s, i) => {
      s.w = this.labelEls[i].offsetWidth;
      s.h = this.labelEls[i].offsetHeight;
    });
  }

  /**
   * Project, then drop any label that would collide with one already placed. There are
   * ~74 of these and at overview zoom they cannot all fit; a readable subset that
   * resolves as you zoom in beats an unreadable pile. Front-to-back so the labels you
   * lose are the ones furthest away.
   */
  updateLabels(camera: THREE.Camera, container: HTMLElement): void {
    if (!this.labelHost) return;
    const w = container.clientWidth;
    const h = container.clientHeight;
    this.placed.length = 0;

    const order = this.slots
      .map((s, i) => ({ s, i, d: s.z })) // nearer the camera = larger z in this rig
      .sort((a, b) => b.d - a.d);

    for (const { s, i } of order) {
      const el = this.labelEls[i];
      this.v.set(s.x, 0, s.z + 1.5).project(camera);
      if (this.v.z > 1 || Math.abs(this.v.x) > 1.15 || Math.abs(this.v.y) > 1.15) {
        el.style.display = 'none';
        continue;
      }
      const px = (this.v.x * 0.5 + 0.5) * w;
      const py = (-this.v.y * 0.5 + 0.5) * h;
      const l = px - s.w / 2;
      const r = px + s.w / 2;
      const t = py;
      const b = py + s.h;
      let hit = false;
      for (const p of this.placed) {
        if (l < p[2] && r > p[0] && t < p[3] && b > p[1]) { hit = true; break; }
      }
      if (hit) {
        el.style.display = 'none';
        continue;
      }
      this.placed.push([l, t, r, b]);
      el.style.display = '';
      el.style.left = `${px}px`;
      el.style.top = `${py}px`;
    }
  }

  private placed: Array<[number, number, number, number]> = [];
}

function plinth(w: number, d: number, cx: number, cz: number): THREE.Mesh {
  const m = new THREE.Mesh(
    new THREE.BoxGeometry(w, PLINTH_H, d),
    new THREE.MeshStandardMaterial({
      color: 0x14121d,
      emissive: 0x2a3550,
      emissiveIntensity: 0.35,
      roughness: 0.9,
    }),
  );
  m.position.set(cx, PLINTH_H / 2, cz);
  m.receiveShadow = true;
  return m;
}

const esc = (s: string): string =>
  s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
