type DuckReason = 'qte' | 'choice' | 'hotspot'

export class AudioSystem {
  private ctx: AudioContext | null = null
  private gainNode: GainNode | null = null
  private currentSource: AudioBufferSourceNode | null = null
  private currentUrl = ''
  private currentVolume = 0.8
  private bufferCache: Map<string, AudioBuffer> = new Map()
  private maxCache = 3
  private duckCount: Map<DuckReason, number> = new Map()
  private duckLevel = 0.35
  private duckFade = 0.5

  private getCtx(): AudioContext {
    if (!this.ctx) {
      this.ctx = new AudioContext()
      this.gainNode = this.ctx.createGain()
      this.gainNode.connect(this.ctx.destination)
    }
    if (this.ctx.state === 'suspended') {
      this.ctx.resume()
    }
    return this.ctx
  }

  async play(
    url: string,
    volume: number,
    crossFade: number,
    duckLvl?: number,
    duckFd?: number,
  ) {
    if (duckLvl !== undefined) this.duckLevel = duckLvl
    if (duckFd !== undefined) this.duckFade = duckFd
    this.currentVolume = volume

    if (url === this.currentUrl) {
      const target = this.duckTarget()
      if (this.gainNode) {
        this.gainNode.gain.cancelScheduledValues(this.getCtx().currentTime)
        this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, this.getCtx().currentTime)
        this.gainNode.gain.exponentialRampToValueAtTime(
          Math.max(0.001, target),
          this.getCtx().currentTime + 0.3,
        )
      }
      return
    }

    const prevSource = this.currentSource

    this.currentUrl = url
    const buffer = await this.loadBuffer(url)
    if (!buffer) return

    const ctx = this.getCtx()
    const gain = this.gainNode!

    const target = this.duckTarget()

    if (prevSource) {
      gain.gain.cancelScheduledValues(ctx.currentTime)
      gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime)
      gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + crossFade)
      try { prevSource.stop(ctx.currentTime + crossFade + 0.1) } catch {}
    }

    const source = ctx.createBufferSource()
    source.buffer = buffer
    source.loop = true
    source.connect(gain)

    gain.gain.cancelScheduledValues(ctx.currentTime)
    gain.gain.setValueAtTime(0.001, ctx.currentTime)
    gain.gain.exponentialRampToValueAtTime(
      Math.max(0.001, target),
      ctx.currentTime + crossFade,
    )

    source.start(0)
    this.currentSource = source
  }

  async stop(fadeOut: number) {
    if (!this.currentSource || !this.gainNode) return
    const ctx = this.getCtx()
    this.currentUrl = ''
    const gain = this.gainNode
    try {
      gain.gain.cancelScheduledValues(ctx.currentTime)
      gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime)
      gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + fadeOut)
      const src = this.currentSource
      setTimeout(() => {
        try { src.stop() } catch {}
      }, (fadeOut + 0.1) * 1000)
    } catch {}
    this.currentSource = null
  }

  duckOn(reason: DuckReason) {
    this.duckCount.set(reason, (this.duckCount.get(reason) ?? 0) + 1)
    this.applyDuck()
  }

  duckOff(reason: DuckReason) {
    const cur = this.duckCount.get(reason) ?? 0
    if (cur <= 1) {
      this.duckCount.delete(reason)
    } else {
      this.duckCount.set(reason, cur - 1)
    }
    this.applyDuck()
  }

  private applyDuck() {
    if (!this.gainNode || !this.currentSource) return
    const target = this.duckTarget()
    const ctx = this.getCtx()
    const dt = this.duckCount.size > 0 ? this.duckFade : this.duckFade
    this.gainNode.gain.cancelScheduledValues(ctx.currentTime)
    this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, ctx.currentTime)
    this.gainNode.gain.exponentialRampToValueAtTime(
      Math.max(0.001, target),
      ctx.currentTime + dt,
    )
  }

  private duckTarget(): number {
    if (this.duckCount.size > 0) {
      return this.currentVolume * this.duckLevel
    }
    return this.currentVolume
  }

  setVolumeRaw(v: number) {
    if (!this.gainNode) return
    const ctx = this.getCtx()
    this.gainNode.gain.cancelScheduledValues(ctx.currentTime)
    this.gainNode.gain.setValueAtTime(v, ctx.currentTime)
  }

  private async loadBuffer(url: string): Promise<AudioBuffer | null> {
    const cached = this.bufferCache.get(url)
    if (cached) return cached

    try {
      const resp = await fetch(url)
      const arrayBuf = await resp.arrayBuffer()
      const ctx = this.getCtx()
      const buffer = await ctx.decodeAudioData(arrayBuf)
      this.bufferCache.set(url, buffer)

      // LRU eviction
      if (this.bufferCache.size > this.maxCache) {
        const firstKey = this.bufferCache.keys().next().value
        if (firstKey) this.bufferCache.delete(firstKey)
      }

      return buffer
    } catch {
      return null
    }
  }

  destroy() {
    if (this.currentSource) {
      try { this.currentSource.stop() } catch {}
      this.currentSource = null
    }
    this.bufferCache.clear()
    this.duckCount.clear()
    this.currentUrl = ''
    if (this.ctx) {
      this.ctx.close()
      this.ctx = null
      this.gainNode = null
    }
  }
}
