'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { voiceApi } from '@/lib/api/voice';
import type { ApiError } from '@/types/api';
import type { VoiceExtractionResult, VoicePhase } from '@/types/voice';
import {
  blobToBase64,
  isMediaRecorderSupported,
  mimeTypeToFormat,
  pickRecordingMimeType,
} from './audioFormat';

export interface UseVoiceCaptureOptions {
  /** The locale the clinician is speaking, sent so the server does not have to guess. */
  locale: string;
  /** null means uncapped; otherwise the recorder auto-stops here. */
  maxMs: number | null;
  onExtracted: (result: VoiceExtractionResult) => void;
  onError: (error: unknown) => void;
}

export interface VoiceCaptureState {
  phase: VoicePhase;
  elapsedMs: number;
  /** 0..1, for the level meter — proves the microphone is actually hearing something. */
  level: number;
  maxMs: number | null;
  onStart: () => void;
  onStop: () => void;
  onCancel: () => void;
}

const LEVEL_POLL_MS = 100;

/**
 * Client-side failures must be ApiError-shaped or getUserFacingError cannot resolve them
 * and every one renders the generic fallback, leaving errors.VOICE_MIC_DENIED dead.
 */
function clientError(code: string): ApiError {
  return { statusCode: 0, code };
}

/**
 * Microphone capture for treatment voice entry.
 *
 * Lives in lib/ rather than in the editor: TreatmentDetailsEditor stays presentational
 * and receives only a `voice` prop, so MediaRecorder and the API call never enter ui/.
 */
export function useVoiceCapture({
  locale,
  maxMs,
  onExtracted,
  onError,
}: UseVoiceCaptureOptions): VoiceCaptureState {
  const [phase, setPhase] = useState<VoicePhase>('idle');
  const [elapsedMs, setElapsedMs] = useState(0);
  const [level, setLevel] = useState(0);

  const recorderRef = useRef<MediaRecorder | null>(null);
  const streamRef = useRef<MediaStream | null>(null);
  const chunksRef = useRef<Blob[]>([]);
  const startedAtRef = useRef(0);
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const audioContextRef = useRef<AudioContext | null>(null);
  const abortRef = useRef<AbortController | null>(null);
  /** Set when the user cancels, so the recorder's stop handler discards instead of sending. */
  const cancelledRef = useRef(false);
  /** getUserMedia is async; without this a permission granted after unmount leaks the mic. */
  const mountedRef = useRef(true);
  /**
   * Set synchronously on click: `phase` only becomes 'recording' once getUserMedia resolves,
   * so a second click during the permission prompt would orphan the first stream.
   */
  const startingRef = useRef(false);

  const teardown = useCallback(() => {
    if (timerRef.current) {
      clearInterval(timerRef.current);
      timerRef.current = null;
    }
    streamRef.current?.getTracks().forEach((track) => track.stop());
    streamRef.current = null;
    void audioContextRef.current?.close().catch(() => undefined);
    audioContextRef.current = null;
    recorderRef.current = null;
    setLevel(0);
  }, []);

  // Releasing the microphone on unmount matters: the browser shows a recording indicator
  // for as long as the track is live, and an orphaned one looks like the app is listening.
  useEffect(() => {
    // Re-armed on every mount: React StrictMode runs mount → unmount → mount in dev, and
    // a ref that is only ever set false would leave the hook permanently "unmounted".
    mountedRef.current = true;
    return () => {
      mountedRef.current = false;
      cancelledRef.current = true;
      abortRef.current?.abort();
      try {
        recorderRef.current?.stop();
      } catch {
        // already stopped
      }
      teardown();
    };
  }, [teardown]);

  const send = useCallback(
    async (blob: Blob, mimeType: string, durationMs: number) => {
      setPhase('processing');
      const controller = new AbortController();
      abortRef.current = controller;
      try {
        const audio = await blobToBase64(blob);
        const response = await voiceApi.extract(
          {
            audio,
            format: mimeTypeToFormat(mimeType),
            timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
            durationMs,
            locale,
          },
          controller.signal,
        );
        if (cancelledRef.current) return;
        onExtracted(response.data);
      } catch (error) {
        if (cancelledRef.current || controller.signal.aborted) return;
        onError(error);
      } finally {
        abortRef.current = null;
        setPhase('idle');
        setElapsedMs(0);
      }
    },
    [locale, onExtracted, onError],
  );

  const stop = useCallback(() => {
    // No recorder means nothing will fire `onstop`, so nothing else moves the phase.
    if (!recorderRef.current) {
      teardown();
      setPhase('idle');
      return;
    }
    try {
      recorderRef.current.stop();
    } catch {
      teardown();
      setPhase('idle');
    }
  }, [teardown]);

  const onStart = useCallback(() => {
    if (phase !== 'idle' || startingRef.current) return;
    if (!isMediaRecorderSupported()) {
      // VOICE_UNSUPPORTED_FORMAT, not MIC_DENIED: nothing asked for a permission yet, and
      // blaming the microphone sends the clinician into site settings for no reason. Same
      // for the two paths below.
      onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
      return;
    }

    cancelledRef.current = false;
    startingRef.current = true;

    void (async () => {
      try {
        let stream: MediaStream;
        try {
          stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        } catch {
          // Permission refused, or no input device. Never a server round-trip.
          onError(clientError('VOICE_MIC_DENIED'));
          return;
        }

        if (!mountedRef.current) {
          // Permission resolved after the component went away — release it immediately
          // rather than leaving the browser's recording indicator lit.
          stream.getTracks().forEach((track) => track.stop());
          return;
        }

        const mimeType = pickRecordingMimeType();
        if (mimeType === null) {
          stream.getTracks().forEach((track) => track.stop());
          onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
          return;
        }

        streamRef.current = stream;
        chunksRef.current = [];
        const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
        recorderRef.current = recorder;

        recorder.ondataavailable = (event) => {
          if (event.data.size > 0) chunksRef.current.push(event.data);
        };
        recorder.onstop = () => {
          const durationMs = Date.now() - startedAtRef.current;
          const blob = new Blob(chunksRef.current, { type: recorder.mimeType || mimeType });
          teardown();
          if (cancelledRef.current || blob.size === 0) {
            setPhase('idle');
            setElapsedMs(0);
            return;
          }
          // Prefer what the recorder actually produced, then the blob's own type. Old
          // Safari accepts no mimeType hint, and defaulting to webm would mislabel its
          // mp4/aac clips as something they are not.
          void send(blob, recorder.mimeType || blob.type || mimeType || 'audio/webm', durationMs);
        };

        attachLevelMeter(stream, audioContextRef, setLevel);

        startedAtRef.current = Date.now();
        recorder.start();
        setPhase('recording');
        setElapsedMs(0);

        timerRef.current = setInterval(() => {
          const elapsed = Date.now() - startedAtRef.current;
          setElapsedMs(elapsed);
          // Auto-stop proceeds to processing with what was captured; discarding two
          // minutes of dictation because a timer expired would be the worst failure.
          if (maxMs != null && elapsed >= maxMs) stop();
        }, LEVEL_POLL_MS);
      } catch {
        // `new MediaRecorder()` and `recorder.start()` both throw on some browsers, and by
        // then the stream is live — without this the mic indicator stays lit until unmount.
        teardown();
        setPhase('idle');
        onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
      } finally {
        startingRef.current = false;
      }
    })();
  }, [maxMs, onError, phase, send, stop, teardown]);

  const onCancel = useCallback(() => {
    cancelledRef.current = true;
    // Aborting closes the connection, which aborts the vendor call server-side.
    abortRef.current?.abort();
    try {
      recorderRef.current?.stop();
    } catch {
      // already stopped
    }
    teardown();
    setPhase('idle');
    setElapsedMs(0);
  }, [teardown]);

  return { phase, elapsedMs, level, maxMs, onStart, onStop: stop, onCancel };
}

