import Phaser from 'phaser';
import { EventBus } from '../../core/EventBus';
import { GameClock } from '../../core/GameClock';
import { SeededRNG } from '../../core/SeededRNG';
import { StubBeatClock } from '../../core/StubBeatClock';
import { Meters, freshNightState } from '../../core/meters';
import { drunkStage } from '../../rules/drunk';
import { generatePatron, _resetPatronSerial } from '../../patrons/generator';
import type { DrunkStage, NightState } from '../../data/types';
import { HudStub } from '../shared/HudStub';

import { VENUE } from './venueMap';
import { NORMAL_CONE, UV_CONE, inCone, type ConeSpec } from './cone';
import { CrowdSim, type Agent } from './crowdSim';
import { FightDirector } from './fightDirector';
import { freshPlayer, stepPlayer, type PlayerInput, type PlayerState } from './player';
import { FloorView } from './FloorView';
import { maggotOverdue } from './escalation';
import { layoutPockets, type Pocket } from './patDown';
import { CutOffOverlay } from './overlays/CutOffOverlay';
import { StallOverlay } from './overlays/StallOverlay';
import { PatDownOverlay } from './overlays/PatDownOverlay';
import { HELP_TEXT, NO_STAMP_RADIO } from '../../data/strings/floor';
import { MeterHud } from '../../ui/MeterHud';
import type { NightContext } from '../door/NightScene';
import type { Patron } from '../../data/types';

const VIEW_H = 360;
const INTERACT_RANGE = 46;
const MAX_CROWD = 44;
const SPAWN_EVERY_MS = 420;
/** Admitted at the rope → appears on the floor after the walk in. */
const WALK_IN_MS: [number, number] = [6000, 14000];

const STAGE_RANK: Readonly<Record<DrunkStage, number>> = {
  sober: 0, tipsy: 1, loose: 2, messy: 3, maggot: 4,
};

/** What pressing E on the current target would do. */
type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit';

interface InteractTarget {
  kind: InteractKind;
  agent?: Agent;
  stallId?: string;
  prompt: string;
}

/**
 * The venue floor. Two modes, one scene class:
 * - 'FloorDemo' (standalone): self-populates from the patron generator, owns its
 *   own bus/clock/meters. The Phase-1 demo, unchanged.
 * - 'Floor' (night mode): launched by NightScene with a NightContext. Shares the
 *   night's bus/clock/state, is populated by ACTUAL door admissions, runs its
 *   sim all night (even while the player works the rope), and hands the player
 *   back to the door via the exit.
 */
export class FloorDemoScene extends Phaser.Scene {
  private bus!: EventBus;
  private rng!: SeededRNG;
  private clock!: GameClock;
  private beat: StubBeatClock | null = null;
  private meters: Meters | null = null;
  private night!: NightState;
  private hud!: HudStub | MeterHud;
  private nightCtx: NightContext | null = null;
  /** In night mode: is the player physically on the floor right now? */
  private present = true;
  private elapsedMs = 0;
  private pendingSpawns: Array<{ patron: Patron; atMs: number }> = [];
  private ctxOffs: Array<() => void> = [];
  private view!: FloorView;
  private crowd!: CrowdSim;
  private fights!: FightDirector;
  private player!: PlayerState;

  private cutOff!: CutOffOverlay;
  private stall!: StallOverlay;
  private patDown!: PatDownOverlay;

  private keys!: Record<string, Phaser.Input.Keyboard.Key>;
  private uvMode = false;
  private seed = 4207;
  private spawnAccMs = 0;
  private escorting: Agent | null = null;
  private target: InteractTarget | null = null;
  /** patronId -> clock minute we first saw them as a maggot (deferred heat risk). */
  private maggotSeen = new Map<string, number>();
  private spotted = new Set<string>();
  private liveFight: { id: string; cause: string } | null = null;
  /** Worst drunk stage we've already had words about, per patron. */
  private cutOffDealt = new Map<string, DrunkStage>();

  private promptText!: Phaser.GameObjects.Text;
  private statusText!: Phaser.GameObjects.Text;
  private radioText!: Phaser.GameObjects.Text;
  private radioMs = 0;
  private calmAccMs = 0;

  constructor(key: string = 'FloorDemo') {
    super(key);
  }

  create(data?: { night?: NightContext }): void {
    this.nightCtx = data?.night ?? null;
    // Wired explicitly: Phaser does not invoke a `shutdown` METHOD on a Scene
    // subclass; without this, listeners and sprites leak on every scene stop.
    this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.teardown());
    this.cameras.main.setBounds(0, 0, VENUE.width * 16, VENUE.height * 16);
    this.cameras.main.setBackgroundColor('#04040a');

