import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useMemo, useRef, useState } from 'react';

import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { getVoiceSpeed, setVoiceSpeed, VOICE_SPEEDS, type VoiceSpeed } from '../lib/voiceSpeedSettings';
import { supabase } from '../lib/supabase';
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';

interface Props {
  handle: AttachmentHandle;
}

const BAR_COUNT = 48;

// Custom voice-message player with waveform visualisation. Decoded peaks are
// computed once per blob via OfflineAudioContext so playback only carries the
// rendered DOM. Falls back to a rectangular bar if decoding fails (e.g. the
// blob mime is recognised by <audio> but not by AudioContext).
export function AttachmentAudio({ handle }: Props) {
  const [blobUrl, setBlobUrl] = useState<string | null>(null);
  const [arrayBuf, setArrayBuf] = useState<ArrayBuffer | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [peaks, setPeaks] = useState<number[] | null>(null);
  const [duration, setDuration] = useState<number>(0);
  const [position, setPosition] = useState<number>(0);
  const [playing, setPlaying] = useState(false);
  const [speed, setSpeed] = useState<VoiceSpeed>(() => getVoiceSpeed());
  const audioRef = useRef<HTMLAudioElement | null>(null);

  useEffect(() => {
    let cancelled = false;
    let url: string | null = null;
    setError(null);
    setBlobUrl(null);
    setArrayBuf(null);

    void (async () => {
      const cached = await getCachedAttachment(handle.id);
      if (cached) {
        if (cancelled) return;
        url = URL.createObjectURL(cached);
        setBlobUrl(url);
        const buf = await cached.arrayBuffer();
        if (!cancelled) setArrayBuf(buf);
        return;
      }
      try {
        const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
        if (cancelled) return;
        url = URL.createObjectURL(blob);
        setBlobUrl(url);
        const buf = await blob.arrayBuffer();
        if (!cancelled) setArrayBuf(buf);
        void putCachedAttachment(handle.id, blob);
      } catch (err: unknown) {
        if (!cancelled) {
          setError(err instanceof Error ? err.message : 'download failed');
        }
      }
    })();

    return () => {
      cancelled = true;
      if (url) URL.revokeObjectURL(url);
    };
  }, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);

  // Compute peaks via OfflineAudioContext. Cheap O(n) scan over PCM samples
  // bucketed into BAR_COUNT bars. Done once per attachment.
  useEffect(() => {
    if (!arrayBuf) return;
    let cancelled = false;
    const Ctx =
      window.AudioContext ||
      (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
    const ctx = new Ctx();
    ctx
      .decodeAudioData(arrayBuf.slice(0))
      .then((decoded) => {
        if (cancelled) return;
        setDuration(decoded.duration);
        const channel = decoded.getChannelData(0);
        const bucket = Math.max(1, Math.floor(channel.length / BAR_COUNT));
        const out = new Array<number>(BAR_COUNT).fill(0);
        for (let i = 0; i < BAR_COUNT; i++) {
          let max = 0;
          const start = i * bucket;
          const end = Math.min(channel.length, start + bucket);
          for (let j = start; j < end; j++) {
            const v = Math.abs(channel[j]!);
            if (v > max) max = v;
          }
          out[i] = max;
        }
        // Normalize so loudest peak is 1; keeps quiet recordings visible.
        const peak = Math.max(...out, 0.001);
        setPeaks(out.map((v) => v / peak));
      })
      .catch(() => {
        // Fall through — UI shows a flat bar but playback still works.
      })
      .finally(() => {
        void ctx.close().catch(() => {});
      });
    return () => {
      cancelled = true;
    };
  }, [arrayBuf]);

  useEffect(() => {
    const el = audioRef.current;
    if (!el) return;
    el.playbackRate = speed;
  }, [speed, blobUrl]);

  const fallbackPeaks = useMemo(
    () => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
    [peaks],
  );
  const visiblePeaks = peaks ?? fallbackPeaks!;
  const progress = duration > 0 ? position / duration : 0;

  const onTogglePlay = () => {
    const el = audioRef.current;
    if (!el || !blobUrl) return;
    if (el.paused) void el.play();
    else el.pause();
  };

  const onSeek = (e: React.MouseEvent<HTMLDivElement>) => {
    const el = audioRef.current;
    if (!el || duration === 0) return;
    const rect = e.currentTarget.getBoundingClientRect();
    const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
    el.currentTime = ratio * duration;
  };

  if (error) {
    return (
      <div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
        <AlertIcon className="h-4 w-4" />
        <span>{error}</span>
      </div>
    );
  }

  return (
    <div className="mt-2 flex w-[280px] min-w-[280px] items-center gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2">
      <button
        type="button"
        onClick={onTogglePlay}
        disabled={!blobUrl}
        aria-label={playing ? 'Pause' : 'Wiedergabe'}
        title={playing ? 'Pause' : 'Wiedergabe'}
        className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
      >
        {!blobUrl ? (
          <SpinnerIcon className="h-4 w-4" />
        ) : playing ? (
          <PauseGlyph />
        ) : (
          <PlayGlyph />
        )}
      </button>
      <div className="flex shrink-0 items-center gap-0.5 rounded-md bg-surface-3 p-0.5 text-[10px] font-semibold text-fg-muted">
        {VOICE_SPEEDS.map((s) => {
          const active = s === speed;
          return (
            <button
              key={s}
              type="button"
              onClick={() => {
                setSpeed(s);
                setVoiceSpeed(s);
              }}
              className={
                'flex h-6 w-7 cursor-pointer items-center justify-center rounded transition ' +
                (active ? 'bg-accent text-accent-fg' : 'hover:bg-surface hover:text-fg')
              }
              aria-pressed={active}
              title={'Wiedergabegeschwindigkeit ' + s + '×'}
            >
              {s}×
            </button>
          );
        })}
      </div>
      <div className="flex min-w-0 flex-1 flex-col gap-1">
        <div
          role="slider"
          aria-label="Position"
          aria-valuemin={0}
          aria-valuemax={Math.max(1, Math.floor(duration))}
          aria-valuenow={Math.floor(position)}
          tabIndex={0}
          onClick={onSeek}
          className="flex h-7 cursor-pointer items-center gap-[2px]"
        >
          {visiblePeaks.map((v, i) => {
            const playedRatio = (i + 0.5) / BAR_COUNT;
            const played = playedRatio <= progress;
            const h = Math.max(2, Math.round(v * 22));
            return (
              <span
                key={i}
                style={{ height: h + 'px' }}
                className={
                  'w-[3px] rounded-full ' +
                  (played ? 'bg-accent' : 'bg-fg-muted/40')
                }
              />
            );
          })}
        </div>
        <div className="flex items-center justify-between text-[10px] tabular-nums text-fg-muted">
          <span className="inline-flex items-center gap-1">
            <MicIcon className="h-3 w-3" />
            <span>{formatSec(playing ? position : duration)}</span>
          </span>
        </div>
      </div>
      {blobUrl && (
        <audio
          ref={audioRef}
          src={blobUrl}
          preload="metadata"
          onLoadedMetadata={(e) => {
            // Some webm/opus blobs report Infinity until first seek (Chrome
            // bug). Force a seek to flush real duration.
            const el = e.currentTarget;
            if (!Number.isFinite(el.duration)) {
              el.currentTime = 1e9;
              setTimeout(() => {
                el.currentTime = 0;
              }, 0);
            } else if (duration === 0) {
              setDuration(el.duration);
            }
          }}
          onDurationChange={(e) => {
            const d = e.currentTarget.duration;
            if (Number.isFinite(d) && d > 0) setDuration(d);
          }}
          onTimeUpdate={(e) => setPosition(e.currentTarget.currentTime)}
          onPlay={() => setPlaying(true)}
          onPause={() => setPlaying(false)}
          onEnded={() => {
            setPlaying(false);
            setPosition(0);
          }}
          className="hidden"
        >
          <track kind="captions" />
        </audio>
      )}
    </div>
  );
}

function PlayGlyph() {
  return (
    <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
      <path d="M5 3.5l8 4.5-8 4.5z" />
    </svg>
  );
}

function PauseGlyph() {
  return (
    <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
      <rect x="4" y="3" width="3" height="10" rx="1" />
      <rect x="9" y="3" width="3" height="10" rx="1" />
    </svg>
  );
}

function formatSec(sec: number): string {
  if (!Number.isFinite(sec) || sec < 0) sec = 0;
  const m = Math.floor(sec / 60);
  const s = Math.floor(sec % 60);
  return m + ':' + s.toString().padStart(2, '0');
}
