import Phaser from 'phaser';
import type { EventBus } from '../core/EventBus';
import { UI, font, panel } from './style';

// The player's only line to management. Dazza is a man who is frightened of the
// owner, so every buzz is a small emergency. The phone is deliberately fiddly:
// looking at it costs you attention at the door, which is exactly the point —
// see `door:phoneTheatre`.

const W = 640;
const H = 360;

const PHONE_W = 22;
const PHONE_H = 38;

const PANEL_W = 150;
const PANEL_H = 180;
const PAD = 6;
const BUBBLE_MAX_W = 104;
const BUBBLE_PAD = 4;
const BUBBLE_GAP = 4;
/** Vertical room the clock stamp adds under a bubble. */
const STAMP_H = 8;
const HEADER_H = 13;
const DOTS_H = 12;

/**
 * Bubbles are built only for the visible window plus this many either side.
 * Scrolling is then independent of thread length; opening the panel still costs
 * one arithmetic pass over the (bounded) history to lay out offsets. Overscan
 * hides the pop at the edges.
 */
const OVERSCAN = 2;
/** Even culled, an unbounded array is a leak. Oldest lines fall off the top. */
const MAX_MESSAGES = 240;

/** Panel interior the thread scrolls inside, in threadRoot-local coordinates. */
const INTERIOR_TOP = -PANEL_H / 2 + HEADER_H + 3;
const INTERIOR_BOTTOM = PANEL_H / 2 - PAD;
const CUE_H = 5;
const NEW_BELOW_H = 10;

const WHEEL_SCALE = 0.4;
const WHEEL_CLAMP = 100;
const DRAG_SLOP = 3;
/**
 * How close to the bottom still counts as "reading the latest". Must exceed
 * DOTS_H, or reserving room for the typing dots would itself unstick the view.
 */
const STICK_SLOP = DOTS_H + 4;

const TOAST_W = 112;
const TOAST_H = 26;
const TOAST_MS = 3500;
const TOAST_CHARS = 40;

const BUZZ_MS = 400;
const BUZZ_AMP = 2.4;
const BUZZ_CYCLES = 9;
const TYPING_MS = 700;
const DOTS_STEP_MS = 220;

interface ThreadMessage {
  from: 'dazza' | 'you';
  text: string;
  carriedRule: boolean;
  /** clock-min the text landed, if a clock:tick has been seen yet */
  clockMin?: number;
  /** Bubble box, measured once on arrival by the off-screen ruler. Layout needs
   *  every message's size but must not build a GameObject to learn it. */
  bw?: number;
  bh?: number;
}

export interface PhoneSfx {
  phoneBuzz(): void;
}

export interface PhoneOptions {
  x: number;
  y: number;
  sfx?: PhoneSfx;
  depth?: number;
}

/** Minutes since 21:00 -> venue-clock label. */
function clockLabel(clockMin: number): string {
  const total = (21 * 60 + Math.max(0, Math.floor(clockMin))) % (24 * 60);
  const h = Math.floor(total / 60);
  const m = total % 60;
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}

export class Phone {
  private readonly scene: Phaser.Scene;
  private readonly bus: EventBus;
  private readonly sfx: PhoneSfx | undefined;
  private readonly depth: number;
  private readonly homeX: number;
  private readonly homeY: number;
  /** -1 when the phone sits on the right of the canvas: overlays open inward. */
  private readonly side: number;

  private readonly root: Phaser.GameObjects.Container;
  private readonly screen: Phaser.GameObjects.Rectangle;
  private readonly unreadDot: Phaser.GameObjects.Arc;

  private readonly threadRoot: Phaser.GameObjects.Container;
  private readonly threadBody: Phaser.GameObjects.Container;
  private readonly dots: Phaser.GameObjects.Text;

  private readonly maskShape: Phaser.GameObjects.Graphics;
  private readonly bodyMask: Phaser.Display.Masks.GeometryMask;
  private readonly scrollCue: Phaser.GameObjects.Container;
  private readonly newBelow: Phaser.GameObjects.Container;
  /** Off-display-list Text used purely to size bubbles. One texture, forever. */
  private readonly ruler: Phaser.GameObjects.Text;

