import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { conePolygon, type ConeSpec } from './cone';
import { FIGHT_FUSE_MS } from './fight';
import { LIGHTS, PROPS, TILE, tileAt, tileColour, type PropKind, type VenueMap } from './venueMap';
import type { Agent } from './crowdSim';

// Pure drawing for the floor. Darkness is the mechanic: the venue is painted
// once into a flat texture, buried under a black sheet, and the torch cone is
// the only thing that puts colour back on a punter's face.

// Phaser Light2D is deliberately NOT used — the pipeline swap fights the
// geometry mask that drives the reveal, and the mask is the gameplay.

const TILES_TEX = 'floor:tiles';
const GLOW_TEX = 'floor:glow';
const PLAYER_TEX = 'floor:player';
const MARKER_TEX = 'floor:marker';

const D_TILES = 0;
const D_SIL = 10;
const D_DARK = 15;
const D_CONE = 16;
const D_NEON = 17;
const D_DETAIL = 20;
const D_MARKER = 40;

// A body in the dark should read as a presence, not vanish — the cone supplies
// identity (who, what they're wearing, how drunk), never the fact of them.
const SIL_TINT = 0x3d3d55;
const CONE_WARM = 0xffe0a8;
const CONE_UV = 0x7b2fd0;
const UV_STAMP = 0x50ff90;
const NEON_TINTS = [0xff3d9a, 0x38e0ff, 0xb45cff] as const;

const GLOW_SIZE = 64;

// A fight is spotted by motion, not by torchlight (design §3.2) — the tell has to
// survive the darkness sheet, so the marker rides at D_MARKER and the shove is
// carried by the silhouettes, which stay legible through the 50% black.
const FIGHT_PINK = 0xd03470;
const FIGHT_AMBER = 0xd8a020;
const SHOVE_STEPS = 5;
// Wobble: bigger and faster every rung, so a fight you clocked late still looks
// worse than one you clocked early.
const SHOVE_AMP_PX = 1.2;
const SHOVE_AMP_STEP_PX = 1.1;
const SHOVE_MS = 260;
const SHOVE_MS_STEP = 22;
// Pulse period at a full fuse, and how much of it the last second buys back.
const PULSE_SLOW_MS = 520;
const PULSE_SPAN_MS = 390;

interface AgentGfx {
  sil: Phaser.GameObjects.Image;
  detail: Phaser.GameObjects.Image;
  uvDot: Phaser.GameObjects.Image;
  swayPx: number;
  intox: number;
  phase: number; // sway offset from dollSeed, so two neighbours don't wobble in lockstep
  stamp: number;
}

export class FloorView {
  private readonly scene: Phaser.Scene;
  private readonly map: VenueMap;

  private readonly tileImg: Phaser.GameObjects.Image;
  private readonly neon: Phaser.GameObjects.Image[] = [];
  private readonly dark: Phaser.GameObjects.Rectangle;
  private readonly coneG: Phaser.GameObjects.Graphics;
  private readonly maskG: Phaser.GameObjects.Graphics;
  private readonly mask: Phaser.Display.Masks.GeometryMask;
  private readonly detailC: Phaser.GameObjects.Container;
  private readonly playerLight: Phaser.GameObjects.Image;
  private readonly playerImg: Phaser.GameObjects.Image;
  private readonly marker: Phaser.GameObjects.Image;
  private readonly fightHalo: Phaser.GameObjects.Image;
  private readonly fightPip: Phaser.GameObjects.Image;

  private readonly agents = new Map<string, AgentGfx>();
  private readonly propImgs: Phaser.GameObjects.Image[] = [];
  /** glow images that pulse on beat:tick (dance grid + emissive beat props) */
  private readonly beatLights: Phaser.GameObjects.Image[] = [];
  /** glow images that flicker (dunny fluoro, booth lamp) with their base alpha */
  private readonly flickerLights: Array<{ img: Phaser.GameObjects.Image; base: number; phase: number }> = [];
  private beatKick = 0;
  private yardRain: Phaser.GameObjects.Particles.ParticleEmitter | null = null;
  private readonly beams: Phaser.GameObjects.Image[] = [];
  private beamAngle = 0;
  private dj: Phaser.GameObjects.Image | null = null;
  private djBob = 0;
  // Render-only shove displacement by patron id. The sim owns agent.x/agent.y;
  // syncAgents adds this on top so a shoving pair jitters without the crowd
  // simulation ever hearing about it.
  private readonly shove = new Map<string, { dx: number; dy: number }>();
  private frame = 0;
  private uv = false;

