/**
 * IndexedDB wrapper (docs/architecture/02 §2).
 *
 * The ONLY low-level IndexedDB access in the app. Wraps the event-based native
 * API into promises and exposes a tiny CRUD surface per object store. Typed
 * stores (snippet-store, dataset-store) build on top of this; nothing outside
 * `src/app/infrastructure/` imports `indexedDB` directly.
 */

const DB_NAME = 'astrolabe';
/**
 * Store-layout version. Bump only when the set of object stores / indexes
 * changes — independent of per-record schema versions (see snippet-migrations).
 * v2 added the `themes` store (custom chart themes); v3 added the `fonts` store
 * (user-uploaded font faces — binary bytes, stored verbatim by structured clone).
 */
const DB_VERSION = 3;

export const SNIPPETS_STORE = 'snippets';
export const DATASETS_STORE = 'datasets';
export const THEMES_STORE = 'themes';
export const FONTS_STORE = 'fonts';

/**
 * Raised when a write fails because the origin's storage budget is exhausted.
 * Normalized from the raw `QuotaExceededError` DOMException in `put` (the one
 * write path), so every typed adapter fails loud on quota uniformly and the UI
 * can map it to an actionable "storage full" message rather than losing work.
 */
export class StorageQuotaError extends Error {
  constructor(message = 'Storage is full. Export and remove items to free space.') {
    super(message);
    this.name = 'StorageQuotaError';
  }
}

/** Every object store the app expects — the open-time verification checklist. */
const EXPECTED_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE, FONTS_STORE] as const;

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

/**
 * One `indexedDB.open` as a promise. Omitting `version` opens at whatever
 * version the database already has (never an upgrade). The upgrade handler
 * creates every missing store — guarded per store, so it is idempotent across
 * any old→new version jump.
 */
function openAt(version?: number): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = version === undefined ? indexedDB.open(DB_NAME) : indexedDB.open(DB_NAME, version);

    req.onupgradeneeded = () => {
      const db = req.result;
      for (const store of EXPECTED_STORES) {
        if (!db.objectStoreNames.contains(store)) {
          db.createObjectStore(store, { keyPath: 'id' });
        }
      }
    };

    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error ?? new Error('Failed to open IndexedDB'));
  });
}

/**
 * Open (and memoize) the database, creating object stores on first run.
 *
 * The open **verifies** the store layout instead of trusting the version
 * number: an interrupted upgrade can stamp the new version without creating
 * the new stores (observed in dev — a hot reload opened the bumped version
 * before the store-creation code existed), after which `onupgradeneeded`
 * never fires again and every transaction on the missing store throws
 * NotFoundError. If any expected store is missing after a successful open,
 * reopen at `version + 1` to force another (idempotent) upgrade pass — the
 * database self-heals rather than being stuck until manually deleted.
 */
export function openDB(): Promise<IDBDatabase> {
  if (dbPromise) return dbPromise;

  dbPromise = openAt(DB_VERSION)
    .catch((err: unknown) => {
      // A database already past DB_VERSION (a prior self-heal bump) makes a
      // versioned open throw VersionError; open at its current version instead.
      if (err instanceof DOMException && err.name === 'VersionError') return openAt();
      throw err;
    })
    .then((db) => {
      if (EXPECTED_STORES.every((store) => db.objectStoreNames.contains(store))) return db;
      const next = db.version + 1;
      db.close();
      return openAt(next);
    });

  return dbPromise;
}

/** Run a transaction and resolve on commit (durability), not on request success. */
function tx<T>(
  store: string,
  mode: IDBTransactionMode,
  run: (s: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
  return openDB().then(
    (db) =>
      new Promise<T>((resolve, reject) => {
        const transaction = db.transaction(store, mode);
        const request = run(transaction.objectStore(store));
        transaction.oncomplete = () => resolve(request.result);
        const fail = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'));
        transaction.onerror = fail;
        transaction.onabort = fail;
      }),
  );
}

export const getAll = <T>(store: string): Promise<T[]> =>
  tx<T[]>(store, 'readonly', (s) => s.getAll() as IDBRequest<T[]>);

export const put = <T>(store: string, value: T): Promise<IDBValidKey> =>
  tx<IDBValidKey>(store, 'readwrite', (s) => s.put(value)).catch((err: unknown) => {
    // Quota is whole-origin and surfaces as a QuotaExceededError DOMException;
    // normalize it here so every adapter throws a typed StorageQuotaError.
    if (err instanceof DOMException && err.name === 'QuotaExceededError') {
      throw new StorageQuotaError();
    }
    throw err;
  });

export const del = (store: string, key: IDBValidKey): Promise<undefined> =>
  tx<undefined>(store, 'readwrite', (s) => s.delete(key));

/** Test-only: forget the memoized connection so a fresh `openDB` reopens. */
export function _resetDbForTests(): void {
  dbPromise = null;
}
