import { describe, expect, it } from 'vitest';
import { SeededRNG } from '../src/core/SeededRNG';
import {
  ARRIVAL_CURVE,
  arrivalsPerMin,
  expectedTotalArrivals,
  nextArrivalGapMin,
} from '../src/scenes/door/arrivalCurve';

// Two DIFFERENT private values in arrivalCurve.ts, kept apart on purpose:
//  - the dead-rate sentinel, returned only when the rate is 0;
//  - the real gap cap, the most in-game minutes one live roll may ever burn.
// Asserting only against the sentinel would let the gap cap drift to anything
// under 999 unnoticed, which is exactly the bug the cap exists to prevent.
const DEAD_SENTINEL = 999;
/** A live gap must never eat more than a sixth of the 360-minute night. */
const MAX_LIVE_GAP = 60;

const sweep = (): number[] => Array.from({ length: 361 }, (_, i) => arrivalsPerMin(i));
const peak = (): number => Math.max(...sweep());

describe('ARRIVAL_CURVE', () => {
  it('is sorted, spans the whole night, and never goes negative', () => {
    expect(ARRIVAL_CURVE[0]?.clockMin).toBe(0);
    expect(ARRIVAL_CURVE[ARRIVAL_CURVE.length - 1]?.clockMin).toBe(360);
    for (let i = 1; i < ARRIVAL_CURVE.length; i++) {
      expect(ARRIVAL_CURVE[i]!.clockMin).toBeGreaterThan(ARRIVAL_CURVE[i - 1]!.clockMin);
    }
    for (const p of ARRIVAL_CURVE) expect(p.perMin).toBeGreaterThanOrEqual(0);
  });
});

describe('arrivalsPerMin', () => {
  it('interpolates strictly between adjacent control points', () => {
    const at90 = arrivalsPerMin(90);
    const at150 = arrivalsPerMin(150);
    const mid = arrivalsPerMin(120);
    expect(at150).toBeGreaterThan(at90);
    expect(mid).toBeGreaterThan(at90);
    expect(mid).toBeLessThan(at150);
  });

  it('clamps flat outside 0..360', () => {
    expect(arrivalsPerMin(-50)).toBe(ARRIVAL_CURVE[0]!.perMin);
    expect(arrivalsPerMin(0)).toBe(ARRIVAL_CURVE[0]!.perMin);
    const lastPerMin = ARRIVAL_CURVE[ARRIVAL_CURVE.length - 1]!.perMin;
    expect(arrivalsPerMin(400)).toBe(lastPerMin);
    expect(arrivalsPerMin(360)).toBe(lastPerMin);
  });

  it('peaks inside the 10:30–12:30 slam window', () => {
    const values = sweep();
    const peakIndex = values.indexOf(Math.max(...values));
    expect(peakIndex).toBeGreaterThanOrEqual(90);
    expect(peakIndex).toBeLessThanOrEqual(210);
    expect(peakIndex).toBe(150);
  });

  it('9 PM is quiet — under a third of peak', () => {
    expect(arrivalsPerMin(0)).toBeLessThan(peak() / 3);
  });

  it('2:30 AM has tapered hard — under a quarter of peak', () => {
    expect(arrivalsPerMin(330)).toBeLessThan(peak() / 4);
  });

  it('is never negative and never NaN, including for junk input', () => {
    for (let m = -60; m <= 420; m++) {
      const v = arrivalsPerMin(m);
      expect(Number.isNaN(v)).toBe(false);
      expect(v).toBeGreaterThanOrEqual(0);
    }
    for (const junk of [NaN, Infinity, -Infinity]) {
      const v = arrivalsPerMin(junk);
      expect(Number.isNaN(v)).toBe(false);
      expect(v).toBeGreaterThanOrEqual(0);
    }
  });
});

