"use client";

import { useEffect, useRef, useState } from "react";
import { analyseAudio, encodePeaks } from "./waveform";

const MAX_DURATION_SECONDS = 5 * 60;
// Plenty for speech, and keeps 5 minutes around 1 MB.
const VOICE_BITRATE = 32_000;
const STOP_TIMEOUT_MS = 2_000;
// Bars shown live while recording.
const LIVE_BARS = 40;

// Extensions the API recognises as voice messages.
const FORMATS = [
  { mime: "audio/webm;codecs=opus", extension: "weba" },
  { mime: "audio/webm", extension: "weba" },
  { mime: "audio/mp4", extension: "m4a" },
  { mime: "audio/ogg;codecs=opus", extension: "ogg" },
];

export type MicrophoneIssue = "blocked" | "no-device" | "busy";

export function isVoiceRecordingSupported(): boolean {
  return typeof window !== "undefined" && !!navigator.mediaDevices?.getUserMedia && typeof MediaRecorder !== "undefined";
}

export function microphoneIssueOf(error: unknown): MicrophoneIssue {
  const name = error instanceof Error ? error.name : "";
  if (name === "NotFoundError" || name === "OverconstrainedError") return "no-device";
  if (name === "NotReadableError" || name === "AbortError") return "busy";
  return "blocked";
}

export function useVoiceRecorder(onMaxDuration: () => void) {
  const [isRecording, setIsRecording] = useState(false);
  const [elapsed, setElapsed] = useState(0);
  const [levels, setLevels] = useState<number[]>([]);
  const audioContext = useRef<AudioContext | null>(null);
  const recorder = useRef<MediaRecorder | null>(null);
  const stream = useRef<MediaStream | null>(null);
  const chunks = useRef<Blob[]>([]);
  const timer = useRef<number | null>(null);
  const format = useRef(FORMATS[1]);
  const starting = useRef(false);
  const startedAt = useRef(0);
  const maxDurationHandler = useRef(onMaxDuration);
  maxDurationHandler.current = onMaxDuration;

  const release = () => {
    if (timer.current) window.clearInterval(timer.current);
    timer.current = null;
    stream.current?.getTracks().forEach((track) => track.stop());
    stream.current = null;
    recorder.current = null;
    void audioContext.current?.close().catch(() => {});
    audioContext.current = null;
    setIsRecording(false);
    setElapsed(0);
    setLevels([]);
  };

  /** Microphone loudness sampled for the live bars. */
  const listenToLevels = (source: MediaStream) => {
    try {
      const context = new AudioContext();
      const analyser = context.createAnalyser();
      analyser.fftSize = 512;
      context.createMediaStreamSource(source).connect(analyser);
      audioContext.current = context;
      const samples = new Uint8Array(analyser.fftSize);
      return () => {
        analyser.getByteTimeDomainData(samples);
        let sum = 0;
        for (const sample of samples) sum += ((sample - 128) / 128) ** 2;
        const level = Math.min(1, Math.sqrt(sum / samples.length) * 4);
        setLevels((prev) => [...prev, level].slice(-LIVE_BARS));
      };
    } catch {
      return () => {};
    }
  };

  useEffect(() => release, []);

  /** Asks the browser for the microphone (its own prompt the first time). Throws when refused. */
  const start = async () => {
    if (starting.current || recorder.current) return;
    starting.current = true;
    try {
      stream.current = await navigator.mediaDevices.getUserMedia({ audio: true });
      format.current = FORMATS.find((f) => MediaRecorder.isTypeSupported(f.mime)) ?? FORMATS[1];
      chunks.current = [];
      const media = new MediaRecorder(stream.current, {
        ...(MediaRecorder.isTypeSupported(format.current.mime) ? { mimeType: format.current.mime } : {}),
        audioBitsPerSecond: VOICE_BITRATE,
      });
      media.ondataavailable = (event) => { if (event.data.size > 0) chunks.current.push(event.data); };
      media.start(250);
      recorder.current = media;
    } catch (error) {
      release();
      throw error;
    } finally {
      starting.current = false;
    }

    const sampleLevel = listenToLevels(stream.current!);
    startedAt.current = Date.now();
    setElapsed(0);
    setIsRecording(true);
    timer.current = window.setInterval(() => {
      const seconds = Math.floor((Date.now() - startedAt.current) / 1000);
      setElapsed(seconds);
      sampleLevel();
      if (seconds >= MAX_DURATION_SECONDS) maxDurationHandler.current();
    }, 100);
  };

  const buildFile = async (media: MediaRecorder | null): Promise<File | null> => {
    const mime = (media?.mimeType || format.current.mime).split(";")[0];
    const blob = new Blob(chunks.current, { type: mime });
    let seconds = Math.max(1, Math.round((Date.now() - startedAt.current) / 1000));
    chunks.current = [];
    release();
    if (blob.size === 0) return null;

    // Duration and waveform ride in the name: recorded webm files carry no duration, and
    // the apps can't always read the audio back (cross-origin storage) to draw the bars.
    const analysis = await analyseAudio(await blob.arrayBuffer());
    if (analysis && Number.isFinite(analysis.duration)) seconds = Math.max(1, Math.round(analysis.duration));
    const waveform = analysis ? `-w${encodePeaks(analysis.peaks)}` : "";

    return new File([blob], `voice-message-${Date.now()}-${seconds}s${waveform}.${format.current.extension}`, { type: mime });
  };

  /** Resolves with the recording, or null when nothing was captured. */
  const stop = (): Promise<File | null> =>
    new Promise((resolve) => {
      const media = recorder.current;
      // The browser may already have stopped the recorder: keep what was captured.
      if (!media || media.state === "inactive") {
        void buildFile(media).then(resolve);
        return;
      }
      let done = false;
      const finish = () => {
        if (done) return;
        done = true;
        void buildFile(media).then(resolve);
      };
      media.onstop = finish;
      window.setTimeout(finish, STOP_TIMEOUT_MS);
      media.stop();
    });

  const cancel = () => {
    const media = recorder.current;
    if (media && media.state !== "inactive") {
      media.onstop = null;
      media.stop();
    }
    chunks.current = [];
    release();
  };

  return { isRecording, elapsed, levels, start, stop, cancel };
}
