// IndexedDB-backed soundboard.
//
// Two object stores:
//   `sounds` — one record per user-added sound, keyed by uuid. Contains both
//              metadata (name, category, hotkey, gain, order, timestamps) and
//              the raw blob so decoding can pull everything in one get().
//   `prefs`  — single "prefs" record with global soundboard state (master
//              volume, local monitor volume).
//
// CRUD helpers surface a SoundboardEntry shape without the blob so call sites
// that only need metadata (list views, hotkey registration) don't pull the
// audio payload into memory. `getSoundBlob(id)` fetches the blob on demand.

export interface SoundboardEntry {
  id: string;
  name: string;
  mime: string;
  size: number;
  category: string | null;
  hotkey: string | null;
  gain: number; // 0..1
  order: number; // ascending within (category, uncategorized) bucket
  createdAt: number;
  updatedAt: number;
}

export interface SoundboardPrefs {
  masterGain: number; // 0..1
  monitorGain: number; // 0..1
}

interface StoredSound extends SoundboardEntry {
  blob: Blob;
}

export const DEFAULT_PREFS: SoundboardPrefs = {
  masterGain: 0.8,
  monitorGain: 0.5,
};

export const MAX_SOUND_BYTES = 5 * 1024 * 1024; // 5 MB per clip

// --- Change observer ------------------------------------------------------
// Synchronous tiny pub-sub so interested modules (hotkey registry, in-call
// panel, settings dialog) refresh when the manifest mutates in any tab.

type Listener = () => void;
const listeners = new Set<Listener>();

export function subscribeSoundboardChanges(l: Listener): () => void {
  listeners.add(l);
  return () => {
    listeners.delete(l);
  };
}

function notifyChange(): void {
  for (const l of listeners) {
    try {
      l();
    } catch (err: unknown) {
      console.warn('soundboard change listener threw', err);
    }
  }
}

const DB_NAME = 'netralax-soundboard';
const DB_VERSION = 1;
const SOUNDS_STORE = 'sounds';
const PREFS_STORE = 'prefs';
const PREFS_KEY = 'prefs';

let dbPromise: Promise<IDBDatabase> | null = null;

function openDb(): Promise<IDBDatabase> {
  if (dbPromise) return dbPromise;
  dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, DB_VERSION);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(SOUNDS_STORE)) {
        db.createObjectStore(SOUNDS_STORE, { keyPath: 'id' });
      }
      if (!db.objectStoreNames.contains(PREFS_STORE)) {
        db.createObjectStore(PREFS_STORE);
      }
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error ?? new Error('indexedDB open failed'));
  });
  return dbPromise;
}

function tx<T>(
  store: string,
  mode: IDBTransactionMode,
  fn: (s: IDBObjectStore) => IDBRequest<T> | void,
): Promise<T | undefined> {
  return openDb().then(
    (db) =>
      new Promise<T | undefined>((resolve, reject) => {
        const t = db.transaction(store, mode);
        const s = t.objectStore(store);
        let result: T | undefined = undefined;
        const req = fn(s);
        if (req) {
          req.onsuccess = () => {
            result = req.result;
          };
          req.onerror = () => reject(req.error);
        }
        t.oncomplete = () => resolve(result);
        t.onerror = () => reject(t.error);
        t.onabort = () => reject(t.error);
      }),
  );
}

function stripBlob(stored: StoredSound): SoundboardEntry {
  const { blob: _blob, ...rest } = stored;
  return rest;
}

function clamp01(v: number): number {
  if (!Number.isFinite(v)) return 0;
  if (v < 0) return 0;
  if (v > 1) return 1;
  return v;
}

function genId(): string {
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
    return crypto.randomUUID();
  }
  return 'sb-' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}

