import Phaser from 'phaser';
import { EventBus } from '../core/EventBus';
import { GameClock } from '../core/GameClock';
import { SeededRNG } from '../core/SeededRNG';
import { Meters, freshNightState } from '../core/meters';
import { Ambience } from '../audio/Ambience';
import { arcLabel } from '../audio/arc';
import { VOICE_NAMES } from '../audio/mix';
import { Sfx, type SfxName } from '../audio/Sfx';
import { TechnoEngine } from '../audio/TechnoEngine';
import { DAZZA_TEXTS } from '../data/strings/dazza';
import type { DressCodeRule } from '../data/types';
import { Clicker } from './Clicker';
import { DialogueBox } from './DialogueBox';
import { MeterHud } from './MeterHud';
import { MixDeskScene, type MixDeskData } from './MixDeskScene';
import { Phone } from './Phone';
import { stamp, type StampHandle } from './Stamp';
import { UI, chunkyButton, font } from './style';

const W = 640;
const H = 360;

const SEED = 4207;

// Log slider range. 200Hz is "bass through a fire door"; 20kHz is "standing in
// front of the rig". A linear map spends 90% of the track above 2kHz, where
// nothing audible happens.
const MIN_HZ = 200;
const MAX_HZ = 20000;
const HZ_RATIO = MAX_HZ / MIN_HZ;

const TRACK_X0 = 96;
const TRACK_X1 = 340;
const TRACK_Y = 314;

const BEAT_X = 320;
// Sized so the 1.42x downbeat flash still clears the help line at y 54 — the pad
// grows from its centre, so the resting size has to leave room for the punch.
const BEAT_Y = 26;
const BEAT_SIZE = 28;
/** Flash decay. Shorter than a beat at 128BPM (469ms) so beats stay distinct. */
const FLASH_MS = 170;

const PATRON_X = 300;
const PATRON_Y = 200;
/** Reviewers mash the verdict buttons; keep a readable stack of overprints, not 400. */
const MAX_MARKS = 5;

const SFX_BUTTONS: ReadonlyArray<readonly [SfxName, string]> = [
  ['stampSlam', 'STAMP SLAM'],
  ['stampInk', 'STAMP INK'],
  ['clickerClunk', 'CLICKER'],
  ['doorBang', 'DOOR BANG'],
  ['radioStatic', 'RADIO HISS'],
  ['radioChirp', 'RADIO CHIRP'],
  ['phoneBuzz', 'PHONE BUZZ'],
  ['ropeUnhook', 'ROPE OFF'],
  ['denyCrowdOoh', 'CROWD OOOH'],
  ['typeTick', 'TYPE TICK'],
];

/** Structural copy of Ambience's private `Bed` union — it doesn't export one. */
type BedName = 'rain' | 'crowd' | 'kebab';

/** Beds default to a quiet street: room and kebab shop on, weather off, so RAIN
 *  still reads as a thing you switch on the way Phase 1 had it. */
const BED_DEFAULTS: Readonly<Record<BedName, boolean>> = { rain: false, crowd: true, kebab: true };

const BEDS: readonly BedName[] = ['rain', 'crowd', 'kebab'];

/** Inside-count nudges. ±10 exists because the crowd bed's curve saturates and
 *  you cannot hear that by clicking +1 eleven times. */
const COUNT_STEPS: readonly number[] = [-10, -1, 1, 10];

const MIX_BTN_X = 176;
const MIX_BTN_Y = 84;

const ROOM_X = 100;
const NIGHT_X = 396;

/**
 * Midnight — the PEAK keyframe, where every voice scales to 1. The scene pins here
 * on open because it is a tuning rig, not a night: unpinned it starts at 21:00
 * EMPTY, where bass and pad scale to 0, and GameClock's 360 game-minutes over 13
 * real minutes means nobody hears a bassline for the first ~2 minutes. That reads
 * as a broken engine. The button beside the readout hands the arc back.
 */
const PIN_MINUTE = 180;

const PIN_LABEL = 'PINNED @ PEAK · TAP=FOLLOW';
const FOLLOW_LABEL = 'FOLLOWING CLOCK · TAP=PIN';

