import Phaser from 'phaser';
import { NIGHT_END_MIN, arcLabel } from '../audio/arc';
import { getActiveEngine, type TechnoEngine } from '../audio/TechnoEngine';
import {
  MIX_RANGES,
  VOICE_NAMES,
  clampTo,
  formatMix,
  soloedVoice,
  voiceGain,
  type VoiceName,
} from '../audio/mix';
import { UI, chunkyButton, font, panel } from './style';

// The ear pass, as a scene.
//
// Every number in the track was chosen by an agent that cannot hear. This desk
// exists so a human can sit down for ten minutes, solo one voice, drag the knob
// that is wrong, and leave with a diff. That last part is the whole point: a
// tuning session that does not end in a pasteable TrackMix literal was a
// conversation, not a change. Hence DUMP MIX, and hence the readouts showing
// *effective* gain rather than the raw level — the arc is a multiplier, and a
// kick that reads as too quiet at 9 PM may be perfectly set for midnight.

const CANVAS_W = 640;
const CANVAS_H = 360;

/** Hint grey. UI.dim is tuned for the game's panels and vanishes at 6px on this backdrop. */
const HINT = 0x8a8aa0;

const PARAM_ROWS_MAX = 6;

export interface MixDeskData {
  engine?: TechnoEngine;
  /** Duck-typed: Ambience is a sibling lane's file and the desk must not hard-depend on it. */
  ambience?: unknown;
}

interface RainSource {
  startRain(): void;
  stopRain(): void;
}

const isRainSource = (o: unknown): o is RainSource => {
  if (typeof o !== 'object' || o === null) return false;
  const r = o as Partial<RainSource>;
  return typeof r.startRain === 'function' && typeof r.stopRain === 'function';
};

interface ParamSpec {
  /** Dotted path understood by TechnoEngine.setMixValue. */
  readonly path: string;
  readonly label: string;
  readonly hint: string;
  readonly range: keyof typeof MIX_RANGES;
  /** Engine quantises this one, so the desk must too — see SliderSpec.integer. */
  readonly integer?: true;
}

interface ParamGroup {
  readonly name: string;
  readonly rows: readonly ParamSpec[];
}

/**
 * The knob table. Rows are data so the layout code is written once — twenty
 * hand-rolled rows is twenty places for a path typo to hide.
 */
const GROUPS: readonly ParamGroup[] = [
  {
    name: 'LEVELS',
    rows: [
      { path: 'master', label: 'master', hint: 'post-filter output. mind your ears.', range: 'master' },
      { path: 'levels.kick', label: 'kick level', hint: 'raw gain, before the arc scales it.', range: 'level' },
      { path: 'levels.hat', label: 'hat level', hint: 'the tick. easy to over-serve.', range: 'level' },
      { path: 'levels.bass', label: 'bass level', hint: 'the door filter eats most of this.', range: 'level' },
      { path: 'levels.pad', label: 'pad level', hint: 'glue. hear it as a part, too loud.', range: 'level' },
    ],
  },
  {
    name: 'KICK',
    rows: [
      { path: 'kick.startHz', label: 'kick startHz', hint: 'beater pitch at the transient. click.', range: 'kick.startHz' },
      { path: 'kick.endHz', label: 'kick endHz', hint: 'where the drop lands. the body.', range: 'kick.endHz' },
      { path: 'kick.dropS', label: 'kick dropS', hint: 'length of the fall. longer = bigger box.', range: 'kick.dropS' },
      { path: 'kick.attackS', label: 'kick attackS', hint: 'slower attack rounds off the front.', range: 'kick.attackS' },
      { path: 'kick.decayS', label: 'kick decayS', hint: 'tail. too long and it smears the bass.', range: 'kick.decayS' },
    ],
  },
  {
    name: 'HAT + PAD',
    rows: [
      { path: 'hat.hpHz', label: 'hat hpHz', hint: 'highpass corner. lower = more body.', range: 'hat.hpHz' },
      { path: 'hat.decayS', label: 'hat decayS', hint: 'tick versus sizzle lives right here.', range: 'hat.decayS' },
      { path: 'pad.lpHz', label: 'pad lpHz', hint: 'brightness of the wash behind it all.', range: 'pad.lpHz' },
      { path: 'pad.bars', label: 'pad bars', hint: 'bars per fade. longer = unnoticed.', range: 'pad.bars', integer: true },
    ],
  },
  {
    name: 'BASS',
    rows: [
      { path: 'bass.rootHz', label: 'bass rootHz', hint: 'A1 by default. move it, move the lot.', range: 'bass.rootHz' },
      { path: 'bass.filterLoHz', label: 'bass filterLo', hint: 'floor of the per-note filter sweep.', range: 'bass.filterLoHz' },
      { path: 'bass.filterHiHz', label: 'bass filterHi', hint: 'peak of the sweep. this is the wow.', range: 'bass.filterHiHz' },
      { path: 'bass.q', label: 'bass q', hint: 'resonance on the sweep. past 12 it whistles.', range: 'bass.q' },
      { path: 'bass.decayS', label: 'bass decayS', hint: 'note length. short rolls, long drones.', range: 'bass.decayS' },
      { path: 'bass.detuneCents', label: 'bass detune', hint: 'cents between the saws. the character.', range: 'bass.detuneCents' },
    ],
  },
  {
    name: 'ROOM',
    rows: [
      { path: 'doorQ', label: 'door Q', hint: 'resonant peak outside — the wall cue.', range: 'doorQ' },
      { path: 'floorQ', label: 'floor Q', hint: 'inside. flat is right; you are in the room.', range: 'floorQ' },
    ],
  },
];

