import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { activeRules, announcedRules, violations, type DoorDressCodeRule } from '../../rules/dressCode';
import { idVerdict, formatDate } from '../../rules/idCheck';
import { judge } from '../../rules/judge';
import type { DoorOutcome } from '../../rules/doorTypes';
import { CONTRABAND } from '../../data/contraband';
import type { Patron, Verdict } from '../../data/types';
import {
  ADMIT_REACTIONS,
  DENY_REACTIONS,
  DOOR_TUTORIAL,
  DOOR_UI,
  PATDOWN_LINES,
  RULE_RETRACTIONS,
} from '../../data/strings/door';
import { QueueManager } from './QueueManager';
import { PatronUpView } from './PatronUpView';
import { IdCardView } from './IdCardView';
import { PhoneWidget } from './PhoneWidget';
import { DressCodeCard } from './DressCodeCard';
import { ClipboardPanel, type ClipboardTab } from './ClipboardPanel';
import { encounterById } from '../../rules/encounters';
import { SobrietyModal } from './SobrietyModal';
import { stamp, type StampHandle } from '../../ui/Stamp';
import { Button, DOOR_PALETTE, MeterBar, MONO, panel, text } from './ui';
import { shouldRecordStrike, type NightContext } from './NightScene';

const W = 640;
const H = 360;
const DESK_Y = 250;
const UP_X = 300;
/** Real-ms between Kayden's rulings while the player is on the floor (~2.3 clock-min). */
const KAYDEN_RULE_MS = 5000;
const UP_FOOT_Y = 244;
const DOOR_X = 566;
const QUEUE_FOOT_Y = 228;
const TUTORIAL_MS = 26_000;

/**
 * TODO(contract): CCR-4 — `dazza:text` carries `rule?: DressCodeRule`, so a rule
 * emitted by this lane comes back without the `short` card label the lane added.
 * Until the payload widens, narrow it here honestly instead of casting.
 */
const isDoorRule = (r: unknown): r is DoorDressCodeRule =>
  typeof r === 'object' && r !== null && typeof (r as DoorDressCodeRule).short === 'string';

interface QueueSprite {
  patron: Patron;
  image: Phaser.GameObjects.Image;
  link: Phaser.GameObjects.Rectangle | null;
}

// The Door. Thin by design: every number it acts on comes from src/rules/, every
// arrival comes from QueueManager, and the night's lifetime belongs to
// NightScene. What lives here is layout, input and feel.
export class DoorScene extends Phaser.Scene {
  private night!: NightContext;
  private queue!: QueueManager;
  private up!: PatronUpView;
  private idCard!: IdCardView;
  private phone!: PhoneWidget;
  private codeCard!: DressCodeCard;
  private clipboard!: ClipboardPanel;
  private deskTab: ClipboardTab = 'rules';
  private tabButtons: Partial<Record<ClipboardTab, Button>> = {};
  private sobriety!: SobrietyModal;

  private queueSprites: QueueSprite[] = [];
  private trayCard: Phaser.GameObjects.Container | null = null;
  private buttons: Record<string, Button> = {};
  private vibeBar!: MeterBar;
  private aggroBar!: MeterBar;
  private hypeText!: Phaser.GameObjects.Text;
  private clockText!: Phaser.GameObjects.Text;
  private countText!: Phaser.GameObjects.Text;
  private heatText!: Phaser.GameObjects.Text;
  private clickerText!: Phaser.GameObjects.Text;
  private toast!: Phaser.GameObjects.Text;
  private tutorial!: Phaser.GameObjects.Text;
  private ropeProp!: Phaser.GameObjects.Container;

  private busy = false;
  private rulingToken = 0;
  private elapsedMs = 0;
  private testedThisPatron = false;
  private pattedThisPatron = false;
  private offs: Array<() => void> = [];
  /** Is the player at the rope? When false, Kayden works the door. Badly. */
  private present = true;
  private kaydenAccMs = 0;
  private readonly retiredRules = new Set<string>();
  private venueDoorGlow: Phaser.GameObjects.Rectangle | null = null;

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

  init(data: { night: NightContext }): void {
    this.night = data.night;
  }

