class Player {
  // hold references to all ui elements
  private readonly nowPlaying: HTMLElement;
  private readonly rewButton: HTMLButtonElement;
  private readonly playButton: HTMLButtonElement;
  private readonly playButtonPath: SVGPathElement;
  private readonly ffwButton: HTMLButtonElement;
  private readonly skipButton: HTMLButtonElement;
  private readonly cover: HTMLImageElement;
  private readonly seriesName: HTMLElement;
  private readonly episodeName: HTMLElement;
  private readonly timeDisplay: HTMLElement;
  private readonly volumeSlider: HTMLInputElement;
  private readonly progress: HTMLElement;

  // ticker timer
  private ticker: number | null;

  // the actual howler that plays the episodes
  private howl: Howl | null;

  // currently playing episode and playlist
  private episode: Episode | null;
  private playlist: Playlist;
  private stateChangedHandlers: Array<() => void> = [];

  public constructor(playlist: Playlist) {
    this.playlist = playlist;

    const controls = getOrThrow(document.getElementById('controls'));
    const timeVolume = getOrThrow(document.getElementById('timeVolume'));
    this.nowPlaying = getOrThrow(document.getElementById('nowPlaying'));
    this.rewButton = getOrThrow(
      controls.getElementsByTagName('button').item(0)
    );
    this.playButton = getOrThrow(
      controls.getElementsByTagName('button').item(1)
    );
    this.playButtonPath = getOrThrow(
      this.playButton.getElementsByTagName('path').item(0)
    );
    this.ffwButton = getOrThrow(
      controls.getElementsByTagName('button').item(2)
    );
    this.skipButton = getOrThrow(
      controls.getElementsByTagName('button').item(3)
    );
    this.cover = getOrThrow(
      this.nowPlaying.getElementsByTagName('img').item(0)
    );
    this.seriesName = getOrThrow(
      this.nowPlaying.getElementsByTagName('span').item(0)
    );
    this.episodeName = getOrThrow(
      this.nowPlaying.getElementsByTagName('span').item(1)
    );
    this.timeDisplay = getOrThrow(
      timeVolume.getElementsByTagName('span').item(0)
    );
    this.volumeSlider = getOrThrow(
      timeVolume.getElementsByTagName('input').item(0)
    );
    this.progress = getOrThrow(document.getElementById('progress'));
    this.ticker = null;
    this.howl = null;
    this.episode = null;

    // initialize to stopped state
    this.stopPlaybackAndResetUi();

    // wire up static ui elements
    this.playButton.addEventListener('click', () => this.playPause());
    this.rewButton.addEventListener('click', () => this.rewind());
    this.ffwButton.addEventListener('click', () => this.fastForward());
    this.skipButton.addEventListener('click', () => this.nextEpisode());
    this.volumeSlider.addEventListener('change', () => this.setVolume());

    // wire up mediaSession events
    if ('mediaSession' in navigator) {
      navigator.mediaSession.setActionHandler('pause', () => this.playPause());
      navigator.mediaSession.setActionHandler('play', () => this.playPause());
      navigator.mediaSession.setActionHandler('stop', () =>
        this.stopPlaybackAndResetUi()
      );
      navigator.mediaSession.setActionHandler('seekforward', () =>
        this.fastForward()
      );
      navigator.mediaSession.setActionHandler('seekbackward', () =>
        this.rewind()
      );
      navigator.mediaSession.setActionHandler('nexttrack', () =>
        this.nextEpisode()
      );
      // don't support previous track yet, queue removes them once finished
      // wire this up to rewind instead
      navigator.mediaSession.setActionHandler('previoustrack', () =>
        this.rewind()
      );
    }

    // set up playlist changed handler
    this.playlist.addPlaylistChangedHandler(() => {
      this.skipButton.disabled = !this.playlist.hasNextEpisode();
    });
  }

  public addStateChangedHandler(handler: () => void): void {
    this.stateChangedHandlers.push(handler);
  }

  public currentEpisode(): Episode | null {
    return this.episode;
  }

  public playEpisode(episode: Episode, paused = false): void {
    this.stopPlaybackAndResetUi();
    this.setPauseButtonUI();
    this.episode = episode;
    this.playlist.removeEpisode(episode, false, false);
    this.updateNowPlayingUI(true);

    void fetch(`/api/r/${episode.file}`)
      .then(async (res) => {
        if (!res.ok) {
          this.setErrorUI(`Error fetching episode (${res.status})`);
          return;
        }
        const link = <UrlResponse>await res.json();
        if (this.howl) this.howl.unload();
        this.howl = new Howl({
          src: `${link.url}?Authorization=${link.token}`,
          html5: true,
          autoplay: !paused,
          volume: this.getVolume(),
          onload: (): void => {
            this.updateTimeUI();
            this.sendMediaSessionMetadata();
            this.updateNowPlayingUI();
            this.sendMediaSessionMetadata();
            if (paused) this.setPlayButtonUI();
          },
          onplay: (): void => {
            this.setPauseButtonUI();
            this.startTicker();
            for (const handler of this.stateChangedHandlers) {
              handler();
            }
          },
          onpause: (): void => {
            this.setPlayButtonUI();
            this.stopTicker();
            for (const handler of this.stateChangedHandlers) {
              handler();
            }
          },
          onend: () => this.nextEpisode(),
          onloaderror: () => this.setErrorUI('Error playing episode.'),
        });
      })
      .catch(() => {
        this.setErrorUI('Error downloading episode.');
      });
  }

