/**
 * The deck — a Web Audio transport that loops sample kits the way Tidal will.
 *
 * The point of this file is that auditioning a loop in a normal audio player tells
 * you almost nothing about whether it is a good loop. A player stops at the end, so
 * you never hear the seam; it plays at the file's own rate, so you never hear it
 * against another loop; and it has no bar grid, so you never find out that the
 * "4-bar" loop is 4.02 bars and walks away from the beat after a minute.
 *
 * So the rack schedules every voice explicitly on one grid, and sets
 *
 *     rate = buffer.duration / (bars * 4 * 60 / bpm)
 *
 * which is exactly `loopAt bars` in Tidal: squeeze the file into that many bars of
 * the current tempo. What you hear here is what `d1 $ loopAt 4 $ s "kit" # n 3`
 * will do on the rig — the SPA is a test of the kit, not a preview of it.
 *
 * Two playback paths on purpose:
 *
 * - `audition` uses a single looping AudioBufferSourceNode at rate 1. One node, so
 *   drift cannot exist, and the file is heard UNTOUCHED — which is the only way to
 *   judge the seam the cut actually produced.
 * - `Deck` schedules one node per bar per voice from a look-ahead clock. Zero drift
 *   by construction even when a loop is a millisecond off a bar, because every
 *   iteration is placed against the grid rather than against the previous iteration.
 *   That millisecond is real: the cutter's tolerance is 1 ms, which is 60 ms of skew
 *   after a minute if you let loops free-run.
 */

export type Voice = {
  id: string            // stable key: `${kit}/${name}`
  url: string
  bars: number          // 0 = a chop: sub-bar, fired on the 16th grid, never stretched
  durS: number
  gain: number          // 0..1.5, linear (this is a mixer fader, not Tidal's gain^4)
  muted: boolean
  cells: boolean[]      // bar-loops: one per bar of the phrase. chops: 16 per bar.
}

export const PHRASE_BARS = 8
export const CHOP_STEPS = 16

const ctxRef: { ctx?: AudioContext } = {}

export function audioCtx(): AudioContext {
  if (!ctxRef.ctx) ctxRef.ctx = new AudioContext({ latencyHint: 'playback' })
  if (ctxRef.ctx.state === 'suspended') void ctxRef.ctx.resume()
  return ctxRef.ctx
}

const buffers = new Map<string, Promise<AudioBuffer>>()

/** Decode once per URL. The rack re-triggers the same file every bar; re-fetching it
 *  would put a network request on the audio path. */
export function loadBuffer(url: string): Promise<AudioBuffer> {
  let p = buffers.get(url)
  if (!p) {
    p = fetch(url)
      .then((r) => (r.ok ? r.arrayBuffer() : Promise.reject(new Error(`${r.status} ${url}`))))
      .then((b) => audioCtx().decodeAudioData(b))
    buffers.set(url, p)
    p.catch(() => buffers.delete(url))   // a failed decode must not be cached as a result
  }
  return p
}

export const isLoaded = (url: string) => buffers.has(url)

export const barSeconds = (bpm: number) => (4 * 60) / bpm

/** `loopAt bars` — the rate that makes this file exactly `bars` long at `bpm`. */
export function loopAtRate(durS: number, bars: number, bpm: number): number {
  if (!bars || durS <= 0) return 1
  return durS / (bars * barSeconds(bpm))
}

/** How far off a whole bar the file is, in ms, at its own declared tempo. The number
 *  the cutter promises is under 1 ms; showing it is how you notice when it isn't. */
export function barErrorMs(durS: number, bars: number, bpm: number): number {
  if (!bars || !bpm) return 0
  const want = bars * barSeconds(bpm)
  return (durS - want) * 1000
}

// ── auditioning one sample, untouched ────────────────────────────────────────
export type Audition = { stop: () => void; startedAt: number; durS: number }

export function audition(buf: AudioBuffer, opts: { loop: boolean; gain?: number }): Audition {
  const ctx = audioCtx()
  const src = ctx.createBufferSource()
  src.buffer = buf
  src.loop = opts.loop
  const g = ctx.createGain()
  g.gain.value = opts.gain ?? 1
  src.connect(g).connect(ctx.destination)
  const t0 = ctx.currentTime
  src.start(t0)
  return {
    startedAt: t0,
    durS: buf.duration,
    stop: () => { try { src.stop() } catch { /* already ended */ } },
  }
}

export type Hit = { offsetS: number; rate: number; holdS: number }

/**
 * What this voice plays during bar `barIndex`, as offsets from the top of that bar.
 *
 * Extracted from the scheduler because it is the only part with a musical rule in it,
 * and a rule you cannot test is a rule you find out about on stage. The rule: a
 * bar-loop only starts on a bar that is a multiple of ITS OWN length. A 4-bar loop in
 * an 8-bar phrase therefore has two possible entries, not eight — firing it on bar 3
 * would play its bar 1 underneath the phrase's bar 4, which is the groove turning
 * around in the wrong place, and not something anyone should have to reason about while
 * performing. A chop has no such constraint: it is sub-bar, so it fires on any of the
 * 16 steps, with odd steps optionally pushed late by `swing`.
 */
