/**
 * The scheduler's one musical rule, tested away from the audio graph.
 *
 * `hitsInBar` decides what plays and when. Get it wrong and the rack still makes
 * sound — it just makes the wrong sound, with a groove that turns around in the wrong
 * place, which is exactly the class of bug you discover mid-set.
 */
import { describe, expect, it } from 'vitest'
import { hitsInBar, barSeconds, CHOP_STEPS, PHRASE_BARS, type Voice } from '../engine'

const loop = (o: Partial<Voice> = {}): Voice => ({
  id: 'k/x', url: '/x.wav', bars: 4, durS: 7.805, gain: 1, muted: false,
  cells: Array.from({ length: PHRASE_BARS }, () => true),
  ...o,
})

describe('hitsInBar — bar loops', () => {
  it('starts a 4-bar loop only on bars 0 and 4 of an 8-bar phrase', () => {
    const v = loop({ bars: 4 })
    const fires = Array.from({ length: PHRASE_BARS }, (_, b) => hitsInBar(v, b, 123).length)
    expect(fires).toEqual([1, 0, 0, 0, 1, 0, 0, 0])
  })

  it('starts a 2-bar loop on every even bar, and a 1-bar loop on every bar', () => {
    expect(Array.from({ length: 8 }, (_, b) => hitsInBar(loop({ bars: 2 }), b, 123).length))
      .toEqual([1, 0, 1, 0, 1, 0, 1, 0])
    expect(Array.from({ length: 8 }, (_, b) => hitsInBar(loop({ bars: 1 }), b, 123).length))
      .toEqual([1, 1, 1, 1, 1, 1, 1, 1])
  })

  it('holds the voice for exactly its own bar count', () => {
    const [h] = hitsInBar(loop({ bars: 4 }), 0, 123)
    expect(h.holdS).toBeCloseTo(4 * barSeconds(123), 6)
    expect(h.offsetS).toBe(0)
  })

  it('respects a cleared cell at the entry it would otherwise use', () => {
    const cells = Array.from({ length: PHRASE_BARS }, () => true)
    cells[4] = false
    expect(hitsInBar(loop({ bars: 4, cells }), 0, 123)).toHaveLength(1)
    expect(hitsInBar(loop({ bars: 4, cells }), 4, 123)).toHaveLength(0)
  })

  it('keeps the phrase when the transport runs past it, and when it runs backwards', () => {
    // bar 8 is bar 0 of the next phrase; a negative index must not fall off the array
    expect(hitsInBar(loop({ bars: 4 }), 8, 123)).toHaveLength(1)
    expect(hitsInBar(loop({ bars: 4 }), 12, 123)).toHaveLength(1)
    expect(() => hitsInBar(loop({ bars: 4 }), -3, 123)).not.toThrow()
  })

  it('plays nothing when muted or at zero gain', () => {
    expect(hitsInBar(loop({ muted: true }), 0, 123)).toHaveLength(0)
    expect(hitsInBar(loop({ gain: 0 }), 0, 123)).toHaveLength(0)
  })

  it('stretches to the DECK tempo, not the file\'s — that is what layering means', () => {
    const v = loop({ bars: 4, durS: 7.805 })   // 4 bars at 123 bpm
    expect(hitsInBar(v, 0, 123)[0].rate).toBeCloseTo(1, 3)
    expect(hitsInBar(v, 0, 150)[0].rate).toBeCloseTo(150 / 123, 3)
  })
})

describe('hitsInBar — chops', () => {
  const chop = (cells: boolean[]): Voice =>
    ({ ...loop(), bars: 0, durS: 0.4, cells })

  it('fires on every armed 16th of every bar', () => {
    const cells = Array.from({ length: CHOP_STEPS }, (_, i) => i % 4 === 0)
    const hits = hitsInBar(chop(cells), 0, 120)
    expect(hits).toHaveLength(4)
    const step = barSeconds(120) / CHOP_STEPS
    expect(hits.map((h) => h.offsetS / step)).toEqual([0, 4, 8, 12])
    // unlike a bar-loop, a chop is not constrained to a stride — bar 3 plays too
    expect(hitsInBar(chop(cells), 3, 120)).toHaveLength(4)
  })

  it('never stretches a chop: a stab is a stab at any tempo', () => {
    const cells = Array.from({ length: CHOP_STEPS }, (_, i) => i === 0)
    expect(hitsInBar(chop(cells), 0, 90)[0].rate).toBe(1)
    expect(hitsInBar(chop(cells), 0, 174)[0].rate).toBe(1)
  })

  it('pushes odd 16ths late by the swing fraction, and leaves even ones alone', () => {
    const cells = Array.from({ length: CHOP_STEPS }, () => true)
    const step = barSeconds(120) / CHOP_STEPS
    const hits = hitsInBar(chop(cells), 0, 120, 0.25)
    expect(hits[0].offsetS).toBeCloseTo(0, 9)
    expect(hits[1].offsetS).toBeCloseTo(step * 1.25, 9)
    expect(hits[2].offsetS).toBeCloseTo(step * 2, 9)
  })

  it('never holds a chop past the end of its buffer', () => {
    const cells = Array.from({ length: CHOP_STEPS }, (_, i) => i === 0)
    expect(hitsInBar(chop(cells), 0, 120, 0, 0.12)[0].holdS).toBe(0.12)
  })
})