  constructor(scene: Phaser.Scene, map: VenueMap) {
    this.scene = scene;
    this.map = map;
    const worldW = map.width * TILE;
    const worldH = map.height * TILE;

    this.bakeGlow();
    this.bakePlayer();
    this.bakeMarker();
    this.bakeTiles(worldW, worldH);

    this.tileImg = scene.add.image(0, 0, TILES_TEX).setOrigin(0, 0).setDepth(D_TILES);

    this.dark = scene.add
      .rectangle(0, 0, worldW, worldH, 0x000000, 0.5)
      .setOrigin(0, 0)
      .setDepth(D_DARK);

    this.coneG = scene.add.graphics().setDepth(D_CONE);
    this.coneG.setBlendMode(Phaser.BlendModes.ADD);

    this.playerLight = scene.add
      .image(0, 0, GLOW_TEX)
      .setDepth(D_CONE)
      .setBlendMode(Phaser.BlendModes.ADD)
      .setTint(0xffd9a0)
      .setAlpha(0.35)
      .setScale(1.1);

    // Neon lives above the black sheet so the room has colour pools you can
    // navigate by with the torch off.
    for (let ty = 0; ty < map.height; ty++) {
      for (let tx = 0; tx < map.width; tx++) {
        if (tileAt(map, tx, ty) !== 'neon') continue;
        const tint = NEON_TINTS[(tx * 7 + ty * 3) % NEON_TINTS.length] ?? NEON_TINTS[0];
        this.neon.push(
          scene.add
            .image(tx * TILE + TILE / 2, ty * TILE + TILE / 2, GLOW_TEX)
            .setDepth(D_NEON)
            .setBlendMode(Phaser.BlendModes.ADD)
            .setTint(tint)
            .setAlpha(0.72)
            .setScale(3),
        );
      }
    }

    this.buildProps();
    this.buildLights();
    this.buildYardRain();
    this.buildDj();
    this.buildScanBeams();

    this.maskG = scene.make.graphics({}, false);
    this.mask = this.maskG.createGeometryMask();
    this.detailC = scene.add.container(0, 0).setDepth(D_DETAIL);
    this.detailC.setMask(this.mask);

    this.playerImg = scene.add.image(0, 0, PLAYER_TEX).setDepth(D_MARKER);
    this.marker = scene.add.image(0, 0, MARKER_TEX).setDepth(D_MARKER).setVisible(false);

    // Built here and only ever shown/hidden — a fight starts on a frame where
    // there is already plenty going on.
    this.fightHalo = scene.add
      .image(0, 0, GLOW_TEX)
      .setDepth(D_MARKER)
      .setBlendMode(Phaser.BlendModes.ADD)
      .setTint(FIGHT_PINK)
      .setVisible(false);
    this.fightPip = scene.add
      .image(0, 0, MARKER_TEX)
      .setDepth(D_MARKER)
      .setTint(FIGHT_AMBER)
      .setVisible(false);
  }

  /**
   * Props prefer a GENERATED texture ('gen:prop:<kind>', loaded from
   * public/props via the manifest — docs/SPACES.md §4) and fall back to a baked
   * placeholder. They sit under the darkness sheet like the tiles do, so they
   * read as silhouettes until the torch finds them.
   */
  private buildProps(): void {
    for (const p of PROPS) {
      const gen = `gen:prop:${p.kind}`;
      const key = this.scene.textures.exists(gen) ? gen : bakePropPlaceholder(this.scene, p.kind);
      const img = this.scene.add
        .image(p.tx * TILE, p.ty * TILE, key)
        .setOrigin(0, 0)
        .setDepth(D_TILES + 1)
        .setDisplaySize(p.tw * TILE, p.th * TILE);
      this.propImgs.push(img);
      if (p.emissive) this.addGlow(p, p.emissive.colour, p.emissive.radius, p.emissive.pulse);
    }
  }

