import Phaser from 'phaser';
import { EventBus } from '../../core/EventBus';
import { GameClock, DEFAULT_CLOCK, type ClockConfig } from '../../core/GameClock';
import { SeededRNG } from '../../core/SeededRNG';
import { StubBeatClock } from '../../core/StubBeatClock';
import { Meters, freshNightState, vibeCoolingPerMin } from '../../core/meters';
import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save';
import { _resetPatronSerial } from '../../patrons/generator';
import { ruleById } from '../../rules/dressCode';
import { TechnoEngine } from '../../audio/TechnoEngine';
import { Ambience } from '../../audio/Ambience';
import { Sfx } from '../../audio/Sfx';
import type { DeferredHit } from '../../rules/doorTypes';
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
import { nextDazza } from './dazzaSchedule';
import { TOTAL_VENUES, nightDateFor, venueByIndex, type VenueDef } from '../../data/venues';
import { buildRegularCast } from '../../patrons/regularCast';
import { recordDenialBy } from '../../patrons/memory';
import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector';

// Phase-2: the night-flow machine. Door and Floor both run for the whole night;
// this scene owns WHERE THE PLAYER IS (visibility + input), the clock, the
// meters, the audio engine (and the location filter that goes with it), Kayden's
// stamp accuracy, the radio, and the run-level save. It still renders nothing.

/** The Royal's licence — kept for tests/back-compat; live nights use the venue def. */
export const LICENSED_CAPACITY = 80;
const VIBE_SAMPLE_EVERY_MIN = 5;

import { MAX_HEAT_STRIKES, shouldRecordStrike } from '../../rules/heat';
export { MAX_HEAT_STRIKES, shouldRecordStrike };

/** Thu → Fri → Sat at The Royal. Index = nightIndex. */
export const NIGHT_DATES = ['2026-07-16', '2026-07-17', '2026-07-18'] as const;
export const TOTAL_NIGHTS = NIGHT_DATES.length;

/** Kayden stamps most hands. Most. */
export const KAYDEN_STAMP_CHANCE = 0.86;
/** Scripted "get in here" pulls: the floor demands a lap around these minutes. */
const RADIO_PULL_MINS = [120, 250] as const;
/** Dynamic maggot-radio rate limit (clock minutes). */
const RADIO_MAGGOT_COOLDOWN = 15;


export type NightEndReason = 'clock' | 'vibe' | 'aggro' | 'licence';

export interface VerdictRecord {
  patron: Patron;
  verdict: Verdict;
  outcome: VerdictOutcome;
  clockMin: number;
}

export interface NightLog {
  vibeHistory: number[];
  verdicts: VerdictRecord[];
  admitted: number;
  denied: number;
  tested: number;
  pattedDown: number;
  hypePeak: number;
  heatStrikes: HeatStrike[];
  endReason: NightEndReason;
  seed: number;
}

/** Run-level result attached to the summary. */
export interface RunInfo {
  /** The run save itself, so the report form can file into it and persist. */
  game: GameState;
  nightIndex: number;
  totalNights: number;
  venue: VenueDef;
  /** the week is done and a HARDER venue wants you (venue ladder, design §6) */
  promotedTo: VenueDef | null;
  /** licence pulled — the run is dead, only a fresh one remains */
  runOver: boolean;
  /** survived all three nights */
  weekDone: boolean;
  /** survived every week at every venue — the season is complete */
  seasonDone: boolean;
}

/** Everything the door and floor need from the night. Passed by reference. */
export interface NightContext {
  bus: EventBus;
  rng: SeededRNG;
  clock: GameClock;
  state: NightState;
  seed: number;
  nightDate: Date;
  /** UI sfx — routed around the location filter, always crisp. */
  sfx: Sfx | null;
  beatIntervalMs: number;
  scheduleDeferred(hits: readonly DeferredHit[]): void;
  reportQueue(length: number): void;
  /** Scripted encounters this RUN has already met (cross-night repeat gating). */
  seenEncounters: readonly string[];
  /** This week's venue — capacity/pressure knobs (venue ladder, design §6). */
  venue: VenueDef;
  /** The run's stable regulars and their grudges (design §4.1). */
  regulars: { cast: readonly Patron[]; memory: Readonly<Record<string, import('../../data/types').RegularMemory>> };
  /** Door/Floor ask to move the player; the night decides and flips the scenes. */
  requestLocation(loc: 'door' | 'floor'): void;
}