/**
 * What to actually listen for. The difference between "sounds fine" and a useful
 * answer is usually just knowing which question was being asked.
 */
const LISTEN_FOR: Record<VoiceName, string> = {
  kick: 'KICK — does it read as a kick through a wall, or just a thud? If it thuds, more startHz and a shorter dropS.',
  hat: 'HAT — should tick, not hiss. Sizzling means hpHz is too low or decayS too long.',
  bass: 'BASS — should roll under the kick, not fight it. If the two blur together, cut kick decayS first.',
  pad: 'PAD — glue, not a part. If you can name the chord, the level is too high.',
};

const NO_SOLO = 'Nothing soloed — SOLO a voice to judge it on its own, then unsolo to hear it in the room.';

/** Readable at a glance across four orders of magnitude (0.008 s up to 14000 Hz). */
const fmt = (v: number): string => {
  const a = Math.abs(v);
  if (a >= 100) return v.toFixed(0);
  if (a >= 10) return v.toFixed(1);
  if (a >= 1) return v.toFixed(2);
  return v.toFixed(3);
};

/**
 * Text.setColor is unguarded: it goes through TextStyle.setFill to updateText(),
 * which resizes and clears the backing canvas, re-measures, re-fills and re-uploads
 * the GL texture — no no-op check, unlike setText. refreshAll() runs every frame, so
 * an unconditional setColor is hundreds of texture uploads a second on the same main
 * thread as the audio scheduler, and the rig degrades the sound it exists to judge.
 * Keyed weakly: entries die with the Text, so a scene restart starts cold.
 */
const lastColour = new WeakMap<Phaser.GameObjects.Text, string>();
const setColour = (t: Phaser.GameObjects.Text | null | undefined, css: string): void => {
  if (!t || lastColour.get(t) === css) return;
  lastColour.set(t, css);
  t.setColor(css);
};

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

/** clockMin is minutes since 21:00. */
const wallClock = (clockMin: number): string => {
  const t = (21 * 60 + Math.round(clockMin)) % (24 * 60);
  return `${pad2(Math.floor(t / 60))}:${pad2(t % 60)}`;
};