interface PendingBeat {
  beatIndex: number;
  audioTimeMs: number;
  bar: boolean;
}

const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));

const pad2 = (n: number): string => String(n).padStart(2, '0');

/** arcMinute is minutes since 21:00, and may be pinned away from the game clock. */
const wallClock = (min: number): string => {
  const t = (21 * 60 + Math.round(min)) % (24 * 60);
  return `${pad2(Math.floor(t / 60))}:${pad2(t % 60)}`;
};

/** Ten cells of ASCII. Monospace is already the house font; a real bar would be
 *  four game objects per voice refreshed every frame for the same information. */
const meterBar = (v: number): string => {
  const n = clamp(Math.round(v * 10), 0, 10);
  return '#'.repeat(n) + '-'.repeat(10 - n);
};

const hzFromT = (t: number): number => MIN_HZ * Math.pow(HZ_RATIO, clamp(t, 0, 1));
const tFromHz = (hz: number): number =>
  clamp(Math.log(clamp(hz, MIN_HZ, MAX_HZ) / MIN_HZ) / Math.log(HZ_RATIO), 0, 1);

/** chunkyButton hands back a Container; this is the only way to relabel one. */
function labelOf(btn: Phaser.GameObjects.Container): Phaser.GameObjects.Text | undefined {
  return btn.list.find((o): o is Phaser.GameObjects.Text => o instanceof Phaser.GameObjects.Text);
}

/**
 * LANE-JUICE proof scene: every widget and every SFX on one screen, plus a beat
 * indicator that is honest about latency. Not shipped in the game — this is the
 * thing a reviewer opens to check the juice lane actually works.
 */
export class JuiceDemoScene extends Phaser.Scene {
  private bus!: EventBus;
  private clock!: GameClock;
  private rng!: SeededRNG;
  private meters!: Meters;

  private engine!: TechnoEngine;
  private sfx!: Sfx;
  private ambience!: Ambience;

  private hud!: MeterHud;
  private phone!: Phone;
  private clicker!: Clicker;
  private dialogue: DialogueBox | null = null;
  private dialogueKill = false;

  private statusText!: Phaser.GameObjects.Text;
  private hzText!: Phaser.GameObjects.Text;
  private arcText!: Phaser.GameObjects.Text;
  private insideText!: Phaser.GameObjects.Text;
  private beatPad!: Phaser.GameObjects.Rectangle;
  private beatRing!: Phaser.GameObjects.Rectangle;
  private handle!: Phaser.GameObjects.Rectangle;
  private locButton!: Phaser.GameObjects.Container;
  private arcPinButton!: Phaser.GameObjects.Container;
  private patronRoot!: Phaser.GameObjects.Container;

  private readonly stampMarks: StampHandle[] = [];
  private readonly pendingBeats: PendingBeat[] = [];
  private readonly bedButtons: Array<{ bed: BedName; btn: Phaser.GameObjects.Container }> = [];
  private readonly bedsOn: Record<BedName, boolean> = { ...BED_DEFAULTS };
  private beatFlash = 0;
  private beatFlashBar = false;
  private lastBeat = -1;
  private lastBar = -1;

  private overrideHz: number | null = null;
  private dragging = false;
  private dazzaIndex = 0;
  private audioStarting = false;
  /** Last pin state pushed to the button label; null forces a sync on the first frame. */
  private arcPinShown: boolean | null = null;

  /**
   * The desk duck-types anything with startRain/stopRain and drives it from its
   * header. Handing it this adapter rather than the Ambience keeps a single owner
   * of rain policy — two objects both calling Sfx.startRain is how you get a bed
   * that will not switch off — and keeps the demo's own RAIN button in step.
   */
  private readonly deskRain = {
    startRain: (): void => this.setBed('rain', true),
    stopRain: (): void => this.setBed('rain', false),
  };

  constructor() {
    super('JuiceDemo');
  }