  /** Distance from the top of the content to the top of the interior. */
  private scrollY = 0;
  private contentH = 0;
  private maskH = 0;
  private dragging = false;
  private dragLastY = 0;
  private dragMoved = false;

  /** Content-space Y of each message top, parallel to `messages`. */
  private readonly offsets: number[] = [];
  /** Message index range currently realised as GameObjects; -1 = nothing built. */
  private builtFrom = -1;
  private builtTo = -1;
  /** Whether the last layout kept the view pinned to the newest message. */
  private lastStick = true;
  private unreadBelow = false;

  private toast: Phaser.GameObjects.Container | undefined;
  private toastMs = 0;
  private toastTween: Phaser.Tweens.Tween | undefined;

  private readonly offs: Array<() => void> = [];
  private readonly messages: ThreadMessage[] = [];
  private pending: ThreadMessage | undefined;
  private pendingMs = 0;
  private dotsMs = 0;

  private open_ = false;
  private openMs = 0;
  private clockMin: number | undefined;

  private readonly buzzPhase = { v: 1 };
  private buzzTween: Phaser.Tweens.Tween | undefined;
  private slideTween: Phaser.Tweens.Tween | undefined;
  private readonly glowTween: Phaser.Tweens.Tween;

  constructor(scene: Phaser.Scene, bus: EventBus, opts: PhoneOptions) {
    this.scene = scene;
    this.bus = bus;
    this.sfx = opts.sfx;
    this.depth = opts.depth ?? 900;
    this.homeX = opts.x;
    this.homeY = opts.y;
    this.side = opts.x > W / 2 ? -1 : 1;

    this.root = scene.add.container(this.homeX, this.homeY).setDepth(this.depth);
    const body = scene.add.rectangle(0, 0, PHONE_W, PHONE_H, UI.grime).setStrokeStyle(1, UI.ink);
    this.screen = scene.add.rectangle(0, -1, PHONE_W - 6, PHONE_H - 12, UI.ink);
    const glow = scene.add.rectangle(0, -1, PHONE_W - 8, PHONE_H - 14, UI.neonGreen, 0.09);
    const speaker = scene.add.rectangle(0, -PHONE_H / 2 + 4, 7, 1, UI.panelLip);
    const homeKey = scene.add.circle(0, PHONE_H / 2 - 4, 2, UI.panelLip);
    this.unreadDot = scene.add.circle(PHONE_W / 2 - 3, -PHONE_H / 2 + 3, 2.5, UI.neonPink).setVisible(false);
    this.root.add([body, this.screen, glow, speaker, homeKey, this.unreadDot]);

    // Standby breathing — a dark screen that is completely still reads as a prop.
    this.glowTween = scene.tweens.add({ targets: glow, alpha: 0.2, duration: 1800, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });

    body.setInteractive({ useHandCursor: true });
    // Arm on our own down (and only if the pointer never turned into a scroll
    // drag) so a release that started on the thread can't phantom-toggle.
    let armed = false;
    body.on('pointerdown', () => {
      armed = true;
      this.dragMoved = false;
    });
    body.on('pointerout', () => {
      armed = false;
    });
    body.on('pointerup', () => {
      const fire = armed && !this.dragMoved;
      armed = false;
      if (fire) this.toggle();
    });

    const anchor = this.threadAnchor();
    this.threadRoot = scene.add.container(anchor.x, anchor.y).setDepth(this.depth + 2).setVisible(false);
    const chrome = panel(scene, 0, 0, PANEL_W, PANEL_H);
    const title = scene.add.text(-PANEL_W / 2 + PAD, -PANEL_H / 2 + 4, 'DAZZA (MANAGEMENT)', font(7, UI.paper));
    title.setAlpha(0.55);
    const rule = scene.add.rectangle(0, -PANEL_H / 2 + HEADER_H, PANEL_W - PAD * 2, 1, UI.panelLip, 0.7);
    this.threadBody = scene.add.container(0, 0);
    this.dots = scene.add
      .text(-PANEL_W / 2 + PAD + 4, PANEL_H / 2 - PAD - 9, '', font(8, UI.paper))
      .setAlpha(0.6)
      .setVisible(false);
    // The mask graphic lives off the display list and tracks threadRoot in world
    // space — a geometry mask is not transformed by its parent container.
    this.maskShape = scene.make.graphics({}, false);
    this.bodyMask = this.maskShape.createGeometryMask();
    this.threadBody.setMask(this.bodyMask);

    this.ruler = scene.make.text(
      { style: { ...font(7, UI.ink), wordWrap: { width: BUBBLE_MAX_W - BUBBLE_PAD * 2, useAdvancedWrap: true } } },
      false,
    );

    // Sits outside the mask so it draws over the clipped edge: without it there
    // is nothing to tell the player history exists at all.
    this.scrollCue = scene.add.container(0, 0).setVisible(false);
    this.scrollCue.add([
      scene.add.rectangle(0, INTERIOR_TOP + CUE_H / 2, PANEL_W - 2, CUE_H, UI.panel, 0.72),
      scene.add.triangle(PANEL_W / 2 - PAD - 2, INTERIOR_TOP + 3, 0, 4, 3, 0, 6, 4, UI.paper).setAlpha(0.5),
    ]);

    // fillAlpha 0 (not alpha 0 — that clears the render flag and kills hit testing).
    const dragPad = scene.add
      .rectangle(0, (INTERIOR_TOP + INTERIOR_BOTTOM) / 2, PANEL_W - 2, INTERIOR_BOTTOM - INTERIOR_TOP, 0xffffff, 0)
      .setInteractive();
    dragPad.on('pointerdown', (p: Phaser.Input.Pointer) => {
      if (!this.open_) return;
      this.dragging = true;
      this.dragLastY = p.worldY;
      this.dragMoved = false;
    });

    // Below the fold needs its own signal: the scroll cue only ever says "history
    // above", and a rule-bearing text landing off-screen is a sacking offence.
    // Added after dragPad so it wins the topOnly hit test and the bar stays
    // clickable instead of starting a scroll drag.
    this.newBelow = scene.add.container(0, 0).setVisible(false);
    const nbBg = scene.add.rectangle(0, 0, PANEL_W - 2, NEW_BELOW_H, UI.neonPink, 0.92).setStrokeStyle(1, UI.ink, 0.5);
    const nbLabel = scene.add.text(0, 0, 'NEW BELOW v', font(6, UI.paper)).setOrigin(0.5);
    this.newBelow.add([nbBg, nbLabel]);
    nbBg.setInteractive({ useHandCursor: true });
    let nbArmed = false;
    nbBg.on('pointerdown', () => {
      nbArmed = true;
    });
    nbBg.on('pointerout', () => {
      nbArmed = false;
    });
    nbBg.on('pointerup', () => {
      const fire = nbArmed;
      nbArmed = false;
      if (fire) this.scrollToBottom();
    });

    this.threadRoot.add([chrome, title, rule, this.threadBody, this.scrollCue, this.dots, dragPad, this.newBelow]);

    const onWheel = (p: Phaser.Input.Pointer, _over: unknown[], _dx: number, dy: number): void => {
      if (!this.open_ || !this.pointerOverPanel(p)) return;
      this.scrollBy(Phaser.Math.Clamp(dy, -WHEEL_CLAMP, WHEEL_CLAMP) * WHEEL_SCALE);
    };
    // Tracked on the plugin rather than the pad so a drag survives the pointer
    // leaving the panel mid-flick.
    const onMove = (p: Phaser.Input.Pointer): void => {
      if (!this.dragging) return;
      const dy = p.worldY - this.dragLastY;
      this.dragLastY = p.worldY;
      if (Math.abs(dy) >= DRAG_SLOP) this.dragMoved = true;
      this.scrollBy(-dy);
    };
    const endDrag = (): void => {
      this.dragging = false;
    };
    scene.input.on('wheel', onWheel);
    scene.input.on('pointermove', onMove);
    scene.input.on('pointerup', endDrag);
    scene.input.on('pointerupoutside', endDrag);
    this.offs.push(() => {
      scene.input.off('wheel', onWheel);
      scene.input.off('pointermove', onMove);
      scene.input.off('pointerup', endDrag);
      scene.input.off('pointerupoutside', endDrag);
    });

    this.offs.push(bus.on('dazza:text', ({ text, rule: dressRule }) => this.receive(text, dressRule !== undefined)));
    this.offs.push(bus.on('clock:tick', ({ clockMin }) => { this.clockMin = clockMin; }));
  }