interface SliderSpec {
  x: number;
  y: number;
  w: number;
  range: readonly [number, number];
  /** Frequencies get a log taper: linear Hz spends most of its travel where nothing changes. */
  log?: boolean;
  /**
   * Snap to whole numbers on write. Without it a slider can hold a value the engine
   * never uses (pad.bars rounds internally) and DUMP MIX exports that phantom — a
   * mix literal that does not describe what was heard is worse than no dump at all.
   */
  integer?: boolean;
  read: () => number;
  write: (v: number) => void;
  format?: (v: number) => string;
}

interface Refreshable {
  refresh: () => void;
}

interface ToggleSpec {
  x: number;
  y: number;
  w: number;
  h?: number;
  label: string;
  on: () => boolean;
  click: () => void;
  onFill?: number;
}

/**
 * Tuning desk for the techno engine, run as an overlay above whatever scene owns
 * the audio. It never pauses that scene — the music has to keep playing while
 * you turn things.
 */
export class MixDeskScene extends Phaser.Scene {
  private engine: TechnoEngine | null = null;
  /** Arc pin as we found it, restored on close. null = was following the clock. */
  private entryArc: number | null = null;
  private entryEnabled: Record<VoiceName, boolean> | null = null;
  private rain: RainSource | null = null;
  private raining = false;

  /** Everything alive for the whole scene; page-scoped objects live in `paramOwned`. */
  private owned: Phaser.GameObjects.GameObject[] = [];
  private paramOwned: Phaser.GameObjects.GameObject[] = [];

  private refreshers: Refreshable[] = [];
  private paramRefreshers: Refreshable[] = [];

  private page = 0;
  private groupTitle: Phaser.GameObjects.Text | null = null;
  private pageLabel: Phaser.GameObjects.Text | null = null;
  private listenText: Phaser.GameObjects.Text | null = null;
  private statusText: Phaser.GameObjects.Text | null = null;
  private arcText: Phaser.GameObjects.Text | null = null;
  private scaleText: Phaser.GameObjects.Text | null = null;
  private toastText: Phaser.GameObjects.Text | null = null;
  private toastTween: Phaser.Tweens.Tween | null = null;

  private readonly onEsc = (): void => {
    this.scene.stop();
  };

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

  init(data: MixDeskData): void {
    // A desk that only works in the demo is useless on a real night, where the
    // scene holding the engine is not one we are allowed to import.
    this.engine = data.engine ?? getActiveEngine();
    this.rain = isRainSource(data.ambience) ? data.ambience : null;
  }

  create(): void {
    // Phaser reuses the scene instance across restarts and only re-runs create();
    // field initialisers fired once, ever.
    this.owned = [];
    this.paramOwned = [];
    this.refreshers = [];
    this.paramRefreshers = [];
    this.page = 0;
    this.raining = false;
    this.toastTween = null;

    // Snapshot the monitoring state we are about to borrow. Closing the desk
    // restores this rather than forcing a default: JuiceDemoScene deliberately
    // pins the arc at PEAK so every voice is audible the moment you open it, and
    // a desk that resets that on the way out silently un-tunes the very scene it
    // was opened from.
    const eng = this.engine;
    this.entryArc = eng && eng.arcPinned ? eng.arcMinute : null;
    this.entryEnabled = eng
      ? Object.fromEntries(VOICE_NAMES.map((v) => [v, eng.mix.enabled[v]])) as Record<VoiceName, boolean>
      : null;

    // Interactive so clicks land on the desk instead of leaking through to the
    // scene underneath, which is still running and still listening.
    this.own(
      this.add.rectangle(CANVAS_W / 2, CANVAS_H / 2, CANVAS_W, CANVAS_H, UI.ink, 0.93).setInteractive(),
    );

    this.own(this.add.text(10, 4, 'MIX DESK', font(10, UI.neonPink)));
    this.own(chunkyButton(this, 600, 11, 'CLOSE  ESC', { w: 54, h: 13, onClick: () => this.scene.stop() }));
    this.input.keyboard?.on('keydown-ESC', this.onEsc);
    this.events.once('shutdown', () => this.teardown());

    const engine = this.engine;
    if (!engine) {
      this.own(
        this.add
          .text(
            CANVAS_W / 2,
            CANVAS_H / 2,
            'NO AUDIO ENGINE\n\nLaunch this scene with { engine } in its scene data,\nor start the audio first so getActiveEngine() has something to hand back.',
            { ...font(9, UI.paper), align: 'center' },
          )
          .setOrigin(0.5),
      );
      return;
    }

    if (this.rain) {
      const rain = this.toggle({
        x: 540,
        y: 11,
        w: 44,
        h: 13,
        label: 'RAIN',
        on: () => this.raining,
        click: () => this.setRain(!this.raining),
      });
      this.own(rain.node);
      this.refreshers.push(rain);
    }

    this.statusText = this.own(this.add.text(90, 6, '', font(7, HINT)));

    this.buildVoiceStrip(engine);
    this.buildParamRegion(engine);
    this.buildArcRegion(engine);
    this.buildExportBar(engine);

    this.refreshAll();
  }