  create(): void {
    this.queue = new QueueManager(
      this.night.bus,
      this.night.rng,
      this.night.nightDate,
      this.night.seenEncounters,
      this.night.venue.arrivalScale,
      this.night.venue.archetypeWeights,
      this.night.regulars,
    );
    this.up = new PatronUpView(this, UP_X, UP_FOOT_Y);
    this.idCard = new IdCardView(this, this.night.nightDate);
    this.sobriety = new SobrietyModal(this);
    this.present = true;
    this.kaydenAccMs = 0;

    this.buildStreet();
    this.buildDesk();
    this.buildHud();

    this.offs = [
      this.night.bus.on('clock:tick', ({ clockMin }) => this.queue.onClockMinute(clockMin)),
      this.night.bus.on('meters:changed', ({ vibe, aggro, hype }) => {
        this.vibeBar.set(vibe / 100, vibe < 20);
        this.aggroBar.set(aggro / 100, aggro > 80);
        this.hypeText.setText(`HYPE x${hype.toFixed(1)}`);
      }),
      this.night.bus.on('dazza:text', ({ rule }) => {
        this.night.sfx?.play('phoneBuzz');
        // The bus payload is typed with the frozen DressCodeRule, which has no
        // `short`. Narrowing back to the lane's own DoorDressCodeRule is a real
        // contract gap (CCR-4) — this predicate is the honest version of it
        // rather than an `as never` punch-through.
        if (isDoorRule(rule)) {
          this.codeCard.add(this, rule, this.elapsedMs);
          this.retireStaleRules();
        }
      }),
      this.night.bus.on('heat:strike', () => this.refreshCounters()),
      this.night.bus.on('night:phaseChange', ({ location }) => {
        this.present = location === 'door';
        if (this.present) this.refreshCounters();
        else this.kaydenAccMs = 0;
      }),
      this.night.bus.on('radio:call', ({ line, urgency }) => {
        if (!this.present) return;
        this.showToast(`RADIO — ${line}`);
        // The venue door glows when the floor wants you: the way in IS the button.
        if (this.venueDoorGlow) {
          this.venueDoorGlow.setAlpha(urgency >= 3 ? 0.5 : 0.3);
          this.tweens.add({ targets: this.venueDoorGlow, alpha: 0.08, duration: 6000 });
        }
      }),
    ];

    this.events.once('shutdown', () => this.teardown());
    this.refreshCounters();
    this.vibeBar.set(this.night.state.vibe / 100);
    this.aggroBar.set(this.night.state.aggro / 100);
  }

  // ---------- construction ----------

  private buildStreet(): void {
    if (this.textures.exists('gen:prop:street_backdrop')) {
      // The generated plate (SPACES §5 item 11): brick, posters, grime. Sits
      // where the flat wall-of-night rect went; everything else layers on top.
      this.add.image(0, 0, 'gen:prop:street_backdrop').setOrigin(0, 0).setDisplaySize(W, 176).setAlpha(0.9);
    } else {
      this.add.rectangle(W / 2, 88, W, 176, 0x0a0a12); // wall of night
    }
    this.add.rectangle(W / 2, 212, W, 76, 0x1a1a24); // footpath
    this.add.rectangle(W / 2, 176, W, 2, 0x2a2a38);

    // kebab shop, stage left — visible all night, the moral centre (design §5)
    this.add.rectangle(62, 120, 112, 106, 0x2a1c08);
    this.add.rectangle(62, 88, 96, 18, DOOR_PALETTE.kebab, 0.9);
    this.add.text(28, 82, 'KEBABS', { fontFamily: MONO, fontSize: '12px', color: '#3a2404' });
    this.add.rectangle(62, 148, 92, 46, 0xe8c060, 0.12);
    this.add.rectangle(62, 214, 96, 8, DOOR_PALETTE.kebab, 0.13);

    // the venue
    this.add.rectangle(DOOR_X, 108, 116, 140, 0x181828);
    this.add.rectangle(DOOR_X, 58, 92, 16, DOOR_PALETTE.neon, 0.92);
    this.add.text(DOOR_X - 40, 51, 'NOT TONIGHT', { fontFamily: MONO, fontSize: '10px', color: '#2a0818' });
    this.add.rectangle(DOOR_X, 140, 34, 74, 0x05050a);
    this.add.rectangle(DOOR_X, 214, 66, 8, DOOR_PALETTE.neon, 0.16);

    // The doorway is how you get to the floor — click it to step inside.
    // It glows when the radio wants you in there.
    this.venueDoorGlow = this.add.rectangle(DOOR_X, 140, 38, 78, DOOR_PALETTE.neon, 0.08);
    const doorway = this.add
      .rectangle(DOOR_X, 140, 40, 80, 0xffffff, 0)
      .setInteractive({ useHandCursor: true });
    doorway.on('pointerover', () => this.venueDoorGlow?.setAlpha(0.28));
    doorway.on('pointerout', () => this.venueDoorGlow?.setAlpha(0.08));
    doorway.on('pointerdown', () => {
      if (!this.busy) this.night.requestLocation('floor');
    });
    this.add
      .text(DOOR_X, 184, 'step inside', { fontFamily: MONO, fontSize: '7px', color: '#6a4a5a' })
      .setOrigin(0.5, 0);

    this.buildRain();
    this.buildRope();
  }

