// Per-provider audio bus.
//
// Owns the actual `<audio>` elements (one per (bus × event)) plus the global
// "unlocked" flag. Pure module / class — no React. The hook layer is in
// `hooks/useChatAudio.ts`.
//
// Pitfalls this addresses (lessons from AudioPlayer/audio):
//  - Safari needs a user-gesture transaction to unlock playback. We pre-allocate
//    an `<audio>` per event and call `play()` on each during the unlock event.
//  - Multiple `play()` calls in quick succession on the same element: we clone
//    a fresh `HTMLAudioElement` from the cache for each fire so they don't
//    cancel each other (cheap — same `src` reuses the HTTP cache).
//  - SSR safety: ALL DOM access is gated; the bus is only constructed in
//    a `'use client'` component (the provider).
//  - `play()` returns a Promise; uncaught rejections show up as warnings on
//    Chrome. We attach `.catch()` everywhere.
//  - Module unload cleanup: `dispose()` revokes blob URLs, drops listeners,
//    and clears the cache.

import type { ChatAudioEvent, ChatAudioSounds } from './types';

interface BusOptions {
  sounds: ChatAudioSounds;
  /** Returns the current master volume 0..1. Read on each play. */
  getVolume: () => number;
  /** Returns master mute. Read on each play. */
  getMuted: () => boolean;
  /** Per-event predicate. */
  isEnabled: (event: ChatAudioEvent) => boolean;
}

export interface ChatAudioBus {
  play: (event: ChatAudioEvent) => void;
  preload: (event: ChatAudioEvent) => void;
  unlock: () => void;
  isUnlocked: () => boolean;
  /** Lets the provider re-publish unlock changes to React via a listener. */
  subscribeUnlock: (cb: (unlocked: boolean) => void) => () => void;
  /** Hot-swap the sounds map without re-creating the bus. */
  setSounds: (sounds: ChatAudioSounds) => void;
  dispose: () => void;
}

// One unlock state per tab — first gesture inside ANY <ChatProvider> unlocks
// every bus. AudioPlayer follows the same "global per tab" rule for its
// AudioContext (ADR-004).
let unlocked = false;
const unlockListeners = new Set<(v: boolean) => void>();

function setUnlocked(value: boolean) {
  if (unlocked === value) return;
  unlocked = value;
  for (const cb of unlockListeners) cb(value);
}

export function _resetUnlockForTesting(): void {
  unlocked = false;
  unlockListeners.clear();
}

export function createAudioBus(options: BusOptions): ChatAudioBus {
  // SSR guard.
  if (typeof window === 'undefined') {
    return noopBus();
  }

  let sounds = options.sounds;
  /** Cache of "template" audio elements per URL — reused across plays. */
  const cache = new Map<string, HTMLAudioElement>();

  const getOrCreate = (url: string): HTMLAudioElement => {
    const hit = cache.get(url);
    if (hit) return hit;
    const el = new Audio(url);
    el.preload = 'auto';
    el.crossOrigin = 'anonymous';
    cache.set(url, el);
    return el;
  };

  const resolveUrl = (event: ChatAudioEvent): string | null => {
    const v = sounds[event];
    if (!v) return null;
    return v;
  };

  const play = (event: ChatAudioEvent) => {
    if (options.getMuted()) return;
    if (!options.isEnabled(event)) return;
    const url = resolveUrl(event);
    if (!url) return;

    // Use the cached template just for HTTP cache warming; clone so two rapid
    // events don't cut each other off on the same element.
    getOrCreate(url);
    const fresh = new Audio(url);
    fresh.preload = 'auto';
    fresh.volume = options.getVolume();
    // Fire-and-forget; the promise rejects when autoplay is blocked.
    const p = fresh.play();
    if (p && typeof p.catch === 'function') {
      p.catch(() => {
        // Browser blocked playback (no gesture yet) — ignore.
      });
    }
  };

  const preload = (event: ChatAudioEvent) => {
    const url = resolveUrl(event);
    if (!url) return;
    const el = getOrCreate(url);
    // Trigger a low-priority load.
    try {
      el.load();
    } catch {
      // ignore
    }
  };

  const unlock = () => {
    if (unlocked) return;
    // Play (silently) every cached element in one user-gesture transaction so
    // Safari/iOS lifts the autoplay block for the whole bus at once.
    for (const el of cache.values()) {
      const wasMuted = el.muted;
      el.muted = true;
      const p = el.play();
      if (p && typeof p.then === 'function') {
        p.then(() => {
          el.pause();
          el.currentTime = 0;
          el.muted = wasMuted;
        }).catch(() => {
          el.muted = wasMuted;
        });
      } else {
        el.pause();
        el.muted = wasMuted;
      }
    }
    setUnlocked(true);
  };

  return {
    play,
    preload,
    unlock,
    isUnlocked: () => unlocked,
    subscribeUnlock(cb) {
      unlockListeners.add(cb);
      return () => unlockListeners.delete(cb);
    },
    setSounds(next) {
      sounds = next;
    },
    dispose() {
      cache.clear();
    },
  };
}

function noopBus(): ChatAudioBus {
  return {
    play: () => undefined,
    preload: () => undefined,
    unlock: () => undefined,
    isUnlocked: () => false,
    subscribeUnlock: () => () => undefined,
    setSounds: () => undefined,
    dispose: () => undefined,
  };
}
