import { describe, expect, it } from 'vitest';
import {
  barOf,
  beatInBar,
  beatIntervalMs,
  beatTimeMs,
  dueBeats,
  isDownbeat,
  patternStep,
  subdivisions,
  type BeatGrid,
} from '../src/audio/scheduling';

const grid = (bpm: number, originMs = 0): BeatGrid => ({ bpm, originMs });

describe('beatIntervalMs', () => {
  it('converts BPM to milliseconds per beat', () => {
    expect(beatIntervalMs(120)).toBe(500);
    expect(beatIntervalMs(128)).toBe(468.75);
  });
});

describe('beatTimeMs', () => {
  it('places beat 0 at the origin and steps by one interval', () => {
    const g = grid(120);
    expect(beatTimeMs(g, 0)).toBe(0);
    expect(beatTimeMs(g, 1)).toBe(500);
    expect(beatTimeMs(g, 8)).toBe(4000);
  });

  it('offsets every beat by a non-zero origin', () => {
    const g = grid(128, 1234.5);
    expect(beatTimeMs(g, 0)).toBe(1234.5);
    for (let i = 0; i < 16; i++) {
      expect(beatTimeMs(g, i)).toBeCloseTo(1234.5 + i * 468.75, 6);
    }
  });

  it('handles negative indices (beats before the origin)', () => {
    expect(beatTimeMs(grid(120, 1000), -2)).toBe(0);
  });
});

describe('dueBeats', () => {
  it('returns only beats inside the lookahead horizon, ascending from fromBeat', () => {
    // 500ms grid, now 0, 1200ms horizon -> beats 0,1,2 (2 sounds at 1000)
    const due = dueBeats(grid(120), 0, 0, 1200);
    expect(due.map((b) => b.beatIndex)).toEqual([0, 1, 2]);
    expect(due.map((b) => b.timeMs)).toEqual([0, 500, 1000]);
  });

  it('includes a beat landing exactly on the horizon', () => {
    expect(dueBeats(grid(120), 0, 0, 1000).map((b) => b.beatIndex)).toEqual([0, 1, 2]);
  });

  it('returns nothing when the next beat is beyond the horizon', () => {
    // beat 4 sounds at 2000; waking at 1600 with a 100ms lookahead is too early
    expect(dueBeats(grid(120), 4, 1600, 100)).toEqual([]);
  });

  it('respects fromBeat rather than replaying earlier beats', () => {
    const due = dueBeats(grid(120), 3, 0, 2500);
    expect(due.map((b) => b.beatIndex)).toEqual([3, 4, 5]);
  });

  it('respects the grid origin', () => {
    // origin 10s: nothing is due while audio time is still near zero
    expect(dueBeats(grid(120, 10_000), 0, 0, 100)).toEqual([]);
    expect(dueBeats(grid(120, 10_000), 0, 9_950, 100).map((b) => b.beatIndex)).toEqual([0]);
  });

  it('yields every beat exactly once, in order, across ragged wakeups', () => {
    const g = grid(128, 40);
    const lookahead = 100;
    // Irregular timer wakeups: some shorter than the lookahead, some longer than a beat.
    const wakes = [25, 31, 18, 40, 25, 7, 63, 25, 12, 90, 25, 33];
    const seen: number[] = [];
    let cursor = 0;
    let now = 0;
    // Bounded by wake count, not by beats collected: a regression that stops
    // returning beats must go red, not spin forever and hang `npm test`.
    for (let w = 0; w < 2000; w++) {
      now += wakes[w % wakes.length] ?? 25;
      const due = dueBeats(g, cursor, now, lookahead);
      for (const b of due) {
        expect(b.timeMs).toBeCloseTo(beatTimeMs(g, b.beatIndex), 6);
        expect(b.timeMs).toBeLessThanOrEqual(now + lookahead);
        seen.push(b.beatIndex);
      }
      const last = due.at(-1);
      if (last) cursor = last.beatIndex + 1;
    }
    // Every beat whose time fell inside the final horizon should have sounded.
    const expected = Math.floor((now + lookahead - 40) / beatIntervalMs(128)) + 1;
    expect(expected).toBeGreaterThan(100); // sanity: the walk really is long
    expect(seen.length).toBe(expected);
    // No gaps, no duplicates, strictly ascending from 0.
    expect(seen).toEqual(seen.map((_, i) => i));
  });

  it('never schedules a beat more than a lookahead ahead of the wake time', () => {
    const g = grid(120);
    let cursor = 0;
    let scheduled = 0;
    let lastNow = 0;
    for (let now = 0; now < 20_000; now += 25) {
      const due = dueBeats(g, cursor, now, 120);
      for (const b of due) expect(b.timeMs).toBeLessThanOrEqual(now + 120);
      scheduled += due.length;
      const last = due.at(-1);
      if (last) cursor = last.beatIndex + 1;
      lastNow = now;
    }
    // Scheduling nothing would satisfy the bound above vacuously; silence is the
    // failure mode this test exists to catch.
    expect(scheduled).toBe(Math.floor((lastNow + 120) / beatIntervalMs(120)) + 1);
  });

  it('caps a suspended-tab backlog at MAX_BURST and drains it without loss', () => {
    const g = grid(128);
    const stalledNow = 5 * 60_000; // tab was hidden for five minutes
    const lookahead = 100;
    const expectedTotal = Math.floor((stalledNow + lookahead) / beatIntervalMs(128)) + 1;
    expect(expectedTotal).toBeGreaterThan(600); // sanity: the burst really is huge

    const first = dueBeats(g, 0, stalledNow, lookahead);
    expect(first.length).toBe(64);
    expect(first.map((b) => b.beatIndex)).toEqual(first.map((_, i) => i));

    // Subsequent wakes at the same audio time drain the rest, 64 at a time.
    const seen = first.map((b) => b.beatIndex);
    let cursor = 64;
    for (let guard = 0; guard < 100; guard++) {
      const due = dueBeats(g, cursor, stalledNow, lookahead);
      if (due.length === 0) break;
      expect(due.length).toBeLessThanOrEqual(64);
      for (const b of due) seen.push(b.beatIndex);
      cursor = due[due.length - 1]!.beatIndex + 1;
    }
    expect(seen).toEqual(seen.map((_, i) => i));
    expect(seen.length).toBe(expectedTotal);
  });
});