export async function listSounds(): Promise<SoundboardEntry[]> {
  const db = await openDb();
  return new Promise<SoundboardEntry[]>((resolve, reject) => {
    const t = db.transaction(SOUNDS_STORE, 'readonly');
    const s = t.objectStore(SOUNDS_STORE);
    const req = s.getAll();
    req.onsuccess = () => {
      const all = (req.result as StoredSound[]).map(stripBlob);
      // Stable sort: category name asc (null last), then order asc, then
      // createdAt as a tie-breaker so freshly added sounds don't leapfrog.
      all.sort((a, b) => {
        const catCmp = compareCategory(a.category, b.category);
        if (catCmp !== 0) return catCmp;
        if (a.order !== b.order) return a.order - b.order;
        return a.createdAt - b.createdAt;
      });
      resolve(all);
    };
    req.onerror = () => reject(req.error);
  });
}

function compareCategory(a: string | null, b: string | null): number {
  if (a === b) return 0;
  if (a === null) return 1; // uncategorized last
  if (b === null) return -1;
  return a.localeCompare(b);
}

export async function listCategories(): Promise<string[]> {
  const all = await listSounds();
  const set = new Set<string>();
  for (const s of all) {
    if (s.category) set.add(s.category);
  }
  return Array.from(set).sort((a, b) => a.localeCompare(b));
}

async function nextOrderFor(category: string | null): Promise<number> {
  const all = await listSounds();
  let max = -1;
  for (const s of all) {
    if (s.category === category && s.order > max) max = s.order;
  }
  return max + 1;
}

export interface AddSoundInput {
  file: File;
  name?: string;
  category?: string | null;
}

export async function addSound(input: AddSoundInput): Promise<SoundboardEntry> {
  const { file, name, category = null } = input;
  if (file.size === 0) throw new Error('empty file');
  if (file.size > MAX_SOUND_BYTES) throw new Error('sound_too_large');
  const mime = file.type || 'application/octet-stream';
  if (!mime.startsWith('audio/')) throw new Error('sound_not_audio');

  const now = Date.now();
  const trimmedName = (name ?? file.name.replace(/\.[^.]+$/, '')).trim() || 'Untitled';
  const entry: StoredSound = {
    id: genId(),
    name: trimmedName,
    mime,
    size: file.size,
    category: category ?? null,
    hotkey: null,
    gain: 1,
    order: await nextOrderFor(category ?? null),
    createdAt: now,
    updatedAt: now,
    blob: file,
  };
  await tx(SOUNDS_STORE, 'readwrite', (s) => s.put(entry));
  notifyChange();
  return stripBlob(entry);
}

export interface UpdateSoundPatch {
  name?: string;
  category?: string | null;
  hotkey?: string | null;
  gain?: number;
}

export async function updateSound(
  id: string,
  patch: UpdateSoundPatch,
): Promise<SoundboardEntry> {
  const db = await openDb();
  return new Promise<SoundboardEntry>((resolve, reject) => {
    const t = db.transaction(SOUNDS_STORE, 'readwrite');
    const s = t.objectStore(SOUNDS_STORE);
    const getReq = s.get(id);
    getReq.onsuccess = () => {
      const current = getReq.result as StoredSound | undefined;
      if (!current) {
        reject(new Error('sound_not_found'));
        return;
      }
      const nextCategory = patch.category !== undefined ? patch.category : current.category;
      const categoryChanged = nextCategory !== current.category;
      const next: StoredSound = {
        ...current,
        ...(patch.name !== undefined ? { name: patch.name.trim() || current.name } : {}),
        ...(patch.category !== undefined ? { category: nextCategory } : {}),
        ...(patch.hotkey !== undefined ? { hotkey: patch.hotkey } : {}),
        ...(patch.gain !== undefined ? { gain: clamp01(patch.gain) } : {}),
        updatedAt: Date.now(),
      };
      // When moving categories, append to the destination's end so the move
      // doesn't collide with existing order values.
      if (categoryChanged) {
        const getAll = s.getAll();
        getAll.onsuccess = () => {
          const all = getAll.result as StoredSound[];
          let max = -1;
          for (const e of all) {
            if (e.category === nextCategory && e.order > max) max = e.order;
          }
          next.order = max + 1;
          const putReq = s.put(next);
          putReq.onsuccess = () => {
            notifyChange();
            resolve(stripBlob(next));
          };
          putReq.onerror = () => reject(putReq.error);
        };
        getAll.onerror = () => reject(getAll.error);
        return;
      }
      const putReq = s.put(next);
      putReq.onsuccess = () => {
        notifyChange();
        resolve(stripBlob(next));
      };
      putReq.onerror = () => reject(putReq.error);
    };
    getReq.onerror = () => reject(getReq.error);
    t.onerror = () => reject(t.error);
  });
}