  // ---------------------------------------------------------------- regions

  private buildVoiceStrip(engine: TechnoEngine): void {
    this.own(panel(this, CANVAS_W / 2, 49, 632, 58));

    VOICE_NAMES.forEach((v, i) => {
      const y = 32 + i * 13;
      this.own(this.add.text(10, y, v.toUpperCase(), font(8, UI.paper)).setOrigin(0, 0.5));

      const mute = this.toggle({
        x: 46,
        y,
        w: 32,
        h: 11,
        label: 'MUTE',
        on: () => !engine.mix.enabled[v],
        click: () => engine.setVoiceEnabled(v, !engine.mix.enabled[v]),
        onFill: UI.danger,
      });
      const solo = this.toggle({
        x: 82,
        y,
        w: 32,
        h: 11,
        label: 'SOLO',
        on: () => soloedVoice(engine.mix) === v,
        click: () => this.solo(engine, v),
        onFill: UI.kebabAmber,
      });
      this.own(mute.node);
      this.own(solo.node);

      const level = this.slider(
        {
          x: 108,
          y,
          w: 110,
          range: MIX_RANGES.level,
          read: () => engine.getMixValue(`levels.${v}`),
          write: (val) => engine.setMixValue(`levels.${v}`, val),
        },
        this.owned,
      );

      // Effective gain, not the slider value: the arc is a multiplier, and hiding
      // it is how you end up "fixing" a level that the arc had already dropped.
      const eff = this.own(this.add.text(272, y, '', font(8, UI.neonGreen)).setOrigin(0, 0.5));

      this.refreshers.push({
        refresh: () => {
          mute.refresh();
          solo.refresh();
          level.refresh();
          const g = voiceGain(engine.mix, v, engine.arcScales[v]);
          eff.setText(`= ${fmt(g)}`);
          setColour(eff, g > 0.0005 ? '#9fe8a0' : '#c03434');
        },
      });
    });

    this.listenText = this.own(
      this.add.text(336, 49, '', { ...font(7, UI.kebabAmber), wordWrap: { width: 292 } }).setOrigin(0, 0.5),
    );
  }

  /** Solo toggles: on, this voice alone; off, everyone back. */
  private solo(engine: TechnoEngine, v: VoiceName): void {
    const already = soloedVoice(engine.mix) === v;
    for (const other of VOICE_NAMES) engine.setVoiceEnabled(other, already || other === v);
  }

  private buildParamRegion(engine: TechnoEngine): void {
    this.own(panel(this, CANVAS_W / 2, 163, 632, 162));
    this.groupTitle = this.own(this.add.text(14, 92, '', font(9, UI.neonPink)).setOrigin(0, 0.5));
    this.pageLabel = this.own(this.add.text(160, 92, '', font(7, HINT)).setOrigin(0, 0.5));
    this.own(
      chunkyButton(this, 600, 92, 'PAGE >', {
        w: 54,
        h: 13,
        onClick: () => {
          this.page = (this.page + 1) % GROUPS.length;
          this.buildParamPage(engine);
        },
      }),
    );
    this.buildParamPage(engine);
  }