  /**
   * Phaser reuses the scene instance across restarts and only re-runs create(), so
   * field initialisers fire once ever. Every per-run field has to be cleared here or
   * it leaks into the next run: stale `pendingBeats` carry `audioTimeMs` from the old
   * AudioContext, whose clock restarts near zero, and head-of-line-block drainBeats
   * for as long as the previous run lasted.
   */
  private resetRunState(): void {
    this.pendingBeats.length = 0;
    this.beatFlash = 0;
    this.beatFlashBar = false;
    this.lastBeat = -1;
    this.lastBar = -1;

    this.overrideHz = null;
    this.dragging = false;
    this.dazzaIndex = 0;
    this.audioStarting = false;
    this.arcPinShown = null;

    this.dialogue = null;
    this.dialogueKill = false;
    // Shutdown already destroyed the objects these handles point at.
    this.stampMarks.length = 0;
    this.bedButtons.length = 0;
    Object.assign(this.bedsOn, BED_DEFAULTS);
  }

  create(): void {
    this.resetRunState();

    this.bus = new EventBus();
    this.rng = new SeededRNG(SEED);
    this.clock = new GameClock(this.bus);
    this.meters = new Meters(this.bus, freshNightState('theRoyal', 0, 80));

    // The graph is built in the constructor, so Sfx can be wired now and simply
    // stays silent until the context resumes. No post-unlock re-wiring needed.
    this.engine = new TechnoEngine(this.bus);
    // Every run, not just the first: a restart builds a fresh engine whose arc
    // defaults to following a clock that has just been reset to 21:00.
    this.engine.setArcMinute(PIN_MINUTE);
    this.sfx = new Sfx(this.engine.ctx, this.engine.sfxBus);
    // Same lifetime as Sfx, and on the same bus: the beds are dry ambience, not
    // music, so the door's lowpass has no business touching them.
    this.ambience = new Ambience(this.bus, this.engine.ctx, this.engine.sfxBus, this.sfx, {
      ...this.bedsOn,
    });

    // Registering the desk here is the only route to it: main.ts is
    // integration-owned, so nothing else can put it in the scene manager.
    // Re-adding a live key throws, and Phaser keeps scenes across a restart.
    if (!this.scene.get('MixDesk')) this.scene.add('MixDesk', MixDeskScene, false);

    this.buildBackdrop();
    this.buildBeatIndicator();
    this.buildPatron();
    this.buildStatus();
    this.buildSfxColumn();
    this.buildActionColumn();
    this.buildMixDeskButton();
    this.buildRoomPanel();
    this.buildNightPanel();
    this.buildAudioStrip();

    this.hud = new MeterHud(this, this.bus, { x: 6, y: 6 });
    this.clicker = new Clicker(this, this.bus, { x: 40, y: 320, sfx: this.sfx });
    this.phone = new Phone(this, this.bus, { x: 600, y: 320, sfx: this.sfx });

    this.bus.on('beat:tick', ({ beatIndex, audioTimeMs }) => {
      this.pendingBeats.push({ beatIndex, audioTimeMs, bar: false });
    });
    // Fires straight after beat:tick for the same index, so the entry is there.
    this.bus.on('beat:bar', ({ beatIndex }) => {
      const b = this.pendingBeats.find((p) => p.beatIndex === beatIndex);
      if (b) b.bar = true;
    });

    this.clock.start();
    // Kick the HUD once so it shows the opening numbers instead of empty bars.
    this.bus.emit('meters:delta', { vibe: 0 });

    this.buildUnlockOverlay();

    this.input.keyboard?.on('keydown-SPACE', () => this.dialogue?.skip());
    this.input.keyboard?.on('keydown-M', () => this.openMixDesk());

    this.events.once('shutdown', () => this.teardown());
  }

  // --- world ----------------------------------------------------------------

  private buildBackdrop(): void {
    this.add.rectangle(W / 2, H / 2, W, H, UI.ink);
    this.add.rectangle(W / 2, 62, W, 1, UI.panelLip, 0.35);
    this.add.rectangle(W / 2, 286, W, 1, UI.panelLip, 0.35);
    this.add
      .text(W / 2, 54, 'the door · press the buttons · nothing here is load-bearing', font(7, UI.paper))
      .setOrigin(0.5, 0)
      .setAlpha(0.4);
    this.add
      .text(
        TRACK_X0,
        336,
        'sounds left · verdicts right · M for the mix desk · drag MUFFLE to open the door',
        font(7, UI.paper),
      )
      .setAlpha(0.45);
  }

