// Decode + cache AudioBuffers for soundboard entries and play them through a
// MicPipeline. Cache is LRU-bounded per sound id. `invalidate(id)` drops the
// decoded buffer when the underlying blob changes (rename keeps blob intact,
// but re-upload of the same id rebuilds the buffer on next play).

import type { MicPipeline, PlayHandle } from './micPipeline';
import { getSoundBlob, type SoundboardEntry } from './soundboardStorage';

const MAX_CACHE_ENTRIES = 64;

const cache = new Map<string, AudioBuffer>();

// Decoding requires an AudioContext. We keep one short-lived context just for
// decoding — the pipeline's own ctx is used for playback, so we don't share.
// `decodeAudioData` is legacy-sync in Safari/WKWebView: it mutates the input
// ArrayBuffer, so we always pass a fresh slice() copy.
let decodeCtx: AudioContext | null = null;

function getDecodeCtx(): AudioContext {
  if (decodeCtx) return decodeCtx;
  const AudioCtx =
    window.AudioContext ??
    (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
  if (!AudioCtx) throw new Error('AudioContext unavailable');
  decodeCtx = new AudioCtx();
  return decodeCtx;
}

function touch(id: string, buffer: AudioBuffer): void {
  // Re-insert to push to the back of insertion order (Map is ordered by
  // insertion, oldest first).
  cache.delete(id);
  cache.set(id, buffer);
  if (cache.size > MAX_CACHE_ENTRIES) {
    const first = cache.keys().next().value;
    if (first !== undefined) cache.delete(first);
  }
}

export async function preload(id: string): Promise<AudioBuffer | null> {
  const existing = cache.get(id);
  if (existing) {
    touch(id, existing);
    return existing;
  }
  const blob = await getSoundBlob(id);
  if (!blob) return null;
  const arrayBuffer = await blob.arrayBuffer();
  // Clone into a fresh buffer because Safari's decodeAudioData transfers
  // ownership on its legacy promise path.
  const copy = arrayBuffer.slice(0);
  const ctx = getDecodeCtx();
  const buffer = await new Promise<AudioBuffer>((resolve, reject) => {
    // The `(data, success, error)` callback form is the only one guaranteed
    // on older WebKit. Modern browsers accept promise chains too.
    const maybe = ctx.decodeAudioData(
      copy,
      (b) => resolve(b),
      (e) => reject(e ?? new Error('decode failed')),
    );
    if (maybe && typeof (maybe as Promise<AudioBuffer>).then === 'function') {
      (maybe as Promise<AudioBuffer>).then(resolve, reject);
    }
  });
  touch(id, buffer);
  return buffer;
}

export function invalidate(id: string): void {
  cache.delete(id);
}

export function clearCache(): void {
  cache.clear();
}

export interface PlayOptions {
  /** Overrides entry.gain. Falls back to entry.gain when omitted. */
  gain?: number;
  /** Overlap mode: omit this flag to stop previous instance of the same id.
   *  Defaults to single-fire (id-keyed) so hotkey spamming doesn't stack. */
  overlap?: boolean;
  onEnded?: () => void;
}

/**
 * Decode (if needed) and play the entry through the supplied pipeline.
 * Returns the pipeline handle, or null if the pipeline is unavailable or
 * the sound no longer exists.
 */
export async function playEntry(
  pipeline: MicPipeline,
  entry: SoundboardEntry,
  opts: PlayOptions = {},
): Promise<PlayHandle | null> {
  const buffer = await preload(entry.id);
  if (!buffer) return null;
  return pipeline.playBuffer(buffer, {
    gain: opts.gain ?? entry.gain,
    ...(opts.overlap ? {} : { id: entry.id }),
    ...(opts.onEnded ? { onEnded: opts.onEnded } : {}),
  });
}
