// Bot-driven door-economy simulation. Not a test by itself — the harness the
// tuning tests drive. Node-only: EventBus + GameClock + Meters + QueueManager +
// judge, no Phaser. Mirrors DoorScene.applyOutcome / NightScene.fireDeferred
// closely enough that a knob change here means the same thing in the game.

import { EventBus } from '../../src/core/EventBus';
import { GameClock, DEFAULT_CLOCK } from '../../src/core/GameClock';
import { Meters, freshNightState, vibeCoolingPerMin } from '../../src/core/meters';
import { SeededRNG } from '../../src/core/SeededRNG';
import { _resetPatronSerial } from '../../src/patrons/generator';
import { QueueManager } from '../../src/scenes/door/QueueManager';
import { shouldRecordStrike } from '../../src/rules/heat';
import { judge } from '../../src/rules/judge';
import { violations } from '../../src/rules/dressCode';
import { idVerdict } from '../../src/rules/idCheck';
import type { DeferredHit } from '../../src/rules/doorTypes';
import type { Patron } from '../../src/data/types';

export interface BotPolicy {
  name: string;
  /** Real ms the bot waits before pulling the rope (its "walking back" time). */
  callDelayMs: (queueLen: number) => number;
  /** Real ms spent inspecting before the verdict lands. Models human reading. */
  ruleDelayMs: (p: Patron, clockMin: number, nightDate: Date) => number;
  decide: (p: Patron, clockMin: number, nightDate: Date, insideCount: number, licensed: number) => 'admit' | 'deny';
}

export interface NightMetrics {
  policy: string;
  seed: number;
  endReason: 'clock' | 'vibe' | 'aggro';
  endClockMin: number;
  admitted: number;
  denied: number;
  /** vibe sampled every 5 clock-min */
  vibeSamples: number[];
  vibeMin: number;
  vibeMax: number;
  /** share of post-11PM samples pinned at >= 95 (the "meter is dead" signal) */
  vibePinnedShare: number;
  aggroMax: number;
  aggroSamples: number[];
  hypePeak: number;
  heatStrikes: number;
  /** queue depth stats inside the slam window (clockMin 90..210) */
  slamQueueMax: number;
  slamQueueAvg: number;
  inside: number;
}

const TICK_MS = 100;

export interface SimVenue {
  arrivalScale: number;
  licensed: number;
}

/** Voltage-equivalent knobs — the tier the feel-bands are calibrated to. */
export const SIM_DEFAULT_VENUE: SimVenue = { arrivalScale: 1.0, licensed: 80 };

