import type { EventBus } from '../../core/EventBus';
import type { RngStream, SeededRNG } from '../../core/SeededRNG';
import { generatePatron } from '../../patrons/generator';
import type { Patron } from '../../data/types';
import { nextArrivalGapMin } from './arrivalCurve';
import { assignListedName, generateGuestList, type GuestList } from '../../rules/guestList';
import { adoptCastIdentity } from '../../patrons/regularCast';
import { disguisedFromMemory } from '../../patrons/memory';
import type { RegularMemory } from '../../data/types';
import {
  ENCOUNTERS,
  encounterById,
  scheduleEncounters,
  type EncounterId,
} from '../../rules/encounters';

/** Second patron of the night lands here regardless of the curve's opinion. */
const OPENING_ARRIVAL_MIN = 3;
/** 2:45 AM. Last drinks is 3:00; the line has to stop growing before then. */
const DOORS_SHUT_MIN = 345;

// Pure queue logic — no Phaser. DoorScene renders whatever this says is true.
// Owning the queue outside the scene keeps the "how much does neglect cost?"
// tuning in one testable place instead of smeared across render code.

export const QUEUE_TUNING = {
  /** dolls drawn on the footpath; everyone past this is an offscreen count */
  visibleSlots: 8,
  /** hard cap on people waiting — past this they wander off to the kebab shop */
  maxQueue: 22,
  /** aggro per real second, per waiting patron, before the impatience ramp */
  aggroPerSecPerWaiter: 0.06,
  /** the ramp: waiting gets worse the longer it goes on (design §2: Aggro punishes slowness) */
  impatienceRampSec: 15,
  /** hype per real second of making people wait — the theatre bonus */
  hypePerSec: 0.035,
  /** hype earned per single patron-wait, so you cannot farm one punter forever */
  hypePerWaitCap: 0.5,
  /**
   * A queue this short reads as "under control" and bleeds aggro. Above it, only
   * the idle penalty applies. Relief has to scale with queue LENGTH rather than
   * only firing on an empty street: during the 11 PM slam the queue is never
   * empty, so an empty-only rule meant a perfectly-played night still rioted.
   */
  comfortableQueue: 4,
  /** aggro bled off per real second with nobody waiting at all
   * (tuning pass 2026-07-19: was 0.9 — relief refunded ~10:1 and aggro never moved) */
  calmPerSec: 0.35,
  /**
   * Hype decays toward x1 while a patron is actually being served. Theatre
   * fades the moment you get back to work — without this, hype was a pure
   * ratchet and every night ended at x3 regardless of play.
   */
  hypeDecayPerSec: 0.045,
  /** looking at your phone while someone waits: pure theatre, pure profit */
  phoneHype: 0.12,
  phoneCooldownMs: 2500,
  /** a denied regular comes back to try again this many in-game minutes later */
  regularRetryMin: [25, 60] as const,
  /**
   * ...but only this many times. Uncapped, one stubborn regular reappeared six
   * times in a single night and ate a third of the night's denials on his own.
   */
  regularMaxComebacks: 2,
  /** chance a new arrival brings a hand-holding partner (feeds noHandHolders) */
  coupleChance: 0.12,
  /** scripted moral encounters injected per night (design §4.3) */
  encountersPerNight: 3,
} as const;

export interface QueueSnapshot {
  waiting: Patron[];
  visible: Patron[];
  offscreen: number;
  up: Patron | null;
  /** real ms the front of the queue has been left standing with nobody up */
  waitMs: number;
}

export class QueueManager {
  private waiting: Patron[] = [];
  private up: Patron | null = null;
  private waitMs = 0;
  private hypeEarnedThisWait = 0;
  private phoneCooldownMs = 0;
  private nextArrivalMin = 0;
  private clockMin = 0;
  private readonly arrivalRng: RngStream;
  private readonly coupleRng: RngStream;
  private readonly comebackRng: RngStream;
  private readonly listRng: RngStream;
  private readonly encounterRng: RngStream;
  private readonly castRng: RngStream;
  /** tonight's clipboard — read by the door's guest-list panel */
  readonly guestList: GuestList;
  /** scripted arrivals still owed tonight, soonest first */
  private pendingEncounters: Array<{ atMin: number; id: EncounterId }> = [];
  /** patronId -> the script they are playing, for the door to look up */
  private readonly encounterOf = new Map<string, EncounterId>();
  /** patrons queued to re-enter the queue: [clockMin, patron] */
  private readonly comebacks: Array<{ atMin: number; patron: Patron }> = [];

  /** everyone who reached the front tonight, for the summary screen */
  readonly seen: Patron[] = [];

