import { useCallback, useMemo, useRef, useState } from 'react';

import audioContext from '@/lib/audioContext';
import decodeAudioFromArrayBuffer from '@/lib/decodeAudioFromArrayBuffer';
import { getSafariCompatibleMimeType } from '@/lib/safariAudioUtils';

import useDismount from './useDismount';

export enum RecordingStage {
  Ready = 'Ready',
  Preparing = 'Preparing',
  Recording = 'Recording',
}

const useRecording = (
  visualizedWindowLengthSamples: number = audioContext.sampleRate * 4
) => {
  const visualizedWindow = useMemo(
    () => new Float32Array(visualizedWindowLengthSamples),
    []
  );
  const [stage, setStage] = useState(RecordingStage.Ready);
  const [recordedBuffer, setRecordedBuffer] = useState<AudioBuffer | null>(
    null
  );
  const recordingStartTime = useRef(Infinity);
  const onStopRecordingRef = useRef<() => Promise<AudioBuffer> | null>(
    () => null
  );

  const getCurrentDuration = useCallback(() => {
    return Math.max(0, Date.now() - recordingStartTime.current);
  }, []);

  const workletNodeRef = useRef<null | AudioWorkletNode>(null);

  const readyToRecord = stage === RecordingStage.Ready;

  const startRecording = useCallback(
    async (
      deviceId: string = 'default',
      settingOverrides: Partial<{
        echoCancellation: boolean;
        autoGainControl: boolean;
        noiseSuppression: boolean;
      }> = {}
    ) => {
      if (!readyToRecord) return;
      setStage(RecordingStage.Preparing);
      setRecordedBuffer(null);
      try {
        // the first time a user does this, they'll see a popup asking for mic permissions here.
        const inputStream = await new Promise<MediaStream>((resolve, reject) =>
          navigator.mediaDevices
            .getUserMedia({
              audio: {
                deviceId,
                echoCancellation: true,
                noiseSuppression: true,
                autoGainControl: true,
                ...settingOverrides,
                latency: 0,
                sampleRate: audioContext.sampleRate,
              } as MediaTrackConstraints, // typescript is missing the latency property
              video: false,
            })
            .then((inputStream) => {
              resolve(inputStream);
            })
            .catch((e) => {
              reject(e);
            })
        );

        const mediaStreamSource =
          audioContext.createMediaStreamSource(inputStream);

        if (!workletNodeRef.current) {
          // we feed audio into this worklet, it spits out waveform data in a way that we can reliably capture
          await audioContext.audioWorklet.addModule(
            new URL('../../workers/waveform-getter-worklet.js', import.meta.url)
          );
          workletNodeRef.current = new window.AudioWorkletNode(
            audioContext,
            'waveformGetterWorklet',
            {
              outputChannelCount: [2],
              numberOfInputs: 1,
            }
          );
        }

        const waveformGetterWorklet = workletNodeRef.current;

        // although we are using a worklet to capture audio data for visualization,
        // the MediaRecorder will hang on to the actual recording.
        // if our worklet crashes, or takes too long to process a buffer, or something, we don't lose the recording.

        // Safari-specific MediaRecorder configuration
        const mediaRecorderOptions: MediaRecorderOptions = {};
        const safariMimeType = getSafariCompatibleMimeType();
        if (safariMimeType) {
          mediaRecorderOptions.mimeType = safariMimeType;
        }

        const mediaRecorder = new MediaRecorder(
          inputStream,
          mediaRecorderOptions
        );
        let hasReceivedMessageFromWorklet = false;
        let hasStartedRecording = false;

        mediaStreamSource.connect(waveformGetterWorklet);
        waveformGetterWorklet.port.onmessage = (message) => {
          if (!hasReceivedMessageFromWorklet && !hasStartedRecording) {
            hasReceivedMessageFromWorklet = true;
          }
          if (message.data.type === 'waveform') {
            // this ensures we only start recording once, and only start the media recorder when it is in a startable state.
            if (!hasStartedRecording && mediaRecorder.state === 'inactive') {
              mediaRecorder.start();
              hasStartedRecording = true;
            }
            // shift the existing data to the left:
            // [12345678] -> [34567878]
            visualizedWindow.set(
              visualizedWindow.subarray(message.data.waveform.length)
            );

            // write new data at the end:
            // [34567878] -> [34567890]
            visualizedWindow.set(
              message.data.waveform,
              visualizedWindow.length - message.data.waveform.length
            );
          }
        };

        const blobs: Blob[] = [];
        let hasReceivedRecordedData = false;
        // mediaRecorder will call this some unknown number of times, at some unknown point after recording is started.
        mediaRecorder.ondataavailable = (e) => {
          if (!hasReceivedRecordedData) {
            hasReceivedRecordedData = true;
          }
          blobs.push(e.data);
        };

        let resolveMediaRecorderPromise: (
          buffer: AudioBuffer
        ) => void = () => {};
        let rejectMediaRecorderPromise: (error: Error) => void = () => {};

        const mediaRecorderPromise = new Promise<AudioBuffer>(
          (resolve, reject) => {
            resolveMediaRecorderPromise = resolve;
            rejectMediaRecorderPromise = reject;
          }
        );

        // this is called when the mediaRecorder is done capturing and making data available.
        mediaRecorder.onstop = async () => {
          try {
            const audioBuffer = await decodeAudioFromArrayBuffer(
              await new Blob(blobs).arrayBuffer()
            );
            resolveMediaRecorderPromise(audioBuffer);
          } catch (error) {
            console.error('Error processing recorded audio:', error);

            rejectMediaRecorderPromise(error as Error);
          }
        };

        // Handle MediaRecorder errors
        mediaRecorder.onerror = (event) => {
          console.error('MediaRecorder error:', event);
          rejectMediaRecorderPromise(new Error('MediaRecorder failed'));
        };

        recordingStartTime.current = Date.now();
        setStage(RecordingStage.Recording);
        onStopRecordingRef.current = () => {
          console.log('Cleanup function called - stopping recording resources');

          try {
            mediaStreamSource.disconnect();
            console.log('MediaStreamSource disconnected');
          } catch (e) {
            console.warn('Error disconnecting MediaStreamSource:', e);
          }

          try {
            waveformGetterWorklet.disconnect();
            console.log('WaveformGetterWorklet disconnected');
          } catch (e) {
            console.warn('Error disconnecting WaveformGetterWorklet:', e);
          }

          try {
            const tracks = inputStream.getAudioTracks();
            console.log('Stopping', tracks.length, 'audio tracks');
            tracks.forEach((track, index) => {
              console.log(
                `Stopping track ${index}:`,
                track.label,
                'readyState:',
                track.readyState
              );
              track.stop();
            });
            console.log('All audio tracks stopped');
          } catch (e) {
            console.warn('Error stopping audio tracks:', e);
          }

          try {
            if (mediaRecorder.state !== 'inactive') {
              console.log(
                'Stopping MediaRecorder, current state:',
                mediaRecorder.state
              );
              mediaRecorder.stop();
            } else {
              console.log('MediaRecorder already inactive');
              // If already inactive, we might not get an onstop event
              // Check if we have data to process
              if (blobs.length === 0) {
                rejectMediaRecorderPromise(
                  new Error('No recorded data available')
                );
              }
            }
          } catch (e) {
            console.warn('Error stopping MediaRecorder:', e);
            rejectMediaRecorderPromise(e as Error);
          }

          return mediaRecorderPromise;
        };
      } catch (e) {
        onStopRecordingRef.current = () => null;
        setStage(RecordingStage.Ready);
        console.error(e);
      }
    },
    [readyToRecord, visualizedWindow]
  );

  const stopRecording = useCallback(async () => {
    console.log('stopRecording called, current stage:', stage);

    // Only proceed if we're actually recording
    if (stage !== RecordingStage.Recording) {
      console.log('stopRecording: not currently recording, stage:', stage);
      return recordedBuffer;
    }

    setStage(RecordingStage.Ready);
    console.log('stopRecording: calling cleanup function');
    const buffer = await onStopRecordingRef.current();
    console.log(
      'stopRecording: cleanup completed, buffer:',
      buffer ? 'received' : 'null'
    );
    setRecordedBuffer(buffer);
    return buffer;
  }, [stage, recordedBuffer]);

  useDismount(stopRecording);

  return {
    startRecording,
    stopRecording,
    recordedBuffer,
    visualizedWindow,
    stage,
    getCurrentDuration,
  };
};

export default useRecording;
