/**
 * LANE-RENDER — build ghost, remove-mode hover, and cooling-aura annotation.
 *
 * The ghost answers one question: what happens if I click here?
 *   cyan   — it fits
 *   red    — its footprint collides with something already placed
 *   amber  — the machine exists but research hasn't unlocked it yet
 *
 * That third state is why this reads `research`: the sim silently drops a `place` for a
 * locked def, so without a tell the click just does nothing and reads as a bug. Amber is
 * deliberately not red — "not yet" and "not here" are different sentences.
 *
 * Occupancy and lock state are previews only; the sim still gets the final say when the
 * command lands.
 *
 * v4: the mode comes from `setGhost`'s 4th argument. The old `'__remove'` def sentinel
 * still arrives from main.ts as a bridge and is simply ignored — mode is the truth.
 */
import * as THREE from 'three';
import type {
  Dir, EntityState, GameData, MachineDef, SimSnapshot, TechDef, Vec2,
} from '../contracts';
import { AssetRegistry } from './registry';
import { centerOf, rotatedFootprint, tileKey, tilesOf, yawFor } from './coords';
import { idleOf, type IdleFn } from './registry';
import { WORLD_HALF } from './camera';
import { HEIGHT_BY_KIND } from './palette';
import { RemoveHover } from './remove';
import { CoolAura } from './aura';
import { ERA_TELL } from './era';

const VALID = 0x3fd4ff;
const INVALID = 0xff3b30;
const LOCKED = 0xffb02e;

function ghostMaterial(color: number): THREE.MeshStandardMaterial {
  return new THREE.MeshStandardMaterial({
    color,
    emissive: color,
    emissiveIntensity: 0.85,
    transparent: true,
    opacity: 0.42,
    depthWrite: false,
    roughness: 0.4,
  });
}

/** A padlock, floating over a build you can't have yet. Tinted by the era gating it. */
function makePadlock(): { obj: THREE.Group; tint: (c: number) => void } {
  const g = new THREE.Group();
  const mat = new THREE.MeshStandardMaterial({
    color: LOCKED, emissive: LOCKED, emissiveIntensity: 1.4,
    transparent: true, opacity: 0.9, depthWrite: false,
  });
  const body = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.2, 0.12), mat);
  const shackle = new THREE.Mesh(new THREE.TorusGeometry(0.08, 0.025, 6, 12, Math.PI), mat);
  shackle.position.y = 0.1;
  g.add(body, shackle);
  g.renderOrder = 12;
  return {
    obj: g,
    tint: (c: number) => {
      mat.color.setHex(c);
      mat.emissive.setHex(c);
    },
  };
}

export class GhostLayer {
  readonly group = new THREE.Group();
  private obj: THREE.Object3D | null = null;
  private objDef: string | null = null;
  private objVersion = -1;
  /** GLB clones share the template's geometry — only dispose what we built ourselves. */
  private owns = false;
  private idle: IdleFn | null = null;
  private def: MachineDef | null = null;
  private pos: Vec2 | null = null;
  private dir: Dir = 0;
  private removing = false;
  private occupied = new Map<number, EntityState>();
  private matValid = ghostMaterial(VALID);
  private matInvalid = ghostMaterial(INVALID);
  private matLocked = ghostMaterial(LOCKED);
  readonly remove = new RemoveHover();
  /** Lives outside `group` so it can annotate a hovered machine with nothing selected. */
  readonly aura = new CoolAura();
  private padlock = makePadlock();
  /** machine id -> the tech that gates it (v4 rule: unreferenced ids are always open). */
  private lockedBy = new Map<string, TechDef>();
  /** Skip the per-frame occupancy walk entirely when no machine in data can cool. */
  private anyCoolers = false;
  /** True while the inspector owns the aura, so hover logic leaves it alone. */
  private inspectAura = false;
  /** Selection ring drawn under the inspected machine. */
  private selectRing: THREE.Mesh;

  constructor(
    private registry: AssetRegistry,
    private defs: Map<string, MachineDef>,
    data: GameData,
  ) {
    this.group.name = 'ghost';
    this.group.renderOrder = 10;
    this.group.add(this.remove.group);
    this.padlock.obj.visible = false;
    this.group.add(this.padlock.obj);

    // Selection ring: a flat outline under the inspected machine. Lives outside `group`
    // so it survives the ghost being hidden (inspect has no hovered tile).
    this.selectRing = new THREE.Mesh(
      new THREE.RingGeometry(0.62, 0.72, 4, 1, Math.PI / 4),
      new THREE.MeshBasicMaterial({
        color: VALID, transparent: true, opacity: 0.4, depthWrite: false, side: THREE.DoubleSide,
      }),
    );
    this.selectRing.rotation.x = -Math.PI / 2;
    this.selectRing.visible = false;
    this.selectRing.renderOrder = 9;
    this.aura.group.add(this.selectRing);

    const machines = new Set(data.machines.map((m) => m.id));
    for (const t of data.tech) {
      for (const u of t.unlocks) {
        // First tech to claim an id wins — matching LANE-UI's `indexTech`. Two techs
        // unlocking one id is a data bug either way, but we must both blame the same one.
        if (machines.has(u) && !this.lockedBy.has(u)) this.lockedBy.set(u, t);
      }
    }
    this.anyCoolers = data.machines.some((m) => m.coolRadius);
  }

