import { beforeEach, describe, expect, it } from 'vitest';
import { EventBus } from '../../src/core/EventBus';
import { SeededRNG } from '../../src/core/SeededRNG';
import type { EventMap, 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 { FIGHT_COOLDOWN_MS, FIGHT_FUSE_MS } from '../../src/scenes/floor/fight';
import { FightDirector } from '../../src/scenes/floor/fightDirector';
import { FIGHT_CAUSE } from '../../src/data/strings/floor';
import { VENUE } from '../../src/scenes/floor/venueMap';

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

// Nowhere near the pair, so nothing accidentally counts as intervening.
const AWAY_X = 20;
const AWAY_Y = 20;

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;
}

interface Rig {
  bus: EventBus;
  crowd: CrowdSim;
  dir: FightDirector;
  agents: Agent[];
  meters: Array<EventMap['meters:delta']>;
  heat: Array<EventMap['heat:strike']>;
  spotted: Array<EventMap['floor:infractionSpotted']>;
}

/**
 * `count` agents parked shoulder-to-shoulder and all well past FIGHT_MIN_INTOX,
 * so every pair is eligible and the director has a real choice to make. Nothing
 * calls crowd.update() unless a test wants it — that keeps the bus to fight events.
 */
function rig(count = 2, seed = 7): Rig {
  const bus = new EventBus();
  const rng = new SeededRNG(seed);
  const crowd = new CrowdSim({ map: VENUE, bus, rng: rng.stream('crowd') });

  const agents = makeCrowd(count).map((p, i) => {
    const a = crowd.spawn(p);
    a.patron.intoxication = 0.8;
    a.activity = 'dancing';
    a.dwellMs = 10 * 60 * 1000;
    // Open floor, 20px apart: inside FIGHT_MIN_DIST_PX (so neighbours are
    // eligible), outside the ambient bump radius, and clear of every wall so a
    // released agent can actually walk off.
    a.x = 240 + i * 20;
    a.y = 100;
    return a;
  });

  const meters: Array<EventMap['meters:delta']> = [];
  const heat: Array<EventMap['heat:strike']> = [];
  const spotted: Array<EventMap['floor:infractionSpotted']> = [];
  bus.on('meters:delta', (p) => meters.push(p));
  bus.on('heat:strike', (p) => heat.push(p));
  bus.on('floor:infractionSpotted', (p) => spotted.push(p));

  return {
    bus, crowd, agents, meters, heat, spotted,
    dir: new FightDirector({ bus, rng: rng.stream('fights'), crowd }),
  };
}

function runAway(dir: FightDirector, ms: number, step = 100): void {
  for (let t = 0; t < ms; t += step) dir.update(AWAY_X, AWAY_Y, step);
}

/** The point the player has to stand on to get between them. */
function midpoint(p: [Agent, Agent]): [number, number] {
  return [(p[0].x + p[1].x) / 2, (p[0].y + p[1].y) / 2];
}

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

describe('starting', () => {
  it('kicks off a fight in a drunk crowd and flags it as an infraction', () => {
    const r = rig(6);
    runAway(r.dir, 500);

    const pair = r.dir.pair();
    expect(pair).not.toBeNull();
    expect(r.dir.active?.phase).toBe('shoving');
    expect(FIGHT_CAUSE).toContain(r.dir.cause);
    expect(r.spotted).toEqual([{ patronId: pair?.[0].patron.id, kind: 'fight' }]);
  });

  it('never runs two fights at once', () => {
    const r = rig(6);
    // Derived from the constants rather than hardcoded: a wall of frames that
    // covered several fights at a 20s cooldown covers less than one at 150s,
    // and the >1 assertion below is what proves the window actually exercised
    // the between-fights transition rather than passing vacuously.
    const dt = 50;
    const frames = Math.ceil((3 * (FIGHT_FUSE_MS + FIGHT_COOLDOWN_MS)) / dt);
    for (let i = 0; i < frames; i++) {
      r.dir.update(AWAY_X, AWAY_Y, dt);
      const squaring = r.agents.filter((a) => a.activity === 'squaringUp');
      expect(squaring.length).toBeLessThanOrEqual(2);
      expect(squaring.length).toBe(r.dir.active ? 2 : 0);
    }
    expect(r.spotted.length).toBeGreaterThan(1);
  });

  it('leaves a sober crowd alone', () => {
    const r = rig(6);
    for (const a of r.agents) a.patron.intoxication = 0.2;
    runAway(r.dir, 60_000);
    expect(r.dir.active).toBeNull();
    expect(r.spotted).toHaveLength(0);
  });

  it('pins both agents so the sim stops moving them', () => {
    const r = rig(3);
    // Walking, not parked — without the pin the sim would carry them off, which
    // is what makes the unchanged coordinates below mean anything.
    for (const a of r.agents) a.activity = 'walking';

    r.dir.update(AWAY_X, AWAY_Y, 16);
    const pair = r.dir.pair();
    expect(pair).not.toBeNull();
    if (!pair) return;

    const loose = r.agents.find((a) => !pair.includes(a));
    const before = [...pair, loose].map((a) => ({ x: a?.x, y: a?.y }));

    for (let i = 0; i < 20; i++) {
      r.crowd.update(16, 60 + i / 60);
      r.dir.update(AWAY_X, AWAY_Y, 16);
    }

    for (const [i, a] of pair.entries()) {
      expect(a.activity).toBe('squaringUp');
      expect(a.x).toBe(before[i]?.x);
      expect(a.y).toBe(before[i]?.y);
    }
    // Control: the one nobody pinned kept walking.
    expect(loose?.x).not.toBe(before[2]?.x);
  });
});