  private buildBeatIndicator(): void {
    this.beatRing = this.add
      .rectangle(BEAT_X, BEAT_Y, BEAT_SIZE + 10, BEAT_SIZE + 10, UI.neonPink, 0)
      .setStrokeStyle(1, UI.neonPink, 0.25);
    this.beatPad = this.add
      .rectangle(BEAT_X, BEAT_Y, BEAT_SIZE, BEAT_SIZE, UI.neonGreen, 0.12)
      .setStrokeStyle(1, UI.panelLip);
    // Beside the pad, not under it — the backdrop's help line runs the full width
    // of this row and a label below the ring lands right on top of it.
    this.add.text(BEAT_X - 26, BEAT_Y, 'BEAT', font(7, UI.paper)).setOrigin(1, 0.5).setAlpha(0.5);
  }

  private buildPatron(): void {
    // Stand-in only. The real doll needs a Patron record and lives in patrons/.
    const root = this.add.container(PATRON_X, PATRON_Y);
    this.patronRoot = root;
    root.add(this.add.rectangle(0, 0, 40, 90, UI.grime).setStrokeStyle(1, UI.panelLip));
    root.add(this.add.rectangle(0, -58, 26, 26, 0x8a6a52).setStrokeStyle(1, UI.ink));
    root.add(this.add.rectangle(0, -14, 40, 3, UI.neonPink, 0.35));
    root.add(
      this.add.text(0, 56, 'SOME BLOKE', font(7, UI.paper)).setOrigin(0.5, 0).setAlpha(0.5),
    );
    root.add(
      this.add.text(0, 66, 'stamp lands here', font(7, UI.paper)).setOrigin(0.5, 0).setAlpha(0.28),
    );
  }

  private buildStatus(): void {
    this.statusText = this.add.text(W - 6, 6, '', font(8, UI.neonGreen)).setOrigin(1, 0);
  }

  // --- controls -------------------------------------------------------------

  private buildSfxColumn(): void {
    this.add.text(12, 66, 'SOUNDS', font(7, UI.kebabAmber)).setAlpha(0.8);
    SFX_BUTTONS.forEach(([name, label], i) => {
      chunkyButton(this, 50, 80 + i * 16, label, {
        w: 76,
        h: 13,
        fontSize: 7,
        onClick: () => this.sfx.play(name),
      });
    });
  }

  /**
   * The lane's headline: everything else on this screen is a widget, this is the
   * thing you sit at for ten minutes and leave with a diff. Sized to say so.
   */
  private buildMixDeskButton(): void {
    chunkyButton(this, MIX_BTN_X, MIX_BTN_Y, 'MIX DESK  [M]', {
      w: 152,
      h: 26,
      fontSize: 12,
      fill: UI.neonPink,
      onClick: () => this.openMixDesk(),
    });
    this.add
      .text(MIX_BTN_X, MIX_BTN_Y + 20, 'the ear pass lives here', font(7, UI.paper))
      .setOrigin(0.5, 0)
      .setAlpha(0.45);
  }

  /** Ambience beds plus a way to fill the room without admitting ninety people. */
  private buildRoomPanel(): void {
    this.add.text(ROOM_X, 116, 'THE ROOM', font(7, UI.kebabAmber)).setAlpha(0.8);

    BEDS.forEach((bed, i) => {
      const btn = chunkyButton(this, ROOM_X + 38, 130 + i * 16, this.bedLabel(bed), {
        w: 84,
        h: 13,
        fontSize: 7,
        fill: UI.grime,
        onClick: () => this.setBed(bed, !this.bedsOn[bed]),
      });
      this.bedButtons.push({ bed, btn });
    });

    // Rain and kebab are door-only and the crowd all but vanishes out there, so
    // an ON bed can still be silent. Say so before someone files it as a bug.
    this.add
      .text(ROOM_X, 172, 'rain + kebab outside · crowd inside', font(7, UI.paper))
      .setAlpha(0.35);

    this.insideText = this.add.text(ROOM_X, 186, '', font(8, UI.neonGreen));

    COUNT_STEPS.forEach((step, i) => {
      chunkyButton(this, ROOM_X + 17 + i * 36, 206, step > 0 ? `+${step}` : String(step), {
        w: 34,
        h: 13,
        fontSize: 7,
        onClick: () => this.ambience.setInsideCount(this.ambience.insideCount + step),
      });
    });

    this.add
      .text(ROOM_X, 218, 'the crowd bed swells as it fills', font(7, UI.paper))
      .setAlpha(0.35);
  }