  get isOpen(): boolean {
    return this.open_;
  }

  open(): void {
    if (this.open_) return;
    this.open_ = true;
    this.openMs = 0;
    this.clearUnreadBelow();
    this.screen.setFillStyle(UI.panel);
    this.dismissToast();

    const anchor = this.threadAnchor();
    this.threadRoot.setVisible(true).setPosition(anchor.x, anchor.y + 14).setAlpha(0);
    this.slideTween?.remove();
    this.slideTween = this.scene.tweens.add({
      targets: this.threadRoot,
      y: anchor.y,
      alpha: 1,
      duration: 160,
      ease: 'Back.easeOut',
    });
    this.layoutThread(true);
  }

  close(): void {
    if (!this.open_) return;
    this.open_ = false;
    this.screen.setFillStyle(UI.ink);
    this.flushPending();

    const anchor = this.threadAnchor();
    this.slideTween?.remove();
    this.slideTween = this.scene.tweens.add({
      targets: this.threadRoot,
      y: anchor.y + 14,
      alpha: 0,
      duration: 130,
      ease: 'Quad.easeIn',
      onComplete: () => this.threadRoot.setVisible(false),
    });

    // Standing at the door reading your phone while a queue waits is the purest
    // expression of power a bouncer has. The door scene pays hype for it.
    this.bus.emit('door:phoneTheatre', { durationMs: Math.round(this.openMs) });
  }