export class NightScene extends Phaser.Scene {
  private bus!: EventBus;
  private rng!: SeededRNG;
  private clock!: GameClock;
  private stubBeat: StubBeatClock | null = null;
  private engine: TechnoEngine | null = null;
  private sfx: Sfx | null = null;
  private ambience: Ambience | null = null;
  private meters!: Meters;
  private state!: NightState;
  private run!: GameState;
  private seed = 4207;
  private nightIndex = 0;
  private venue!: VenueDef;
  private ended = false;
  private deferred: DeferredHit[] = [];
  private readonly firedDazza = new Set<string>();
  private lastDazzaMin = -99;
  private lastVibeSampleMin = -99;
  /** Live inspector watch, or null. At most one per night. */
  private inspector: InspectorWatch | null = null;
  /** Minors the player (or Kayden) has actually let past the rope. */
  private minorsInside = 0;
  /** True while the floor has an unhandled maggot — the inspector's worst find. */
  private maggotLive = false;
  private lastMaggotRadioMin = -99;
  private readonly firedPulls = new Set<number>();
  private queueLength = 0;
  private log!: NightLog;
  private offs: Array<() => void> = [];
  private ctx!: NightContext;

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

  init(data: { seed?: number; nightIndex?: number; freshRun?: boolean }): void {
    if (data.freshRun) clearSave();
    this.run = loadGame(typeof data.seed === 'number' ? data.seed : 4207);
    if (typeof data.seed === 'number' && this.run.seed !== data.seed) {
      this.run = freshGameState(data.seed);
    }
    this.nightIndex = data.nightIndex ?? this.run.nightIndex;
    if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0;
    this.venue = venueByIndex(this.run.venueIndex);
    // Each night is its own deterministic world derived from the run seed —
    // and each venue-week its own block of them.
    this.seed = this.run.seed + this.run.venueIndex * 1009 + this.nightIndex * 101;
  }