export function simulateNight(
  seed: number,
  policy: BotPolicy,
  venue: SimVenue = SIM_DEFAULT_VENUE,
): NightMetrics {
  _resetPatronSerial();
  const bus = new EventBus();
  const rng = new SeededRNG(seed);
  const clock = new GameClock(bus, DEFAULT_CLOCK);
  const state = freshNightState('sim', 0, venue.licensed);
  const meters = new Meters(bus, state);
  const queue = new QueueManager(bus, rng, clock.nightDate, [], venue.arrivalScale);
  const deferred: DeferredHit[] = [];
  const lapRng = rng.stream('dazzaLaps');
  // Mirrors CrowdSim's natural churn: admitted patrons go home after 50-140
  // in-game minutes, freeing capacity (floor:leave in the real game).
  const leaverRng = rng.stream('simLeavers');
  const leaveAt: number[] = [];

  const metrics: NightMetrics = {
    policy: policy.name,
    seed,
    endReason: 'clock',
    endClockMin: 360,
    admitted: 0,
    denied: 0,
    vibeSamples: [state.vibe],
    vibeMin: state.vibe,
    vibeMax: state.vibe,
    vibePinnedShare: 0,
    aggroMax: 0,
    aggroSamples: [state.aggro],
    hypePeak: 1,
    heatStrikes: 0,
    slamQueueMax: 0,
    slamQueueAvg: 0,
    inside: 0,
  };

  let ended = false;
  bus.on('meters:changed', ({ vibe, aggro, hype }) => {
    metrics.vibeMin = Math.min(metrics.vibeMin, vibe);
    metrics.vibeMax = Math.max(metrics.vibeMax, vibe);
    metrics.aggroMax = Math.max(metrics.aggroMax, aggro);
    metrics.hypePeak = Math.max(metrics.hypePeak, hype);
    if (ended) return;
    if (vibe <= 0) {
      ended = true;
      metrics.endReason = 'vibe';
      metrics.endClockMin = state.clockMin;
    } else if (aggro >= 100) {
      ended = true;
      metrics.endReason = 'aggro';
      metrics.endClockMin = state.clockMin;
    }
  });

  let lastSample = -5;
  let lastMinute = -1;
  const fireDeferred = (clockMin: number): void => {
    const due: DeferredHit[] = [];
    for (let i = deferred.length - 1; i >= 0; i--) {
      if (clockMin >= deferred[i]!.atClockMin) due.push(...deferred.splice(i, 1));
    }
    for (const hit of due) {
      if (ended) return;
      if (hit.vibeDelta !== undefined || hit.aggroDelta !== undefined) {
        bus.emit('meters:delta', {
          ...(hit.vibeDelta !== undefined ? { vibe: hit.vibeDelta } : {}),
          ...(hit.aggroDelta !== undefined ? { aggro: hit.aggroDelta } : {}),
        });
      }
      if (hit.heatStrike && shouldRecordStrike(state, hit.heatStrike)) {
        bus.emit('heat:strike', hit.heatStrike);
      }
    }
  };
  bus.on('heat:strike', () => metrics.heatStrikes++);
  bus.on('clock:tick', ({ clockMin }) => {
    queue.onClockMinute(clockMin);
    fireDeferred(clockMin);
    for (let i = leaveAt.length - 1; i >= 0; i--) {
      if (clockMin >= leaveAt[i]!) {
        leaveAt.splice(i, 1);
        state.capacity.inside = Math.max(0, state.capacity.inside - 1);
      }
    }
    const cooling = vibeCoolingPerMin(state.vibe);
    if (cooling !== 0 && !ended) bus.emit('meters:delta', { vibe: cooling });
    if (clockMin - lastSample >= 5) {
      lastSample = clockMin;
      metrics.vibeSamples.push(state.vibe);
      metrics.aggroSamples.push(state.aggro);
    }
  });

  // ---- the bot ----
  let waitTimerMs = 0;
  let ruleTimerMs = -1; // -1: nobody up

  const applyVerdict = (p: Patron, verdict: 'admit' | 'deny'): void => {
    const outcome = judge(p, verdict, {
      clockMin: state.clockMin,
      nightDate: clock.nightDate,
      lapRoll: lapRng.next(),
      insideCount: state.capacity.inside,
      licensed: state.capacity.licensed,
    });
    if (verdict === 'admit') {
      state.capacity.inside++;
      metrics.admitted++;
      leaveAt.push(state.clockMin + leaverRng.int(50, 140));
    } else metrics.denied++;
    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) bus.emit('meters:delta', delta);
    if (outcome.heatStrike && !outcome.heatStrike.deferred && shouldRecordStrike(state, outcome.heatStrike)) {
      bus.emit('heat:strike', outcome.heatStrike);
    }
    if (outcome.deferred?.length) deferred.push(...outcome.deferred);
    queue.resolveUp(verdict === 'deny');
  };

  clock.start();
  let slamTicks = 0;
  let slamQueueSum = 0;

  const totalMs = DEFAULT_CLOCK.nightRealMinutes * 60_000;
  for (let t = 0; t < totalMs && !ended; t += TICK_MS) {
    clock.update(TICK_MS);
    queue.update(TICK_MS);
    if (state.clockMin !== lastMinute) lastMinute = state.clockMin;

    if (state.clockMin >= 90 && state.clockMin <= 210) {
      slamTicks++;
      slamQueueSum += queue.queueLength;
      metrics.slamQueueMax = Math.max(metrics.slamQueueMax, queue.queueLength);
    }

    const up = queue.patronUp;
    if (up) {
      if (ruleTimerMs < 0) ruleTimerMs = policy.ruleDelayMs(up, state.clockMin, clock.nightDate);
      ruleTimerMs -= TICK_MS;
      if (ruleTimerMs <= 0) {
        applyVerdict(up, policy.decide(up, state.clockMin, clock.nightDate, state.capacity.inside, state.capacity.licensed));
        ruleTimerMs = -1;
        waitTimerMs = 0;
      }
    } else if (queue.queueLength > 0) {
      waitTimerMs += TICK_MS;
      if (waitTimerMs >= policy.callDelayMs(queue.queueLength)) {
        queue.callNext();
        waitTimerMs = 0;
      }
    }
  }

  // 3 AM audit: whatever deferred heat is left lands now.
  for (const hit of deferred) {
    if (hit.heatStrike && shouldRecordStrike(state, hit.heatStrike)) bus.emit('heat:strike', hit.heatStrike);
  }

  const post11 = metrics.vibeSamples.slice(Math.floor(120 / 5));
  metrics.vibePinnedShare = post11.length
    ? post11.filter((v) => v >= 95).length / post11.length
    : 0;
  metrics.slamQueueAvg = slamTicks ? slamQueueSum / slamTicks : 0;
  metrics.inside = state.capacity.inside;
  metrics.endClockMin = ended ? metrics.endClockMin : 360;
  meters.destroy();
  return metrics;
}

// ---- policies ----------------------------------------------------------------

/** Plays the rules straight, with human-ish deliberation time. */
export const CAREFUL: BotPolicy = {
  name: 'careful',
  callDelayMs: () => 700,
  ruleDelayMs: (p, _clockMin, nightDate) => {
    const id = idVerdict(p.idCard, nightDate);
    // Reading a dodgy card or a near-boundary DOB costs real seconds.
    return 2300 + (id.looksFake || id.underage || p.age <= 19 ? 1400 : 0);
  },
  decide: (p, clockMin, nightDate, insideCount, licensed) => {
    // Watches the clicker: one out, one in doesn't exist yet, so at capacity
    // the door simply shuts. This is what playing the licence looks like.
    if (insideCount >= licensed) return 'deny';
    const id = idVerdict(p.idCard, nightDate);
    if (id.underage || id.looksFake || p.age < 18) return 'deny';
    if (violations(p, clockMin).length > 0) return 'deny';
    if (p.intoxication > 0.62) return 'deny';
    return 'admit';
  },
};

/** Waves everyone in as fast as the buttons allow. */
export const SLOPPY: BotPolicy = {
  name: 'sloppy',
  callDelayMs: () => 300,
  ruleDelayMs: () => 700,
  decide: () => 'admit',
};

/** Careful verdicts, but farms the wait: lets the queue stew before every call. */
export const DAWDLER: BotPolicy = {
  name: 'dawdler',
  callDelayMs: () => 6000,
  ruleDelayMs: CAREFUL.ruleDelayMs,
  decide: CAREFUL.decide,
};