export async function deleteSound(id: string): Promise<void> {
  await tx(SOUNDS_STORE, 'readwrite', (s) => s.delete(id));
  notifyChange();
}

export async function reorderCategory(
  category: string | null,
  orderedIds: string[],
): Promise<void> {
  const db = await openDb();
  return new Promise<void>((resolve, reject) => {
    const t = db.transaction(SOUNDS_STORE, 'readwrite');
    const s = t.objectStore(SOUNDS_STORE);
    let remaining = orderedIds.length;
    if (remaining === 0) {
      resolve();
      return;
    }
    orderedIds.forEach((id, idx) => {
      const getReq = s.get(id);
      getReq.onsuccess = () => {
        const current = getReq.result as StoredSound | undefined;
        if (!current || current.category !== category) {
          remaining--;
          if (remaining === 0) resolve();
          return;
        }
        const next: StoredSound = { ...current, order: idx, updatedAt: Date.now() };
        const putReq = s.put(next);
        putReq.onsuccess = () => {
          remaining--;
          if (remaining === 0) resolve();
        };
        putReq.onerror = () => reject(putReq.error);
      };
      getReq.onerror = () => reject(getReq.error);
    });
    t.oncomplete = () => notifyChange();
    t.onerror = () => reject(t.error);
  });
}

export async function getSoundBlob(id: string): Promise<Blob | null> {
  const db = await openDb();
  return new Promise<Blob | null>((resolve, reject) => {
    const t = db.transaction(SOUNDS_STORE, 'readonly');
    const s = t.objectStore(SOUNDS_STORE);
    const req = s.get(id);
    req.onsuccess = () => {
      const cur = req.result as StoredSound | undefined;
      resolve(cur ? cur.blob : null);
    };
    req.onerror = () => reject(req.error);
  });
}

export async function getPrefs(): Promise<SoundboardPrefs> {
  const raw = await tx<SoundboardPrefs | undefined>('prefs', 'readonly', (s) =>
    s.get(PREFS_KEY) as IDBRequest<SoundboardPrefs | undefined>,
  );
  if (!raw) return { ...DEFAULT_PREFS };
  return {
    masterGain: clamp01(raw.masterGain ?? DEFAULT_PREFS.masterGain),
    monitorGain: clamp01(raw.monitorGain ?? DEFAULT_PREFS.monitorGain),
  };
}

export async function updatePrefs(patch: Partial<SoundboardPrefs>): Promise<SoundboardPrefs> {
  const cur = await getPrefs();
  const next: SoundboardPrefs = {
    masterGain: patch.masterGain !== undefined ? clamp01(patch.masterGain) : cur.masterGain,
    monitorGain: patch.monitorGain !== undefined ? clamp01(patch.monitorGain) : cur.monitorGain,
  };
  await tx('prefs', 'readwrite', (s) => s.put(next, PREFS_KEY));
  return next;
}

// Convenience: checks whether `accelerator` is already bound to a sound other
// than `excludeId`. Pair with PTT check at the CallContext layer.
export async function isHotkeyTaken(
  accelerator: string,
  excludeId: string | null = null,
): Promise<boolean> {
  const all = await listSounds();
  for (const s of all) {
    if (s.hotkey === accelerator && s.id !== excludeId) return true;
  }
  return false;
}