  /** Zone light signatures — navigate by glow colour (docs/SPACES.md §1). */
  private buildLights(): void {
    for (const l of LIGHTS) this.addGlow(l, l.colour, l.radius, l.pulse);
  }

  private addGlow(
    at: { tx: number; ty: number; tw?: number; th?: number },
    colour: number,
    radius: number,
    pulse?: 'beat' | 'flicker',
  ): void {
    const cx = (at.tx + (at.tw ?? 1) / 2) * TILE;
    const cy = (at.ty + (at.th ?? 1) / 2) * TILE;
    const alpha = pulse === 'beat' ? 0.22 : 0.4;
    const img = this.scene.add
      .image(cx, cy, GLOW_TEX)
      .setDepth(D_NEON)
      .setBlendMode(Phaser.BlendModes.ADD)
      .setTint(colour)
      .setAlpha(alpha)
      .setScale(radius / 32);
    this.neon.push(img); // shares neon lifecycle (destroy)
    if (pulse === 'beat') this.beatLights.push(img);
    else if (pulse === 'flicker') {
      this.flickerLights.push({ img, base: alpha, phase: (cx * 13 + cy * 7) % 100 });
    }
  }

  /**
   * The light rig: two scan beams pivoting on the disco ball, sweeping the
   * dance floor in opposite directions. Baked wedge texture, additive blend —
   * all show, no cost.
   */
  private buildScanBeams(): void {
    const key = 'floor:beam';
    if (!this.scene.textures.exists(key)) {
      const canvas = this.scene.textures.createCanvas(key, 180, 36);
      if (canvas) {
        const c = canvas.getContext();
        const grad = c.createLinearGradient(0, 0, 180, 0);
        grad.addColorStop(0, 'rgba(255,255,255,0.55)');
        grad.addColorStop(1, 'rgba(255,255,255,0)');
        c.fillStyle = grad;
        c.beginPath();
        c.moveTo(0, 16); c.lineTo(180, 0); c.lineTo(180, 36); c.lineTo(0, 20);
        c.closePath(); c.fill();
        canvas.refresh();
      }
    }
    // Pivot on the disco ball (PROPS: 'ball' at 37,22 with a 2x2 footprint).
    const cx = 38 * TILE;
    const cy = 23 * TILE;
    for (let i = 0; i < 2; i++) {
      this.beams.push(
        this.scene.add
          .image(cx, cy, key)
          .setOrigin(0, 0.5)
          .setDepth(D_NEON)
          .setBlendMode(Phaser.BlendModes.ADD)
          .setAlpha(0.16)
          .setRotation(i * Math.PI),
      );
    }
  }

  /** Rain falls INSIDE the smoking yard — it is the one open-sky pocket. */
  private buildYardRain(): void {
    const yard = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
    for (let ty = 0; ty < this.map.height; ty++) {
      for (let tx = 0; tx < this.map.width; tx++) {
        if (tileAt(this.map, tx, ty) !== 'yard') continue;
        yard.x0 = Math.min(yard.x0, tx * TILE);
        yard.y0 = Math.min(yard.y0, ty * TILE);
        yard.x1 = Math.max(yard.x1, (tx + 1) * TILE);
        yard.y1 = Math.max(yard.y1, (ty + 1) * TILE);
      }
    }
    if (!Number.isFinite(yard.x0)) return; // no yard on this map
    const g = this.scene.add.graphics();
    g.fillStyle(0x8899bb, 1);
    g.fillRect(0, 0, 1, 3);
    g.generateTexture('floor:rain', 1, 3);
    g.destroy();
    this.yardRain = this.scene.add.particles(0, 0, 'floor:rain', {
      x: { min: yard.x0, max: yard.x1 },
      y: yard.y0 - 4,
      lifespan: 700,
      speedY: { min: 90, max: 130 },
      speedX: { min: -8, max: -2 },
      quantity: 1,
      alpha: { start: 0.5, end: 0.1 },
    });
    this.yardRain.setDepth(D_NEON);
  }