  toggle(): void {
    if (this.open_) this.close();
    else this.open();
  }

  update(deltaMs: number): void {
    if (this.open_) this.openMs += deltaMs;
    // The panel slides on open/close, so the mask has to follow it every frame.
    if (this.threadRoot.visible) this.syncMask();

    if (this.pending) {
      this.pendingMs += deltaMs;
      this.dotsMs += deltaMs;
      const step = Math.floor(this.dotsMs / DOTS_STEP_MS) % 3;
      this.dots.setText('.'.repeat(step + 1));
      if (this.pendingMs >= TYPING_MS) {
        this.pushMessage(this.pending);
        this.pending = undefined;
        this.dots.setVisible(false);
        this.layoutThread();
        this.flagIfLandedOffscreen();
      }
    }

    if (this.toast) {
      this.toastMs += deltaMs;
      if (this.toastMs >= TOAST_MS) this.dismissToast();
    }
  }

  destroy(): void {
    for (const off of this.offs) off();
    this.offs.length = 0;
    this.buzzTween?.remove();
    this.slideTween?.remove();
    // Tweens outlive their targets: destroy() outside a scene shutdown leaves
    // anything still running writing to a dead GameObject every frame.
    this.glowTween.remove();
    this.dismissToast();
    // The mask pair is off the display list; destroying threadRoot won't take it.
    this.threadBody.clearMask();
    this.bodyMask.destroy();
    this.maskShape.destroy();
    this.ruler.destroy();
    this.threadRoot.destroy(true);
    this.root.destroy(true);
  }

  private receive(text: string, carriedRule: boolean): void {
    const msg: ThreadMessage = { from: 'dazza', text, carriedRule, ...(this.clockMin !== undefined ? { clockMin: this.clockMin } : {}) };
    this.buzz();
    this.sfx?.phoneBuzz();

    if (this.open_) {
      // Land any earlier pending line first so nothing gets swallowed by a
      // second buzz arriving mid-typing.
      if (this.pending) this.pushMessage(this.pending);
      this.pending = msg;
      this.pendingMs = 0;
      this.dotsMs = 0;
      this.dots.setText('.').setVisible(true);
      this.layoutThread();
      this.flagIfLandedOffscreen();
      return;
    }

    this.deliverClosed(msg);
  }

