/**
 * Lane F — AudioEngine.
 *
 * Procedural WebAudio, comedy-first. NO asset files, NO deps.
 *
 * Autoplay policy: the `AudioContext` is created LAZILY, only inside `unlock()`,
 * which integration calls from the `ui:play` event or a first user gesture.
 * Constructing the engine touches nothing — `ctx` stays `null`, so the browser
 * (and headless verification) sees a fully dormant engine until a real gesture.
 *
 * Everything a one-shot needs is here: a shared master chain (gain → soft
 * limiter → destination) kept well below pain (~-12 dBFS), a persisted mute
 * toggle (`blobbo:muted`), a reusable white-noise buffer, and a small polyphony
 * cap so paint/squish event-storms can't stack into clipping.
 */

const MASTER_DB = -12 // master gain, well below pain
const MAX_VOICES = 10 // polyphony cap — storms steal the voice nearest to finishing
const MUTE_KEY = 'blobbo:muted'

/** dBFS → linear amplitude. */
export const dbToGain = (db: number): number => Math.pow(10, db / 20)

function readMuted(): boolean {
  try {
    return localStorage.getItem(MUTE_KEY) === '1'
  } catch {
    return false
  }
}

/** One live one-shot: its output gain + when it is expected to be silent. */
interface Voice {
  g: GainNode
  end: number
  cleanup: () => void
}

export type MuteListener = (muted: boolean) => void

export class AudioEngine {
  /** The live context, or `null` while dormant. Public so tests can assert it. */
  ctx: AudioContext | null = null

  private master: GainNode | null = null
  private limiter: DynamicsCompressorNode | null = null
  private _noise: AudioBuffer | null = null
  private voices: Voice[] = []
  private _muted: boolean
  private muteListeners = new Set<MuteListener>()

  /** Max simultaneous one-shot voices (exposed for the demo / tests). */
  static readonly MAX_VOICES = MAX_VOICES

  constructor() {
    // Read persisted mute here — cheap, no audio nodes, keeps the ctx dormant.
    this._muted = readMuted()
  }

  /** True once `unlock()` has actually created the context. */
  get ready(): boolean {
    return this.ctx !== null
  }

  get muted(): boolean {
    return this._muted
  }

  /** Current audio clock (0 while dormant so callers can no-op safely). */
  get time(): number {
    return this.ctx ? this.ctx.currentTime : 0
  }

  /** Shared white-noise buffer. Only valid once `ready`. */
  get noiseBuffer(): AudioBuffer | null {
    return this._noise
  }

  /** Live voice count — used by tests to prove the polyphony cap holds. */
  get activeVoices(): number {
    return this.voices.length
  }

  /**
   * Create + resume the context. Idempotent: safe to call from BOTH `ui:play`
   * and a first-gesture fallback — the second call just resumes. Must be invoked
   * from a user-gesture turn or the browser leaves the context suspended.
   */
  unlock(): void {
    if (this.ctx) {
      if (this.ctx.state === 'suspended') void this.ctx.resume()
      return
    }
    const Ctor: typeof AudioContext | undefined =
      typeof AudioContext !== 'undefined'
        ? AudioContext
        : (globalThis as unknown as { webkitAudioContext?: typeof AudioContext })
            .webkitAudioContext
    if (!Ctor) return

    const ctx = new Ctor()
    this.ctx = ctx

    // Soft limiter so simultaneous voices round off instead of clipping.
    const limiter = ctx.createDynamicsCompressor()
    limiter.threshold.value = -6
    limiter.knee.value = 8
    limiter.ratio.value = 12
    limiter.attack.value = 0.003
    limiter.release.value = 0.12
    this.limiter = limiter

    const master = ctx.createGain()
    master.gain.value = this._muted ? 0 : dbToGain(MASTER_DB)
    master.connect(limiter).connect(ctx.destination)
    this.master = master

    this._noise = makeNoiseBuffer(ctx)

    if (ctx.state === 'suspended') void ctx.resume()
  }

  /**
   * Allocate a voice: a fresh gain node already wired to the master chain.
   * `endTime` is the ctx-time the sound is expected to be silent — used to steal
   * voices (the one nearest finishing dies first) and to schedule teardown.
   * Returns `null` while dormant so every sound can early-out cleanly.
   */
  voice(endTime: number): GainNode | null {
    const ctx = this.ctx
    const master = this.master
    if (!ctx || !master) return null

    // Polyphony cap: over budget → retire whichever voice finishes soonest.
    if (this.voices.length >= MAX_VOICES) {
      let idx = 0
      for (let i = 1; i < this.voices.length; i++) {
        if (this.voices[i].end < this.voices[idx].end) idx = i
      }
      const [victim] = this.voices.splice(idx, 1)
      if (victim) this.retire(victim, true)
    }

    const g = ctx.createGain()
    g.connect(master)

    const voice: Voice = {
      g,
      end: endTime,
      cleanup: () => {
        try {
          g.disconnect()
        } catch {
          /* already gone */
        }
      },
    }
    this.voices.push(voice)

    // Free the node a hair after it should be silent.
    const ms = Math.max(0, (endTime - ctx.currentTime) * 1000) + 80
    setTimeout(() => {
      const i = this.voices.indexOf(voice)
      if (i >= 0) this.voices.splice(i, 1)
      voice.cleanup()
    }, ms)

    return g
  }

  private retire(v: Voice, fade: boolean): void {
    if (fade && this.ctx) {
      const t = this.ctx.currentTime
      try {
        v.g.gain.cancelScheduledValues(t)
        v.g.gain.setValueAtTime(v.g.gain.value, t)
        v.g.gain.linearRampToValueAtTime(0, t + 0.02)
      } catch {
        /* param may be finished */
      }
      setTimeout(v.cleanup, 40)
    } else {
      v.cleanup()
    }
  }

  /** A fresh source playing the shared noise buffer. `null` while dormant. */
  noiseSource(): AudioBufferSourceNode | null {
    if (!this.ctx || !this._noise) return null
    const src = this.ctx.createBufferSource()
    src.buffer = this._noise
    src.loop = true
    return src
  }

  setMuted(muted: boolean): void {
    this._muted = muted
    try {
      localStorage.setItem(MUTE_KEY, muted ? '1' : '0')
    } catch {
      /* private mode — mute simply won't persist */
    }
    if (this.ctx && this.master) {
      const t = this.ctx.currentTime
      const g = this.master.gain
      g.cancelScheduledValues(t)
      g.setValueAtTime(g.value, t)
      g.linearRampToValueAtTime(muted ? 0 : dbToGain(MASTER_DB), t + 0.05)
    }
    this.muteListeners.forEach((fn) => fn(muted))
  }

  toggleMute(): boolean {
    this.setMuted(!this._muted)
    return this._muted
  }

  /** Subscribe to mute changes (the 🔈/🔇 button keeps its glyph in sync). */
  onMuteChange(fn: MuteListener): () => void {
    this.muteListeners.add(fn)
    return () => this.muteListeners.delete(fn)
  }
}

/** ~1 s of white noise, reused by every noise-based one-shot. */
function makeNoiseBuffer(ctx: AudioContext): AudioBuffer {
  const len = Math.floor(ctx.sampleRate)
  const buf = ctx.createBuffer(1, len, ctx.sampleRate)
  const data = buf.getChannelData(0)
  for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1
  return buf
}