describe('resolving', () => {
  it('breaks up when you stand between them', () => {
    const r = rig(2);
    r.dir.update(AWAY_X, AWAY_Y, 16);
    const pair = r.dir.pair();
    expect(pair).not.toBeNull();
    if (!pair) return;

    const [px, py] = midpoint(pair);
    r.dir.update(px, py, 16);

    expect(r.dir.active).toBeNull();
    expect(r.meters).toEqual([{ vibe: 6, aggro: 1 }]);
    expect(r.heat).toHaveLength(0);
    for (const a of pair) {
      expect(a.handled).toBe(true);
      expect(a.activity).toBe('walking');
    }
  });

  it('lets the swing land if you walk away, and takes a heat strike for it', () => {
    const r = rig(2);
    runAway(r.dir, FIGHT_FUSE_MS + 500);

    expect(r.dir.active).toBeNull();
    expect(r.meters).toEqual([{ vibe: -12, aggro: 20 }]);
    // Deferred: a missed brawl surfaces at inspection rather than striking on the spot.
    expect(r.heat).toEqual([{ reason: 'Fight on the floor', deferred: true }]);
  });

  it('does not count a swung fight as dealt with', () => {
    const r = rig(2);
    r.dir.update(AWAY_X, AWAY_Y, 16);
    const pair = r.dir.pair();
    runAway(r.dir, FIGHT_FUSE_MS + 500);

    expect(r.dir.takeResolution()?.phase).toBe('swung');
    for (const a of pair ?? []) {
      expect(a.handled).toBe(false);
      expect(a.activity).toBe('walking');
    }
  });

  it('hands the resolution over exactly once', () => {
    const r = rig(2);
    r.dir.update(AWAY_X, AWAY_Y, 16);
    const pair = r.dir.pair();
    expect(r.dir.takeResolution()).toBeNull(); // nothing to say mid-fight
    if (!pair) return;

    const [px, py] = midpoint(pair);
    r.dir.update(px, py, 16);

    const res = r.dir.takeResolution();
    expect(res?.phase).toBe('broken');
    expect(res?.outcome.heat).toBe(false);
    expect(res?.line).toBeTruthy();
    expect(r.dir.takeResolution()).toBeNull();
  });
});

describe('cooldown', () => {
  it('will not start another fight straight after one resolves', () => {
    const r = rig(6);
    runAway(r.dir, FIGHT_FUSE_MS + 200);
    expect(r.spotted).toHaveLength(1);
    expect(r.dir.active).toBeNull();

    runAway(r.dir, FIGHT_COOLDOWN_MS - 1000);
    expect(r.dir.active).toBeNull();
    expect(r.spotted).toHaveLength(1);

    runAway(r.dir, 2000);
    expect(r.spotted).toHaveLength(2);
  });
});

describe('bail-outs', () => {
  it('aborts without scoring if one of them leaves mid-fight', () => {
    const r = rig(2);
    r.dir.update(AWAY_X, AWAY_Y, 16);
    const pair = r.dir.pair();
    expect(pair).not.toBeNull();
    if (!pair) return;

    const [a, b] = pair;
    a.gone = true;
    r.dir.update(AWAY_X, AWAY_Y, 16);

    expect(r.dir.active).toBeNull();
    expect(r.dir.takeResolution()).toBeNull();
    expect(r.meters).toHaveLength(0);
    expect(r.heat).toHaveLength(0);
    expect(b.activity).toBe('walking');
    expect(b.handled).toBe(false);
    // Nobody drags a departed patron back onto the dance floor.
    expect(a.activity).toBe('squaringUp');

    // And the cooldown started, so the survivor isn't instantly in another one.
    runAway(r.dir, FIGHT_COOLDOWN_MS - 2000);
    expect(r.dir.active).toBeNull();
  });

  it('leaves nobody frozen when destroyed', () => {
    const r = rig(6);
    r.dir.update(AWAY_X, AWAY_Y, 16);
    expect(r.agents.filter((a) => a.activity === 'squaringUp')).toHaveLength(2);

    r.dir.destroy();

    expect(r.dir.active).toBeNull();
    expect(r.dir.pair()).toBeNull();
    expect(r.agents.filter((a) => a.activity === 'squaringUp')).toHaveLength(0);
  });
});

describe('determinism', () => {
  it('picks the same pair and the same cause from the same seed', () => {
    const runOnce = (): { ids: string[]; cause: string } => {
      const r = rig(6, 4242);
      r.dir.update(AWAY_X, AWAY_Y, 16);
      const pair = r.dir.pair();
      return { ids: (pair ?? []).map((a) => a.patron.id), cause: r.dir.cause };
    };

    const first = runOnce();
    expect(first.ids).toHaveLength(2);
    expect(runOnce()).toEqual(first);
  });

  it('diverges on a different seed', () => {
    const causeFor = (seed: number): string => {
      const r = rig(6, seed);
      runAway(r.dir, 200_000); // several fights, so the streams have room to drift
      return r.spotted.map((s) => s.patronId).join(',');
    };
    expect(causeFor(1)).not.toBe(causeFor(2));
  });
});
