export class AudioController {
  private audio = new Audio()
  private handle = -1
  private volume = 1.0
  private fadeDuration = 200
  private buffer = new Audio()

  ontimeupdate: (value: number) => void = () => { /* do nothing */ }
  ondurationchange: (value: number) => void = () => { /* do nothing */ }
  onended: () => void = () => { /* do nothing */ }
  onerror: (err: MediaError | null) => void = () => { /* do nothing */ }

  currentTime() {
    return this.audio.currentTime
  }

  duration() {
    return this.audio.duration
  }

  setBuffer(url: string) {
    this.buffer.src = url
  }

  setVolume(value: number) {
    this.cancelFade()
    this.volume = value
    this.audio.volume = value
  }

  async pause() {
    await this.fadeOut()
    this.audio.pause()
  }

  async resume() {
    this.audio.volume = 0.0
    await this.audio.play()
    this.fadeIn()
  }

  async seek(value: number) {
    await this.fadeOut(this.fadeDuration / 2.0)
    this.audio.volume = 0.0
    this.audio.currentTime = value
    await this.fadeIn(this.fadeDuration / 2.0)
  }

  async changeTrack(url: string, options: { paused?: boolean } = {}) {
    if (this.audio) {
      this.cancelFade()
      endPlayback(this.audio, this.fadeDuration)
    }
    this.audio = new Audio(url)
    this.audio.onerror = () => {
      this.onerror(this.audio.error)
    }
    this.audio.onended = () => {
      this.onended()
    }
    this.audio.ontimeupdate = () => {
      this.ontimeupdate(this.audio.currentTime)
    }
    this.audio.ondurationchange = () => {
      this.ondurationchange(this.audio.duration)
    }
    this.ondurationchange(this.audio.duration)
    this.ontimeupdate(this.audio.currentTime)
    this.audio.volume = 0.0

    if (options.paused !== true) {
      try {
        await this.audio.play()
      } catch (error) {
        if (error.name === 'AbortError') {
          console.warn(error)
          return
        }
        throw error
      }
      this.fadeIn()
    }
  }

  private cancelFade() {
    clearTimeout(this.handle)
  }

  private fadeIn(duration: number = this.fadeDuration) {
    this.fadeFromTo(0.0, this.volume, duration).then()
  }

  private fadeOut(duration: number = this.fadeDuration) {
    return this.fadeFromTo(this.volume, 0.0, duration)
  }

  private fadeFromTo(from: number, to: number, duration: number) {
    console.info(`AudioController: start fade (${from}, ${to}, ${duration})`)
    const startTime = Date.now()
    const step = (to - from) / duration
    if (duration <= 0.0) {
      this.audio.volume = to
    }
    clearTimeout(this.handle)
    return new Promise<void>((resolve) => {
      const run = () => {
        if (this.audio.volume === to) {
          console.info(
            'AudioController: fade result. ' +
            `duration: ${duration}ms, actual: ${Date.now() - startTime}ms, ` +
            `volume: ${this.audio.volume}`)
          resolve()
          return
        }
        const elapsed = Date.now() - startTime
        this.audio.volume = clamp(0.0, this.volume, from + (elapsed * step))
        this.handle = setTimeout(run, 10)
      }
      run()
    })
  }
}

function endPlayback(audio: HTMLAudioElement, duration: number) {
  async function fade(audio: HTMLAudioElement, from: number, to: number, duration: number) {
    if (duration <= 0.0) {
      audio.volume = to
      return audio
    }
    const startTime = Date.now()
    const step = (to - from) / duration
    while (audio.volume !== to) {
      const elapsed = Date.now() - startTime
      audio.volume = clamp(0.0, 1.0, from + (elapsed * step))
      await sleep(10)
    }
    return audio
  }
  console.info(`AudioController: ending payback for ${audio}`)
  audio.ontimeupdate = null
  audio.ondurationchange = null
  audio.onerror = null
  audio.onended = null
  const startTime = Date.now()
  fade(audio, audio.volume, 0.0, duration)
    .catch((err) => console.warn('Error during fade out: ' + err.stack))
    .finally(() => {
      audio.pause()
      console.info(`AudioController: ending payback done. actual ${Date.now() - startTime}ms`)
    })
}

function sleep(ms: number) {
  return new Promise(resolve => setTimeout(resolve, ms))
}

function clamp(min: number, max: number, value: number) {
  return Math.max(min, Math.min(value, max))
}
