import { createContext, useCallback, useEffect, useRef, useState } from 'react';

import audioContext from '@/lib/audioContext';

import useDismount from './useDismount';

// This hook enables playback control over an AudioBuffer, with optional spectrum analysis.
export default function useAudioBufferPlayback(
  enableSpectrum: boolean = false
) {
  const [volume, setVolumeState] = useState(0.8);
  const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(null);
  const [duration, setDuration] = useState(0);
  const [playing, setPlaying] = useState(false);
  const [muted, setMuted] = useState(false);

  const lastSeekTimeRef = useRef(0);
  const lastVolumeRef = useRef(volume);

  useEffect(() => {
    lastVolumeRef.current = volume;
  }, [volume]);

  const lastReportedTimeRef = useRef(0);
  const lastReportedAtRef = useRef(0);
  const lastStartedFromRef = useRef(0);
  const onStopRef = useRef<null | (() => void)>(null);
  const playingRef = useRef<boolean>(false);
  const analyserNodeRef = useRef<AnalyserNode | null>(null);
  const gainNodeRef = useRef<GainNode | null>(null);
  const spectrumBufferRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
  const endTimeoutRef = useRef<NodeJS.Timeout>(undefined);

  const getSpectrum = useCallback(() => {
    if (!analyserNodeRef.current || !enableSpectrum) return null;

    const bufferLength = analyserNodeRef.current.frequencyBinCount;

    const dataArray = spectrumBufferRef.current || new Uint8Array(bufferLength);
    spectrumBufferRef.current = dataArray;

    analyserNodeRef.current.getByteFrequencyData(dataArray);

    return dataArray;
  }, [enableSpectrum]);

  // providing a continuously-updating `currentTime` value will either be low-resolution, or will nuke react.
  // instead, let components call this function as often as they need to to update UI outside of the react lifecycle.
  const getCurrentTime = useCallback(() => {
    if (!playingRef.current) return lastStartedFromRef.current;

    const now = Date.now();

    // lastReportedAtRef.current = real-world-time that we last checked the internal playback time.
    // lastReportedTimeRef.current = the playback time that was reported when we checked.
    // the current time is lastReportedTime plus the amount of time since it was reported.
    return (
      lastReportedTimeRef.current + (now - lastReportedAtRef.current) / 1000
    );
  }, []);

  const play = useCallback(
    (
      startTime: number = lastSeekTimeRef.current,
      endTime: number = Infinity
    ) => {
      clearTimeout(endTimeoutRef.current);
      if (!audioBuffer) {
        console.error('Tried to play audio without loading a buffer first.');
        return;
      }

      const bufferSourceNode = audioContext.createBufferSource();
      bufferSourceNode.buffer = audioBuffer;

      if (onStopRef.current) onStopRef.current();

      const gain = gainNodeRef.current || audioContext.createGain();
      gainNodeRef.current = gain;
      gain.gain.value = muted ? 0 : lastVolumeRef.current;

      if (enableSpectrum) {
        const analyser =
          analyserNodeRef.current || audioContext.createAnalyser();
        analyser.smoothingTimeConstant = 0.85;

        analyserNodeRef.current = analyser;

        bufferSourceNode.connect(analyser);
      }

      bufferSourceNode.connect(gain);
      gain.connect(audioContext.destination);

      bufferSourceNode.start(
        0, // how long from now should we start hearing audio?
        startTime // how far into the audio buffer should we start playback from?
      );

      setPlaying(true);
      playingRef.current = true;
      lastReportedAtRef.current = Date.now();
      lastReportedTimeRef.current = startTime;

      endTimeoutRef.current = setTimeout(
        () => {
          setPlaying(false);
          playingRef.current = false;
          onStopRef.current?.();
        },
        1000 * (Math.min(audioBuffer.duration, endTime) - startTime)
      );

      lastStartedFromRef.current = startTime;
      lastReportedTimeRef.current = startTime;
      lastReportedAtRef.current = Date.now();

      if (audioContext.state !== 'running') {
        audioContext.resume();
      }

      onStopRef.current = () => {
        bufferSourceNode.stop();
        bufferSourceNode.disconnect();
        onStopRef.current = null;
      };
    },
    [audioBuffer, muted, enableSpectrum]
  );

  // if the end time is moved during playback, we need to cancel the stop-playback timeout and set a new one.
  // example: changing the trim endpoint while auditioning a long audio file.
  // if we don't do this, the audio will stop at the old endpoint, even though the UI gives the impression that
  // it should stop at the new endpoint.
  const setEndTime = useCallback(
    (endTime: number) => {
      if (endTime <= getCurrentTime()) {
        setPlaying(false);
        playingRef.current = false;
        onStopRef.current?.();
        return;
      }

      clearTimeout(endTimeoutRef.current);
      endTimeoutRef.current = setTimeout(
        () => {
          setPlaying(false);
          playingRef.current = false;
          onStopRef.current?.();
        },
        1000 * (endTime - getCurrentTime())
      );
    },
    [getCurrentTime, play, playing]
  );

  const seek = useCallback(
    (time: number, andUpdateWhilePlaying: boolean = true) => {
      lastSeekTimeRef.current = time;
      lastStartedFromRef.current = time;
      if (andUpdateWhilePlaying || !playingRef.current) {
        lastReportedTimeRef.current = time;
        lastReportedAtRef.current = Date.now();
      }
      if (andUpdateWhilePlaying && playingRef.current) {
        play(time);
      }
    },
    [play]
  );

  const stop = useCallback(
    (andSeekToStart: boolean = false) => {
      if (andSeekToStart) {
        lastStartedFromRef.current = 0;
      } else {
        lastStartedFromRef.current = getCurrentTime();
      }

      if (onStopRef.current) {
        onStopRef.current();
      }

      if (andSeekToStart) {
        lastSeekTimeRef.current = 0;
      }
      playingRef.current = false;
      setPlaying(false);
    },
    [getCurrentTime]
  );

  const load = useCallback((audioBuffer: AudioBuffer) => {
    setDuration(audioBuffer.duration);
    setAudioBuffer(audioBuffer);
  }, []);

  const setVolume = useCallback((volume: number, andCommit: boolean) => {
    if (andCommit) {
      setVolumeState(volume);
    }

    if (!gainNodeRef.current) return;

    // easy upgrade that I haven't tested: add a ~20Hz transition to this change to avoid audible popping.
    // something like: gainNodeRef.current.gain.setValueAtTime(volume, audioContext.currentTime + 0.05);
    gainNodeRef.current.gain.value = volume;
  }, []);

  useEffect(() => {
    if (muted) {
      setVolume(0, false);
    } else {
      setVolume(volume, false);
    }
  }, [muted, volume, setVolume]);

  useDismount(stop);

  return {
    load,
    duration,
    playing,
    seek,
    play,
    stop,
    setEndTime,
    getCurrentTime,
    getSpectrum,
    volume,
    setVolume,
    muted,
    setMuted,
  };
}

export const AudioBufferPlaybackContext = createContext<
  ReturnType<typeof useAudioBufferPlayback>
>(undefined as never);