  constructor(
    private readonly bus: EventBus,
    private readonly rng: SeededRNG,
    private readonly nightDate: Date,
    /** Encounters already met this run — the scheduler prefers new people. */
    seenEncounters: readonly string[] = [],
    /** Venue arrival multiplier (ladder, design §6). */
    private readonly arrivalScale: number = 1,
    /** Venue crowd identity — archetype weight overrides (ladder, design §6). */
    private readonly archetypeWeights?: Partial<Record<import('../../data/types').Archetype, number>>,
    /** The run's stable regulars + what they remember (design §4.1). */
    private readonly regulars?: {
      cast: readonly import('../../data/types').Patron[];
      memory: Readonly<Record<string, RegularMemory>>;
    },
  ) {
    this.arrivalRng = rng.stream('arrivals');
    this.coupleRng = rng.stream('couples');
    // Its own stream on purpose. Drawing the comeback delay from 'arrivals'
    // meant one player decision — denying a regular — shifted every remaining
    // arrival time for the rest of the night, which is exactly the cross-system
    // perturbation CONTRACTS.md §6 gives named streams to prevent.
    this.comebackRng = rng.stream('comebacks');
    this.listRng = rng.stream('guestList');
    this.encounterRng = rng.stream('encounters');
    this.castRng = rng.stream('castPick');
    this.guestList = generateGuestList(
      this.listRng,
      this.listRng.int(8, Math.min(14, ENCOUNTERS.length + 12)),
    );
    this.pendingEncounters = scheduleEncounters(
      this.encounterRng,
      QUEUE_TUNING.encountersPerNight,
      new Set(seenEncounters),
    );
    // Somebody is ALWAYS at the rope when the shift starts. A game that opens on
    // an empty street reads as broken, however accurate a 9 PM lull would be.
    this.enqueue(this.make());
    this.nextArrivalMin = OPENING_ARRIVAL_MIN;
  }

  get snapshot(): QueueSnapshot {
    const visible = this.waiting.slice(0, QUEUE_TUNING.visibleSlots);
    return {
      waiting: [...this.waiting],
      visible,
      offscreen: Math.max(0, this.waiting.length - visible.length),
      up: this.up,
      waitMs: this.waitMs,
    };
  }

  get queueLength(): number {
    return this.waiting.length;
  }

  get patronUp(): Patron | null {
    return this.up;
  }

  get isPhoneReady(): boolean {
    return this.phoneCooldownMs <= 0;
  }

  /** Called on every clock:tick — arrivals and comebacks run on in-game minutes. */
  onClockMinute(clockMin: number): void {
    this.clockMin = clockMin;

    // Doors shut at 2:45. Whoever is already in the line still gets served, but
    // nobody joins it after this — including a denied regular whose comeback
    // happens to be due. Shut means shut.
    if (clockMin >= DOORS_SHUT_MIN) return;

    // Scripted arrivals jump the curve — they are people who turned up at a
    // particular moment, not draws from a distribution.
    while (this.pendingEncounters.length > 0 && clockMin >= this.pendingEncounters[0]!.atMin) {
      const due = this.pendingEncounters.shift()!;
      const scripted = this.makeScripted(due.id);
      if (scripted) this.enqueue(scripted);
    }

    for (let i = this.comebacks.length - 1; i >= 0; i--) {
      const c = this.comebacks[i]!;
      if (clockMin >= c.atMin) {
        this.comebacks.splice(i, 1);
        this.enqueue(c.patron);
      }
    }

    while (clockMin >= this.nextArrivalMin) {
      this.spawnArrival();
      const gap = nextArrivalGapMin(this.nextArrivalMin, this.arrivalRng.next(), this.arrivalScale);
      this.nextArrivalMin += Math.max(1, Math.round(gap));
    }
  }

  /** Called every frame with real elapsed ms. Owns the impatience economy. */
  update(deltaMs: number): void {
    const sec = deltaMs / 1000;
    this.phoneCooldownMs = Math.max(0, this.phoneCooldownMs - deltaMs);

    const idle = this.up === null && this.waiting.length > 0;
    if (idle) {
      this.waitMs += deltaMs;
      const ramp = 1 + this.waitMs / 1000 / QUEUE_TUNING.impatienceRampSec;
      const aggro = QUEUE_TUNING.aggroPerSecPerWaiter * this.waiting.length * ramp * sec;

      // Hype is capped per wait; aggro is not. Theatre has diminishing returns,
      // resentment does not.
      const room = Math.max(0, QUEUE_TUNING.hypePerWaitCap - this.hypeEarnedThisWait);
      const hype = Math.min(room, QUEUE_TUNING.hypePerSec * sec);
      this.hypeEarnedThisWait += hype;

      this.bus.emit('meters:delta', { aggro, ...(hype > 0 ? { hype } : {}) });
      return;
    }

    if (this.waiting.length === 0) this.waitMs = 0;

    // Somebody is being served, or nobody is waiting: the line relaxes in
    // proportion to how short it is. This is the pressure valve that makes the
    // night survivable — keep it moving and the aggro you earn bleeds back off.
    const slack = QUEUE_TUNING.comfortableQueue - this.waiting.length;
    if (slack > 0) {
      const relief = (QUEUE_TUNING.calmPerSec * slack) / QUEUE_TUNING.comfortableQueue;
      this.bus.emit('meters:delta', { aggro: -relief * sec });
    }

    // Somebody is at the rope being dealt with: the theatre wears off.
    if (this.up !== null) {
      this.bus.emit('meters:delta', { hype: -QUEUE_TUNING.hypeDecayPerSec * sec });
    }
  }