  private deliverClosed(msg: ThreadMessage): void {
    this.pushMessage(msg);
    if (msg.carriedRule) this.unreadDot.setVisible(true);
    this.showToast(msg.text);
  }

  /**
   * Append, trimming the oldest lines past the cap. Trimming shifts every
   * remaining message up by the shed height, so scrollY moves with it or the
   * player's place in the thread jumps.
   */
  private pushMessage(msg: ThreadMessage): void {
    this.measure(msg);
    this.messages.push(msg);
    const over = this.messages.length - MAX_MESSAGES;
    if (over <= 0) return;

    let shed = 0;
    for (let i = 0; i < over; i++) {
      const dropped = this.messages[i];
      if (dropped) shed += this.heightOf(dropped) + BUBBLE_GAP;
    }
    this.messages.splice(0, over);
    this.scrollY = Math.max(0, this.scrollY - shed);
    // Indices shifted under the built range; force the next pass to rebuild.
    this.builtFrom = -1;
    this.builtTo = -1;
  }

  /**
   * A message that arrived while the player was reading history is silent and
   * invisible — the sticky-bottom rule is right, but it needs an alarm.
   */
  private flagIfLandedOffscreen(): void {
    if (this.lastStick) return;
    this.unreadBelow = true;
    this.unreadDot.setVisible(true);
    this.newBelow.setVisible(true);
  }

  private clearUnreadBelow(): void {
    this.unreadBelow = false;
    this.unreadDot.setVisible(false);
    this.newBelow.setVisible(false);
  }

  private scrollToBottom(): void {
    this.scrollY = this.maxScroll;
    this.clearUnreadBelow();
    this.applyScroll();
  }

  /**
   * Closing mid-typing has to land the held message now. Left in `pending` it
   * would arrive behind anything received meanwhile — out of order, and with no
   * toast or unread dot to announce it to a player who has looked away.
   */
  private flushPending(): void {
    const msg = this.pending;
    if (!msg) return;
    this.pending = undefined;
    this.pendingMs = 0;
    this.dots.setVisible(false);
    this.deliverClosed(msg);
  }

  /** Vibration driven off a tweened phase so it is frame-rate stable and seedless. */
  private buzz(): void {
    this.buzzTween?.remove();
    this.buzzPhase.v = 0;
    this.buzzTween = this.scene.tweens.add({
      targets: this.buzzPhase,
      v: 1,
      duration: BUZZ_MS,
      onUpdate: () => {
        const t = this.buzzPhase.v;
        const amp = BUZZ_AMP * (1 - t);
        this.root.setPosition(
          this.homeX + Math.sin(t * Math.PI * 2 * BUZZ_CYCLES) * amp,
          this.homeY + Math.cos(t * Math.PI * 2 * (BUZZ_CYCLES + 4)) * amp * 0.55,
        );
      },
      onComplete: () => this.root.setPosition(this.homeX, this.homeY),
    });
  }

  private threadAnchor(): { x: number; y: number } {
    const cx = Phaser.Math.Clamp(this.homeX, PANEL_W / 2 + 4, W - PANEL_W / 2 - 4);
    const cy = Phaser.Math.Clamp(this.homeY - PHONE_H / 2 - 6 - PANEL_H / 2, PANEL_H / 2 + 4, H - PANEL_H / 2 - 4);
    return { x: cx, y: cy };
  }