  private buildRain(): void {
    const g = this.add.graphics();
    g.fillStyle(0x8899bb, 1);
    g.fillRect(0, 0, 1, 4);
    g.generateTexture('door:rain', 1, 4);
    g.destroy();
    this.add
      .particles(0, 0, 'door:rain', {
        x: { min: -20, max: W },
        y: -6,
        lifespan: 800,
        speedY: { min: 230, max: 310 },
        speedX: { min: -34, max: -12 },
        quantity: 2,
        alpha: { start: 0.45, end: 0.12 },
      })
      .setDepth(400);
  }

  private buildRope(): void {
    const c = this.add.container(0, 0).setDepth(280);
    c.add(this.add.rectangle(238, 216, 4, 56, 0x8a7a4a)); // post
    c.add(this.add.rectangle(238, 190, 8, 8, 0xc0a860));
    const rope = this.add.rectangle(206, 198, 62, 3, 0x8a2038).setName('rope');
    c.add(rope);
    this.ropeProp = c;

    const hit = this.add
      .rectangle(222, 200, 78, 40, 0xffffff, 0)
      .setInteractive({ useHandCursor: true })
      .setDepth(281);
    hit.on('pointerdown', () => this.callNext());
    hit.on('pointerover', () => rope.setFillStyle(0xc03050));
    hit.on('pointerout', () => rope.setFillStyle(0x8a2038));
  }

  private buildDesk(): void {
    this.add.rectangle(W / 2, (DESK_Y + H) / 2, W, H - DESK_Y, DOOR_PALETTE.desk).setDepth(20);
    this.add.rectangle(W / 2, DESK_Y + 1, W, 2, DOOR_PALETTE.deskEdge).setDepth(21);

    // Two panels, one footprint. The tab header sits above them both.
    this.codeCard = new DressCodeCard(this, 6, 268, 168, 86);
    this.clipboard = new ClipboardPanel(this, this.queue.guestList, 6, 268, 168, 86);
    this.buildDeskTabs();

    // ID tray
    panel(this, 180, 256, 96, 98).setDepth(30);
    text(this, 185, 260, DOOR_UI.idTray, 7, DOOR_PALETTE.inkDim).setDepth(31);
    IdCardView.holoSwatch(this, 228, 336).setDepth(31);

    // clicker
    panel(this, 282, 256, 74, 98).setDepth(30);
    text(this, 287, 260, DOOR_UI.clicker, 7, DOOR_PALETTE.inkDim).setDepth(31);
    this.clickerText = this.add
      .text(319, 286, '0', { fontFamily: MONO, fontSize: '20px', color: '#f0e4d0' })
      .setOrigin(0.5)
      .setDepth(31);
    this.buttons.clickIn = new Button(this, {
      x: 286, y: 304, w: 66, h: 22, label: 'CLICK IN', size: 7,
      fill: DOOR_PALETTE.amber, hover: DOOR_PALETTE.amberHot,
      onClick: () => this.click('in'),
    });
    this.buttons.clickIn.setDepth(32);
    this.buttons.clickOut = new Button(this, {
      x: 286, y: 328, w: 66, h: 20, label: 'out', size: 7,
      onClick: () => this.click('out'),
    });
    this.buttons.clickOut.setDepth(32);

    // phone
    panel(this, 362, 256, 74, 98).setDepth(30);
    text(this, 367, 260, DOOR_UI.phone, 7, DOOR_PALETTE.inkDim).setDepth(31);
    this.phone = new PhoneWidget(
      this,
      this.night.bus,
      399,
      302,
      () => this.queue.lookAtPhone(),
      () => this.night.clock.label,
    );

    // verdicts
    this.buttons.rope = new Button(this, {
      x: 442, y: 256, w: 192, h: 24, label: DOOR_UI.rope, size: 9,
      fill: 0x5a2030, hover: 0x8a2038,
      onClick: () => this.callNext(),
    });
    this.buttons.test = new Button(this, {
      x: 442, y: 284, w: 94, h: 22, label: DOOR_UI.test, size: 7,
      onClick: () => this.runSobriety(),
    });
    this.buttons.pat = new Button(this, {
      x: 540, y: 284, w: 94, h: 22, label: DOOR_UI.patDown, size: 7,
      onClick: () => this.runPatDown(),
    });
    this.buttons.admit = new Button(this, {
      x: 442, y: 310, w: 94, h: 44, label: DOOR_UI.admit, size: 11,
      fill: DOOR_PALETTE.green, hover: DOOR_PALETTE.greenHot,
      onClick: () => this.rule('admit'),
    });
    this.buttons.deny = new Button(this, {
      x: 540, y: 310, w: 94, h: 44, label: DOOR_UI.deny, size: 11,
      fill: DOOR_PALETTE.red, hover: DOOR_PALETTE.redHot,
      onClick: () => this.rule('deny'),
    });
    for (const b of Object.values(this.buttons)) b.setDepth(32);
    this.setVerdictButtons(false);
  }