  create(): void {
    this.ended = false;
    this.deferred = [];
    this.firedDazza.clear();
    this.firedPulls.clear();
    this.lastDazzaMin = -99;
    this.lastVibeSampleMin = -99;
    this.lastMaggotRadioMin = -99;
    _resetPatronSerial();

    this.bus = new EventBus();
    this.rng = new SeededRNG(this.seed);
    const clockCfg: ClockConfig = {
      ...DEFAULT_CLOCK,
      nightDate: nightDateFor(NIGHT_DATES[this.nightIndex]!, this.run.venueIndex),
    };
    this.clock = new GameClock(this.bus, clockCfg);
    this.state = freshNightState(this.venue.id, this.nightIndex, this.venue.licensed);
    // Heat is run-scoped: strikes from earlier nights are already on the licence.
    this.state.heatStrikes.push(...this.run.heatStrikes);
    this.meters = new Meters(this.bus, this.state);

    // Audio: the engine is the beat authority once the browser lets it speak.
    // Until the first gesture, a stub keeps beat:tick honest (then hands over).
    this.engine = new TechnoEngine(this.bus);
    this.sfx = new Sfx(this.engine.ctx, this.engine.sfxBus);
    // Rain outside, murmur inside, fluoro hum — starts once audio unlocks.
    this.ambience = new Ambience(this.bus, this.engine.ctx, this.engine.sfxBus, this.sfx);
    this.stubBeat = new StubBeatClock(this.bus);
    this.stubBeat.start();
    this.engine.start();
    const unlock = (): void => {
      void this.engine?.unlock();
    };
    this.input.on('pointerdown', unlock);
    this.offs.push(() => this.input.off('pointerdown', unlock));

    this.log = {
      vibeHistory: [this.state.vibe],
      verdicts: [],
      admitted: 0,
      denied: 0,
      tested: 0,
      pattedDown: 0,
      hypePeak: 1,
      heatStrikes: [],
      endReason: 'clock',
      seed: this.seed,
    };

    this.offs.push(
      this.bus.on('clock:tick', ({ clockMin }) => this.onMinute(clockMin)),
      this.bus.on('meters:changed', ({ vibe, aggro, hype }) => this.onMeters(vibe, aggro, hype)),
      this.bus.on('heat:strike', (s) => this.onStrike(s)),
      this.bus.on('door:verdict', (v) => this.onVerdict(v)),
      this.bus.on('floor:ejection', () => {
        this.state.capacity.inside = Math.max(0, this.state.capacity.inside - 1);
      }),
      this.bus.on('floor:leave', () => {
        this.state.capacity.inside = Math.max(0, this.state.capacity.inside - 1);
      }),
      this.bus.on('audio:unlocked', () => {
        this.stubBeat?.stop();
        this.stubBeat = null;
        this.ambience?.start();
      }),
      this.bus.on('radio:call', () => this.sfx?.play('radioChirp')),
      this.bus.on('incident:log', (inc) => {
        if (inc.kind === 'encounter' && inc.detail.includes(':')) {
          // The door logs encounters as "<id>: let in/turned away" — the id is
          // what gates repeats across the week.
          const encId = inc.detail.split(':')[0]!;
          const seen = (this.run.seenEncounters ??= []);
          if (!seen.includes(encId)) seen.push(encId);
        }
        if (inc.kind === 'maggotUnhandled') {
          this.maggotLive = true;
          if (this.state.location === 'door') this.maggotRadio();
        }
        // A swing on the floor while you're at the rope: the radio tells you.
        if (inc.kind === 'fight' && this.state.location === 'door' && inc.detail.startsWith('swung')) {
          this.bus.emit('radio:call', { urgency: 3, line: 'there was a SWING inside. get in here. NOW' });
        }
        // The floor cleared them out; the room is legal again.
        if (inc.kind === 'ejection' || inc.kind === 'cutOff') this.maggotLive = false;
      }),
    );

    this.ctx = {
      bus: this.bus,
      rng: this.rng,
      clock: this.clock,
      state: this.state,
      seed: this.seed,
      nightDate: this.clock.nightDate,
      sfx: this.sfx,
      beatIntervalMs: 60_000 / 128,
      scheduleDeferred: (hits) => this.deferred.push(...hits),
      reportQueue: (length) => {
        this.queueLength = length;
      },
      seenEncounters: this.run.seenEncounters ?? [],
      venue: this.venue,
      regulars: {
        // Cast is RUN-seeded: same six faces every night, every venue.
        cast: buildRegularCast(this.run.seed, this.clock.nightDate),
        memory: this.run.regulars,
      },
      requestLocation: (loc) => this.setLocation(loc),
    };

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

    // Generated art loads BEFORE the scenes that consume it (textures are
    // game-global): the door's street backdrop and the floor's props both ride
    // public/props + manifest. Missing manifest = placeholders, zero waiting.
    this.load.json('propManifest', 'props/manifest.json');
    this.load.once(Phaser.Loader.Events.COMPLETE, () => {
      const manifest = this.cache.json.get('propManifest') as { props?: string[] } | undefined;
      const missing = (manifest?.props ?? []).filter((k) => !this.textures.exists(`gen:prop:${k}`));
      const launch = (): void => {
        this.scene.launch('Door', { night: this.ctx });
        this.scene.launch('Floor', { night: this.ctx });
        // Door and Floor BOTH run all night; the flags below decide which one
        // the player is inhabiting. Floor starts hidden.
        this.time.delayedCall(0, () => this.applyLocation('door'));
      };
      if (missing.length === 0) return launch();
      for (const k of missing) this.load.image(`gen:prop:${k}`, `props/${k}.png`);
      this.load.once(Phaser.Loader.Events.COMPLETE, launch);
      this.load.start();
    });
    this.load.start();
  }

  // ---- location ----------------------------------------------------------

  private setLocation(loc: 'door' | 'floor'): void {
    if (this.ended || this.state.location === loc) return;
    this.applyLocation(loc);
    this.bus.emit('night:phaseChange', { location: loc });
    this.bus.emit('audio:location', { location: loc });
  }

  private applyLocation(loc: 'door' | 'floor'): void {
    const doorHere = loc === 'door';
    this.scene.setVisible(doorHere, 'Door');
    this.scene.setVisible(!doorHere, 'Floor');
    const door = this.scene.get('Door');
    const floor = this.scene.get('Floor');
    if (door) door.input.enabled = doorHere;
    if (floor) floor.input.enabled = !doorHere;
    this.state.location = loc; // meters also tracks via night:phaseChange; direct set covers the first frame
  }

  // ---- per-event ---------------------------------------------------------