  private showToast(text: string): void {
    this.dismissToast();
    const preview = text.length > TOAST_CHARS ? `${text.slice(0, TOAST_CHARS).trimEnd()}…` : text;
    const targetX = Phaser.Math.Clamp(
      this.homeX + this.side * (PHONE_W / 2 + 5 + TOAST_W / 2),
      TOAST_W / 2 + 4,
      W - TOAST_W / 2 - 4,
    );
    const y = Phaser.Math.Clamp(this.homeY - 8, TOAST_H / 2 + 4, H - TOAST_H / 2 - 4);

    const c = panel(this.scene, targetX - this.side * 10, y, TOAST_W, TOAST_H).setDepth(this.depth + 1).setAlpha(0);
    const from = this.scene.add.text(-TOAST_W / 2 + 5, -TOAST_H / 2 + 3, 'dazza', font(6, UI.neonPink));
    const body = this.scene.add.text(-TOAST_W / 2 + 5, -TOAST_H / 2 + 11, preview, {
      ...font(7, UI.paper),
      wordWrap: { width: TOAST_W - 10, useAdvancedWrap: true },
    });
    c.add([from, body]);
    this.toast = c;
    this.toastMs = 0;
    this.toastTween = this.scene.tweens.add({ targets: c, x: targetX, alpha: 1, duration: 180, ease: 'Quad.easeOut' });
  }

  private dismissToast(): void {
    this.toastTween?.remove();
    this.toastTween = undefined;
    this.toast?.destroy(true);
    this.toast = undefined;
    this.toastMs = 0;
  }

  /** Visible thread height; the typing dots eat into it while Dazza is typing. */
  private get interiorH(): number {
    return INTERIOR_BOTTOM - INTERIOR_TOP - (this.pending ? DOTS_H : 0);
  }

  private get maxScroll(): number {
    return Math.max(0, this.contentH - this.interiorH);
  }

  private pointerOverPanel(p: Phaser.Input.Pointer): boolean {
    return (
      Math.abs(p.worldX - this.threadRoot.x) <= PANEL_W / 2 &&
      Math.abs(p.worldY - this.threadRoot.y) <= PANEL_H / 2
    );
  }

  private scrollBy(dy: number): void {
    const next = Phaser.Math.Clamp(this.scrollY + dy, 0, this.maxScroll);
    if (next === this.scrollY) return;
    this.scrollY = next;
    this.applyScroll();
  }

  private applyScroll(): void {
    this.rebuildVisible(false);
    // Short threads hang off the bottom of the panel, the way a chat app does.
    const slack = Math.max(0, this.interiorH - this.contentH);
    this.threadBody.setY(INTERIOR_TOP + slack - this.scrollY);
    this.scrollCue.setVisible(this.scrollY > 0.5);
    // Reaching the bottom is the read receipt.
    if (this.maxScroll - this.scrollY <= STICK_SLOP) this.clearUnreadBelow();
    this.newBelow.setY(INTERIOR_TOP + this.interiorH - NEW_BELOW_H / 2).setVisible(this.unreadBelow);
    this.redrawMask();
    this.syncMask();
  }

  private redrawMask(): void {
    const h = this.interiorH;
    if (h === this.maskH) return;
    this.maskH = h;
    this.maskShape.clear().fillStyle(0xffffff).fillRect(-PANEL_W / 2 + 1, INTERIOR_TOP, PANEL_W - 2, h);
  }

  private syncMask(): void {
    this.maskShape.setPosition(this.threadRoot.x, this.threadRoot.y);
  }

  /** Size a message without building it. Measured once, then cached forever. */
  private measure(msg: ThreadMessage): void {
    if (msg.bw !== undefined && msg.bh !== undefined) return;
    this.ruler.setText(msg.text);
    msg.bw = Math.min(BUBBLE_MAX_W, Math.ceil(this.ruler.width) + BUBBLE_PAD * 2);
    msg.bh = Math.ceil(this.ruler.height) + BUBBLE_PAD * 2;
  }

  private heightOf(msg: ThreadMessage): number {
    this.measure(msg);
    return (msg.bh ?? 0) + (msg.clockMin !== undefined ? STAMP_H : 0);
  }

  /** Recompute content-space offsets. Arithmetic only — no GameObjects. */
  private remeasure(): void {
    this.offsets.length = this.messages.length;
    let cursor = 0;
    for (let i = 0; i < this.messages.length; i++) {
      const msg = this.messages[i];
      if (!msg) continue;
      this.offsets[i] = cursor;
      cursor += this.heightOf(msg) + BUBBLE_GAP;
    }
    this.contentH = Math.max(0, cursor - BUBBLE_GAP);
  }