  /** What the night is doing to the mix, which is otherwise entirely invisible. */
  private buildNightPanel(): void {
    this.add.text(NIGHT_X, 116, 'THE NIGHT', font(7, UI.kebabAmber)).setAlpha(0.8);
    this.arcText = this.add.text(NIGHT_X, 130, '', font(7, UI.neonGreen)).setLineSpacing(2);

    // Width 128 keeps this clear of THE JOB's column, which starts at x 528.
    this.arcPinButton = chunkyButton(this, NIGHT_X + 64, 198, PIN_LABEL, {
      w: 128,
      h: 14,
      fontSize: 7,
      fill: UI.kebabAmber,
      textColour: UI.ink,
      onClick: () => this.toggleArcPin(),
    });

    this.add
      .text(NIGHT_X, 208, 'pinned at PEAK: everything on', font(7, UI.paper))
      .setAlpha(0.35);
    this.add
      .text(NIGHT_X, 218, 'scrub the hour in the mix desk', font(7, UI.paper))
      .setAlpha(0.35);
  }

  /** Reads the engine rather than a local flag: the mix desk pins and unpins too. */
  private toggleArcPin(): void {
    this.engine.setArcMinute(this.engine.arcPinned ? null : PIN_MINUTE);
  }

  private buildActionColumn(): void {
    this.add.text(528, 66, 'THE JOB', font(7, UI.kebabAmber)).setAlpha(0.8);

    const rows: ReadonlyArray<readonly [string, () => void, number]> = [
      ['NOT TONIGHT', () => this.doStamp('DENIED', UI.danger), UI.danger],
      ['IN YOU GO', () => this.doStamp('ADMITTED', UI.ok), UI.ok],
      ['HAVE A WORD', () => this.openDialogue(false), UI.panelLip],
      ['ABSOLUTELY GONE', () => this.openDialogue(true), UI.panelLip],
      ['TEXT FROM DAZZA', () => this.pushDazza(), UI.kebabAmber],
      ['VIBE UP', () => this.bus.emit('meters:delta', { vibe: 10 }), UI.panelLip],
      ['VIBE DOWN', () => this.bus.emit('meters:delta', { vibe: -15 }), UI.panelLip],
      ['AGGRO UP', () => this.bus.emit('meters:delta', { aggro: 15 }), UI.panelLip],
      ['HYPE UP', () => this.bus.emit('meters:delta', { hype: 0.3 }), UI.panelLip],
      ['HEAT STRIKE', () => this.bus.emit('heat:strike', { reason: 'let a bloke in wearing crocs' }), UI.danger],
    ];

    rows.forEach(([label, onClick, fill], i) => {
      chunkyButton(this, 580, 80 + i * 16, label, {
        w: 104,
        h: 13,
        fontSize: 7,
        fill,
        textColour: fill === UI.kebabAmber ? UI.ink : UI.paper,
        onClick,
      });
    });
  }