  /** Rebuilt wholesale per page — cheaper to reason about than repointing closures. */
  private buildParamPage(engine: TechnoEngine): void {
    for (const o of this.paramOwned) o.destroy();
    this.paramOwned = [];
    this.paramRefreshers = [];

    const group = GROUPS[this.page] ?? GROUPS[0];
    if (!group) return;

    this.groupTitle?.setText(group.name);
    this.pageLabel?.setText(`page ${this.page + 1} of ${GROUPS.length}`);

    group.rows.slice(0, PARAM_ROWS_MAX).forEach((spec, i) => {
      const y = 106 + i * 22;
      this.paramOwned.push(this.add.text(14, y - 5, spec.label, font(8, UI.paper)).setOrigin(0, 0.5));
      this.paramOwned.push(this.add.text(14, y + 5, spec.hint, font(6, HINT)).setOrigin(0, 0.5));

      const handle = this.slider(
        {
          x: 200,
          y,
          w: 320,
          range: MIX_RANGES[spec.range],
          log: spec.path.endsWith('Hz'),
          integer: spec.integer === true,
          read: () => engine.getMixValue(spec.path),
          write: (v) => engine.setMixValue(spec.path, v),
        },
        this.paramOwned,
      );
      this.paramRefreshers.push(handle);
    });
  }

  private buildArcRegion(engine: TechnoEngine): void {
    this.own(panel(this, CANVAS_W / 2, 275, 632, 54));
    this.own(this.add.text(14, 262, 'ARC', font(8, UI.paper)).setOrigin(0, 0.5));

    // Pinning is the only way the night's shape gets checked at all — otherwise
    // auditioning 1 AM means waiting four hours for it.
    const arc = this.slider(
      {
        x: 48,
        y: 262,
        w: 260,
        range: [0, NIGHT_END_MIN],
        integer: true,
        read: () => engine.arcMinute,
        write: (v) => engine.setArcMinute(v),
        format: wallClock,
      },
      this.owned,
    );
    this.refreshers.push(arc);

    this.arcText = this.own(this.add.text(370, 262, '', font(8, UI.neonGreen)).setOrigin(0, 0.5));
    const follow = this.toggle({
      x: 580,
      y: 262,
      w: 100,
      h: 14,
      label: 'FOLLOW CLOCK',
      on: () => !engine.arcPinned,
      click: () => engine.setArcMinute(null),
      onFill: UI.ok,
    });
    this.own(follow.node);
    this.refreshers.push(follow);

    this.scaleText = this.own(this.add.text(14, 284, '', font(8, UI.paper)).setOrigin(0, 0.5));
    this.own(
      this.add
        .text(370, 284, 'closing the desk puts the arc back how you found it', font(6, HINT))
        .setOrigin(0, 0.5),
    );
  }

  private buildExportBar(engine: TechnoEngine): void {
    this.own(panel(this, CANVAS_W / 2, 329, 632, 46));
    this.own(
      chunkyButton(this, 60, 320, 'DUMP MIX', {
        w: 100,
        h: 16,
        fill: UI.neonPink,
        fontSize: 9,
        onClick: () => this.dump(engine),
      }),
    );
    this.own(
      chunkyButton(this, 155, 320, 'RESET', {
        w: 70,
        h: 16,
        fontSize: 9,
        onClick: () => {
          engine.resetMix();
          this.refreshAll();
          this.toast('back to DEFAULT_MIX');
        },
      }),
    );
    this.toastText = this.own(this.add.text(200, 320, '', font(8, UI.neonGreen)).setOrigin(0, 0.5).setAlpha(0));
    this.own(
      this.add.text(10, 340, 'DUMP writes a TrackMix literal — paste it over DEFAULT_MIX in src/audio/mix.ts', font(6, HINT)),
    );
  }

  // ---------------------------------------------------------------- widgets

