/**
 * LANE-RENDER — machine entities (everything except belts, which are instanced).
 *
 * Syncs the scene to `snap.entities`: add, remove, re-transform, and rebuild when a
 * MODELBEAST GLB hot-swaps under an asset key. Sim is truth; we only ever read.
 *
 * Feedback this layer owns:
 *   heat      — dull-red emissive ramp + rising shimmer, from `EntityState.heat`
 *   throttle  — heat > 0.7 visibly slows idle motion AND any GLB animation clip
 *   scram     — heat >= 1: dead-dark, blinking amber until it restarts
 *   jam       — blinking amber klaxon
 *   buffer    — tank fill from the machine's share of `bandwidth.stored`
 */
import * as THREE from 'three';
import type { Dir, MachineDef, SimSnapshot } from '../contracts';
import { AssetRegistry, clipsOf, fillOf, idleOf, type FillFn, type IdleFn } from './registry';
import { centerOf, yawFor } from './coords';
import { HEIGHT_BY_KIND, JAM_AMBER } from './palette';
import { createHeatHaze, type HeatHaze } from './heat';
import type { Juice } from './juice';

/** Placement thunk: how long the scale-pop takes, in seconds. */
const POP_TIME = 0.22;

/** Heat is throttled above this (contracts: "throttles >0.7, scrams at 1"). */
const THROTTLE_AT = 0.7;
const THROTTLE_FLOOR = 0.25;
/** Used when LANE-DATA hasn't set `bufferCap` yet — tanks still read as tanks. */
const DEFAULT_BUFFER_CAP = 100;
const HEAT_RED = new THREE.Color(0xff3a12);

interface HeatTarget {
  mat: THREE.MeshStandardMaterial;
  baseEmissive: THREE.Color;
  baseIntensity: number;
}

interface Rec {
  obj: THREE.Object3D;
  def: string;
  dir: Dir;
  x: number;
  y: number;
  assetVersion: number;
  /** Placeholders own their geometry. GLB clones share the template's — disposing one
   *  would break every other instance. Materials we always own (GLB ones are cloned). */
  ownsGeo: boolean;
  idle: IdleFn | null;
  fill: FillFn | null;
  mixer: THREE.AnimationMixer | null;
  jam: THREE.Mesh;
  haze: HeatHaze | null;
  hazeSize: { w: number; h: number };
  phase: number;
  /** Advances at the throttled rate, so a hot machine visibly labours. */
  idleClock: number;
  heat: HeatTarget[];
  all: HeatTarget[];
  /** Whether dead-dark is currently applied, so restart knows to restore. */
  wasScrammed: boolean;
  /** Seconds into the placement scale-pop; >= POP_TIME means settled. */
  pop: number;
}

const JAM_GEO = new THREE.SphereGeometry(0.09, 8, 6);

export class EntityLayer {
  readonly group = new THREE.Group();
  private live = new Map<number, Rec>();
  private seen = new Set<number>();
  /** False until the first sync completes — stops a save-load firing juice for everything. */
  private primed = false;
  /** One shared material: every alarm blinks together, like a real klaxon. */
  private jamMat = new THREE.MeshStandardMaterial({
    color: JAM_AMBER,
    emissive: JAM_AMBER,
    emissiveIntensity: 1.5,
  });

  constructor(
    private registry: AssetRegistry,
    private defs: Map<string, MachineDef>,
    private juice?: Juice,
  ) {
    this.group.name = 'entities';
  }

