import { beforeEach, describe, expect, it } from 'vitest';
import { EventBus } from '../../src/core/EventBus';
import { SeededRNG } from '../../src/core/SeededRNG';
import type { Patron } from '../../src/data/types';
import { _resetPatronSerial, generatePatron } from '../../src/patrons/generator';
import { CrowdSim } from '../../src/scenes/floor/crowdSim';
import type { Agent } from '../../src/scenes/floor/crowdSim';
import { VENUE, isWalkableWorld } from '../../src/scenes/floor/venueMap';

const NIGHT = new Date('2026-07-19T21:00:00');

function makeSim(seed = 1234): CrowdSim {
  return new CrowdSim({ map: VENUE, bus: new EventBus(), rng: new SeededRNG(seed).stream('crowd') });
}

/** Same seed in, same crowd out — the sims under test must not share patrons. */
function makeCrowd(count: number, seed = 99): Patron[] {
  _resetPatronSerial();
  const rng = new SeededRNG(seed);
  const out: Patron[] = [];
  for (let i = 0; i < count; i++) out.push(generatePatron({ rng, nightDate: NIGHT }, 60));
  return out;
}

const isEntry = (a: Agent): boolean =>
  VENUE.anchors.entry.some((e) => e.x === a.x && e.y === a.y);

/** Park an agent in a fixed activity so the test can drive one thing at a time. */
function pin(a: Agent, activity: Agent['activity'], x?: number, y?: number): Agent {
  a.activity = activity;
  a.dwellMs = 10 * 60 * 1000;
  if (x !== undefined) a.x = x;
  if (y !== undefined) a.y = y;
  return a;
}

beforeEach(() => {
  _resetPatronSerial();
});

describe('spawn', () => {
  it('puts the agent on an entry anchor, unhandled and present', () => {
    const sim = makeSim();
    for (const p of makeCrowd(6)) {
      const a = sim.spawn(p);
      expect(isEntry(a)).toBe(true);
      expect(a.handled).toBe(false);
      expect(a.gone).toBe(false);
      expect(a.activity).toBe('walking');
    }
    expect(sim.agents).toHaveLength(6);
  });

  it('finds agents by patron id', () => {
    const sim = makeSim();
    const crowd = makeCrowd(3);
    for (const p of crowd) sim.spawn(p);
    expect(sim.find(crowd[1]!.id)?.patron).toBe(crowd[1]);
    expect(sim.find('nobody')).toBeUndefined();
  });
});

describe('movement', () => {
  it('keeps every agent on walkable ground for a whole night', () => {
    const sim = makeSim();
    for (const p of makeCrowd(14)) sim.spawn(p);

    for (let step = 0; step < 6000; step++) {
      sim.update(16, step / 60);
      for (const a of sim.agents) {
        expect(
          isWalkableWorld(VENUE, a.x, a.y),
          `${a.patron.id} walked into a wall at ${a.x.toFixed(1)},${a.y.toFixed(1)}`,
        ).toBe(true);
      }
    }
  });

  it('actually gets people to their anchors — nobody walks all night', () => {
    const sim = makeSim();
    for (const p of makeCrowd(10)) sim.spawn(p);
    const settled = new Set<string>();
    for (let step = 0; step < 4000; step++) {
      sim.update(16, step / 60);
      for (const a of sim.agents) if (a.activity !== 'walking') settled.add(a.patron.id);
    }
    expect(settled.size).toBe(10);
  });
});

describe('intoxication drift', () => {
  it('rises with the clock, and faster at the bar', () => {
    const sim = makeSim();
    const [barP, danceP] = makeCrowd(2);
    const bar = pin(sim.spawn(barP!), 'atBar');
    const dance = pin(sim.spawn(danceP!), 'dancing');
    for (const a of [bar, dance]) {
      a.patron.tolerance = 0.5;
      a.patron.intoxication = 0.3;
    }

    sim.update(16, 0);
    sim.update(16, 90);

    expect(bar.patron.intoxication).toBeGreaterThan(0.3);
    expect(dance.patron.intoxication).toBeGreaterThan(0.3);
    expect(bar.patron.intoxication).toBeGreaterThan(dance.patron.intoxication);
  });

  it('is framerate independent — only clock minutes count', () => {
    const coarse = makeSim();
    const fine = makeSim();
    const [a1] = makeCrowd(1);
    const [a2] = makeCrowd(1);
    const slow = pin(coarse.spawn(a1!), 'atBar');
    const fast = pin(fine.spawn(a2!), 'atBar');
    for (const a of [slow, fast]) {
      a.patron.tolerance = 0.4;
      a.patron.intoxication = 0.2;
    }

    coarse.update(16, 0);
    coarse.update(16, 60);
    fine.update(16, 0);
    for (let i = 1; i <= 60; i++) fine.update(16, i);

    expect(fast.patron.intoxication).toBeCloseTo(slow.patron.intoxication, 10);
  });

  it('never tips past legless', () => {
    const sim = makeSim();
    const a = pin(sim.spawn(makeCrowd(1)[0]!), 'atBar');
    a.patron.tolerance = 0;
    a.patron.intoxication = 0.9;
    sim.update(16, 0);
    sim.update(16, 10000);
    expect(a.patron.intoxication).toBe(1);
  });
});