  /**
   * The one slider. Track, fill, handle, readout; drag the handle or click the
   * track. Reads its value from the engine every refresh rather than caching, so
   * a knob moved elsewhere (the arc, a reset) shows up here without plumbing.
   */
  private slider(spec: SliderSpec, sink: Phaser.GameObjects.GameObject[]): Refreshable {
    const lo = spec.range[0];
    const hi = spec.range[1];
    const logOk = spec.log === true && lo > 0 && hi > lo;
    const int = spec.integer === true;
    const format = spec.format ?? (int ? (v: number): string => v.toFixed(0) : fmt);
    const quantise = (v: number): number => (int ? Math.round(v) : v);

    const toT = (v: number): number => {
      const c = clampTo(v, spec.range);
      if (logOk) return Math.log(c / lo) / Math.log(hi / lo);
      return hi === lo ? 0 : (c - lo) / (hi - lo);
    };
    const toV = (t: number): number => {
      const k = Math.max(0, Math.min(1, t));
      return logOk ? lo * Math.pow(hi / lo, k) : lo + (hi - lo) * k;
    };

    const groove = this.add
      .rectangle(spec.x, spec.y, spec.w, 5, UI.grime)
      .setOrigin(0, 0.5)
      .setStrokeStyle(1, UI.ink);
    const fill = this.add.rectangle(spec.x, spec.y, 1, 3, UI.neonPink).setOrigin(0, 0.5);
    const knob = this.add
      .rectangle(spec.x, spec.y, 5, 13, UI.paper)
      .setOrigin(0.5)
      .setStrokeStyle(1, UI.ink);
    const readout = this.add.text(spec.x + spec.w + 8, spec.y, '', font(8, UI.neonGreen)).setOrigin(0, 0.5);
    sink.push(groove, fill, knob, readout);

    let lastW = -1;
    const refresh = (): void => {
      const v = spec.read();
      const t = toT(v);
      knob.x = spec.x + t * spec.w;
      // setSize rebuilds the shape's path data; refresh() is per-frame, so diff it.
      const w = Math.max(1, t * spec.w);
      if (w !== lastW) {
        lastW = w;
        fill.setSize(w, 3);
      }
      readout.setText(format(v));
    };
    const setFromX = (px: number): void => {
      // Quantise after clamping — every integer param's range has whole bounds.
      spec.write(quantise(clampTo(toV((px - spec.x) / spec.w), spec.range)));
      refresh();
    };

    groove.setInteractive({ useHandCursor: true });
    groove.on('pointerdown', (p: Phaser.Input.Pointer) => setFromX(p.x));
    knob.setInteractive({ draggable: true, useHandCursor: true });
    knob.on('drag', (_p: Phaser.Input.Pointer, dragX: number) => setFromX(dragX));

    return { refresh };
  }

  /**
   * A button that shows a state. chunkyButton restores its own fill on release,
   * so a latching control has to draw itself.
   */
  private toggle(spec: ToggleSpec): Refreshable & { node: Phaser.GameObjects.Container } {
    const h = spec.h ?? 13;
    const onFill = spec.onFill ?? UI.neonGreen;
    const node = this.add.container(spec.x, spec.y);
    const face = this.add.rectangle(0, 0, spec.w, h, UI.panelLip).setStrokeStyle(1, UI.ink);
    const text = this.add.text(0, 0, spec.label, font(7, UI.paper)).setOrigin(0.5);
    node.add([face, text]);

    // refreshAll() runs every frame; both setters below are unguarded in Phaser.
    let lastOn: boolean | null = null;
    const refresh = (): void => {
      const on = spec.on();
      if (on === lastOn) return;
      lastOn = on;
      face.setFillStyle(on ? onFill : UI.panelLip);
      setColour(text, on ? '#0b0b12' : '#dad4c4');
    };

    // Same anti-phantom-click arming as chunkyButton: Phaser fires pointerup on
    // any release over an object, including one whose press landed elsewhere.
    let armed = false;
    face.setInteractive({ useHandCursor: true });
    face.on('pointerdown', () => {
      armed = true;
    });
    face.on('pointerout', () => {
      armed = false;
    });
    face.on('pointerup', () => {
      if (!armed) return;
      armed = false;
      spec.click();
      this.refreshAll();
    });

    refresh();
    return { refresh, node };
  }

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