describe('subdivisions', () => {
  it('returns n evenly spaced times starting on the beat', () => {
    const g = grid(120, 250);
    const subs = subdivisions(g, 2, 4); // beat 2 sounds at 1250
    expect(subs.length).toBe(4);
    expect(subs[0]).toBe(beatTimeMs(g, 2));
    expect(subs).toEqual([1250, 1375, 1500, 1625]);
  });

  it('lands the next beat exactly one step past the last subdivision', () => {
    const g = grid(128, 12);
    const subs = subdivisions(g, 7, 16);
    const step = beatIntervalMs(128) / 16;
    expect(subs.length).toBe(16);
    expect(subs[15]! + step).toBeCloseTo(beatTimeMs(g, 8), 6);
  });
});

describe('bar position (4/4)', () => {
  it('maps beat indices to bar and position', () => {
    expect(barOf(0)).toBe(0);
    expect(beatInBar(0)).toBe(0);
    expect(isDownbeat(0)).toBe(true);

    for (let i = 0; i < 32; i++) {
      expect(barOf(i)).toBe(Math.floor(i / 4));
      expect(beatInBar(i)).toBe(i % 4);
      expect(isDownbeat(i)).toBe(i % 4 === 0);
    }
  });

  it('keeps beatInBar non-negative before the origin', () => {
    expect(beatInBar(-1)).toBe(3);
    expect(beatInBar(-4)).toBe(0);
    expect(isDownbeat(-4)).toBe(true);
    // barOf must floor, not truncate, or beat -1 lands in bar 0 alongside beat 3.
    expect(barOf(-1)).toBe(-1);
    expect(barOf(-4)).toBe(-1);
    expect(barOf(-5)).toBe(-2);
  });
});

describe('patternStep', () => {
  it('walks 0..31 across two bars then wraps', () => {
    const walk: number[] = [];
    for (let beat = 0; beat < 8; beat++) {
      for (let s = 0; s < 4; s++) walk.push(patternStep(beat, s));
    }
    expect(walk.length).toBe(32);
    expect(walk).toEqual(walk.map((_, i) => i));
    expect(patternStep(8, 0)).toBe(0);
  });

  it('repeats with period 32 rather than drifting', () => {
    for (let beat = 0; beat < 64; beat++) {
      for (let s = 0; s < 4; s++) {
        expect(patternStep(beat, s)).toBe(patternStep(beat + 8, s));
        expect(patternStep(beat, s)).toBe((beat * 4 + s) % 32);
      }
    }
  });

  it('honours a custom step count and negative indices', () => {
    expect(patternStep(4, 0, 16)).toBe(0);
    expect(patternStep(3, 2, 16)).toBe(14);
    expect(patternStep(-1, 0)).toBe(28);
  });
});