    // Generated prop art loads FIRST (public/props + manifest — docs/SPACES.md
    // §4) so FloorView can prefer 'gen:prop:*' textures at build time. A missing
    // or empty manifest just builds with placeholders; nothing waits on art.
    this.load.json('propManifest', 'props/manifest.json');
    this.load.once(Phaser.Loader.Events.COMPLETE, () => this.loadGenProps());
    this.load.start();
  }

  private loadGenProps(): void {
    const manifest = this.cache.json.get('propManifest') as { props?: string[] } | undefined;
    const kinds = manifest?.props ?? [];
    const missing = kinds.filter((k) => !this.textures.exists(`gen:prop:${k}`));
    if (missing.length === 0) {
      this.buildWorld();
      return;
    }
    for (const k of missing) this.load.image(`gen:prop:${k}`, `props/${k}.png`);
    this.load.once(Phaser.Loader.Events.COMPLETE, () => this.buildWorld());
    this.load.start();
  }

  private buildWorld(): void {
    this.view = new FloorView(this, VENUE);
    this.cutOff = new CutOffOverlay(this);
    this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null);

    this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0');
    this.statusText = this.fixedText(4, VIEW_H - 12, '#556');
    this.radioText = this.fixedText(4, 26, '#d8a020');
    this.fixedText(4, 14, '#445').setText(HELP_TEXT);

    this.keys = this.input.keyboard!.addKeys(
      'W,A,S,D,UP,DOWN,LEFT,RIGHT,E,TAB,SPACE,ESC,R',
    ) as Record<string, Phaser.Input.Keyboard.Key>;

    // Overlays own no keyboard listeners of their own — the scene routes keys so
    // exactly one thing consumes input at a time.
    this.input.keyboard!.on('keydown', (ev: KeyboardEvent) => this.onKey(ev));

    if (this.nightCtx) this.bindNight(this.nightCtx);
    else this.resetRun(this.seed);
  }

  /** Night mode: adopt the night's world instead of building our own. */
  private bindNight(ctx: NightContext): void {
    this.seed = ctx.seed;
    this.bus = ctx.bus;
    this.rng = ctx.rng;
    this.clock = ctx.clock;
    this.night = ctx.state;
    this.beat = null; // beat:tick arrives on the shared bus (stub, then TechnoEngine)
    this.meters = null; // NightScene owns the single writer
    this.hud = new MeterHud(this, this.bus, { initialHeat: this.night.heatStrikes.length });
    this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs, ctx.sfx);

    this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
    this.fights?.destroy();
    this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });

    const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 };
    this.player = freshPlayer(entry.x, entry.y);

    this.view.reset();
    this.escorting = null;
    this.target = null;
    this.liveFight = null;
    this.uvMode = false;
    // From STATE, not an assumption: if the player stepped inside during the
    // prop-manifest load window, the phaseChange event fired before our
    // listener existed and 'false' would stick forever.
    this.present = ctx.state.location === 'floor';
    this.elapsedMs = 0;
    this.pendingSpawns = [];
    this.maggotSeen.clear();
    this.spotted.clear();
    this.cutOffDealt.clear();

    this.ctxOffs = [
      // The floor is fed by the door: every admission walks in a few seconds later.
      this.bus.on('door:verdict', ({ patron, verdict }) => {
        if (verdict !== 'admit') return;
        const delay = this.rng.stream('floorArrive').int(WALK_IN_MS[0], WALK_IN_MS[1]);
        this.pendingSpawns.push({ patron, atMs: this.elapsedMs + delay });
      }),
      this.bus.on('night:phaseChange', ({ location }) => {
        this.present = location === 'floor';
        if (this.present) this.target = null;
      }),
      this.bus.on('beat:tick', ({ beatIndex }) => {
        this.crowd.onBeat(beatIndex);
        this.view.onBeat(beatIndex);
      }),
    ];
  }

  private fixedText(x: number, y: number, colour: string): Phaser.GameObjects.Text {
    return this.add
      .text(x, y, '', { fontFamily: 'monospace', fontSize: '8px', color: colour })
      .setScrollFactor(0)
      .setDepth(4000);
  }

  private resetRun(seed: number): void {
    this.seed = seed;
    _resetPatronSerial();

    this.crowd?.destroy();
    this.stall?.destroy();
    this.bus?.removeAll();
    this.meters?.destroy();
    this.hud?.destroy();

    this.bus = new EventBus();
    this.rng = new SeededRNG(seed);
    this.clock = new GameClock(this.bus);
    this.beat = new StubBeatClock(this.bus);
    this.night = freshNightState('voltage', 0, 200);
    this.night.location = 'floor';
    this.meters = new Meters(this.bus, this.night);
    this.hud = new HudStub(this, this.bus);
    this.stall = new StallOverlay(this, this.bus, this.beat.beatIntervalMs);

    this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
    this.fights?.destroy();
    this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
    this.bus.on('beat:tick', ({ beatIndex }) => {
      this.crowd.onBeat(beatIndex);
      this.view.onBeat(beatIndex);
    });

    const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 };
    this.player = freshPlayer(entry.x, entry.y);

    this.view.reset();
    this.escorting = null;
    this.target = null;
    this.liveFight = null;
    this.uvMode = false;
    this.spawnAccMs = 0;
    this.maggotSeen.clear();
    this.spotted.clear();
    this.cutOffDealt.clear();

    this.clock.start();
    this.beat.start();
  }

  // ---- input -------------------------------------------------------------

  private onKey(ev: KeyboardEvent): void {
    const key = ev.key === ' ' ? 'SPACE' : ev.key.toUpperCase();

    if (this.cutOff.isOpen) return this.cutOff.handleKey(key);
    if (this.stall.isOpen) return this.stall.handleKey(key);
    if (this.patDown.isOpen) return this.patDown.handleKey(key);

    if (key === 'TAB') {
      ev.preventDefault();
      this.uvMode = !this.uvMode;
      return;
    }
    if (key === 'R' && !this.nightCtx) return this.resetRun(this.seed + 1);
    if (key === 'E') return this.interact();
  }

  private get overlayOpen(): boolean {
    return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen;
  }

  private readInput(): PlayerInput {
    const k = this.keys;
    const down = (a?: Phaser.Input.Keyboard.Key, b?: Phaser.Input.Keyboard.Key) =>
      Boolean(a?.isDown || b?.isDown);
    return {
      up: down(k['W'], k['UP']),
      down: down(k['S'], k['DOWN']),
      left: down(k['A'], k['LEFT']),
      right: down(k['D'], k['RIGHT']),
    };
  }

  // ---- the loop ----------------------------------------------------------

  override update(_time: number, deltaMs: number): void {
    if (!this.view) return; // world still waiting on the prop manifest
    const dt = Math.min(deltaMs, 50);

    // These run REGARDLESS of overlays. The music doesn't stop because you
    // opened a cubicle door — and the stall minigame judges against beat:tick,
    // so freezing the beat clock behind a modal makes it literally unplayable.
    // The night clock keeps running too: time is the one thing you never get back.
    // (In night mode NightScene drives both; this scene just consumes the bus.)
    if (!this.nightCtx) {
      this.clock.update(dt);
      this.beat?.update(dt);
    }

    this.elapsedMs += dt;
    if (this.nightCtx) {
      for (let i = this.pendingSpawns.length - 1; i >= 0; i--) {
        const s = this.pendingSpawns[i]!;
        if (this.elapsedMs < s.atMs) continue;
        this.pendingSpawns.splice(i, 1);
        // Licensed capacity is 80 but 44 agents is the sim's perf budget; past
        // that, arrivals dissolve into the (conceptual) back room. log()-style
        // honesty: this is a cap, not a simulation. Counts LIVE bodies (ejections
        // must not permanently shrink the venue).
        if (this.liveCrowd < MAX_CROWD) this.crowd.spawn(s.patron);
      }

      // Player at the door: the floor keeps living (drinks keep being drunk,
      // stalls keep filling, maggots keep ripening → radio pressure), but
      // nothing needs the camera, the cone, or input.
      if (!this.present) {
        this.crowd.update(dt, this.clock.clockMin);
        // Fights don't wait for you. An off-map player position means the
        // interpose check can never accidentally succeed — unattended fights
        // swing, and the incident radios the door.
        this.fights.update(-9999, -9999, dt);
        this.tickFight();
        this.trackMaggots();
        this.tickCalm(dt);
        return;
      }
    }

    // Fights burn their fuse through overlays too. You can't be in a cubicle and
    // between two blokes at once — choosing which one to abandon IS the floor.
    // ESC is always the way out of a modal, so it's a decision, not a trap.
    this.fights.update(this.player.x, this.player.y, dt);
    this.tickFight();

    if (this.overlayOpen) {
      this.cutOff.update(dt);
      this.stall.update(dt);
      this.patDown.update(dt);
      // World keeps breathing behind the panel, but you can't move while you're
      // dealing with someone — that's the point of the mechanic.
      this.crowd.update(dt, this.clock.clockMin);
      this.view.updateLights(dt);
      this.view.syncAgents(this.crowd.agents);
      return;
    }

    // Escorting costs you the walk: no interacting, slower going.
    const input = this.readInput();
    this.player = stepPlayer(this.player, input, dt, VENUE);

    if (!this.nightCtx) {
      // Demo mode stands in for the door with a synthetic spawner.
      // CrowdSim deliberately keeps departed agents around so find() still works,
      // so the cap has to count LIVE bodies — otherwise every ejection permanently
      // shrinks the venue and the floor drains to empty over the night.
      this.spawnAccMs += dt;
      while (this.spawnAccMs > SPAWN_EVERY_MS && this.liveCrowd < MAX_CROWD) {
        this.spawnAccMs -= SPAWN_EVERY_MS;
        this.spawnOne();
      }
    }

    if (this.hud instanceof MeterHud) this.hud.update(dt);

    this.crowd.update(dt, this.clock.clockMin);
    if (this.escorting) this.tickEscort(dt);

    this.trackMaggots();
    this.tickCalm(dt);
    this.target = this.escorting ? null : this.findTarget();

    this.cameras.main.centerOn(this.player.x, this.player.y);
    this.view.updateLights(dt);
    this.view.updatePlayer(this.player.x, this.player.y, this.player.facing);
    this.view.updateCone(this.coneSpec(), this.uvMode);
    this.view.syncAgents(this.crowd.agents);
    this.view.setInteractTarget(this.target?.agent ?? null);

    this.drawHud(dt);
  }

  /**
   * Aggro physiology (Phase-1 floor request, approved): a calm floor slowly
   * cools the night — whether or not the player is watching it. "Calm" means no
   * fight brewing and no unhandled maggot; the drift is deliberately slower
   * than any single mistake so it reads as breathing, not forgiveness.
   * ~0.3 aggro per clock-minute (1 clock-min ≈ 2.17s real at DEFAULT_CLOCK).
   */
  private tickCalm(dt: number): void {
    if (this.fights.active) {
      this.calmAccMs = 0;
      return;
    }
    for (const a of this.crowd.agents) {
      if (!a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot') {
        this.calmAccMs = 0;
        return;
      }
    }
    this.calmAccMs += dt;
    if (this.calmAccMs >= 1000) {
      this.calmAccMs -= 1000;
      this.bus.emit('meters:delta', { aggro: -0.14 });
    }
  }

  /** Have we already dealt with this patron at their current level of drunk? */
  private settledAt(agent: Agent, stage: DrunkStage): boolean {
    const dealt = this.cutOffDealt.get(agent.patron.id);
    return dealt !== undefined && STAGE_RANK[stage] <= STAGE_RANK[dealt];
  }

  private get liveCrowd(): number {
    let n = 0;
    for (const a of this.crowd.agents) if (!a.gone) n++;
    return n;
  }

  private coneSpec(): ConeSpec {
    const c = this.uvMode ? UV_CONE : NORMAL_CONE;
    return { x: this.player.x, y: this.player.y, facing: this.player.facing, ...c };
  }

  private spawnOne(): void {
    const patron = generatePatron(
      { rng: this.rng, nightDate: this.clock.nightDate },
      this.clock.clockMin,
    );
    // Standing in for the door phase: most people got stamped on the way in.
    // The ones who didn't snuck past Kayden — that's the UV infraction.
    patron.flags.uvStamped = this.rng.stream('floorStamp').chance(0.86);
    this.crowd.spawn(patron);
  }

  // ---- infraction detection ---------------------------------------------

  private trackMaggots(): void {
    const now = this.clock.clockMin;
    for (const a of this.crowd.agents) {
      if (a.gone || a.handled) continue;
      if (drunkStage(a.patron.intoxication) !== 'maggot') continue;
      if (this.settledAt(a, 'maggot')) continue;
      const seen = this.maggotSeen.get(a.patron.id);
      if (seen === undefined) {
        this.maggotSeen.set(a.patron.id, now);
        this.spot(a.patron.id, 'drunk');
      } else if (maggotOverdue(seen, now)) {
        this.maggotSeen.set(a.patron.id, now); // re-arm so it logs once per grace period
        this.logIncident('maggotUnhandled', a.patron.id, 'Left on the floor past the grace period.');
      }
    }
  }

  private spot(patronId: string, kind: 'drunk' | 'stall' | 'contraband' | 'noStamp'): void {
    const key = `${patronId}:${kind}`;
    if (this.spotted.has(key)) return;
    this.spotted.add(key);
    this.bus.emit('floor:infractionSpotted', { patronId, kind });
  }

  private findTarget(): InteractTarget | null {
    const spec = this.coneSpec();

    // Night mode: the way back out to the rope is itself an interaction.
    if (this.nightCtx) {
      const entry = VENUE.anchors.entry[0];
      if (entry) {
        const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y);
        if (d <= INTERACT_RANGE) return { kind: 'exit', prompt: 'E — back out to the door' };
      }
    }

    // Stall doors first — you interact with the door, not a person.
    for (const s of VENUE.stalls) {
      const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, s.door.x, s.door.y);
      if (d > INTERACT_RANGE) continue;
      const occupants = this.crowd.stallOccupancy().get(s.id) ?? [];
      if (occupants.length < 2) continue;
      // Report a real patron, not the cubicle: `patronId` is a frozen contract
      // field and a stall id resolves to nobody. Keying on the occupant also
      // means a fresh pair in the same cubicle reports again later in the night.
      const first = occupants[0];
      if (first) this.spot(first.patron.id, 'stall');
      return { kind: 'stall', stallId: s.id, prompt: 'E — two pairs of feet under that door' };
    }

    let best: Agent | null = null;
    let bestDist = INTERACT_RANGE;
    for (const a of this.crowd.agents) {
      if (a.gone || a.handled || a.activity === 'inStall' || a.activity === 'escorted') continue;
      if (!inCone(spec, a.x, a.y)) continue;
      const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, a.x, a.y);
      if (d < bestDist) {
        bestDist = d;
        best = a;
      }
    }
    if (!best) return null;

    // UV mode is a different lens on the same crowd: it only surfaces stamps.
    if (this.uvMode) {
      if (best.patron.flags.uvStamped) return null;
      this.spot(best.patron.id, 'noStamp');
      return { kind: 'noStamp', agent: best, prompt: 'E — no stamp. Walk them back to the door' };
    }

    const stage = drunkStage(best.patron.intoxication);
    if (stage === 'messy' || stage === 'maggot' || stage === 'loose') {
      // Dealing with someone settles that stage, not the whole night. The Quiet
      // Messy One you sensibly watered at 'loose' has to come back around when
      // they hit 'maggot' — otherwise a correct early call blinds you to them.
      if (!this.settledAt(best, stage)) {
        return { kind: 'drunk', agent: best, prompt: 'E — have a word' };
      }
    }
    if (best.patron.flags.contraband?.length) {
      this.spot(best.patron.id, 'contraband');
      return { kind: 'contraband', agent: best, prompt: 'E — pat them down' };
    }
    return null;
  }

  // ---- interactions ------------------------------------------------------

  private interact(): void {
    const t = this.target;
    if (!t || this.escorting) return;

    if (t.kind === 'exit') return this.nightCtx?.requestLocation('door');
    if (t.kind === 'stall' && t.stallId) return this.openStall(t.stallId);
    if (!t.agent) return;
    if (t.kind === 'noStamp') return this.startEscort(t.agent, 'clean', 'noStamp');
    if (t.kind === 'drunk') return this.openCutOff(t.agent);
    if (t.kind === 'contraband') return this.openPatDown(t.agent);
  }

  private openStall(stallId: string): void {
    const occupants = this.crowd.stallOccupancy().get(stallId) ?? [];
    this.stall.open(
      { stallId, patrons: occupants.map((a) => a.patron) },
      (r) => {
        this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta });
        if (r.solved) {
          // Re-query: the crowd keeps simulating behind the modal, so whoever was
          // in there when you started banging may have wandered off. Scattering
          // the captured list would retire innocent patrons standing elsewhere.
          const inThere = this.crowd.stallOccupancy().get(stallId) ?? [];
          // Zeroing the dwell makes the sim's own stall-exit path fire next
          // frame: both get put back at the door and independently re-target,
          // which reads exactly like scattering in opposite directions.
          for (const a of inThere) {
            a.handled = true;
            a.dwellMs = 0;
          }
          this.logIncident('stallBusted', inThere[0]?.patron.id, 'Two out of one cubicle.');
        }
      },
    );
  }

  private openCutOff(agent: Agent): void {
    const stage = drunkStage(agent.patron.intoxication);
    this.cutOff.open({ patron: agent.patron, stage }, (r) => {
      if (!r) return;
      this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta });
      // Only an ejection retires someone for good. A resolved-but-staying patron
      // is settled at THIS stage and becomes your problem again if they get worse.
      if (r.resolved && !r.eject) this.cutOffDealt.set(agent.patron.id, stage);
      if (r.eject) {
        this.startEscort(agent, r.outcome === 'clean' ? 'clean' : 'messy', 'drunk');
      } else {
        this.logIncident('cutOff', agent.patron.id, `${stage} — ${r.outcome}`);
      }
    });
  }

  private openPatDown(agent: Agent): void {
    const pockets: Pocket[] = layoutPockets(agent.patron, this.rng.stream('patdown'));
    this.patDown.open({ patron: agent.patron, pockets }, (r) => {
      this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta });
      agent.handled = true;
      this.logIncident('patDown', agent.patron.id, r.summary);
      if (r.eject) this.startEscort(agent, 'clean', 'contraband');
    });
  }

  private startEscort(agent: Agent, style: 'clean' | 'messy', kind: InteractKind): void {
    if (!this.crowd.beginEscort(agent)) return;
    agent.handled = true;
    this.escorting = agent;
    if (kind === 'noStamp') {
      this.radio(NO_STAMP_RADIO[this.rng.stream('radio').int(0, NO_STAMP_RADIO.length - 1)] ?? '');
    }
    this.bus.emit('floor:ejection', { patronId: agent.patron.id, style });
    this.logIncident('ejection', agent.patron.id, `${kind} / ${style}`);
  }

  /** Draw the brewing-fight tell and narrate the resolution when one lands. */
  private tickFight(): void {
    const f = this.fights.active;
    const pair = this.fights.pair();
    this.view.setFight(pair, f?.shoves ?? 0, f?.msLeft ?? 0);

    // The director releases both agents the instant it resolves, so pair() and
    // cause are already gone by the time we're told about it — remember them
    // while the fight is still live.
    if (pair) this.liveFight = { id: pair[0].patron.id, cause: this.fights.cause };

    const res = this.fights.takeResolution();
    if (!res) return;
    const was = this.liveFight;
    this.liveFight = null;
    this.toast(res.line);
    this.logIncident('fight', was?.id, `${res.phase === 'broken' ? 'broken up' : 'swung'} — ${was?.cause ?? ''}`);
  }

  private tickEscort(dt: number): void {
    const a = this.escorting;
    if (!a) return;
    this.crowd.updateEscorts(this.player.x, this.player.y, dt);
    if (a.gone) {
      this.escorting = null;
      this.bus.emit('meters:delta', { vibe: 1 });
    }
  }

  // ---- chrome ------------------------------------------------------------

  private radio(line: string): void {
    this.toast(line ? `KAYDEN: ${line}` : '');
  }

  /** Transient line in the corner — radio chatter, fight outcomes, anything shouted. */
  private toast(line: string): void {
    this.radioText.setText(line);
    this.radioMs = 5000;
  }

  private logIncident(kind: string, patronId: string | undefined, detail: string): void {
    this.bus.emit('incident:log', { clockMin: this.clock.clockMin, kind, patronId, detail });
  }

  private drawHud(dt: number): void {
    if (this.radioMs > 0) {
      this.radioMs -= dt;
      if (this.radioMs <= 0) this.radioText.setText('');
    }
    // A brewing fight outranks everything — it's the one thing on a clock.
    this.promptText.setText(
      this.fights.active
        ? 'GET BETWEEN THEM'
        : this.escorting
          ? 'escorting — walk them to the door (no interacting)'
          : (this.target?.prompt ?? ''),
    );
    const maggots = this.crowd.agents.filter(
      (a) => !a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot',
    ).length;
    this.statusText.setText(
      `${this.clock.label}  seed ${this.seed}  crowd ${this.liveCrowd}  maggots ${maggots}  ${this.uvMode ? 'UV' : 'torch'}  incidents ${this.night.incidents.length}`,
    );
  }

  private teardown(): void {
    for (const off of this.ctxOffs) off();
    this.ctxOffs = [];
    this.fights?.destroy();
    this.crowd?.destroy();
    this.view?.destroy();
    this.cutOff?.destroy();
    this.stall?.destroy();
    this.patDown?.destroy();
    // Night mode: the bus/meters belong to NightScene — touch nothing shared.
    this.meters?.destroy();
    this.hud?.destroy();
  }
}