/** Drives the level meter from the live stream; failure here must not stop recording. */
function attachLevelMeter(
  stream: MediaStream,
  contextRef: React.MutableRefObject<AudioContext | null>,
  setLevel: (value: number) => void,
) {
  try {
    const AudioContextCtor =
      window.AudioContext ?? (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
    if (!AudioContextCtor) return;

    const context = new AudioContextCtor();
    contextRef.current = context;
    const source = context.createMediaStreamSource(stream);
    const analyser = context.createAnalyser();
    analyser.fftSize = 512;
    source.connect(analyser);

    const data = new Uint8Array(analyser.frequencyBinCount);
    // Sample every frame so a transient is not missed, publish at LEVEL_POLL_MS. The hook
    // lives in TreatmentWorkspace, so an unthrottled setLevel is ~7,200 whole-tree renders
    // across a two-minute recording.
    let peakSinceEmit = 0;
    let lastEmit = 0;
    const tick = (now: number) => {
      if (contextRef.current !== context || context.state === 'closed') return;
      analyser.getByteTimeDomainData(data);
      let peak = 0;
      for (const sample of data) peak = Math.max(peak, Math.abs(sample - 128));
      peakSinceEmit = Math.max(peakSinceEmit, peak);
      if (now - lastEmit >= LEVEL_POLL_MS) {
        lastEmit = now;
        setLevel(Math.min(1, peakSinceEmit / 128));
        peakSinceEmit = 0;
      }
      requestAnimationFrame(tick);
    };
    requestAnimationFrame(tick);
  } catch {
    // A missing or blocked AudioContext costs the meter, not the recording.
  }
}
