<script lang="ts">
  import IconButton from "./IconButton.svelte";
  import { Mic, Square } from "lucide-svelte";
  import { onDestroy, onMount } from "svelte";

  type MicRecorderProps = {
    class?: string;
    barCount?: number;
    recording?: Blob | null;
    onrecordingcomplete?: (payload: RecordingCompletePayload) => void;
  };

  type RecordingMetadata = {
    mimeType: string;
    sizeBytes: number;
    durationMs: number;
    startedAt: string;
    endedAt: string;
  };

  type RecordingCompletePayload = {
    blob: Blob;
    metadata: RecordingMetadata;
  };

  let {
    class: className = "",
    barCount = 48,
    recording = $bindable<Blob | null>(null),
    onrecordingcomplete,
  }: MicRecorderProps = $props();

  let isRecording = $state(false);
  let errorMessage = $state("");
  let bars = $state<number[]>([]);
  let effectiveBarCount = $state(0);

  let stream: MediaStream | null = null;
  let mediaRecorder: MediaRecorder | null = null;
  let audioContext: AudioContext | null = null;
  let analyser: AnalyserNode | null = null;
  let sourceNode: MediaStreamAudioSourceNode | null = null;
  let visualizerEl: HTMLDivElement | null = null;
  let resizeObserver: ResizeObserver | null = null;
  let frameId = 0;
  let frameStep = 0;
  let recordingStartedAt = 0;
  let recordingStartedAtIso = "";
  let isFinalizing = false;
  let chunks: BlobPart[] = [];

  function syncBarsLength(nextCount: number) {
    const currentCount = bars.length;
    if (nextCount === currentCount) return;

    if (nextCount > currentCount) {
      bars = [...Array.from({ length: nextCount - currentCount }, () => 0.08), ...bars];
      return;
    }

    bars = bars.slice(currentCount - nextCount);
  }

  function updateBarCount(width: number) {
    const autoCount = Math.max(barCount, Math.floor(width / 8));
    effectiveBarCount = Math.max(1, autoCount);
    syncBarsLength(effectiveBarCount);
  }

  function resetBars() {
    bars = Array.from({ length: effectiveBarCount }, () => 0.08);
  }

  function smoothBarsForStop(values: number[], passes = 2): number[] {
    if (values.length < 3 || passes <= 0) return values;

    let smoothed = [...values];
    for (let pass = 0; pass < passes; pass += 1) {
      const next = [...smoothed];
      for (let i = 1; i < smoothed.length - 1; i += 1) {
        const prev = smoothed[i - 1];
        const current = smoothed[i];
        const upcoming = smoothed[i + 1];
        // Weighted moving average to mimic the visual easing while bars are in motion.
        next[i] = Math.max(0.08, prev * 0.25 + current * 0.5 + upcoming * 0.25);
      }
      smoothed = next;
    }

    return smoothed;
  }

  function getRecorderOptions(): MediaRecorderOptions | undefined {
    if (typeof MediaRecorder === "undefined" || !MediaRecorder.isTypeSupported) return undefined;

    const candidates = [
      "audio/mp4;codecs=mp4a.40.2",
      "audio/mp4",
      "audio/webm;codecs=opus",
      "audio/webm",
    ];

    for (const mimeType of candidates) {
      if (MediaRecorder.isTypeSupported(mimeType)) return { mimeType };
    }

    return undefined;
  }

  function finalizeRecording() {
    if (!mediaRecorder || isFinalizing) return;
    isFinalizing = true;

    const type = mediaRecorder.mimeType || "audio/webm";
    if (chunks.length === 0) {
      errorMessage = "No audio data was captured. Please try recording again.";
      cleanupAudioNodes();
      cleanupStream();
      mediaRecorder = null;
      chunks = [];
      isFinalizing = false;
      return;
    }

    const blob = new Blob(chunks, { type });
    const durationMs = Math.max(0, Date.now() - recordingStartedAt);

    const endedAt = new Date();
    const metadata: RecordingMetadata = {
      mimeType: blob.type || type,
      sizeBytes: blob.size,
      durationMs,
      startedAt: recordingStartedAtIso,
      endedAt: endedAt.toISOString(),
    };

    recording = blob;
    onrecordingcomplete?.({ blob, metadata });

    cleanupAudioNodes();
    cleanupStream();
    mediaRecorder = null;
    chunks = [];
    isFinalizing = false;
  }

  function tick() {
    if (!analyser) return;

    const buffer = new Uint8Array(analyser.fftSize);
    analyser.getByteTimeDomainData(buffer);

    let sum = 0;
    for (const value of buffer) {
      const normalized = (value - 128) / 128;
      sum += normalized * normalized;
    }

    const rms = Math.sqrt(sum / buffer.length);
    const boosted = Math.min(1, rms * 14);
    const logScaled = Math.log1p(boosted * 9) / Math.log(10);
    const scaled = Math.min(1, Math.max(0.08, logScaled));

    frameStep += 1;
    if (frameStep % 3 === 0) {
      bars = [...bars.slice(1), scaled];
    }

    frameId = window.requestAnimationFrame(tick);
  }

  async function startRecording() {
    errorMessage = "";
    recording = null;
    recordingStartedAt = Date.now();
    recordingStartedAtIso = new Date(recordingStartedAt).toISOString();
    isFinalizing = false;

    try {
      stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      chunks = [];
      frameStep = 0;
      resetBars();

      mediaRecorder = new MediaRecorder(stream, getRecorderOptions());
      mediaRecorder.ondataavailable = (event) => {
        if (event.data.size > 0) chunks.push(event.data);
      };
      mediaRecorder.onstop = () => {
        finalizeRecording();
      };
      mediaRecorder.onerror = () => {
        errorMessage = "Recording failed. Please try again.";
        cleanupAudioNodes();
        cleanupStream();
        mediaRecorder = null;
        chunks = [];
      };
      mediaRecorder.start();

      audioContext = new AudioContext();
      analyser = audioContext.createAnalyser();
      analyser.fftSize = 1024;
      analyser.smoothingTimeConstant = 0.8;

      sourceNode = audioContext.createMediaStreamSource(stream);
      sourceNode.connect(analyser);

      isRecording = true;
      tick();
    } catch (error) {
      errorMessage = error instanceof Error ? error.message : "Microphone access failed";
      cleanupAudioNodes();
      cleanupStream();
      isRecording = false;
    }
  }

  function stopRecording() {
    if (!isRecording) return;
    isRecording = false;
    bars = smoothBarsForStop(bars);

    if (frameId) {
      window.cancelAnimationFrame(frameId);
      frameId = 0;
    }
    frameStep = 0;

    if (mediaRecorder && mediaRecorder.state !== "inactive") {
      try {
        mediaRecorder.requestData();
      } catch {
        // Some engines can throw here during stop transitions.
      }

      try {
        mediaRecorder.stop();
      } catch {
        errorMessage = "Unable to stop recording cleanly.";
        cleanupAudioNodes();
        cleanupStream();
        mediaRecorder = null;
        chunks = [];
        return;
      }
      return;
    }

    cleanupAudioNodes();
    cleanupStream();
  }

  async function toggleRecording() {
    if (isRecording) {
      stopRecording();
      return;
    }
    await startRecording();
  }

  function cleanupAudioNodes() {
    sourceNode?.disconnect();
    sourceNode = null;
    analyser = null;

    if (audioContext) {
      void audioContext.close();
      audioContext = null;
    }
  }

  function cleanupStream() {
    stream?.getTracks().forEach((track) => track.stop());
    stream = null;
  }

  onDestroy(() => {
    stopRecording();
    resizeObserver?.disconnect();
    resizeObserver = null;
  });

  onMount(() => {
    if (!visualizerEl) return;

    effectiveBarCount = Math.max(1, barCount);
    resetBars();
    updateBarCount(visualizerEl.clientWidth);
    resizeObserver = new ResizeObserver((entries) => {
      const entry = entries[0];
      if (!entry) return;
      updateBarCount(entry.contentRect.width);
    });
    resizeObserver.observe(visualizerEl);
  });