  /** The Rope. Nothing steps up until the player says so. */
  callNext(): Patron | null {
    if (this.up !== null) return null;
    const next = this.waiting.shift();
    if (!next) return null;
    this.up = next;
    this.waitMs = 0;
    this.hypeEarnedThisWait = 0;
    this.seen.push(next);
    this.bus.emit('door:patronUp', { patron: next });
    return next;
  }

  /** Player checked their phone while someone waits — reward the theatre. */
  lookAtPhone(): boolean {
    if (!this.isPhoneReady || this.waiting.length === 0) return false;
    this.phoneCooldownMs = QUEUE_TUNING.phoneCooldownMs;
    this.bus.emit('meters:delta', { hype: QUEUE_TUNING.phoneHype });
    return true;
  }

  /**
   * The patron at the rope has been dealt with. Denied regulars nurse the grudge
   * and try again later in the night (design §4.1 — regulars have memory).
   */
  resolveUp(denied: boolean): void {
    const p = this.up;
    this.up = null;
    this.waitMs = 0;
    this.hypeEarnedThisWait = 0;
    if (!p || !denied) return;

    const priorDenials = p.flags.timesDeniedBefore ?? 0;
    if (p.archetype === 'regular' && this.clockMin < 300 && priorDenials < QUEUE_TUNING.regularMaxComebacks) {
      const [lo, hi] = QUEUE_TUNING.regularRetryMin;
      const back: Patron = {
        ...p,
        flags: { ...p.flags, timesDeniedBefore: (p.flags.timesDeniedBefore ?? 0) + 1 },
      };
      this.comebacks.push({ atMin: this.clockMin + this.comebackRng.int(lo, hi), patron: back });
    }
  }

  private spawnArrival(): void {
    // EVERY draw here happens unconditionally, before anything looks at whether
    // the queue had room. Queue length is player-controlled, so a draw taken
    // behind an `if (enqueued)` would make the rest of the night's patrons
    // depend on how the player was playing — CONTRACTS.md §6 says same seed,
    // same night, full stop.
    const first = this.make();
    const isCouple = this.coupleRng.chance(QUEUE_TUNING.coupleChance);
    const partner = isCouple ? this.make() : null;

    const p = this.enqueue(first);
    if (p && partner) {
      p.flags.handHolding = true;
      partner.flags.handHolding = true;
      this.enqueue(partner);
    }
  }

  /** Which scripted encounter this patron is, if any. */
  encounterFor(patron: Patron): EncounterId | undefined {
    return this.encounterOf.get(patron.id);
  }

  private make(): Patron {
    let p = generatePatron(
      { rng: this.rng, nightDate: this.nightDate, ...(this.archetypeWeights ? { archetypeWeights: this.archetypeWeights } : {}) },
      this.clockMin,
    );
    // A rolled 'regular' is one of THE regulars — the run's stable cast. Same
    // face every night; the grudge (and the moustache) rides the run save.
    if (p.archetype === 'regular' && this.regulars && this.regulars.cast.length > 0) {
      const member = this.regulars.cast[this.castRng.int(0, this.regulars.cast.length - 1)]!;
      const mem = this.regulars.memory[member.flags.regularId ?? ''];
      p = adoptCastIdentity(p, member, mem?.timesDenied ?? 0, disguisedFromMemory(mem));
    }

    // Anyone who says they're on the list gets a name the clipboard has an
    // opinion about. Done here rather than in the generator because the list is
    // a door-phase prop and patrons/ is frozen — see rules/guestList.ts for why
    // an exact match is the suspicious one.
    if (p.flags.claimsGuestList) {
      p.idCard.name = assignListedName(this.listRng, this.guestList, p.flags.onGuestList === true);
    }
    return p;
  }

  /**
   * A scripted patron, if one is due. Generated through the SAME generator so
   * their doll, ID and outfit are ordinary — `dress()` only changes what the
   * script needs, which is why they don't announce themselves before speaking.
   */
  private makeScripted(id: EncounterId): Patron | null {
    const enc = encounterById(id);
    if (!enc) return null;
    const p = generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin, enc.archetype);
    enc.dress(p, this.encounterRng);
    if (p.flags.claimsGuestList) {
      p.idCard.name = assignListedName(this.listRng, this.guestList, p.flags.onGuestList === true);
    }
    this.encounterOf.set(p.id, id);
    return p;
  }

  private enqueue(p: Patron): Patron | null {
    if (this.waiting.length >= QUEUE_TUNING.maxQueue) return null; // they've gone for a kebab
    this.waiting.push(p);
    return p;
  }
}