describe('beat bob', () => {
  it('advances dancers only', () => {
    const sim = makeSim();
    const [p1, p2] = makeCrowd(2);
    const dancer = pin(sim.spawn(p1!), 'dancing');
    const drinker = pin(sim.spawn(p2!), 'atBar');
    const before = { dancer: dancer.bobPhase, drinker: drinker.bobPhase };

    sim.onBeat(1);
    expect(dancer.bobPhase).not.toBe(before.dancer);
    expect(drinker.bobPhase).toBe(before.drinker);
  });

  it('keeps the phase inside 0..1 across a long set', () => {
    const sim = makeSim();
    const dancer = pin(sim.spawn(makeCrowd(1)[0]!), 'dancing');
    for (let beat = 0; beat < 500; beat++) {
      sim.onBeat(beat);
      expect(dancer.bobPhase).toBeGreaterThanOrEqual(0);
      expect(dancer.bobPhase).toBeLessThan(1);
    }
  });
});

describe('stalls', () => {
  it('reports who is in which cubicle', () => {
    const sim = makeSim();
    const [p1, p2, p3] = makeCrowd(3);
    const a = pin(sim.spawn(p1!), 'inStall');
    const b = pin(sim.spawn(p2!), 'inStall');
    const c = pin(sim.spawn(p3!), 'inStall');
    a.stallId = 's1';
    b.stallId = 's1';
    c.stallId = 's3';

    const occ = sim.stallOccupancy();
    expect(occ.get('s1')).toHaveLength(2);
    expect(occ.get('s2')).toHaveLength(0);
    expect(occ.get('s3')).toEqual([c]);
  });

  it('lets two punters end up in one cubicle on its own', () => {
    const sim = makeSim();
    for (const p of makeCrowd(14)) sim.spawn(p);
    let doubled = false;
    let maxOccupancy = 0;
    for (let step = 0; step < 9000; step++) {
      // Slow clock: at step/60 the crowd aged past its stay window (natural
      // churn, 2026-07-19) and went home before ever doubling up in a cubicle.
      sim.update(16, step / 600);
      for (const [, list] of sim.stallOccupancy()) {
        maxOccupancy = Math.max(maxOccupancy, list.length);
        if (list.length >= 2) doubled = true;
      }
    }
    expect(doubled).toBe(true);
    expect(maxOccupancy).toBeLessThanOrEqual(2); // the cap holds
  });
});

describe('bumping', () => {
  function bumpRig(): { sim: CrowdSim; hits: number[]; drunk: Agent } {
    const bus = new EventBus();
    const sim = new CrowdSim({ map: VENUE, bus, rng: new SeededRNG(5).stream('crowd') });
    const hits: number[] = [];
    bus.on('meters:delta', (d) => hits.push(d.aggro ?? 0));

    const [p1, p2] = makeCrowd(2);
    const drunk = pin(sim.spawn(p1!), 'dancing');
    const mate = pin(sim.spawn(p2!), 'dancing');
    drunk.patron.intoxication = 0.95;
    drunk.patron.tolerance = 1; // no drift, so the stage under test stays put
    mate.patron.intoxication = 0.05;
    mate.patron.tolerance = 1;
    mate.x = drunk.x;
    mate.y = drunk.y;
    return { sim, hits, drunk };
  }

  it('fires once, then not again until the cooldown lapses', () => {
    const { sim, hits } = bumpRig();
    for (let i = 0; i < 100; i++) sim.update(16, 0); // 1.6s of grinding together
    expect(hits).toHaveLength(1);
    expect(hits[0]).toBeGreaterThan(0);

    // Second leak needs BOTH windows open: the 12s per-agent cooldown and the
    // 8s sim-wide gate. 800 more frames puts total elapsed at ~14.4s.
    for (let i = 0; i < 800; i++) sim.update(16, 0);
    expect(hits).toHaveLength(2);
  });

  // Regression: the per-agent cooldown bounds one PAIR, but the leak rate used
  // to scale with crowd size — a full late-night floor pinned vibe to 0 and
  // aggro to 100 on its own, before the player had touched anything. The
  // sim-wide gate is what keeps ambient chaos a background cost.
  it('bounds the total leak no matter how big the rowdy crowd is', () => {
    const bus = new EventBus();
    const sim = new CrowdSim({ map: VENUE, bus, rng: new SeededRNG(9).stream('crowd') });
    let vibe = 0;
    bus.on('meters:delta', (d) => {
      vibe += d.vibe ?? 0;
    });

    // 30 maggots stacked on one spot — the worst case the venue can produce.
    const agents = makeCrowd(30).map((p) => pin(sim.spawn(p), 'dancing'));
    const first = agents[0]!;
    for (const a of agents) {
      a.patron.intoxication = 0.95;
      a.patron.tolerance = 1;
      a.x = first.x;
      a.y = first.y;
    }

    for (let i = 0; i < 3750; i++) sim.update(16, 0); // 60s of maximum chaos
    // One leak per 8s, so a minute of it costs ~8 bumps, not hundreds.
    expect(vibe).toBeGreaterThan(-5);
    expect(vibe).toBeLessThan(0); // but it does still cost you something
  });

  it('stays quiet when the sober one is the only neighbour', () => {
    const { sim, hits, drunk } = bumpRig();
    drunk.patron.intoxication = 0.1; // nobody here is messy
    for (let i = 0; i < 400; i++) sim.update(16, 0);
    expect(hits).toHaveLength(0);
  });

  it('carries the contracted vibe/aggro payload', () => {
    const bus = new EventBus();
    const sim = new CrowdSim({ map: VENUE, bus, rng: new SeededRNG(5).stream('crowd') });
    const seen: Array<{ vibe?: number; aggro?: number }> = [];
    bus.on('meters:delta', (d) => seen.push(d));
    const [p1, p2] = makeCrowd(2);
    const drunk = pin(sim.spawn(p1!), 'dancing');
    const mate = pin(sim.spawn(p2!), 'dancing');
    drunk.patron.intoxication = 0.95;
    drunk.patron.tolerance = 1;
    mate.patron.tolerance = 1;
    mate.x = drunk.x;
    mate.y = drunk.y;
    sim.update(16, 0);
    expect(seen).toEqual([{ vibe: -0.4, aggro: 0.6 }]);
  });
});