  set(defId: string | null, pos: Vec2 | null, dir: Dir, mode: 'build' | 'remove' = 'build'): void {
    this.removing = mode === 'remove';
    this.pos = pos;
    this.dir = dir;

    if (this.removing) {
      this.def = null; // the sentinel def is ignored: mode is the truth
      if (this.obj) this.obj.visible = false;
      this.padlock.obj.visible = false;
      this.group.visible = true;
      return;
    }
    this.remove.hide();

    const def = defId ? this.defs.get(defId) ?? null : null;
    this.def = def;
    if (!def || !pos) {
      this.group.visible = false;
      return;
    }
    const entry = this.registry.get(def.asset);
    if (!entry) {
      this.group.visible = false;
      return;
    }
    if (this.obj) this.obj.visible = true;
    if (!this.obj || this.objDef !== def.id || this.objVersion !== entry.version) {
      this.rebuild(def);
      this.objDef = def.id;
      this.objVersion = entry.version;
    }
    this.group.visible = true;
  }

  private rebuild(def: MachineDef): void {
    this.drop();
    const entry = this.registry.get(def.asset)!;
    const obj = entry.create();
    this.owns = !entry.isGLB;
    this.idle = idleOf(obj);
    // Replace (never mutate) materials: a hot-swapped GLB's materials are shared
    // with every placed copy of that machine.
    obj.traverse((o) => {
      const m = o as THREE.Mesh;
      if (!m.isMesh) return;
      m.material = this.matValid;
      m.castShadow = m.receiveShadow = false;
    });
    this.group.add(obj);
    this.obj = obj;
  }

  private drop(): void {
    if (!this.obj) return;
    this.group.remove(this.obj);
    if (this.owns) {
      this.obj.traverse((o) => {
        const m = o as THREE.Mesh;
        if (m.isMesh) m.geometry?.dispose(); // materials here are the shared ghost mats
      });
    }
    this.obj = null;
  }

  /**
   * v4 gating rule, verbatim: "a machine/recipe id that appears in ANY TechDef.unlocks
   * is locked until that tech completes; ids referenced by no tech are always available."
   * Returns the gating tech, or null if it's buildable.
   *
   * Absent `research` means NOTHING has completed, so everything gated reads locked. My
   * instinct was the opposite — treat a missing field as "don't lie" — but that's the
   * `scrammed` lesson misapplied: there the field's absence meant the sim hadn't wired a
   * behaviour, here the rule is satisfiable from data alone and the contract says
   * "until that tech completes". LANE-UI's `research.ts` reads it the same way, and the
   * build bar and the ghost disagreeing about what's buildable would be worse than
   * either answer.
   */
  private lockOf(defId: string, snap: SimSnapshot): TechDef | null {
    const tech = this.lockedBy.get(defId);
    if (!tech) return null;
    return snap.research?.unlocked.includes(tech.id) ? null : tech;
  }