  private onVerdict({ patron, verdict, outcome }: { patron: Patron; verdict: Verdict; outcome: VerdictOutcome }): void {
    this.log.verdicts.push({ patron, verdict, outcome, clockMin: this.state.clockMin });
    if (verdict === 'admit') {
      this.log.admitted++;
      // The UV stamp happens at the door. The player never forgets; Kayden
      // forgets one hand in seven — those are the floor's noStamp infractions.
      patron.flags.uvStamped =
        this.state.location === 'door' ? true : this.rng.stream('kaydenStamp').chance(KAYDEN_STAMP_CHANCE);
      // The inspector reads as a boring punter and is scored as one — the ONLY
      // thing that marks them is that admitting one starts a clock (§4.2).
      // ...and only at venues the inspector actually works (venue ladder).
      if (patron.archetype === 'inspector' && this.inspector === null && this.venue.inspector) {
        this.inspector = startWatch(patron.id, this.state.clockMin);
      }
      if (patron.age < 18) this.minorsInside++;
    } else if (verdict === 'deny') {
      this.log.denied++;
      // The regulars keep count across nights AND venues (same town).
      if (patron.flags.regularId) {
        recordDenialBy(this.run, patron.flags.regularId, this.run.venueIndex * TOTAL_NIGHTS + this.nightIndex);
      }
    }
    else if (verdict === 'sobrietyTest') this.log.tested++;
    else if (verdict === 'patDown') this.log.pattedDown++;
  }

  private onStrike(s: HeatStrike): void {
    this.log.heatStrikes.push(s);
    // Third strike doesn't wait for 3 AM: the inspector pulls the licence on the
    // spot and the run is over (review ruling, design §2).
    if (this.state.heatStrikes.length >= MAX_HEAT_STRIKES) this.endNight('licence');
  }

  private onMeters(vibe: number, aggro: number, hype: number): void {
    if (hype > this.log.hypePeak) this.log.hypePeak = hype;
    if (this.ended) return;
    if (vibe <= 0) this.endNight('vibe');
    else if (aggro >= 100) this.endNight('aggro');
  }

  private onMinute(clockMin: number): void {
    if (this.ended) return;

    if (clockMin - this.lastVibeSampleMin >= VIBE_SAMPLE_EVERY_MIN) {
      this.lastVibeSampleMin = clockMin;
      this.log.vibeHistory.push(this.state.vibe);
    }

    this.fireDeferred(clockMin);
    this.observeInspector(clockMin);
    this.maybeDazza(clockMin);
    this.maybePull(clockMin);

    // The room cools on its own — keeping it lit is the job (core/meters.ts).
    const cooling = vibeCoolingPerMin(this.state.vibe);
    if (cooling !== 0) this.bus.emit('meters:delta', { vibe: cooling });

    if (clockMin >= this.clock.config.nightClockMinutes) this.endNight('clock');
  }

  /**
   * The inspector, if one is inside. While they watch, a live breach files its
   * strike NOW instead of at the 3 AM audit — the whole point being that you
   * cannot know which half-hour of the night that was.
   *
   * Every strike still goes through shouldRecordStrike: observe() returns one
   * per breach per minute, so emitting them raw would pull the licence within
   * three minutes of any sustained breach.
   */
  private observeInspector(clockMin: number): void {
    if (!this.inspector) return;
    const breaches: VenueBreaches = {
      overCapacity: this.state.capacity.inside > this.state.capacity.licensed,
      maggotOnFloor: this.maggotLive,
      underageInside: this.minorsInside > 0,
    };
    const seen = observe(this.inspector, clockMin, breaches);
    for (const strike of seen.strikes) {
      if (shouldRecordStrike(this.state, strike)) this.bus.emit('heat:strike', strike);
    }
    if (seen.dazzaText) this.bus.emit('dazza:text', { text: seen.dazzaText });
    if (seen.watchEnded) this.inspector = null;
  }

  /** Scripted floor pulls: the radio wants you inside around these times. */
  private maybePull(clockMin: number): void {
    for (const at of RADIO_PULL_MINS) {
      if (clockMin < at || this.firedPulls.has(at)) continue;
      this.firedPulls.add(at);
      if (this.state.location === 'floor') continue; // already in there
      this.bus.emit('radio:call', {
        urgency: 2,
        line: at === RADIO_PULL_MINS[0] ? 'mate do a lap, it is getting loose in here' : 'you are NEEDED inside. now.',
      });
    }
  }

  private maggotRadio(): void {
    const clockMin = this.state.clockMin;
    if (clockMin - this.lastMaggotRadioMin < RADIO_MAGGOT_COOLDOWN) return;
    this.lastMaggotRadioMin = clockMin;
    this.bus.emit('radio:call', { urgency: 3, line: 'someone is MAGGOT on the dance floor, get in here' });
  }

