import { describe, expect, it } from 'vitest';
import {
  BUILD_FIZZLE_BARS,
  DROP_CLEAN_MS,
  DROP_LATE_MS,
  DROP_PAYOUT,
  REQUEST_HIT,
  TIP_AMOUNTS,
  TIP_OFFER_CHANCE,
  judgeDrop,
  msToNearestBar,
  rollTip,
} from '../../src/scenes/floor/djShift';
import { SeededRNG } from '../../src/core/SeededRNG';
import { DJ_REQUESTS } from '../../src/data/strings/floor';

describe('judgeDrop', () => {
  it('rewards the bar line, forgives near misses, punishes the clang', () => {
    expect(judgeDrop(0)).toBe('clean');
    expect(judgeDrop(DROP_CLEAN_MS)).toBe('clean');
    expect(judgeDrop(-DROP_CLEAN_MS)).toBe('clean');
    expect(judgeDrop(DROP_CLEAN_MS + 1)).toBe('late');
    expect(judgeDrop(-DROP_LATE_MS)).toBe('late');
    expect(judgeDrop(DROP_LATE_MS + 1)).toBe('clang');
  });

  it('early and late are the same offence — the room only hears wrong', () => {
    for (const d of [50, 200, 500]) expect(judgeDrop(d)).toBe(judgeDrop(-d));
  });
});

describe('msToNearestBar', () => {
  const BEAT = 500; // 120bpm
  it('is zero exactly on the bar', () => {
    expect(msToNearestBar(0, 0, BEAT)).toBe(0);
  });

  it('measures behind the last bar early in the bar', () => {
    expect(msToNearestBar(0, 120, BEAT)).toBe(120);
    expect(msToNearestBar(1, 0, BEAT)).toBe(500);
  });

  it('measures ahead (negative) late in the bar', () => {
    expect(msToNearestBar(3, 400, BEAT)).toBe(-(4 * BEAT - (3 * BEAT + 400)));
    expect(msToNearestBar(3, 499, BEAT)).toBe(-1);
  });

  it('a mid-bar drop is maximally wrong from either side', () => {
    expect(Math.abs(msToNearestBar(2, 0, BEAT))).toBe(2 * BEAT);
  });
});

describe('the shift is priced, not graded', () => {
  it('a clean drop pays, a clang costs — and neither is free', () => {
    expect(DROP_PAYOUT.clean.vibe).toBeGreaterThan(0);
    expect(DROP_PAYOUT.clang.vibe).toBeLessThan(0);
    expect(DROP_PAYOUT.clang.aggro).toBeGreaterThan(0);
    expect(DROP_PAYOUT.late.vibe).toBeLessThan(DROP_PAYOUT.clean.vibe);
  });

  it('a build cannot be held forever', () => {
    expect(BUILD_FIZZLE_BARS).toBeLessThanOrEqual(8);
  });
});

describe('booth requests', () => {
  it('every scripted request has odds, and every odds entry has a script', () => {
    const scriptIds = DJ_REQUESTS.map((r) => r.id);
    expect(new Set(scriptIds).size).toBe(scriptIds.length);
    for (const id of scriptIds) {
      expect(REQUEST_HIT[id], `no odds for '${id}'`).toBeGreaterThan(0);
      expect(REQUEST_HIT[id]).toBeLessThan(1); // nothing is a sure thing at the booth
    }
    for (const id of Object.keys(REQUEST_HIT)) {
      expect(scriptIds).toContain(id);
    }
  });
});

describe('the tip economy', () => {
  it('rollTip: sometimes cash, always a real amount, never help for the song', () => {
    const rng = new SeededRNG(7).stream('tips');
    let offers = 0;
    for (let i = 0; i < 2000; i++) {
      const tip = rollTip(rng);
      if (tip === null) continue;
      offers++;
      expect(TIP_AMOUNTS).toContain(tip);
    }
    // Roughly TIP_OFFER_CHANCE of requests wave cash.
    expect(offers / 2000).toBeGreaterThan(TIP_OFFER_CHANCE - 0.06);
    expect(offers / 2000).toBeLessThan(TIP_OFFER_CHANCE + 0.06);
  });
});