describe('determinism', () => {
  it('same seed and same calls give identical positions', () => {
    const snap = (sim: CrowdSim): string => sim.agents
      .map((a) => `${a.patron.id}:${a.x.toFixed(6)},${a.y.toFixed(6)}:${a.activity}:${a.patron.intoxication.toFixed(6)}`)
      .join('|');

    const runOne = (): string => {
      const sim = makeSim(4242);
      for (const p of makeCrowd(9)) sim.spawn(p);
      for (let step = 0; step < 3000; step++) sim.update(16, step / 60);
      return snap(sim);
    };

    expect(runOne()).toBe(runOne());
  });
});

describe('escort', () => {
  it('walks them to the door when you follow, and only then', () => {
    const sim = makeSim();
    const a = sim.spawn(makeCrowd(1)[0]!);
    // Start them deep in the dunnies — the nastiest route out of the venue.
    a.x = VENUE.stalls[0]!.door.x;
    a.y = VENUE.stalls[0]!.door.y;

    expect(sim.beginEscort(a)).toBe(true);
    expect(a.activity).toBe('escorted');
    expect(a.handled).toBe(true);
    expect(sim.beginEscort(a)).toBe(false);

    // Stand off and they refuse to budge.
    const parked = { x: a.x, y: a.y };
    for (let i = 0; i < 100; i++) sim.updateEscorts(parked.x + 400, parked.y + 200, 16);
    expect(a.x).toBe(parked.x);
    expect(a.y).toBe(parked.y);
    expect(a.gone).toBe(false);

    // Stay on their heels and they go.
    for (let i = 0; i < 12000 && !a.gone; i++) {
      sim.updateEscorts(a.x, a.y, 16);
      expect(isWalkableWorld(VENUE, a.x, a.y)).toBe(true);
    }
    expect(a.gone).toBe(true);
    expect(a.activity).toBe('leaving');
    expect(a.x).toBe(VENUE.anchors.exit[0]!.x);
    expect(a.y).toBe(VENUE.anchors.exit[0]!.y);
  });

  it('refuses to escort someone already gone', () => {
    const sim = makeSim();
    const a = sim.spawn(makeCrowd(1)[0]!);
    sim.beginEscort(a);
    for (let i = 0; i < 12000 && !a.gone; i++) sim.updateEscorts(a.x, a.y, 16);
    expect(a.gone).toBe(true);
    expect(sim.beginEscort(a)).toBe(false);
  });

  it('pulls them out of the cubicle they were hiding in', () => {
    const sim = makeSim();
    const a = pin(sim.spawn(makeCrowd(1)[0]!), 'inStall');
    a.stallId = 's2';
    a.x = VENUE.stalls[1]!.inside.x;
    a.y = VENUE.stalls[1]!.inside.y;

    expect(sim.beginEscort(a)).toBe(true);
    expect(a.stallId).toBeUndefined();
    expect(a.x).toBe(VENUE.stalls[1]!.door.x);
    expect(sim.stallOccupancy().get('s2')).toHaveLength(0);
  });
});

describe('destroy', () => {
  it('drops the crowd', () => {
    const sim = makeSim();
    for (const p of makeCrowd(4)) sim.spawn(p);
    sim.destroy();
    expect(sim.agents).toHaveLength(0);
    expect(sim.find('p0')).toBeUndefined();
  });
});