  /** The DJ. Sealed in the booth, bobbing on the downbeat, paid in exposure. */
  private buildDj(): void {
    const key = 'floor:dj';
    if (!this.scene.textures.exists(key)) {
      const canvas = this.scene.textures.createCanvas(key, 12, 14);
      if (canvas) {
        const c = canvas.getContext();
        c.fillStyle = '#1c1c26'; c.fillRect(2, 6, 8, 8);   // hoodie shoulders
        c.fillStyle = '#d9a066'; c.fillRect(4, 2, 4, 5);   // head
        c.fillStyle = '#15151c'; c.fillRect(2, 2, 8, 2);   // headphones band
        c.fillStyle = '#3060c0'; c.fillRect(2, 3, 2, 3); c.fillRect(8, 3, 2, 3); // cans
        canvas.refresh();
      }
    }
    // Behind the decks (PROPS puts the gear on the booth's west edge at tx 53;
    // the DJ stands one tile east of it, centred on the mixer row).
    this.dj = this.scene.add.image(55.5 * TILE, 22.5 * TILE, key).setDepth(D_TILES + 2);
  }

  /** Call from the scene's beat:tick handler — the dance floor breathes in time. */
  onBeat(beatIndex: number): void {
    this.beatKick = beatIndex % 4 === 0 ? 1 : 0.6;
    this.djBob = 1;
    // The dance grid cycles colour by bar — each cell offset so the floor
    // ripples instead of blinking as one.
    const bar = Math.floor(beatIndex / 4);
    const PALETTE = [0xd03470, 0x3060c0, 0xd8a020];
    this.beatLights.forEach((img, i) => {
      img.setTint(PALETTE[(bar + i) % PALETTE.length]!);
    });
  }

  /** Per-frame light life: beat decay + fluoro flicker. */
  updateLights(dtMs: number): void {
    if (this.beatKick > 0) {
      this.beatKick = Math.max(0, this.beatKick - dtMs / 260);
      for (const img of this.beatLights) img.setAlpha(0.22 + 0.3 * this.beatKick);
    }
    this.beamAngle += dtMs * 0.00035 * (1 + this.beatKick * 0.8);
    this.beams.forEach((b, i) => {
      b.setRotation(this.beamAngle * (i === 0 ? 1 : -1) + i * Math.PI);
      b.setAlpha(0.1 + 0.14 * this.beatKick);
    });
    if (this.dj && this.djBob > 0) {
      this.djBob = Math.max(0, this.djBob - dtMs / 200);
      this.dj.setY(22.5 * TILE - 2 * this.djBob);
    }
    const t = this.scene.time.now;
    for (const f of this.flickerLights) {
      // Two incommensurate sines + a hard dropout band reads as dying fluoro.
      const w = Math.sin(t * 0.013 + f.phase) * Math.sin(t * 0.0037 + f.phase * 2);
      const drop = w > 0.93 ? 0 : 1;
      f.img.setAlpha(f.base * (0.75 + 0.25 * w) * drop);
    }
  }

  syncAgents(agents: readonly Agent[]): void {
    this.frame++;
    const t = this.scene.time.now;

    for (const a of agents) {
      if (a.gone) {
        this.drop(a.patron.id);
        continue;
      }
      let g = this.agents.get(a.patron.id);
      if (!g) {
        g = this.create(a);
        this.agents.set(a.patron.id, g);
      }
      g.stamp = this.frame;

      // Drunk tells are baked into the doll texture, so a punter who has kept
      // drinking needs a fresh one — but only when they actually have.
      if (Math.abs(g.intox - a.patron.intoxication) > 1e-3) {
        g.intox = a.patron.intoxication;
        const key = renderDoll(this.scene, a.patron, 'floorTopDown');
        g.swayPx = dollPlan(a.patron, 'floorTopDown').swayPx;
        g.sil.setTexture(key);
        g.detail.setTexture(key);
      }

      const bob = a.activity === 'dancing' ? 2.5 : 0.8;
      const y = a.y - Math.abs(Math.sin(a.bobPhase * Math.PI * 2)) * bob;
      const x = g.swayPx > 0 ? a.x + Math.sin(t / 260 + g.phase) * g.swayPx : a.x;

      // Empty unless this punter is squaring up, so the common path is a miss.
      const sh = this.shove.get(a.patron.id);
      const sx = sh ? x + sh.dx : x;
      const sy = sh ? y + sh.dy : y;

      g.sil.setPosition(sx, sy);
      g.detail.setPosition(sx, sy);
      g.uvDot.setPosition(sx + 4, sy + 2);
      g.detail.setVisible(!this.uv);
      // No stamp, no dot — the absence is the tell.
      g.uvDot.setVisible(this.uv && a.patron.flags.uvStamped === true);
    }

    for (const [id, g] of this.agents) {
      if (g.stamp !== this.frame) {
        this.destroyGfx(g);
        this.agents.delete(id);
      }
    }
  }