  private buildAudioStrip(): void {
    // Right-aligned to the far end of the track: at TRACK_X0 it printed straight
    // through the MUFFLE label, which is the same overlap defect Phase 1 caught.
    this.hzText = this.add.text(TRACK_X1, 296, '', font(8, UI.neonGreen)).setOrigin(1, 0);

    const track = this.add
      .rectangle((TRACK_X0 + TRACK_X1) / 2, TRACK_Y, TRACK_X1 - TRACK_X0, 6, UI.panel)
      .setStrokeStyle(1, UI.panelLip);
    track.setInteractive({ useHandCursor: true });
    track.on('pointerdown', (p: Phaser.Input.Pointer) => this.setCutoffFromX(p.x));

    this.handle = this.add
      .rectangle(TRACK_X1, TRACK_Y, 8, 16, UI.neonPink)
      .setStrokeStyle(1, UI.ink);
    this.handle.setInteractive({ useHandCursor: true, draggable: true });
    this.handle.on('dragstart', () => {
      this.dragging = true;
    });
    this.handle.on('drag', (_p: Phaser.Input.Pointer, dragX: number) => this.setCutoffFromX(dragX));
    this.handle.on('dragend', () => {
      this.dragging = false;
    });

    this.add.text(TRACK_X0, 300, 'MUFFLE', font(7, UI.paper)).setOrigin(0, 1).setAlpha(0.45);

    chunkyButton(this, 382, TRACK_Y, 'LET IT GO', {
      w: 68,
      h: 14,
      fontSize: 7,
      onClick: () => this.releaseCutoff(),
    });

    this.locButton = chunkyButton(this, 474, TRACK_Y, 'STEP INSIDE', {
      w: 100,
      h: 14,
      fontSize: 7,
      fill: UI.neonPink,
      onClick: () => this.toggleLocation(),
    });
  }

  private buildUnlockOverlay(): void {
    const root = this.add.container(0, 0).setDepth(2000);
    const veil = this.add.rectangle(W / 2, H / 2, W, H, UI.ink, 0.92);
    const head = this.add
      .text(W / 2, H / 2 - 14, 'CLICK TO START AUDIO', font(16, UI.neonGreen))
      .setOrigin(0.5);
    const hint = this.add
      .text(
        W / 2,
        H / 2 + 12,
        'the browser wants a hand on the door before it lets the bass out',
        font(8, UI.paper),
      )
      .setOrigin(0.5)
      .setAlpha(0.55);
    root.add([veil, head, hint]);

    veil.setInteractive();
    veil.on('pointerdown', () => {
      if (this.audioStarting) return;
      this.audioStarting = true;
      head.setText('OPENING UP...');
      // A restart mid-unlock destroys this overlay and swaps the engine, but the
      // promise still settles. Identity alone is not enough: teardown() destroys
      // the engine without replacing it, so `this.engine` still points at the
      // closed one. Check the context state too, or we start a closed context and
      // poke text objects the next run has already replaced.
      const engine = this.engine;
      const stale = (): boolean => this.engine !== engine || engine.ctx.state === 'closed';
      void engine
        .unlock()
        .then(() => {
          if (stale()) return;
          engine.start();
          // Beds need a running context to build their sources on; before this
          // they would be four seconds of noise nobody ever hears.
          this.ambience.start();
          root.destroy(true);
        })
        .catch(() => {
          if (stale()) return;
          this.audioStarting = false;
          head.setText('AUDIO KNOCKED US BACK');
          hint.setText('give it another click');
        });
    });
  }

  // --- actions --------------------------------------------------------------

  /**
   * Marks are parented to the patron so they ride along and die with him, which is
   * how the real door will use them. The scene still owns the handles: nothing else
   * reaps a decal, so the oldest goes when the stack gets past reading.
   */
  private doStamp(text: string, colour: number): void {
    while (this.stampMarks.length >= MAX_MARKS) this.stampMarks.shift()?.decal.destroy();

    this.stampMarks.push(
      stamp(this, {
        x: PATRON_X,
        y: PATRON_Y - 20,
        text,
        colour,
        depth: 100,
        sfx: this.sfx,
        parent: this.patronRoot,
        // Fresh seed per impression, so a stack of DENIEDs reads as a knackered
        // stamp rather than one mark printed five times.
        seed: this.rng.stream('juice-demo-stamp').int(0, 0xffff),
        onSlam: () => {
          if (text === 'DENIED') this.sfx.denyCrowdOoh();
        },
      }),
    );
  }