  /** Inclusive message range intersecting the interior, plus overscan. */
  private visibleRange(): { from: number; to: number } {
    const n = this.messages.length;
    if (n === 0) return { from: 0, to: -1 };
    const top = this.scrollY;
    const bottom = top + this.interiorH;

    // Offsets ascend and heights are positive, so "ends above the viewport" is
    // monotonic — binary search it. The linear scan this replaces ran from index 0
    // on every pointermove of a drag, which was the last place thread length still
    // leaked into frame cost.
    let lo = 0;
    let hi = n - 1;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      const msg = this.messages[mid];
      if (msg && (this.offsets[mid] ?? 0) + this.heightOf(msg) <= top) lo = mid + 1;
      else hi = mid;
    }
    const from = lo;

    let to = from;
    while (to < n - 1 && (this.offsets[to + 1] ?? 0) < bottom) to++;

    return { from: Math.max(0, from - OVERSCAN), to: Math.min(n - 1, to + OVERSCAN) };
  }

  /**
   * Realise only the bubbles the player can see. Scrolling a pixel does not
   * rebuild; crossing into a new message does.
   */
  private rebuildVisible(force: boolean): void {
    const { from, to } = this.visibleRange();
    if (!force && from === this.builtFrom && to === this.builtTo) return;
    this.threadBody.removeAll(true);
    this.builtFrom = from;
    this.builtTo = to;
    for (let i = from; i <= to; i++) {
      const msg = this.messages[i];
      if (!msg) continue;
      this.threadBody.add(this.buildBubble(msg).setY(this.offsets[i] ?? 0));
    }
  }

  /**
   * Re-lay the thread top-down, oldest first. A new message only yanks the view
   * to the bottom if the player was already reading there — scrolling back for
   * the dress-code rule and being thrown forward mid-sentence is unforgivable.
   */
  private layoutThread(toBottom = false): void {
    const stick = toBottom || this.maxScroll - this.scrollY <= STICK_SLOP;
    this.lastStick = stick;
    if (!this.open_) {
      this.threadBody.removeAll(true);
      this.builtFrom = -1;
      this.builtTo = -1;
      this.contentH = 0;
      return;
    }

    this.remeasure();
    this.scrollY = stick ? this.maxScroll : Phaser.Math.Clamp(this.scrollY, 0, this.maxScroll);
    this.rebuildVisible(true);
    this.applyScroll();
  }

  /** Container origin is the bubble's top-left; caller only sets Y. */
  private buildBubble(msg: ThreadMessage): Phaser.GameObjects.Container {
    const incoming = msg.from === 'dazza';
    this.measure(msg);
    const w = msg.bw ?? 0;
    const h = msg.bh ?? 0;
    const c = this.scene.add.container(0, 0);

    const text = this.scene.add.text(BUBBLE_PAD, BUBBLE_PAD, msg.text, {
      ...font(7, incoming ? UI.ink : UI.paper),
      wordWrap: { width: BUBBLE_MAX_W - BUBBLE_PAD * 2, useAdvancedWrap: true },
    });
    const bg = this.scene.add
      .rectangle(0, 0, w, h, incoming ? UI.paper : UI.neonPink)
      .setOrigin(0, 0)
      .setStrokeStyle(1, UI.ink, 0.4);
    c.add([bg, text]);

    if (msg.carriedRule) {
      // Rule-bearing texts stay flagged forever — this is the one you get sacked
      // for missing. Mirrored on right-aligned bubbles, which sit flush against
      // the panel edge and would otherwise push the badge outside it.
      const badge = this.scene.add.text(incoming ? w + 3 : -3, 0, 'NEW', font(6, UI.neonPink));
      if (!incoming) badge.setOrigin(1, 0);
      c.add(badge);
    }
    if (msg.clockMin !== undefined) {
      const stamp = this.scene.add.text(0, h + 1, clockLabel(msg.clockMin), font(6, UI.paper));
      stamp.setAlpha(0.4);
      c.add(stamp);
    }

    c.setX(incoming ? -PANEL_W / 2 + PAD : PANEL_W / 2 - PAD - w);
    return c;
  }
}