</script>

<div class={`w-full ${className}`}>
  <div class="flex items-end gap-3">
    <div
      bind:this={visualizerEl}
      class="
        grid h-11 flex-1 items-end gap-[3px] overflow-hidden rounded-md px-2 py-1
        border-s-slate-950 bg-[#4a5527]
        border-l-4 border-t-4 border-r border-b
        shadow-[inset_-1px_-1px_3px_rgba(34,40,18,0.85)]
      "
      style={`grid-template-columns: repeat(${bars.length}, minmax(0, 1fr));`}
      aria-hidden="true"
    >
      {#each bars as bar, index (index)}
        <span
          class="w-full rounded-sm bg-[#ffe600] shadow-[0_0_6px_rgba(255,230,0,0.55)] transition-[height] duration-200"
          style={`height: ${Math.max(8, bar * 100)}%`}
        ></span>
      {/each}
    </div>

    <IconButton
      type="button"
      aria-label={isRecording ? "Stop recording" : "Start recording"}
      onclick={toggleRecording}
    >
      {#if isRecording}
        <Square size={18} />
      {:else}
        <Mic size={18} />
      {/if}
    </IconButton>
  </div>

  {#if errorMessage}
    <p class="mt-2 text-sm text-red-700">{errorMessage}</p>
  {/if}
</div>