  private openDialogue(drunk: boolean): void {
    this.dialogue?.destroy();
    this.dialogueKill = false;
    this.dialogue = new DialogueBox(this, {
      speaker: drunk ? 'ABSOLUTELY GONE' : 'BLOKE IN A GOOD SHIRT',
      text: drunk
        ? 'nah mate listen right, im not even that bad, i had like two, ask her, ask her, im basically the designated driver of this whole situation'
        : 'evening. me and the boys are on the list, should be under Cam, might be under Cameron, might be under his missus actually.',
      drunkenness: drunk ? 0.95 : 0,
      rng: this.rng.stream('juice-demo-drunk'),
      depth: 950,
      sfx: this.sfx,
      choices: [
        { id: 'in', label: 'IN YOU GO' },
        { id: 'test', label: 'WALK THE LINE' },
        { id: 'out', label: 'NOT TONIGHT' },
      ],
      onChoice: () => {
        this.sfx.clickerClunk();
        this.dialogueKill = true;
      },
    });
  }

  private pushDazza(): void {
    const line = DAZZA_TEXTS[this.dazzaIndex % DAZZA_TEXTS.length];
    this.dazzaIndex++;
    if (!line) return;

    // Synthetic rule: the Phone only cares that the field is present (that is
    // what lights the NEW badge), never that the predicate is real.
    const rule: DressCodeRule | undefined =
      line.ruleId === undefined
        ? undefined
        : { id: line.ruleId, text: line.text, activeFrom: line.fromMin, violates: () => false };

    this.bus.emit('dazza:text', { text: line.text, rule });
  }

  private bedLabel(bed: BedName): string {
    return `${bed.toUpperCase().padEnd(5)} ${this.bedsOn[bed] ? 'ON' : 'OFF'}`;
  }

  /** chunkyButton restores its own fill on release, so latched state has to live
   *  in the label. Same trick the Phase-1 rain button used. */
  private setBed(bed: BedName, on: boolean): void {
    this.bedsOn[bed] = on;
    this.ambience.setEnabled(bed, on);
    const entry = this.bedButtons.find((b) => b.bed === bed);
    if (entry) labelOf(entry.btn)?.setText(this.bedLabel(bed));
  }

  /**
   * Launched, not started: the desk is an overlay and the music has to keep
   * running underneath it while you turn things. The engine goes in the payload
   * rather than leaning on getActiveEngine() so a restart cannot hand the desk
   * the previous run's closed context.
   */
  private openMixDesk(): void {
    if (this.scene.isActive('MixDesk')) return;
    const data: MixDeskData = { engine: this.engine, ambience: this.deskRain };
    this.scene.launch('MixDesk', data);
    this.scene.bringToTop('MixDesk');
  }

  private setCutoffFromX(x: number): void {
    const hz = hzFromT((clamp(x, TRACK_X0, TRACK_X1) - TRACK_X0) / (TRACK_X1 - TRACK_X0));
    this.overrideHz = hz;
    this.engine.setCutoffOverride(hz);
  }

  private releaseCutoff(): void {
    this.overrideHz = null;
    this.engine.setCutoffOverride(null);
  }

  /**
   * Goes through the bus rather than calling engine.setLocation directly — the
   * point of the button is to prove the `audio:location` path works end to end.
   * The override has to go first or the sweep is pinned and you hear nothing.
   */
  private toggleLocation(): void {
    this.releaseCutoff();
    const next = this.engine.location === 'door' ? 'floor' : 'door';
    this.bus.emit('audio:location', { location: next });
    this.bus.emit('night:phaseChange', { location: next });
    labelOf(this.locButton)?.setText(next === 'floor' ? 'BACK ON THE DOOR' : 'STEP INSIDE');
  }

  // --- loop -----------------------------------------------------------------

  override update(_time: number, delta: number): void {
    this.clock.update(delta);
    this.drainBeats();
    this.renderBeat(delta);
    this.syncSlider();
    this.renderStatus();
    this.renderNight();

    this.hud.update(delta);
    this.phone.update(delta);
    this.clicker.update(delta);
    this.dialogue?.update(delta);

    if (this.dialogueKill) {
      this.dialogueKill = false;
      this.dialogue?.destroy();
      this.dialogue = null;
    }
  }