  sync(snap: SimSnapshot, timeSec: number, dt: number, harassed?: Set<number>): void {
    this.seen.clear();
    let anyAlarm = false;

    // Tanks share one bandwidth pool, so they all read the same level: the pool's
    // fraction of total installed capacity. v4 publishes that total, so take it from
    // truth rather than re-summing bufferCap here — a second copy of the sim's
    // arithmetic is exactly what the scram latch taught us not to keep.
    let cap = snap.bandwidth.capacity ?? 0;
    if (!snap.bandwidth.capacity) {
      for (const e of snap.entities) {
        const d = this.defs.get(e.def);
        if (d?.kind === 'buffer') cap += d.bufferCap ?? DEFAULT_BUFFER_CAP;
      }
    }
    const fillLevel = cap > 0 ? THREE.MathUtils.clamp(snap.bandwidth.stored / cap, 0, 1) : 0;

    for (const e of snap.entities) {
      const def = this.defs.get(e.def);
      if (!def || def.kind === 'belt') continue; // belts: see belts.ts
      const entry = this.registry.get(def.asset);
      if (!entry) continue;
      this.seen.add(e.id);

      let rec = this.live.get(e.id);
      if (rec && (rec.def !== e.def || rec.assetVersion !== entry.version)) {
        this.drop(e.id, rec); // asset hot-swapped (or def changed): rebuild
        rec = undefined;
      }
      if (!rec) {
        rec = this.build(e.id, e.def, def, entry.version);
        // A machine that was not here last frame was just placed. First frame primes the
        // set without firing, so loading a save doesn't confetti the whole factory.
        if (this.primed) {
          const c = centerOf(e.pos, def, e.dir);
          this.juice?.puff(c.x, c.z);
          rec.pop = 0; // start the scale-pop
        }
      }

      if (rec.x !== e.pos.x || rec.y !== e.pos.y || rec.dir !== e.dir) {
        centerOf(e.pos, def, e.dir, rec.obj.position);
        rec.obj.rotation.y = yawFor(e.dir);
        rec.x = e.pos.x;
        rec.y = e.pos.y;
        rec.dir = e.dir;
      }

      const heat = THREE.MathUtils.clamp(e.heat ?? 0, 0, 1);
      // v3: scram comes from the sim's own latch. `heat` alone cannot express the
      // hysteresis window — a machine stays dead while cooling from 1.0 back down —
      // so deriving it here meant duplicating the sim's restart threshold and silently
      // desyncing the day they retuned it. The `?? heat >= 1` is only a bridge for a
      // sim that hasn't wired the field yet; it carries no local constant.
      const scrammed = e.scrammed ?? heat >= 1;
      // Throttle ramps in rather than snapping, so "labouring" reads before "dead".
      const speed = scrammed
        ? 0
        : heat > THROTTLE_AT
          ? THREE.MathUtils.lerp(1, THROTTLE_FLOOR, (heat - THROTTLE_AT) / (1 - THROTTLE_AT))
          : 1;

      rec.idleClock += dt * speed;
      rec.idle?.(rec.idleClock + rec.phase); // idle runs first; overrides below win
      rec.mixer?.update(dt * speed);
      if (def.kind === 'buffer') rec.fill?.(fillLevel);

      this.applyHeat(rec, heat, scrammed, timeSec);

      const alarm = !!e.jammed || scrammed;
      rec.jam.visible = alarm;
      if (alarm) anyAlarm = true;

      // Placement thunk: overshoot then settle. Reads as weight landing on the floor.
      if (rec.pop < POP_TIME) {
        rec.pop += dt;
        const k = Math.min(1, rec.pop / POP_TIME);
        const s = k < 1 ? 1 + Math.sin(k * Math.PI) * 0.22 : 1;
        rec.obj.scale.setScalar(k < 0.12 ? k / 0.12 : s);
      } else if (rec.obj.scale.x !== 1) {
        rec.obj.scale.setScalar(1);
      }

      // Harassed by a swarm: the machine stutters. Wrongness of motion is the tell (§8).
      if (harassed?.has(e.id)) {
        rec.obj.position.x += Math.sin(timeSec * 47 + e.id) * 0.012;
        rec.obj.position.z += Math.cos(timeSec * 53 + e.id) * 0.012;
      }
    }

    for (const [id, rec] of this.live) {
      if (!this.seen.has(id)) {
        this.juice?.burst(rec.obj.position.x, rec.obj.position.z);
        this.drop(id, rec);
      }
    }
    this.primed = true;

    if (anyAlarm) this.jamMat.emissiveIntensity = Math.sin(timeSec * 7) > 0 ? 2.2 : 0.15;
  }