  updateCone(spec: ConeSpec, uv: boolean): void {
    this.uv = uv;
    const pts = conePolygon(spec);

    this.maskG.clear();
    this.maskG.fillStyle(0xffffff, 1);
    this.maskG.fillPoints(pts, true);

    // Wide wash plus a hotter core, so the beam reads as a beam and not a wedge
    // of flat colour.
    const tint = uv ? CONE_UV : CONE_WARM;
    this.coneG.clear();
    this.coneG.fillStyle(tint, uv ? 0.16 : 0.2);
    this.coneG.fillPoints(pts, true);
    this.coneG.fillStyle(tint, uv ? 0.12 : 0.18);
    this.coneG.fillPoints(conePolygon({ ...spec, range: spec.range * 0.55 }, 16), true);

    this.playerLight.setTint(uv ? CONE_UV : 0xffd9a0);
  }

  updatePlayer(x: number, y: number, facing: number): void {
    this.playerImg.setPosition(x, y).setRotation(facing);
    this.playerLight.setPosition(x, y);
  }

  setInteractTarget(agent: Agent | null): void {
    if (!agent) {
      this.marker.setVisible(false);
      return;
    }
    this.marker.setVisible(true).setPosition(agent.x, agent.y - 12);
    this.marker.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this.scene.time.now / 260)));
  }

  /** Draw the brewing-fight tell between two agents. Pass null when no fight is live. */
  setFight(pair: readonly [Agent, Agent] | null, shoves: number, msLeft: number): void {
    this.shove.clear();
    if (!pair) {
      this.fightHalo.setVisible(false);
      this.fightPip.setVisible(false);
      return;
    }

    const [a, b] = pair;
    const t = this.scene.time.now;
    const rung = Math.max(0, Math.min(SHOVE_STEPS, shoves));

    // Unit vector a -> b. Two punters standing in exactly the same spot still
    // need somewhere to shove, so fall back to sideways.
    const dx = b.x - a.x;
    const dy = b.y - a.y;
    const dist = Math.hypot(dx, dy);
    const ux = dist > 0 ? dx / dist : 1;
    const uy = dist > 0 ? dy / dist : 0;

    // Positive swings them into each other, negative rocks them back off it.
    const amp = SHOVE_AMP_PX + rung * SHOVE_AMP_STEP_PX;
    const period = SHOVE_MS - rung * SHOVE_MS_STEP;
    const off = Math.sin((t / period) * Math.PI * 2) * amp;
    this.shove.set(a.patron.id, { dx: ux * off, dy: uy * off });
    this.shove.set(b.patron.id, { dx: -ux * off, dy: -uy * off });

    const mx = (a.x + b.x) / 2;
    const my = (a.y + b.y) / 2;

    // Urgency drives the pulse, not the rung: the fuse is what you're racing.
    const u = Math.max(0, Math.min(1, 1 - msLeft / FIGHT_FUSE_MS));
    const pulseMs = PULSE_SLOW_MS - PULSE_SPAN_MS * u;
    const p = 0.5 + 0.5 * Math.sin((t / pulseMs) * Math.PI * 2);

    this.fightHalo
      .setVisible(true)
      .setPosition(mx, my)
      .setAlpha(0.22 + 0.5 * p)
      .setScale(1.5 + 0.55 * p + 0.3 * u);
    this.fightPip
      .setVisible(true)
      .setPosition(mx, my - 15 - 3 * p)
      .setAlpha(0.55 + 0.45 * p)
      .setScale(1 + 0.35 * u);
  }

  destroy(): void {
    this.shove.clear();
    this.fightHalo.destroy();
    this.fightPip.destroy();
    for (const g of this.agents.values()) this.destroyGfx(g);
    this.agents.clear();
    this.detailC.clearMask(true);
    this.detailC.destroy();
    this.maskG.destroy();
    this.coneG.destroy();
    this.dark.destroy();
    this.tileImg.destroy();
    for (const n of this.neon) n.destroy();
    this.yardRain?.destroy();
    this.yardRain = null;
    for (const b of this.beams) b.destroy();
    this.beams.length = 0;
    this.dj?.destroy();
    this.dj = null;
    for (const pr of this.propImgs) pr.destroy();
    this.propImgs.length = 0;
    this.beatLights.length = 0;
    this.flickerLights.length = 0;
    this.neon.length = 0;
    this.playerLight.destroy();
    this.playerImg.destroy();
    this.marker.destroy();
  }

  private create(a: Agent): AgentGfx {
    const key = renderDoll(this.scene, a.patron, 'floorTopDown');
    const sil = this.scene.add.image(a.x, a.y, key).setDepth(D_SIL).setTint(SIL_TINT);
    const detail = this.scene.add.image(a.x, a.y, key);
    const uvDot = this.scene.add
      .image(a.x, a.y, GLOW_TEX)
      .setBlendMode(Phaser.BlendModes.ADD)
      .setTint(UV_STAMP)
      .setScale(0.16)
      .setVisible(false);
    this.detailC.add(detail);
    this.detailC.add(uvDot);
    return {
      sil,
      detail,
      uvDot,
      swayPx: dollPlan(a.patron, 'floorTopDown').swayPx,
      intox: a.patron.intoxication,
      phase: (a.patron.dollSeed % 628) / 100,
      stamp: this.frame,
    };
  }

  /**
   * Drop every cached sprite. The scene must call this on reseed: patron ids are
   * recycled from p0 by _resetPatronSerial(), so without it the new run's crowd
   * inherits the old run's dolls, sway and stamps.
   */
  reset(): void {
    for (const g of this.agents.values()) this.destroyGfx(g);
    this.agents.clear();
    this.shove.clear();
    this.fightHalo.setVisible(false);
    this.fightPip.setVisible(false);
    this.marker.setVisible(false);
  }

  private drop(id: string): void {
    const g = this.agents.get(id);
    if (!g) return;
    this.destroyGfx(g);
    this.agents.delete(id);
  }

  private destroyGfx(g: AgentGfx): void {
    g.sil.destroy();
    g.detail.destroy();
    g.uvDot.destroy();
  }

  private bakeTiles(worldW: number, worldH: number): void {
    if (this.scene.textures.exists(TILES_TEX)) return;
    const g = this.scene.make.graphics({}, false);
    for (let ty = 0; ty < this.map.height; ty++) {
      for (let tx = 0; tx < this.map.width; tx++) {
        g.fillStyle(tileColour(tileAt(this.map, tx, ty)), 1);
        g.fillRect(tx * TILE, ty * TILE, TILE, TILE);
      }
    }
    g.generateTexture(TILES_TEX, worldW, worldH);
    g.destroy();
  }

  private bakeGlow(): void {
    if (this.scene.textures.exists(GLOW_TEX)) return;
    const g = this.scene.make.graphics({}, false);
    const c = GLOW_SIZE / 2;
    // Stacked translucent discs — cheap radial falloff without a canvas gradient.
    for (let i = 16; i >= 1; i--) {
      g.fillStyle(0xffffff, 0.055);
      g.fillCircle(c, c, (i / 16) * c);
    }
    g.generateTexture(GLOW_TEX, GLOW_SIZE, GLOW_SIZE);
    g.destroy();
  }

  private bakePlayer(): void {
    if (this.scene.textures.exists(PLAYER_TEX)) return;
    const g = this.scene.make.graphics({}, false);
    g.fillStyle(0x0a0a10, 1);
    g.fillCircle(7, 7, 5);
    g.fillStyle(0xdfe6ff, 1);
    g.fillCircle(7, 7, 4);
    g.fillRect(7, 6, 7, 3); // torch nose, points +x so rotation == facing
    g.generateTexture(PLAYER_TEX, 14, 14);
    g.destroy();
  }

  private bakeMarker(): void {
    if (this.scene.textures.exists(MARKER_TEX)) return;
    const g = this.scene.make.graphics({}, false);
    g.fillStyle(0xffd23f, 1);
    g.fillTriangle(0, 0, 8, 0, 4, 6);
    g.generateTexture(MARKER_TEX, 8, 6);
    g.destroy();
  }
}