  /** RULES | LIST switch above the shared left panel. */
  private buildDeskTabs(): void {
    const mk = (tab: ClipboardTab, label: string, x: number, w: number): Button =>
      new Button(this, {
        x, y: 254, w, h: 13, label, size: 6,
        fill: DOOR_PALETTE.deskEdge,
        hover: DOOR_PALETTE.panelEdge,
        onClick: () => this.showTab(tab),
      }).setDepth(52);
    this.tabButtons.rules = mk('rules', "DAZZA'S RULES", 6, 84);
    this.tabButtons.list = mk('list', 'GUEST LIST', 90, 84);
    this.showTab('rules');
  }

  private showTab(tab: ClipboardTab): void {
    this.deskTab = tab;
    this.codeCard.setVisible(tab === 'rules');
    this.clipboard.setVisible(tab === 'list');
    // NOT setEnabled: disabling the current tab greys it out, which reads as
    // "this one is off" — exactly backwards. Both stay live; the selected one is
    // simply the bright one, sitting flush with the panel it opens.
    for (const key of ['rules', 'list'] as const) {
      const b = this.tabButtons[key];
      if (!b) continue;
      const active = key === tab;
      b.rect.setFillStyle(active ? DOOR_PALETTE.panelEdge : 0x1b1720);
      b.label.setColor(active ? DOOR_PALETTE.inkHot : DOOR_PALETTE.inkDim);
    }
  }

  private buildHud(): void {
    this.add.rectangle(W / 2, 9, W, 18, 0x05050a, 0.75).setDepth(500);
    this.vibeBar = new MeterBar(this, 6, 10, 88, 'VIBE', DOOR_PALETTE.green);
    this.aggroBar = new MeterBar(this, 104, 10, 88, 'AGGRO', DOOR_PALETTE.red);
    this.hypeText = text(this, 202, 6, 'HYPE x1.0', 7, '#d0b060').setDepth(501);
    this.countText = text(this, 268, 6, '', 7, DOOR_PALETTE.inkDim).setDepth(501);
    this.heatText = text(this, 380, 6, '', 7, '#ff8080').setDepth(501);
    this.clockText = this.add
      .text(W - 6, 5, '9:00 PM', { fontFamily: MONO, fontSize: '10px', color: '#9fe8a0' })
      .setOrigin(1, 0)
      .setDepth(501);
    this.add
      .text(W - 6, 16, formatDate(this.night.nightDate), {
        fontFamily: MONO,
        fontSize: '7px',
        color: DOOR_PALETTE.inkDim,
      })
      .setOrigin(1, 0)
      .setDepth(501);

    this.toast = this.add
      .text(UP_X, 178, '', { fontFamily: MONO, fontSize: '8px', color: '#f0e4d0', align: 'center' })
      .setOrigin(0.5)
      .setDepth(600)
      .setAlpha(0);

    this.tutorial = this.add
      .text(240, 24, DOOR_TUTORIAL.join('\n'), {
        fontFamily: MONO,
        fontSize: '8px',
        color: '#6a7a8a',
        align: 'left',
        lineSpacing: 3,
      })
      .setDepth(501);
  }

  // ---------- interaction ----------