export function hitsInBar(v: Voice, barIndex: number, bpm: number, swing = 0,
                          bufDurS = Infinity): Hit[] {
  if (v.muted || v.gain <= 0) return []
  const bar = barSeconds(bpm)
  const slot = ((barIndex % PHRASE_BARS) + PHRASE_BARS) % PHRASE_BARS
  if (v.bars > 0) {
    if (slot % v.bars !== 0) return []
    if (!v.cells[slot]) return []
    return [{ offsetS: 0, rate: loopAtRate(v.durS, v.bars, bpm), holdS: v.bars * bar }]
  }
  const step = bar / CHOP_STEPS
  const hits: Hit[] = []
  for (let i = 0; i < CHOP_STEPS; i++) {
    if (!v.cells[i]) continue
    hits.push({
      offsetS: i * step + (i % 2 === 1 ? swing * step : 0),
      rate: 1,
      holdS: Math.min(bufDurS, step * 4),
    })
  }
  return hits
}

// ── the rack ─────────────────────────────────────────────────────────────────
type Scheduled = { src: AudioBufferSourceNode; at: number }

/**
 * A look-ahead scheduler. `tick` runs on a timer, but nothing it decides depends on
 * WHEN it ran: every event is placed at an absolute AudioContext time derived from
 * the transport origin, so a late timer produces the same sound as an early one (or
 * drops the event, if it is already too late — better a hole than a flam).
 */
export class Deck {
  bpm = 123
  swing = 0            // 0..0.5 — delay of odd 16ths, as a fraction of a 16th
  private voices = new Map<string, { v: Voice; buf: AudioBuffer }>()
  private timer?: number
  private t0 = 0        // ctx time of bar 0 of the phrase
  private nextBar = 0   // next bar index not yet scheduled
  private live: Scheduled[] = []
  private master: GainNode
  onBar?: (barInPhrase: number) => void

  static LOOKAHEAD = 0.35   // s of audio scheduled in advance
  static TICK_MS = 60

  constructor() {
    const ctx = audioCtx()
    this.master = ctx.createGain()
    this.master.gain.value = 0.8
    this.master.connect(ctx.destination)
  }

  get playing() { return this.timer !== undefined }
  get masterGain() { return this.master.gain.value }
  setMaster(v: number) { this.master.gain.value = v }

  setVoice(v: Voice, buf: AudioBuffer) { this.voices.set(v.id, { v, buf }) }
  removeVoice(id: string) { this.voices.delete(id) }
  clear() { this.voices.clear() }

  /** Where the playhead is, as a bar index within the phrase, or -1 when stopped. */
  position(): number {
    if (!this.playing) return -1
    const el = audioCtx().currentTime - this.t0
    return Math.floor(el / barSeconds(this.bpm)) % PHRASE_BARS
  }

  start() {
    if (this.playing) return
    const ctx = audioCtx()
    this.t0 = ctx.currentTime + 0.12    // a beat of slack so bar 0 is not already late
    this.nextBar = 0
    this.timer = window.setInterval(() => this.tick(), Deck.TICK_MS)
    this.tick()
  }

  stop() {
    if (this.timer !== undefined) window.clearInterval(this.timer)
    this.timer = undefined
    for (const s of this.live) { try { s.src.stop() } catch { /* ended */ } }
    this.live = []
  }

  private tick() {
    const ctx = audioCtx()
    const bar = barSeconds(this.bpm)
    const until = ctx.currentTime + Deck.LOOKAHEAD
    while (this.t0 + this.nextBar * bar < until) {
      this.scheduleBar(this.nextBar, this.t0 + this.nextBar * bar)
      this.nextBar++
    }
    this.live = this.live.filter((s) => s.at > ctx.currentTime - 30)
    this.onBar?.(((this.nextBar - 1) % PHRASE_BARS + PHRASE_BARS) % PHRASE_BARS)
  }

  private scheduleBar(barIndex: number, at: number) {
    for (const { v, buf } of this.voices.values()) {
      for (const h of hitsInBar(v, barIndex, this.bpm, this.swing, buf.duration)) {
        this.fire(buf, at + h.offsetS, v, h.rate, h.holdS)
      }
    }
  }

  private fire(buf: AudioBuffer, at: number, v: Voice, rate: number, holdS: number) {
    const ctx = audioCtx()
    if (at < ctx.currentTime) return          // too late: a hole beats a flam
    const src = ctx.createBufferSource()
    src.buffer = buf
    src.playbackRate.value = rate
    const g = ctx.createGain()
    g.gain.value = v.gain
    // A 3 ms release on the tail. Cutting a voice at a hard sample boundary is a
    // click, and a rack of eight clicking voices sounds like the kit is broken when
    // it is the player that is.
    const end = at + Math.min(holdS, buf.duration / rate)
    g.gain.setValueAtTime(v.gain, Math.max(at, end - 0.003))
    g.gain.linearRampToValueAtTime(0, end)
    src.connect(g).connect(this.master)
    src.start(at)
    src.stop(end + 0.005)
    this.live.push({ src, at })
  }
}