  public isPlaying(): boolean {
    if (this.howl) {
      return this.howl.playing();
    }
    return false;
  }

  public playPause(): void {
    if (this.howl && this.howl.playing()) this.howl.pause();
    else if (this.howl && !this.howl.playing()) this.howl.play();
  }

  public stopPlaybackAndResetUi(): void {
    this.howl?.unload();
    this.howl = null;
    this.episode = null;
    this.setPlayButtonUI();
    this.updateNowPlayingUI();
    this.sendMediaSessionMetadata();
    this.stopTicker();
    this.updateTimeUI();
    for (const handler of this.stateChangedHandlers) {
      handler();
    }
  }

  public nextEpisode(): void {
    const next = this.playlist.nextEpisode();
    if (next) this.playEpisode(next);
    else {
      this.stopPlaybackAndResetUi();
      // manually trigger playlist changed
      // so that currently playing media gets wiped
      this.playlist.triggerPlaylistChanged();
    }
  }

  private setErrorUI(message: string): void {
    this.stopPlaybackAndResetUi();
    Toastify({
      text: message,
      gravity: 'bottom',
      position: 'right',
      stopOnFocus: false,
      style: {
        marginBottom: '10ex',
        background: '#a00',
        color: '#fff',
      },
    }).showToast();
  }

  private rewind(): void {
    if (this.howl && this.howl.state() === 'loaded') {
      let newPos = this.howl.seek() - 10;
      if (newPos < 0) newPos = 0;
      this.howl.seek(newPos);
      this.updateTimeUI();
    }
  }

  private fastForward(): void {
    if (this.howl && this.howl.state() === 'loaded') {
      let newPos = this.howl.seek() + 30;
      if (newPos > this.howl.duration()) newPos = this.howl.duration();
      this.howl.seek(newPos);
      this.updateTimeUI();
    }
  }

  private setPlayButtonUI(): void {
    this.playButtonPath.setAttribute(
      'd',
      'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.79 5.093A.5.5 0 0 0 6 5.5v5a.5.5 0 0 0 .79.407l3.5-2.5a.5.5 0 0 0 0-.814l-3.5-2.5z'
    );
  }

  private setPauseButtonUI(): void {
    this.playButtonPath.setAttribute(
      'd',
      'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.25 5C5.56 5 5 5.56 5 6.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C7.5 5.56 6.94 5 6.25 5zm3.5 0c-.69 0-1.25.56-1.25 1.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C11 5.56 10.44 5 9.75 5z'
    );
  }

  private getVolume(): number {
    return parseFloat(this.volumeSlider.value) / 100;
  }

  private setVolume(): void {
    this.howl?.volume(this.getVolume());
  }

  private updateNowPlayingUI(loading = false): void {
    this.nowPlaying.classList.remove('error');
    if (this.episode) {
      this.seriesName.innerHTML = loading
        ? 'Loading episode'
        : this.episode.series.title;
      this.episodeName.innerHTML = this.episode.title;
      this.cover.src = loading ? '/loading.gif' : this.episode.series.cover;
      this.nowPlaying.title = `${this.episode.series.title}\n${this.episode.title}`;
    } else {
      this.seriesName.innerHTML = 'No episode playing';
      this.episodeName.innerHTML = '';
      this.cover.src = '/transparent.png';
      this.nowPlaying.title = 'No episode playing';
    }

    if (loading || !this.episode) {
      this.playButton.disabled = true;
      this.rewButton.disabled = true;
      this.ffwButton.disabled = true;
      this.skipButton.disabled = true;
    } else {
      this.playButton.disabled = false;
      this.rewButton.disabled = false;
      this.ffwButton.disabled = false;
      this.skipButton.disabled = !this.playlist.hasNextEpisode();
    }
  }

  private sendMediaSessionMetadata(): void {
    if ('mediaSession' in navigator) {
      if (this.episode) {
        navigator.mediaSession.metadata = new MediaMetadata({
          title: this.episode.title,
          album: 'Radiostasis',
          artist: this.episode.series.title,
          artwork: [
            {
              src: this.episode.series.cover,
              sizes: '256x256',
              type: 'image/jpeg',
            },
          ],
        });
      } else {
        navigator.mediaSession.metadata = null;
      }
    }
  }

  private timeToDisplayString(time: number): string {
    const mins = Math.floor(time / 60);
    const secs = Math.floor(time - mins * 60);
    const secStr = secs < 10 ? `0${secs}` : secs.toString();
    return `${mins}:${secStr}`;
  }

  private updateTimeUI(): void {
    if (this.howl && this.howl.state() === 'loaded') {
      const total = this.howl.duration();
      const current = this.howl.seek();
      const pct = `${Math.round((current / total) * 1000) / 10}%`;
      const timeStamp = `${this.timeToDisplayString(
        current
      )} / ${this.timeToDisplayString(total)}`;

      // set the new values if they've changed since the last tick
      if (this.timeDisplay.innerHTML !== timeStamp)
        this.timeDisplay.innerHTML = timeStamp;
      if (this.progress.style.width !== pct) this.progress.style.width = pct;
    } else {
      this.timeDisplay.innerHTML = '--:-- / --:--';
      this.progress.style.width = '0%';
    }
  }

  private startTicker(): void {
    if (!this.ticker) {
      this.ticker = setInterval(() => this.updateTimeUI(), 500);
    }
  }

  private stopTicker(): void {
    if (this.ticker) {
      clearInterval(this.ticker);
      this.ticker = null;
    }
  }
}