  private callNext(): void {
    if (this.busy || this.queue.patronUp) return;
    const p = this.queue.callNext();
    if (!p) {
      this.showToast(DOOR_UI.ropeEmpty);
      return;
    }
    this.testedThisPatron = false;
    this.pattedThisPatron = false;
    this.night.sfx?.play('ropeUnhook');
    this.up.show(p, this.queue.encounterFor(p));
    this.showTray(p);
    this.setVerdictButtons(true);
    // Only a claim puts a name on the clipboard. Someone who says nothing is
    // not on the list and is not pretending to be.
    this.clipboard.check(p.flags.claimsGuestList ? p.idCard.name : null);
    if (p.flags.claimsGuestList) this.flashTab('list');

    this.tweens.add({
      targets: this.ropeProp.getByName('rope') as Phaser.GameObjects.Rectangle,
      scaleX: { from: 1, to: 0.2 },
      duration: 160,
      yoyo: true,
    });
  }

  /** Nudge the player toward a tab that just became relevant, without switching it. */
  private flashTab(tab: ClipboardTab): void {
    const b = this.tabButtons[tab];
    if (!b || this.deskTab === tab) return;
    this.tweens.add({ targets: b.label, alpha: { from: 1, to: 0.25 }, duration: 320, yoyo: true, repeat: 3 });
  }

  private showTray(p: Patron): void {
    this.trayCard?.destroy();
    const c = this.idCard.thumbnail(p, 228, 300);
    c.setDepth(31);
    c.setInteractive(new Phaser.Geom.Rectangle(-39, -23, 78, 46), Phaser.Geom.Rectangle.Contains);
    c.on('pointerdown', () => this.idCard.open(p));
    this.tweens.add({ targets: c, angle: { from: -6, to: 0 }, y: { from: 314, to: 300 }, duration: 220 });
    this.trayCard = c;
  }

  private click(direction: 'in' | 'out'): void {
    this.night.sfx?.play('clickerClunk');
    this.night.bus.emit('door:clicker', { direction });
    this.refreshCounters();
    this.tweens.add({ targets: this.clickerText, scale: { from: 1.35, to: 1 }, duration: 120 });
  }

  private runSobriety(): void {
    const p = this.queue.patronUp;
    if (!p || this.busy || this.sobriety.isOpen) return;
    if (this.testedThisPatron) {
      this.showToast('you already tested them');
      return;
    }
    this.testedThisPatron = true;
    this.applyOutcome(p, 'sobrietyTest', judge(p, 'sobrietyTest', this.judgeCtx()));
    this.sobriety.open(p, this.night.rng.stream('sobriety'), ({ ruling, performance }) => {
      // Failing someone who objectively passed is allowed and profitable — and
      // the queue can tell (design §3.1: "You may fail them even if they pass").
      if (ruling === 'fail' && performance.objectivelySober) {
        this.night.bus.emit('meters:delta', { vibe: 2, aggro: 5 });
        this.showToast('they were fine.\nthe queue noticed');
      } else if (ruling === 'pass' && !performance.objectivelySober) {
        this.showToast('generous.');
      }
    });
  }

  private runPatDown(): void {
    const p = this.queue.patronUp;
    if (!p || this.busy) return;
    if (this.pattedThisPatron) {
      this.showToast('their pockets are already out');
      return;
    }
    this.pattedThisPatron = true;
    this.applyOutcome(p, 'patDown', judge(p, 'patDown', this.judgeCtx()));

    const ids = p.flags.contraband ?? [];
    if (ids.length === 0) {
      this.showToast(PATDOWN_LINES.clean);
      return;
    }
    const names = ids.map((id) => CONTRABAND.find((c) => c.id === id)?.name ?? id);
    this.night.bus.emit('meters:delta', { vibe: 1 });
    this.showToast(PATDOWN_LINES.found(names.join(' + ')));
  }