describe('expectedTotalArrivals', () => {
  it('lands in the 95-130 patron design band (tuning 2026-07-19: the slam must outrun the door)', () => {
    const total = expectedTotalArrivals();
    expect(total).toBeGreaterThanOrEqual(95);
    expect(total).toBeLessThanOrEqual(130);
  });
});

describe('nextArrivalGapMin', () => {
  it('is finite, positive and capped across 2000 (clockMin, roll) pairs', () => {
    const rolls = [0, 0.5, 0.9999999];
    for (const roll of rolls) {
      for (let m = 0; m <= 360; m++) {
        const gap = nextArrivalGapMin(m, roll);
        expect(Number.isFinite(gap)).toBe(true);
        expect(gap).toBeGreaterThan(0);
        expect(gap).toBeLessThanOrEqual(DEAD_SENTINEL);
      }
    }
    for (let i = 0; i < 920; i++) {
      const gap = nextArrivalGapMin((i * 7) % 361, i / 920);
      expect(Number.isFinite(gap)).toBe(true);
      expect(gap).toBeGreaterThan(0);
      expect(gap).toBeLessThanOrEqual(DEAD_SENTINEL);
    }
  });

  it('no single unlucky roll can swallow the night', () => {
    // The spec's cap bullet. Sweep the LIVE window (rate > 0) at rolls pressed
    // right up against 1 — the worst draw the exponential can produce — and
    // demand the cap bites. Without this, the cap could be raised to any value
    // under the dead-rate sentinel and every other test would still pass.
    let worst = 0;
    for (const roll of [0.99, 0.999, 0.999999, 0.9999999, 1, 2]) {
      for (let m = 0; m <= 359; m++) {
        const gap = nextArrivalGapMin(m, roll);
        if (gap === DEAD_SENTINEL) continue; // dead rate, not a live draw
        expect(Number.isFinite(gap)).toBe(true);
        worst = Math.max(worst, gap);
      }
    }
    expect(worst).toBeGreaterThan(0);
    expect(worst).toBeLessThanOrEqual(MAX_LIVE_GAP);
  });

  it('gaps are shorter at peak than at 9 PM for the same roll', () => {
    // Non-strict across the whole roll range: the sub-minute floor that stops
    // two patrons landing on one frame also floors BOTH ends for tiny rolls,
    // so strict inequality is genuinely false down there. Assert what is true.
    for (let i = 0; i <= 100; i++) {
      const roll = i / 101;
      expect(nextArrivalGapMin(150, roll)).toBeLessThanOrEqual(nextArrivalGapMin(0, roll));
    }
    // Strict wherever the floor is not binding, which is the range that matters.
    for (const roll of [0.05, 0.1, 0.5, 0.9, 0.99]) {
      expect(nextArrivalGapMin(150, roll)).toBeLessThan(nextArrivalGapMin(0, roll));
    }
  });

  it('a dead rate returns a large finite gap rather than Infinity', () => {
    // 3 AM: the curve is zero, so the maths would divide by zero if unguarded.
    expect(arrivalsPerMin(360)).toBe(0);
    const gap = nextArrivalGapMin(360, 0.5);
    expect(Number.isFinite(gap)).toBe(true);
    expect(gap).toBe(DEAD_SENTINEL);
  });

  it('a simulated night delivers a plausible crowd, on every seed', () => {
    // Twelve seeds, not one: a single seed proves the seed was lucky, not that
    // the curve is tuned. Observed across 200 seeds: min 49, mean 71, max 93.
    for (const seed of [1, 7, 42, 99, 404, 1312, 2600, 4207, 7777, 9001, 31337, 65535]) {
      const rng = new SeededRNG(seed).stream('arrivals');
      let clock = 0;
      let arrivals = 0;
      while (clock < 360) {
        clock += nextArrivalGapMin(clock, rng.next());
        if (clock < 360) arrivals++;
      }
      expect(arrivals, `seed ${seed}`).toBeGreaterThanOrEqual(75);
      expect(arrivals, `seed ${seed}`).toBeLessThanOrEqual(150);
    }
  });
});