  update(snap: SimSnapshot, timeSec: number, inspected?: number | null): void {
    // The inspected machine is highlighted whether or not the pointer is anywhere near
    // it — the inspector is open until it's closed.
    this.updateInspect(snap, timeSec, inspected ?? null);

    if (!this.pos) {
      if (!this.inspectAura) this.aura.hide();
      this.remove.hide();
      this.group.visible = false;
      return;
    }

    const needOccupancy = this.removing || !!this.def || this.anyCoolers;
    if (needOccupancy) {
      this.occupied.clear();
      for (const e of snap.entities) {
        const d = this.defs.get(e.def);
        if (!d) continue;
        for (const t of tilesOf(e.pos, d, e.dir)) this.occupied.set(tileKey(t.x, t.y), e);
      }
    }
    this.updateAura(timeSec);

    if (this.removing) {
      this.updateRemove(timeSec);
      return;
    }
    if (!this.def || !this.obj) {
      this.group.visible = false;
      return;
    }

    const want = tilesOf(this.pos, this.def, this.dir);
    const fits = want.every(
      (t) =>
        !this.occupied.has(tileKey(t.x, t.y)) &&
        t.x >= -WORLD_HALF && t.x < WORLD_HALF &&
        t.y >= -WORLD_HALF && t.y < WORLD_HALF,
    );
    const lock = this.lockOf(this.def.id, snap);

    // Locked outranks collision: "you don't have this yet" is the more useful sentence,
    // and a locked click does nothing wherever you aim it.
    const mat = lock ? this.matLocked : fits ? this.matValid : this.matInvalid;
    this.obj.traverse((o) => {
      const m = o as THREE.Mesh;
      if (m.isMesh) m.material = mat;
    });

    centerOf(this.pos, this.def, this.dir, this.obj.position);
    this.obj.position.y += 0.02;
    this.obj.rotation.y = yawFor(this.dir);
    this.idle?.(timeSec);

    if (lock) {
      const h = HEIGHT_BY_KIND[this.def.kind] ?? 0.8;
      this.padlock.obj.visible = true;
      this.padlock.obj.position.copy(this.obj.position);
      this.padlock.obj.position.y = h + 0.5 + Math.sin(timeSec * 2) * 0.05;
      this.padlock.obj.rotation.y = timeSec * 0.8;
      this.padlock.tint(ERA_TELL[lock.era] ?? LOCKED); // which era is holding it back
    } else {
      this.padlock.obj.visible = false;
    }
  }

  /**
   * v5: the inspected machine gets a selection ring, and — if it cools — its coverage
   * rectangle. This is the "placed-and-selected" case round 4 couldn't reach: the
   * inspected entity now arrives through the typed selection.
   */
  private updateInspect(snap: SimSnapshot, timeSec: number, inspected: number | null): void {
    this.inspectAura = false;
    if (inspected === null) {
      this.selectRing.visible = false;
      return;
    }
    const e = snap.entities.find((x) => x.id === inspected);
    const def = e ? this.defs.get(e.def) : undefined;
    if (!e || !def) {
      this.selectRing.visible = false;
      return;
    }
    const f = rotatedFootprint(def, e.dir);
    const c = centerOf(e.pos, def, e.dir);
    this.selectRing.visible = true;
    this.selectRing.position.set(c.x, 0.04, c.z);
    this.selectRing.scale.set(f.x + 0.5, f.y + 0.5, 1);
    (this.selectRing.material as THREE.MeshBasicMaterial).opacity =
      0.35 + Math.sin(timeSec * 3) * 0.12;

    if (def.coolRadius) {
      this.aura.show(c.x, c.z, f.x, f.y, def.coolRadius, timeSec);
      this.inspectAura = true; // claim the aura so hover logic doesn't hide it
    }
  }

  /**
   * Ring the cooler being placed, or the one under the cursor. Placing wins: while you're
   * holding a cooler the question is where its coverage lands, not what's already there.
   */
  private updateAura(timeSec: number): void {
    if (this.inspectAura) return; // the inspector owns the aura this frame
    if (!this.anyCoolers || !this.pos) {
      this.aura.hide();
      return;
    }
    if (!this.removing && this.def?.coolRadius) {
      const f = rotatedFootprint(this.def, this.dir);
      const c = centerOf(this.pos, this.def, this.dir);
      this.aura.show(c.x, c.z, f.x, f.y, this.def.coolRadius, timeSec);
      return;
    }
    const under = this.occupied.get(tileKey(this.pos.x, this.pos.y));
    const udef = under ? this.defs.get(under.def) : undefined;
    if (under && udef?.coolRadius) {
      const f = rotatedFootprint(udef, under.dir);
      const c = centerOf(under.pos, udef, under.dir);
      this.aura.show(c.x, c.z, f.x, f.y, udef.coolRadius, timeSec);
      return;
    }
    this.aura.hide();
  }

  /**
   * Highlight whatever machine sits under the cursor. Hovering bare ground shows
   * nothing — there is no honest way to demolish a hole.
   */
  private updateRemove(timeSec: number): void {
    const target = this.pos ? this.occupied.get(tileKey(this.pos.x, this.pos.y)) : undefined;
    const def = target ? this.defs.get(target.def) : undefined;
    if (!target || !def) {
      this.remove.hide();
      return;
    }
    // The whole machine lights up, not just the hovered tile: `remove` takes a position
    // but kills the entity that owns it, and the hover should say so.
    const f = rotatedFootprint(def, target.dir);
    const c = centerOf(target.pos, def, target.dir);
    this.remove.show(c.x, c.z, f.x, f.y, HEIGHT_BY_KIND[def.kind] ?? 0.8, timeSec);
  }
}