  private rule(verdict: Extract<Verdict, 'admit' | 'deny'>): void {
    const p = this.queue.patronUp;
    if (!p || this.busy) return;
    this.busy = true;
    this.setVerdictButtons(false);
    this.idCard.close();
    this.trayCard?.destroy();
    this.trayCard = null;

    // STATE SETTLES NOW, ANIMATION IS JUST ANIMATION.
    //
    // This used to run inside the stamp's onImpact callback, which meant a tween
    // that failed to fire left the patron still standing at a rope the player
    // could rule on again — one patron was scored eleven times in a test night.
    // Anything that touches meters, capacity or the queue happens synchronously
    // here; the tween callbacks below may only move pixels.
    const outcome = judge(p, verdict, this.judgeCtx());
    if (verdict === 'admit') {
      // The clicker does NOT move unless the player moves it. The gap between
      // clickerShown and the truth is the mechanic (CONTRACTS.md §3).
      this.night.state.capacity.inside++;
    }
    this.applyOutcome(p, verdict, outcome);
    this.applyEncounter(p, verdict);
    this.queue.resolveUp(verdict === 'deny');

    // Highlight AFTER the call, never before. Lighting up the broken rules the
    // moment a patron steps up hands the player the answer and reduces all
    // eight dress-code rules to reading a red line off the desk — it deletes
    // the inspection loop the whole scene exists for. As post-verdict feedback
    // it teaches instead: here is what you just missed.
    const broken = violations(p, this.night.state.clockMin).map((r) => r.id);
    this.codeCard.highlight(broken);
    this.time.delayedCall(2200, () => this.codeCard.highlight([]));

    // `busy` gates input for the length of the animation. It is still released
    // from a callback, so it still gets a watchdog — but now the worst case is a
    // couple of dead seconds, not a corrupted night. The token stops ruling N's
    // watchdog from unlocking the buttons partway through ruling N+1.
    const token = ++this.rulingToken;
    const release = (): void => {
      if (token === this.rulingToken) this.busy = false;
    };
    this.time.delayedCall(2600, release);

    if (verdict === 'deny') {
      // LANE-JUICE's stamp (screen shake, ink ring, sfx). The decal is ours to
      // destroy — the denied patron takes their mark with them into the night.
      let handle: StampHandle | null = null;
      const sfx = this.night.sfx;
      handle = stamp(this, {
        x: UP_X,
        y: UP_FOOT_Y - 70,
        text: 'NOT TONIGHT',
        depth: 320,
        seed: p.dollSeed,
        ...(sfx
          ? { sfx: { stampSlam: () => sfx.play('stampSlam'), stampInk: () => sfx.play('stampInk') } }
          : {}),
        onSlam: () => {
          this.up.walkOffDenied();
          this.showToast(this.pick(DENY_REACTIONS, p));
        },
        onDone: () => {
          release();
          this.time.delayedCall(900, () => handle?.decal.destroy());
        },
      });
      return;
    }

    this.showToast(this.pick(ADMIT_REACTIONS, p));
    this.up.walkInAdmitted(DOOR_X, () => {
      release();
      this.refreshCounters();
    });
  }

  /**
   * A scripted encounter's own consequence, on top of the ordinary judge()
   * score. Deliberately silent about whether the call was decent: §4.3 says the
   * cost is real and the verdict is absent, so there is no chime here and no
   * tally anywhere.
   */
  private applyEncounter(p: Patron, verdict: 'admit' | 'deny'): void {
    const id = this.queue.encounterFor(p);
    const enc = id ? encounterById(id) : undefined;
    if (!enc) return;
    const out = verdict === 'admit' ? enc.onAdmit : enc.onDeny;

    const delta: { vibe?: number; aggro?: number } = {};
    if (out.vibeDelta) delta.vibe = out.vibeDelta;
    if (out.aggroDelta) delta.aggro = out.aggroDelta;
    if (Object.keys(delta).length > 0) this.night.bus.emit('meters:delta', delta);

    if (out.heatStrike && shouldRecordStrike(this.night.state, out.heatStrike)) {
      // Fresh object per firing: the encounter tables are module singletons, so
      // pushing the literal would alias one strike across the whole run.
      this.night.bus.emit('heat:strike', { ...out.heatStrike });
    }
    if (out.dazzaText) this.night.bus.emit('dazza:text', { text: out.dazzaText });
    if (out.note) this.time.delayedCall(900, () => this.showToast(out.note!));

    this.night.bus.emit('incident:log', {
      clockMin: this.night.state.clockMin,
      kind: 'encounter',
      patronId: p.id,
      detail: `${id}: ${verdict === 'admit' ? 'let in' : 'turned away'}`,
    });
  }

  private judgeCtx(): Parameters<typeof judge>[2] {
    return {
      clockMin: this.night.state.clockMin,
      nightDate: this.night.nightDate,
      lapRoll: this.night.rng.stream('dazzaLaps').next(),
      insideCount: this.night.state.capacity.inside,
      licensed: this.night.state.capacity.licensed,
    };
  }