  private setRain(on: boolean): void {
    if (!this.rain) return;
    this.raining = on;
    if (on) this.rain.startRain();
    else this.rain.stopRain();
  }

  private dump(engine: TechnoEngine): void {
    const text = formatMix(engine.mix);
    // The console copy is the one that always survives; the clipboard is a
    // convenience that browsers refuse without focus or permission.
    console.log(`[MixDesk] TrackMix @ ${wallClock(engine.arcMinute)}\n${text}`);
    const clip = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
    if (!clip) {
      this.toast('dumped to console (no clipboard API)');
      return;
    }
    clip.writeText(text).then(
      () => this.toast('copied to clipboard + console'),
      () => this.toast('clipboard refused — it is in the console'),
    );
  }

  private toast(msg: string): void {
    const t = this.toastText;
    if (!t) return;
    this.toastTween?.remove();
    t.setText(msg).setAlpha(1);
    this.toastTween = this.tweens.add({ targets: t, alpha: 0, delay: 2200, duration: 500 });
  }

  // ---------------------------------------------------------------- refresh

  private refreshAll(): void {
    const engine = this.engine;
    if (!engine) return;

    for (const r of this.refreshers) r.refresh();
    for (const r of this.paramRefreshers) r.refresh();

    const solo = soloedVoice(engine.mix);
    this.listenText?.setText(solo ? LISTEN_FOR[solo] : NO_SOLO);
    setColour(this.listenText, solo ? '#d8a020' : '#8a8aa0');

    const min = engine.arcMinute;
    this.arcText?.setText(`${arcLabel(min)}${engine.arcPinned ? '  [PINNED]' : ''}`);
    setColour(this.arcText, engine.arcPinned ? '#d8a020' : '#9fe8a0');

    const s = engine.arcScales;
    this.scaleText?.setText(
      `arc scales   ${VOICE_NAMES.map((v) => `${v} ${s[v].toFixed(2)}`).join('   ')}`,
    );

    const running = engine.ctx.state === 'running';
    this.statusText?.setText(
      running ? `audio running · ${engine.bpm} bpm` : `AUDIO ${engine.ctx.state.toUpperCase()} — start the audio before trusting your ears`,
    );
    setColour(this.statusText, running ? '#8a8aa0' : '#c03434');
  }

  override update(): void {
    // Cheap enough at a dozen sliders, and it keeps the desk honest about state
    // it does not own: the clock moves the arc, and the arc moves every readout.
    this.refreshAll();
  }

  // ---------------------------------------------------------------- teardown

  private own<T extends Phaser.GameObjects.GameObject>(o: T): T {
    this.owned.push(o);
    return o;
  }

  private teardown(): void {
    this.input.keyboard?.off('keydown-ESC', this.onEsc);
    this.toastTween?.remove();
    this.toastTween = null;
    this.tweens.killAll();

    // Solo and arc-pin are monitoring state, not mix state — formatMix even hard-codes
    // `enabled` back to all-true. Leaving either latched after the desk closes would
    // silently break a real night with no UI left to undo it, so hand back exactly
    // what we borrowed. On a real night nothing is pinned or muted, so this restores
    // to all-true / follow-clock anyway.
    const engine = this.engine;
    if (engine) {
      for (const v of VOICE_NAMES) engine.setVoiceEnabled(v, this.entryEnabled?.[v] ?? true);
      engine.setArcMinute(this.entryArc);
    }
    if (this.raining) this.setRain(false);

    for (const o of this.paramOwned) o.destroy();
    for (const o of this.owned) o.destroy();
    this.paramOwned = [];
    this.owned = [];
    this.refreshers = [];
    this.paramRefreshers = [];
    this.groupTitle = null;
    this.pageLabel = null;
    this.listenText = null;
    this.statusText = null;
    this.arcText = null;
    this.scaleText = null;
    this.toastText = null;
    this.engine = null;
    this.rain = null;
  }
}
