import { describe, expect, it } from 'vitest';
import {
  applyBang,
  BANGS_TO_BUST,
  beatError,
  freshStall,
  judgeBang,
  OK_MS,
  PERFECT_MS,
  scoreStall,
  type StallState,
} from '../../src/scenes/floor/bangJudge';

const INTERVAL = 60_000 / 128;
const TICK = { beatIndex: 4, audioTimeMs: 10_000 };

describe('beatError', () => {
  it('is zero exactly on the tick', () => {
    expect(beatError(TICK.audioTimeMs, TICK, INTERVAL)).toBe(0);
  });

  it('measures to the nearest boundary, not to the tick itself', () => {
    expect(beatError(TICK.audioTimeMs + INTERVAL, TICK, INTERVAL)).toBeCloseTo(0, 6);
    expect(beatError(TICK.audioTimeMs + 3 * INTERVAL + 25, TICK, INTERVAL)).toBeCloseTo(25, 6);
  });

  it('judges a bang landing before the tick against the previous boundary', () => {
    expect(beatError(TICK.audioTimeMs - INTERVAL + 30, TICK, INTERVAL)).toBeCloseTo(30, 6);
    expect(beatError(TICK.audioTimeMs - 20, TICK, INTERVAL)).toBeCloseTo(20, 6);
  });

  it('peaks halfway between beats', () => {
    expect(beatError(TICK.audioTimeMs + INTERVAL / 2, TICK, INTERVAL)).toBeCloseTo(INTERVAL / 2, 6);
  });
});

describe('judgeBang', () => {
  it('grades an exact hit as perfect', () => {
    expect(judgeBang(TICK.audioTimeMs, TICK, INTERVAL)).toBe('perfect');
  });

  it('still grades perfect a full beat interval later', () => {
    expect(judgeBang(TICK.audioTimeMs + INTERVAL, TICK, INTERVAL)).toBe('perfect');
    expect(judgeBang(TICK.audioTimeMs + 8 * INTERVAL, TICK, INTERVAL)).toBe('perfect');
  });

  it('grades halfway between beats as a miss', () => {
    expect(judgeBang(TICK.audioTimeMs + INTERVAL / 2, TICK, INTERVAL)).toBe('miss');
    expect(judgeBang(TICK.audioTimeMs + 2.5 * INTERVAL, TICK, INTERVAL)).toBe('miss');
  });

  it('treats PERFECT_MS as inclusive and just past it as ok', () => {
    expect(judgeBang(TICK.audioTimeMs + PERFECT_MS, TICK, INTERVAL)).toBe('perfect');
    expect(judgeBang(TICK.audioTimeMs - PERFECT_MS, TICK, INTERVAL)).toBe('perfect');
    expect(judgeBang(TICK.audioTimeMs + PERFECT_MS + 0.5, TICK, INTERVAL)).toBe('ok');
  });

  it('treats OK_MS as inclusive and just past it as a miss', () => {
    expect(judgeBang(TICK.audioTimeMs + OK_MS, TICK, INTERVAL)).toBe('ok');
    expect(judgeBang(TICK.audioTimeMs - OK_MS, TICK, INTERVAL)).toBe('ok');
    expect(judgeBang(TICK.audioTimeMs + OK_MS + 0.5, TICK, INTERVAL)).toBe('miss');
  });

  it('grades late bangs relative to whichever boundary is closest', () => {
    // 100ms past the fourth boundary — ok, even though it is ~1875ms past the tick.
    expect(judgeBang(TICK.audioTimeMs + 4 * INTERVAL + 100, TICK, INTERVAL)).toBe('ok');
  });
});

describe('applyBang', () => {
  it('does not mutate the input state', () => {
    const s = freshStall();
    const snapshot: StallState = { ...s };
    applyBang(s, 'perfect');
    expect(s).toEqual(snapshot);
  });

  it('advances the combo on perfect and ok alike', () => {
    const a = applyBang(freshStall(), 'perfect');
    const b = applyBang(a, 'ok');
    expect(b.combo).toBe(2);
    expect(b.bangs).toBe(2);
    expect(b.aggro).toBe(0);
    expect(b.lastGrade).toBe('ok');
  });

  it('resets the combo and adds aggro on a miss', () => {
    const a = applyBang(applyBang(freshStall(), 'ok'), 'miss');
    expect(a.combo).toBe(0);
    expect(a.bangs).toBe(2);
    expect(a.aggro).toBeCloseTo(0.8, 6);
    expect(a.busted).toBe(false);
  });

  it('busts after three consecutive ok bangs', () => {
    let s = freshStall();
    for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'ok');
    expect(s.busted).toBe(true);
  });

  it('stays busted once busted, even after a later miss', () => {
    let s = freshStall();
    for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'perfect');
    s = applyBang(s, 'miss');
    expect(s.busted).toBe(true);
    expect(s.combo).toBe(0);
  });

  it('is not busted when a miss interrupts the run', () => {
    let s = freshStall();
    for (const g of ['ok', 'miss', 'ok', 'ok'] as const) s = applyBang(s, g);
    expect(s.busted).toBe(false);
    expect(s.combo).toBe(2);
    expect(s.bangs).toBe(4);
  });
});

describe('scoreStall', () => {
  it('pays out big when the stall is busted', () => {
    let s = freshStall();
    for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'perfect');
    const out = scoreStall(s);
    expect(out.solved).toBe(true);
    expect(out.vibeDelta).toBe(9);
    expect(out.aggroDelta).toBe(0);
  });

  it('costs vibe when the player made a scene and achieved nothing', () => {
    const out = scoreStall(applyBang(freshStall(), 'miss'));
    expect(out.solved).toBe(false);
    expect(out.vibeDelta).toBe(-1.5);
    expect(out.aggroDelta).toBeCloseTo(0.8, 6);
  });

  it('clamps accumulated aggro at 6', () => {
    let s = freshStall();
    for (let i = 0; i < 12; i++) s = applyBang(s, 'miss');
    expect(s.aggro).toBeGreaterThan(6);
    expect(scoreStall(s).aggroDelta).toBe(6);
  });

  it('carries aggro from misses through a bust', () => {
    let s = freshStall();
    for (const g of ['miss', 'miss', 'ok', 'ok', 'perfect'] as const) s = applyBang(s, g);
    const out = scoreStall(s);
    expect(out.solved).toBe(true);
    expect(out.aggroDelta).toBeCloseTo(1.6, 6);
  });
});