  /**
   * `audioTimeMs` is the SCHEDULED time and arrives up to a lookahead early.
   * Flashing on receipt would put the light ~100ms ahead of the sound, which
   * reads as the whole demo being broken. Hold the beat until the audio clock
   * catches up to it.
   */
  private drainBeats(): void {
    const nowMs = this.engine.ctx.currentTime * 1000;
    while (this.pendingBeats.length > 0) {
      const b = this.pendingBeats[0];
      if (b === undefined || b.audioTimeMs > nowMs) break;
      this.pendingBeats.shift();
      this.lastBeat = b.beatIndex;
      if (b.bar) this.lastBar = Math.floor(b.beatIndex / 4);
      this.beatFlash = 1;
      this.beatFlashBar = b.bar;
    }
  }

  private renderBeat(delta: number): void {
    this.beatFlash = Math.max(0, this.beatFlash - delta / FLASH_MS);
    const f = this.beatFlash;
    const colour = this.beatFlashBar ? UI.neonPink : UI.neonGreen;
    this.beatPad.setFillStyle(colour, 0.1 + f * 0.9);
    this.beatPad.setScale(1 + f * (this.beatFlashBar ? 0.42 : 0.16));
    this.beatRing.setStrokeStyle(1, colour, this.beatFlashBar ? 0.2 + f * 0.6 : 0.18);
    this.beatRing.setScale(this.beatFlashBar ? 1 + f * 0.25 : 1);
  }

  /** In AUTO the handle rides the engine's live sweep; while dragging it leads. */
  private syncSlider(): void {
    if (this.dragging) return;
    const t = tFromHz(this.overrideHz ?? this.engine.cutoffHz);
    this.handle.x = TRACK_X0 + t * (TRACK_X1 - TRACK_X0);
  }

  private renderStatus(): void {
    const hz = Math.round(this.engine.cutoffHz);
    this.hzText.setText(`${hz} Hz ${this.overrideHz === null ? '(AUTO)' : '(HELD)'}`);

    const audio = this.engine.unlocked ? (this.engine.running ? 'LIVE' : 'IDLE') : 'LOCKED';
    this.statusText.setText(
      [
        this.clock.label,
        `AUDIO ${audio} · ${this.engine.bpm} BPM`,
        `BEAT ${this.lastBeat} · BAR ${this.lastBar}`,
        `${this.engine.location.toUpperCase()} · ${hz} Hz`,
      ].join('\n'),
    );
  }

  /**
   * The arc is a silent multiplier on every voice, so without a readout "the bass
   * is missing" at 9 PM looks like a broken engine rather than an empty room.
   */
  private renderNight(): void {
    const min = this.engine.arcMinute;
    const pinned = this.engine.arcPinned;
    // The desk drops the pin on its way out, so the label is derived, not latched.
    if (pinned !== this.arcPinShown) {
      this.arcPinShown = pinned;
      labelOf(this.arcPinButton)?.setText(pinned ? PIN_LABEL : FOLLOW_LABEL);
    }
    const scales = this.engine.arcScales;
    const lines = [
      `${wallClock(min)} · ${arcLabel(min)}${pinned ? ' · PINNED' : ''}`,
      ...VOICE_NAMES.map((v) => `${v.padEnd(4)} |${meterBar(scales[v])}| ${scales[v].toFixed(2)}`),
    ];
    this.arcText.setText(lines.join('\n'));
    this.insideText.setText(`INSIDE: ${this.ambience.insideCount}`);
  }

  private teardown(): void {
    // Before the engine goes: the desk holds a reference to it and writes knobs
    // every frame, and a closed AudioContext throws when you touch its params.
    if (this.scene.isActive('MixDesk')) this.scene.stop('MixDesk');
    this.input.keyboard?.off('keydown-M');
    this.input.keyboard?.off('keydown-SPACE');
    for (const m of this.stampMarks) m.decal.destroy();
    this.stampMarks.length = 0;
    this.dialogue?.destroy();
    this.dialogue = null;
    this.phone.destroy();
    this.clicker.destroy();
    this.hud.destroy();
    // Ambience before Sfx (it calls stopRain on the way out), Sfx before the
    // engine, which closes the context all of their nodes hang off.
    this.ambience.destroy();
    this.sfx.destroy();
    this.engine.destroy();
    this.meters.destroy();
    this.bus.removeAll();
  }
}