  /** The one path from a DoorOutcome to the world. Nothing else touches meters. */
  private applyOutcome(patron: Patron, verdict: Verdict, outcome: DoorOutcome): void {
    const delta: { vibe?: number; aggro?: number; hype?: number } = {};
    if (outcome.vibeDelta) delta.vibe = outcome.vibeDelta;
    if (outcome.aggroDelta) delta.aggro = outcome.aggroDelta;
    if (outcome.hypeDelta) delta.hype = outcome.hypeDelta;
    if (Object.keys(delta).length > 0) this.night.bus.emit('meters:delta', delta);

    if (
      outcome.heatStrike &&
      !outcome.heatStrike.deferred &&
      shouldRecordStrike(this.night.state, outcome.heatStrike)
    ) {
      this.night.bus.emit('heat:strike', outcome.heatStrike);
    }
    if (outcome.dazzaText) this.night.bus.emit('dazza:text', { text: outcome.dazzaText });
    if (outcome.deferred?.length) this.night.scheduleDeferred(outcome.deferred);
    if (outcome.note) this.showToast(outcome.note);

    this.night.bus.emit('incident:log', {
      clockMin: this.night.state.clockMin,
      kind: verdict,
      patronId: patron.id,
      detail: `${patron.archetype} · id ${idVerdict(patron.idCard, this.night.nightDate).claimedAge}`,
    });
    this.night.bus.emit('door:verdict', { patron, verdict, outcome });
    this.refreshCounters();
  }

  private pick(pool: readonly string[], p: Patron): string {
    return pool[p.dollSeed % pool.length] ?? pool[0]!;
  }

  private showToast(msg: string): void {
    this.toast.setText(msg).setAlpha(1).setY(178);
    this.tweens.killTweensOf(this.toast);
    this.tweens.add({ targets: this.toast, alpha: 0, y: 168, delay: 1900, duration: 700 });
  }

  private setVerdictButtons(on: boolean): void {
    this.buttons.admit?.setEnabled(on);
    this.buttons.deny?.setEnabled(on);
    this.buttons.test?.setEnabled(on);
    this.buttons.pat?.setEnabled(on);
  }

  /**
   * One Kayden decision. Synchronous — no animation, no busy flag, and the
   * patron never appears in the up-view, so returning to the door always finds
   * a clean rope. Kayden never tests, never pats down, and admits four in five.
   */
  private kaydenRule(): void {
    if (this.queue.patronUp) return; // player left mid-ruling; don't double-serve
    const p = this.queue.callNext();
    if (!p) return;
    const kayden = this.night.rng.stream('kayden');
    const verdict: Extract<Verdict, 'admit' | 'deny'> = kayden.chance(0.8) ? 'admit' : 'deny';
    const outcome = judge(p, verdict, this.judgeCtx());
    if (verdict === 'admit') this.night.state.capacity.inside++;
    // applyOutcome emits door:verdict (and the verdict incident) — same path as
    // a player ruling, so the floor is fed exactly once per admission.
    this.applyOutcome(p, verdict, outcome);
    this.applyEncounter(p, verdict);
    this.queue.resolveUp(verdict === 'deny');
    this.night.bus.emit('incident:log', {
      clockMin: this.night.state.clockMin,
      kind: 'kayden',
      patronId: p.id,
      detail: `Kayden ${verdict === 'admit' ? 'waved through' : 'knocked back'} ${p.archetype}`,
    });
  }

  /**
   * Rules cap at ACTIVE_RULE_CAP (Dazza's attention span). When a new one lands,
   * grey out whatever fell off the card and let Dazza announce the retraction —
   * judge() and the card must agree about what is live, or the card lies.
   */
  private retireStaleRules(): void {
    const live = new Set(activeRules(this.night.state.clockMin).map((r) => r.id));
    for (const r of announcedRules(this.night.state.clockMin)) {
      if (live.has(r.id)) continue;
      // Always re-sync the card: Dazza's texts can LAG the clock (the send
      // cooldown queues them), so a rule can be rescinded before its card row
      // has even landed. retire() is an idempotent no-op until the row exists,
      // and every later text re-runs this sync — the card converges.
      this.codeCard.retire(r.id);
      if (this.retiredRules.has(r.id)) continue; // toast once per rule, though
      this.retiredRules.add(r.id);
      const line = RULE_RETRACTIONS[this.retiredRules.size % RULE_RETRACTIONS.length]!;
      // A retraction is a TEXT — it lands on the phone with a buzz like every
      // other pronouncement, and lives in the thread as evidence.
      this.night.bus.emit('dazza:text', { text: line.replace('{rule}', r.short).replace('dazza: ', '') });
    }
  }