// ---- prop placeholders --------------------------------------------------------
// Chunky pixel stand-ins, one per PropKind, drawn at the prop's natural footprint.
// Generated art (public/props/<kind>.png via tools/props_import.py) replaces
// these by texture key without touching any of this.

const PROP_SIZES: Record<PropKind, [number, number]> = {
  deck: [32, 32], mixer: [32, 16], monitor: [16, 16], boothLamp: [16, 16],
  speaker: [16, 32], discoball: [32, 32],
  barShelf: [496, 16], taps: [48, 16], till: [32, 16], glassStack: [32, 16],
  glassRack: [32, 16], boothTable: [32, 16], sinkRow: [96, 16],
  buttBin: [16, 16], crate: [16, 16],
  poster1: [32, 16], poster2: [32, 16], poster3: [32, 16],
  cloak: [16, 48], stampPodium: [16, 16], arcade: [32, 32],
  highTable: [16, 16], plant: [16, 16], wetFloor: [16, 16], poolTable: [48, 32],
};

function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string {
  const key = `prop:${kind}`;
  if (scene.textures.exists(key)) return key;
  const [w, h] = PROP_SIZES[kind];
  const canvas = scene.textures.createCanvas(key, w, h);
  if (!canvas) return key;
  const c = canvas.getContext();
  const f = (x: number, y: number, fw: number, fh: number, col: string): void => {
    c.fillStyle = col;
    c.fillRect(x, y, fw, fh);
  };

  switch (kind) {
    case 'deck':
      f(1, 1, 30, 30, '#3a3a46'); f(4, 4, 24, 24, '#18181f');
      c.beginPath(); c.arc(16, 16, 10, 0, Math.PI * 2); c.fillStyle = '#242430'; c.fill();
      c.beginPath(); c.arc(16, 16, 3, 0, Math.PI * 2); c.fillStyle = '#c0c0c8'; c.fill();
      f(25, 6, 3, 14, '#8a8a94');
      break;
    case 'mixer':
      f(1, 1, 30, 14, '#2c2c36');
      for (const x of [7, 15, 23]) { f(x, 3, 2, 10, '#101018'); f(x - 1, 7, 4, 2, '#d03470'); }
      break;
    case 'monitor':
      f(2, 3, 12, 10, '#1a1a24'); f(4, 5, 8, 6, '#3a86c8');
      break;
    case 'boothLamp':
      f(6, 8, 4, 7, '#2a2a34'); f(4, 3, 8, 6, '#3060c0');
      break;
    case 'speaker':
      f(1, 1, 14, 30, '#15151c');
      c.beginPath(); c.arc(8, 10, 5, 0, Math.PI * 2); c.fillStyle = '#2e2e3a'; c.fill();
      c.beginPath(); c.arc(8, 23, 5, 0, Math.PI * 2); c.fillStyle = '#2e2e3a'; c.fill();
      break;
    case 'discoball':
      c.beginPath(); c.arc(16, 16, 12, 0, Math.PI * 2); c.fillStyle = '#8a8a98'; c.fill();
      for (let y = 6; y < 28; y += 5) for (let x = 6 + (y % 2) * 2; x < 28; x += 5) f(x, y, 2, 2, '#e8e8f2');
      break;
    case 'barShelf':
      f(0, 0, 496, 16, '#2a2018');
      for (let x = 6; x < 490; x += 10) {
        const cols = ['#d8a020', '#7a4fc0', '#2e7d46', '#c03434'];
        f(x, 3, 4, 10, cols[(x / 10) % 4 | 0] ?? '#d8a020');
      }
      break;
    case 'taps':
      f(0, 10, 48, 6, '#4a3a28');
      for (const x of [8, 22, 36]) { f(x, 2, 3, 9, '#8a8a94'); f(x - 2, 1, 7, 3, '#d8c23a'); }
      break;
    case 'till':
      f(2, 4, 28, 11, '#3a3a46'); f(5, 6, 10, 5, '#9fe8a0'); f(18, 7, 9, 6, '#22222c');
      break;
    case 'glassStack':
      for (const x of [3, 11, 19, 25]) { f(x, 4, 5, 9, '#aeb8c8'); f(x + 1, 2, 3, 3, '#dce4ee'); }
      break;
    case 'glassRack':
      f(0, 0, 32, 16, '#33333e');
      for (let x = 3; x < 30; x += 6) f(x, 3, 4, 10, '#9fb2c8');
      break;
    case 'boothTable':
      c.beginPath(); c.arc(16, 8, 7, 0, Math.PI * 2); c.fillStyle = '#4a3226'; c.fill();
      f(11, 5, 3, 4, '#d8a050'); f(19, 6, 3, 4, '#c03434');
      break;
    case 'sinkRow':
      f(0, 0, 96, 4, '#5c6c72');
      for (const x of [8, 40, 72]) { f(x, 6, 16, 8, '#8a9aa2'); f(x + 5, 8, 6, 4, '#22303a'); }
      break;
    case 'buttBin':
      f(5, 3, 6, 12, '#4c4c56'); f(4, 2, 8, 3, '#6a6a76'); f(7, 0, 2, 3, '#d8a050');
      break;
    case 'crate':
      f(2, 4, 12, 10, '#2c4a6a'); f(3, 5, 10, 2, '#16283c'); f(3, 9, 10, 2, '#16283c');
      break;
    case 'poster1':
      f(1, 1, 30, 14, '#c8b890'); f(4, 3, 24, 4, '#c03434'); f(4, 9, 18, 3, '#22222c');
      break;
    case 'poster2':
      f(1, 1, 30, 14, '#20303c'); f(4, 4, 24, 8, '#d03470'); f(8, 6, 14, 3, '#e8e8f2');
      break;
    case 'poster3':
      f(1, 1, 30, 14, '#d8d0b8'); f(5, 3, 8, 8, '#6d4726'); f(16, 4, 12, 2, '#22222c'); f(16, 8, 10, 2, '#22222c');
      break;
    case 'cloak':
      f(0, 0, 16, 48, '#2a2230');
      for (const y of [6, 16, 26, 36]) { f(3, y, 10, 6, '#5a4a6a'); f(7, y - 2, 2, 3, '#8a8a94'); }
      break;
    case 'stampPodium':
      f(3, 2, 10, 12, '#3c3040'); f(5, 4, 6, 4, '#7a4fc0');
      break;
    case 'arcade':
      f(3, 1, 26, 30, '#1c2a3c'); f(7, 5, 18, 12, '#40c0d0'); f(9, 20, 6, 4, '#c03434'); f(19, 20, 6, 4, '#d8c23a');
      break;
    case 'highTable':
      c.beginPath(); c.arc(8, 8, 6, 0, Math.PI * 2); c.fillStyle = '#3c2e22'; c.fill();
      f(5, 5, 3, 3, '#d8a050'); f(9, 7, 3, 3, '#9fb2c8');
      break;
    case 'plant':
      f(6, 10, 4, 5, '#6e4a2e');
      f(4, 3, 3, 6, '#2e7d46'); f(9, 2, 3, 7, '#2e7d46'); f(6, 1, 3, 8, '#3a9456');
      break;
    case 'wetFloor':
      f(5, 2, 6, 12, '#d8c23a'); f(6, 4, 4, 4, '#22222c');
      break;
    case 'poolTable':
      f(2, 2, 44, 28, '#4a3226'); f(5, 5, 38, 22, '#1e6e3c');
      for (const [px, py] of [[6, 6], [42, 6], [6, 25], [42, 25], [24, 5], [24, 26]] as const) {
        c.beginPath(); c.arc(px, py, 2, 0, Math.PI * 2); c.fillStyle = '#0c0c12'; c.fill();
      }
      f(20, 14, 3, 3, '#f2f2f2'); f(28, 12, 3, 3, '#c03434'); f(31, 18, 3, 3, '#d8c23a');
      break;
  }
  canvas.refresh();
  return key;
}