  private applyHeat(rec: Rec, heat: number, scrammed: boolean, timeSec: number): void {
    if (scrammed) {
      // Dead-dark: kill every glow. The blinking amber is the only thing left alive.
      for (const t of rec.all) t.mat.emissiveIntensity = 0;
      rec.haze?.set(0, timeSec);
      rec.wasScrammed = true;
      return;
    }
    if (rec.wasScrammed) {
      // Restart: restore what scram zeroed. Idle-driven materials get overwritten by
      // their own idle next frame anyway; the static ones need this.
      for (const t of rec.all) t.mat.emissiveIntensity = t.baseIntensity;
      rec.wasScrammed = false;
    }
    if (heat > 0.001) {
      for (const t of rec.heat) {
        t.mat.emissive.copy(t.baseEmissive).lerp(HEAT_RED, heat);
        t.mat.emissiveIntensity = t.baseIntensity + heat * heat * 1.4;
      }
      if (!rec.haze) {
        // Lazy: most machines never get hot, and a haze quad each would be waste.
        rec.haze = createHeatHaze(rec.hazeSize.w, rec.hazeSize.h);
        rec.obj.add(rec.haze.mesh);
      }
      rec.haze.set(heat, timeSec);
    } else if (rec.haze) {
      rec.haze.set(0, timeSec);
      for (const t of rec.heat) {
        t.mat.emissive.copy(t.baseEmissive);
        t.mat.emissiveIntensity = t.baseIntensity;
      }
    }
  }

  private build(id: number, defId: string, def: MachineDef, version: number): Rec {
    const entry = this.registry.get(def.asset)!;
    const obj = entry.create();

    // GLB clones share the template's materials — per-entity heat/scram would leak
    // across every copy of that machine, so give each instance its own.
    if (entry.isGLB) {
      obj.traverse((o) => {
        const m = o as THREE.Mesh;
        if (!m.isMesh) return;
        m.material = Array.isArray(m.material)
          ? m.material.map((x) => x.clone())
          : (m.material as THREE.Material).clone();
      });
    }

    const all: HeatTarget[] = [];
    const heat: HeatTarget[] = [];
    obj.traverse((o) => {
      const m = o as THREE.Mesh;
      if (!m.isMesh) return;
      const mats = Array.isArray(m.material) ? m.material : [m.material];
      for (const raw of mats) {
        const mat = raw as THREE.MeshStandardMaterial;
        if (!mat || !mat.emissive) continue;
        const t: HeatTarget = {
          mat,
          baseEmissive: mat.emissive.clone(),
          baseIntensity: mat.emissiveIntensity ?? 1,
        };
        all.push(t);
        // Placeholders tag their body; a GLB has no body/accent split, so it all heats.
        if (m.userData.fktryBody || entry.isGLB) heat.push(t);
      }
    });

    const clips = clipsOf(obj);
    let mixer: THREE.AnimationMixer | null = null;
    if (clips.length) {
      mixer = new THREE.AnimationMixer(obj);
      const action = mixer.clipAction(clips[0]);
      action.setLoop(THREE.LoopRepeat, Infinity);
      action.play();
    }

    const h = HEIGHT_BY_KIND[def.kind] ?? 0.8;
    const jam = new THREE.Mesh(JAM_GEO, this.jamMat);
    jam.position.y = h + 0.28;
    jam.visible = false;
    obj.add(jam);
    this.group.add(obj);

    const rec: Rec = {
      obj,
      def: defId,
      dir: 0,
      x: NaN,
      y: NaN,
      assetVersion: version,
      ownsGeo: !entry.isGLB,
      idle: idleOf(obj),
      fill: fillOf(obj),
      mixer,
      jam,
      haze: null,
      hazeSize: { w: Math.max(def.footprint.x, def.footprint.y) * 0.9, h },
      phase: (id % 17) * 0.41, // desync idle motion between machines
      idleClock: 0,
      heat,
      all,
      wasScrammed: false,
      pop: POP_TIME, // settled by default; a real placement rewinds it to 0
    };
    this.live.set(id, rec);
    return rec;
  }

  private drop(id: number, rec: Rec): void {
    this.group.remove(rec.obj);
    // The jam light and haze use module-shared geometry — detach them before any
    // disposal walks the subtree and frees it out from under everyone else.
    rec.obj.remove(rec.jam);
    if (rec.haze) {
      rec.obj.remove(rec.haze.mesh);
      (rec.haze.mesh.material as THREE.Material).dispose(); // own material, shared geo
    }
    rec.mixer?.stopAllAction();
    rec.obj.traverse((o) => {
      const m = o as THREE.Mesh;
      if (!m.isMesh) return;
      if (rec.ownsGeo) m.geometry?.dispose();
      const mat = m.material;
      if (Array.isArray(mat)) mat.forEach((x) => x.dispose());
      else mat?.dispose();
    });
    this.live.delete(id);
  }
}