  private refreshCounters(): void {
    const cap = this.night.state.capacity;
    this.clickerText.setText(String(cap.clickerShown));
    this.clickerText.setColor(cap.clickerShown > cap.licensed ? '#ff8080' : '#f0e4d0');
    this.countText.setText(`INSIDE ${cap.inside}/${cap.licensed}`);
    this.countText.setColor(cap.inside > cap.licensed ? '#ff8080' : DOOR_PALETTE.inkDim);
    const strikes = this.night.state.heatStrikes.length;
    this.heatText.setText(strikes > 0 ? `HEAT ${'!'.repeat(Math.min(3, strikes))}` : '');
  }

  // ---------- frame ----------

  override update(_time: number, deltaMs: number): void {
    this.elapsedMs += deltaMs;
    this.queue.update(deltaMs);
    this.up.update(deltaMs);
    this.codeCard.update(this.elapsedMs);
    this.night.reportQueue(this.queue.queueLength);

    // Player is on the floor: Trainee Kayden works the rope. He is quick,
    // confident, and wrong a lot — his admits go through the same judge(), so
    // his mistakes cost the same vibe/heat yours do. (His stamp accuracy is
    // NightScene's problem; his verdicts are made here on the real queue.)
    if (!this.present) {
      this.kaydenAccMs += deltaMs;
      while (this.kaydenAccMs >= KAYDEN_RULE_MS) {
        this.kaydenAccMs -= KAYDEN_RULE_MS;
        this.kaydenRule();
      }
    }

    this.clockText.setText(this.night.clock.label);
    if (this.tutorial.alpha > 0 && this.elapsedMs > TUTORIAL_MS) {
      this.tweens.add({ targets: this.tutorial, alpha: 0, duration: 800 });
    }

    this.syncQueueSprites();

    const rope = this.buttons.rope;
    if (rope) {
      const waiting = this.queue.queueLength;
      const canCall = !this.queue.patronUp && waiting > 0;
      rope.setLabel(canCall ? `${DOOR_UI.ropeWaiting} (${waiting})` : DOOR_UI.rope);
      rope.setEnabled(canCall);
    }
  }

  /** The visible queue is a view of QueueManager, rebuilt only when it changes. */
  private syncQueueSprites(): void {
    const visible = this.queue.snapshot.visible;
    const same =
      visible.length === this.queueSprites.length &&
      visible.every((p, i) => this.queueSprites[i]?.patron.id === p.id);
    if (same) {
      // still animate the drunk sway of whoever is waiting
      for (const s of this.queueSprites) {
        const sway = dollPlan(s.patron, 'queue').swayPx;
        if (sway > 0) s.image.x = s.image.getData('baseX') + Math.sin(this.elapsedMs / 320) * sway;
      }
      return;
    }

    for (const s of this.queueSprites) {
      s.image.destroy();
      s.link?.destroy();
    }
    this.queueSprites = visible.map((p, i) => {
      const x = 196 - i * 24;
      const y = QUEUE_FOOT_Y - (i % 2) * 3;
      const img = this.add
        .image(x, y, renderDoll(this, p, 'queue'))
        .setOrigin(0.5, 1)
        .setScale(1.5)
        .setDepth(200 - i)
        .setTint(0xc8c8d8);
      img.setData('baseX', x);

      // hand-holding couples are drawn linked, so noHandHolders is a rule you
      // can actually see coming (rules judge data, players judge pixels)
      let link: Phaser.GameObjects.Rectangle | null = null;
      const prev = visible[i - 1];
      if (p.flags.handHolding && prev?.flags.handHolding) {
        link = this.add.rectangle(x + 12, y - 26, 14, 2, 0xe0a0b0).setDepth(210);
      }
      return { patron: p, image: img, link };
    });

    const offscreen = this.queue.snapshot.offscreen;
    this.countText.setText(
      `INSIDE ${this.night.state.capacity.inside}/${this.night.state.capacity.licensed}` +
        (offscreen > 0 ? `  +${offscreen} round the corner` : ''),
    );
  }

  private teardown(): void {
    for (const off of this.offs) off();
    this.offs = [];
    this.phone?.destroy();
    this.codeCard?.destroy();
    this.sobriety?.close();
    this.idCard?.close();
    this.up?.clear();
  }
}