  private fireDeferred(clockMin: number): void {
    // Collect first, THEN process: a hit's vibe delta can end the night, and
    // endNight() reassigns this.deferred — mutating the array we'd be iterating.
    const due: DeferredHit[] = [];
    for (let i = this.deferred.length - 1; i >= 0; i--) {
      if (clockMin >= this.deferred[i]!.atClockMin) due.push(...this.deferred.splice(i, 1));
    }
    for (const hit of due) {
      if (this.ended) return;

      if (hit.vibeDelta !== undefined || hit.aggroDelta !== undefined) {
        this.bus.emit('meters:delta', {
          ...(hit.vibeDelta !== undefined ? { vibe: hit.vibeDelta } : {}),
          ...(hit.aggroDelta !== undefined ? { aggro: hit.aggroDelta } : {}),
        });
      }
      if (hit.dazzaText) this.bus.emit('dazza:text', { text: hit.dazzaText });
      if (hit.heatStrike && shouldRecordStrike(this.state, hit.heatStrike)) {
        this.bus.emit('heat:strike', hit.heatStrike);
      }
      this.bus.emit('incident:log', {
        clockMin,
        kind: 'deferred',
        patronId: hit.patronId,
        detail: hit.reason,
      });
    }
  }

  private maybeDazza(clockMin: number): void {
    if (clockMin - this.lastDazzaMin < 2) return;
    const line = nextDazza(clockMin, {
      vibe: this.state.vibe,
      aggro: this.state.aggro,
      queueLength: this.queueLength,
      insideCount: this.state.capacity.inside,
      fired: this.firedDazza,
      ruleLimit: this.venue.ruleLimit,
    });
    if (!line) return;
    this.firedDazza.add(line.id);
    this.lastDazzaMin = clockMin;
    const rule = line.ruleId ? ruleById(line.ruleId) : undefined;
    this.bus.emit('dazza:text', { text: line.text, ...(rule ? { rule } : {}) });
  }

  /** The one exit. Everything routes through here so the log is always complete. */
  private endNight(reason: NightEndReason): void {
    if (this.ended) return;
    this.ended = true;
    this.log.endReason = reason;
    this.clock.pause();
    this.stubBeat?.stop();

    // Unfired deferred hits are the 3 AM audit — they land whether you made it
    // to closing or not. (Not after a pulled licence: the run is already dead.)
    if (reason !== 'licence') {
      for (const hit of this.deferred) {
        if (hit.heatStrike && shouldRecordStrike(this.state, hit.heatStrike)) {
          this.bus.emit('heat:strike', hit.heatStrike);
        }
      }
    }
    this.deferred = [];
    this.log.vibeHistory.push(this.state.vibe);

    // ---- run bookkeeping (save) ----
    const runOver = reason === 'licence';
    const survived = reason === 'clock';
    const weekDone = survived && this.nightIndex >= TOTAL_NIGHTS - 1;
    const seasonDone = weekDone && this.run.venueIndex >= TOTAL_VENUES - 1;
    this.run.heatStrikes = [...this.state.heatStrikes];
    // pastReports is written by IncidentReportScene now, not here: Phase 4
    // audits what the player CLAIMED happened, and a raw truth dump would have
    // nothing to catch them out with.
    if (survived) this.run.nightIndex = this.nightIndex + 1;
    if (weekDone && !seasonDone) {
      // PROMOTED: next venue, fresh week, fresh licence. Heat is per-venue —
      // the new place has not heard of you (yet). Regulars and met-encounters
      // carry across: it is the same town.
      this.run.venueIndex += 1;
      this.run.nightIndex = 0;
      this.run.heatStrikes = [];
    }
    if (runOver || seasonDone) {
      clearSave();
    } else {
      saveGame(this.run);
    }
    const runInfo: RunInfo = {
      game: this.run,
      nightIndex: this.nightIndex,
      totalNights: TOTAL_NIGHTS,
      venue: this.venue,
      promotedTo: weekDone && !seasonDone ? venueByIndex(this.run.venueIndex) : null,
      runOver,
      weekDone,
      seasonDone,
    };

    this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {
      this.scene.stop('Door');
      this.scene.stop('Floor');
      this.scene.start('NightSummary', { log: this.log, state: this.state, run: runInfo });
    });
  }

  override update(_time: number, deltaMs: number): void {
    if (this.ended) return;
    this.clock.update(deltaMs);
    this.stubBeat?.update(deltaMs);
  }

  private teardown(): void {
    for (const off of this.offs) off();
    this.offs = [];
    this.meters?.destroy();
    this.ambience?.destroy();
    this.ambience = null;
    this.engine?.destroy();
    this.engine = null;
    this.sfx = null;
    this.bus?.removeAll();
  }
}
