/*
 This file is part of TALER
 (C) 2016 GNUnet e.V.
 (C) 2025 Taler Systems S.A.

 TALER is free software; you can redistribute it and/or modify it under the
 terms of the GNU General Public License as published by the Free Software
 Foundation; either version 3, or (at your option) any later version.

 TALER is distributed in the hope that it will be useful, but WITHOUT ANY
 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

 You should have received a copy of the GNU General Public License along with
 TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

/**
 * @fileoverview
 * Query helpers for IndexedDB databases.
 *
 * @author Florian Dold
 */

/**
 * Imports.
 */
import {
  IDBCursor,
  IDBDatabase,
  IDBFactory,
  IDBKeyPath,
  IDBKeyRange,
  IDBRequest,
  IDBTransaction,
  IDBTransactionMode,
  IDBValidKey,
  IDBVersionChangeEvent,
} from "@gnu-taler/idb-bridge";
import {
  CancellationToken,
  Codec,
  Logger,
  openPromise,
  safeStringifyException,
  WalletNotification,
} from "@gnu-taler/taler-util";

const logger = new Logger("query.ts");

/**
 * Exception that should be thrown by client code to abort a transaction.
 */
export const TransactionAbort = Symbol("transaction_abort");

/**
 * Options for an index.
 */
export interface IndexOptions {
  /**
   * If true and the path resolves to an array, create an index entry for
   * each member of the array (instead of one index entry containing the full array).
   *
   * Defaults to false.
   */
  multiEntry?: boolean;

  /**
   * Database version that this store was added in, or
   * undefined if added in the first version.
   */
  versionAdded?: number;

  /**
   * Does this index enforce unique keys?
   *
   * Defaults to false.
   */
  unique?: boolean;
}

/**
 * Log extra stuff that would be too verbose even
 * on loglevel TRACE.
 */
const logExtra = false;

let idbRequestPromId = 1;

function requestToPromise(
  req: IDBRequest,
  internalContext: InternalTransactionContext,
): Promise<any> {
  const myId = idbRequestPromId++;
  if (logExtra) {
    logger.trace(`started db request ${myId}`);
  }
  const stack = Error("Failed request was started here.");
  return new Promise((resolve, reject) => {
    req.onsuccess = () => {
      if (logExtra) {
        logger.trace(`finished db request ${myId} with success`);
      }
      resolve(req.result);
    };
    req.onerror = () => {
      if (logExtra) {
        logger.trace(`finished db request ${myId} with error`);
      }
      if (internalContext.isAborted) {
        reject(
          new TransactionAbortedError(
            internalContext.abortExn?.message ?? "Aborted",
          ),
        );
        return;
      }
      if (
        req.error != null &&
        "name" in req.error &&
        req.error.name === "AbortError"
      ) {
        logger.warn("DB request failed, transaction aborted");
      } else {
        logger.error(`error in DB request: ${req.error}`);
        if (req.error && "stack" in req.error) {
          logger.error(`Stack: ${req.error.stack}`);
        }
        logger.error(`Request failed: ${stack.stack ?? stack}`);
      }
      reject(req.error);
    };
  });
}

type CursorResult<T> = CursorEmptyResult<T> | CursorValueResult<T>;

interface CursorEmptyResult<T> {
  hasValue: false;
}

interface CursorValueResult<T> {
  hasValue: true;
  value: T;
}

class TransactionAbortedError extends Error {
  constructor(m: string) {
    super(m);

    // Set the prototype explicitly.
    Object.setPrototypeOf(this, TransactionAbortedError.prototype);
  }
}

class ResultStream<T> {
  private currentPromise: Promise<void>;
  private gotCursorEnd = false;
  private awaitingResult = false;

  constructor(private req: IDBRequest) {
    this.awaitingResult = true;
    let p = openPromise<void>();
    this.currentPromise = p.promise;
    req.onsuccess = () => {
      if (!this.awaitingResult) {
        throw Error("BUG: invariant violated");
      }
      const cursor = req.result;
      if (cursor) {
        this.awaitingResult = false;
        p.resolve();
        p = openPromise<void>();
        this.currentPromise = p.promise;
      } else {
        this.gotCursorEnd = true;
        p.resolve();
      }
    };
    req.onerror = () => {
      p.reject(req.error);
    };
  }

  async toArray(): Promise<T[]> {
    const arr: T[] = [];
    while (true) {
      const x = await this.next();
      if (x.hasValue) {
        arr.push(x.value);
      } else {
        break;
      }
    }
    return arr;
  }

  async map<R>(f: (x: T) => R): Promise<R[]> {
    const arr: R[] = [];
    while (true) {
      const x = await this.next();
      if (x.hasValue) {
        arr.push(f(x.value));
      } else {
        break;
      }
    }
    return arr;
  }

  async mapAsync<R>(f: (x: T) => Promise<R>): Promise<R[]> {
    const arr: R[] = [];
    while (true) {
      const x = await this.next();
      if (x.hasValue) {
        arr.push(await f(x.value));
      } else {
        break;
      }
    }
    return arr;
  }

  async forEachAsync(f: (x: T) => Promise<void>): Promise<void> {
    while (true) {
      const x = await this.next();
      if (x.hasValue) {
        await f(x.value);
      } else {
        break;
      }
    }
  }

  async forEach(f: (x: T) => void): Promise<void> {
    while (true) {
      const x = await this.next();
      if (x.hasValue) {
        f(x.value);
      } else {
        break;
      }
    }
  }

  async filter(f: (x: T) => boolean): Promise<T[]> {
    const arr: T[] = [];
    while (true) {
      const x = await this.next();
      if (x.hasValue) {
        if (f(x.value)) {
          arr.push(x.value);
        }
      } else {
        break;
      }
    }
    return arr;
  }

  async next(): Promise<CursorResult<T>> {
    if (this.gotCursorEnd) {
      return { hasValue: false };
    }
    if (!this.awaitingResult) {
      const cursor: IDBCursor | undefined = this.req.result;
      if (!cursor) {
        throw Error("assertion failed");
      }
      this.awaitingResult = true;
      cursor.continue();
    }
    await this.currentPromise;
    if (this.gotCursorEnd) {
      return { hasValue: false };
    }
    const cursor = this.req.result;
    if (!cursor) {
      throw Error("assertion failed");
    }
    return { hasValue: true, value: cursor.value };
  }
}

/**
 * Return a promise that resolves to the opened IndexedDB database.
 */
export function openDatabase(
  idbFactory: IDBFactory,
  databaseName: string,
  databaseVersion: number | undefined,
  onVersionChange: () => void,
  onUpgradeNeeded: (
    db: IDBDatabase,
    oldVersion: number,
    newVersion: number,
    upgradeTransaction: IDBTransaction,
  ) => void,
): Promise<IDBDatabase> {
  return new Promise<IDBDatabase>((resolve, reject) => {
    const req = idbFactory.open(databaseName, databaseVersion);
    req.onerror = (event) => {
      // @ts-expect-error
      reject(new Error(`database opening error`, { cause: req.error }));
    };
    req.onsuccess = (e) => {
      req.result.onversionchange = (evt: IDBVersionChangeEvent) => {
        logger.info(
          `handling versionchange on ${databaseName} from ${evt.oldVersion} to ${evt.newVersion}`,
        );
        req.result.close();
        onVersionChange();
      };
      resolve(req.result);
    };
    req.onupgradeneeded = (e) => {
      const db = req.result;
      const newVersion = e.newVersion;
      if (!newVersion) {
        // @ts-expect-error
        throw Error("upgrade needed, but new version unknown", {
          cause: req.error,
        });
      }
      const transaction = req.transaction;
      if (!transaction) {
        // @ts-expect-error
        throw Error("no transaction handle available in upgrade handler", {
          cause: req.error,
        });
      }
      logger.trace(
        `handling upgradeneeded event on ${databaseName} from ${e.oldVersion} to ${e.newVersion}`,
      );
      onUpgradeNeeded(db, e.oldVersion, newVersion, transaction);
    };
  });
}

export interface IndexDescriptor {
  name: string;
  keyPath: IDBKeyPath | IDBKeyPath[];
  multiEntry?: boolean;
  unique?: boolean;
  versionAdded?: number;
}

export interface StoreDescriptor<RecordType> {
  _dummy: RecordType;
  keyPath?: IDBKeyPath | IDBKeyPath[];
  autoIncrement?: boolean;
  /**
   * Database version that this store was added in, or
   * undefined if added in the first version.
   */
  versionAdded?: number;
}

export interface StoreOptions {
  keyPath?: IDBKeyPath | IDBKeyPath[];
  autoIncrement?: boolean;

  /**
   * First minor database version that this store was added in, or
   * undefined if added in the first version.
   */
  versionAdded?: number;
}

export function describeContents<RecordType = never>(
  options: StoreOptions,
): StoreDescriptor<RecordType> {
  return {
    keyPath: options.keyPath,
    _dummy: undefined as any,
    autoIncrement: options.autoIncrement,
    versionAdded: options.versionAdded,
  };
}

export function describeIndex(
  name: string,
  keyPath: IDBKeyPath | IDBKeyPath[],
  options: IndexOptions = {},
): IndexDescriptor {
  return {
    keyPath,
    name,
    multiEntry: options.multiEntry,
    unique: options.unique,
    versionAdded: options.versionAdded,
  };
}

interface IndexReadOnlyAccessor<RecordType> {
  iter(query?: IDBKeyRange | IDBValidKey): ResultStream<RecordType>;
  get(query: IDBValidKey): Promise<RecordType | undefined>;
  getAll(
    query?: IDBKeyRange | IDBValidKey,
    count?: number,
  ): Promise<RecordType[]>;
  getAllKeys(
    query?: IDBKeyRange | IDBValidKey,
    count?: number,
  ): Promise<IDBValidKey[]>;
  count(query?: IDBValidKey): Promise<number>;
}

type GetIndexReadOnlyAccess<RecordType, IndexMap> = {
  [P in keyof IndexMap]: IndexReadOnlyAccessor<RecordType>;
};

interface IndexReadWriteAccessor<RecordType> {
  iter(query: IDBKeyRange | IDBValidKey): ResultStream<RecordType>;
  get(query: IDBValidKey): Promise<RecordType | undefined>;
  getAll(
    query?: IDBKeyRange | IDBValidKey,
    count?: number,
  ): Promise<RecordType[]>;
  getAllKeys(
    query?: IDBKeyRange | IDBValidKey,
    count?: number,
  ): Promise<IDBValidKey[]>;
  count(query?: IDBValidKey): Promise<number>;
}

type GetIndexReadWriteAccess<RecordType, IndexMap> = {
  [P in keyof IndexMap]: IndexReadWriteAccessor<RecordType>;
};

export interface StoreReadOnlyAccessor<RecordType, IndexMap> {
  get(key: IDBValidKey): Promise<RecordType | undefined>;
  getAll(
    query?: IDBKeyRange | IDBValidKey,
    count?: number,
  ): Promise<RecordType[]>;
  iter(query?: IDBValidKey): ResultStream<RecordType>;
  indexes: GetIndexReadOnlyAccess<RecordType, IndexMap>;
}

export interface InsertResponse {
  /**
   * Key of the newly inserted (via put/add) record.
   */
  key: IDBValidKey;
}

export interface StoreReadWriteAccessor<RecordType, IndexMap> {
  get(key: IDBValidKey): Promise<RecordType | undefined>;
  getAll(
    query?: IDBKeyRange | IDBValidKey,
    count?: number,
  ): Promise<RecordType[]>;
  iter(query?: IDBValidKey): ResultStream<RecordType>;
  put(r: RecordType, key?: IDBValidKey): Promise<InsertResponse>;
  add(r: RecordType, key?: IDBValidKey): Promise<InsertResponse>;
  delete(key: IDBValidKey): Promise<void>;
  indexes: GetIndexReadWriteAccess<RecordType, IndexMap>;
}

export interface StoreWithIndexes<
  StoreName extends string,
  RecordType,
  IndexMap,
> {
  storeName: StoreName;
  store: StoreDescriptor<RecordType>;
  indexMap: IndexMap;

  /**
   * Type marker symbol, to check that the descriptor
   * has been created through the right function.
   */
  mark: Symbol;
}

const storeWithIndexesSymbol = Symbol("StoreWithIndexesMark");

export function describeStore<StoreName extends string, RecordType, IndexMap>(
  name: StoreName,
  s: StoreDescriptor<RecordType>,
  m: IndexMap,
): StoreWithIndexes<StoreName, RecordType, IndexMap> {
  return {
    storeName: name,
    store: s,
    indexMap: m,
    mark: storeWithIndexesSymbol,
  };
}

export function describeStoreV2<
  StoreName extends string,
  RecordType,
  IndexMap extends { [x: string]: IndexDescriptor } = {},
>(args: {
  storeName: StoreName;
  recordCodec: Codec<RecordType>;
  keyPath?: IDBKeyPath | IDBKeyPath[];
  autoIncrement?: boolean;
  /**
   * Database version that this store was added in, or
   * undefined if added in the first version.
   */
  versionAdded?: number;
  indexes?: IndexMap;
}): StoreWithIndexes<StoreName, RecordType, IndexMap> {
  return {
    storeName: args.storeName,
    store: {
      _dummy: undefined as any,
      autoIncrement: args.autoIncrement,
      keyPath: args.keyPath,
      versionAdded: args.versionAdded,
    },
    indexMap: args.indexes ?? ({} as IndexMap),
    mark: storeWithIndexesSymbol,
  };
}

type KeyPathComponents = string | number;

/**
 * Follow a key path (dot-separated) in an object.
 */
type DerefKeyPath<T, P> = P extends `${infer PX extends keyof T &
  KeyPathComponents}`
  ? T[PX]
  : P extends `${infer P0 extends keyof T & KeyPathComponents}.${infer Rest}`
    ? DerefKeyPath<T[P0], Rest>
    : unknown;

/**
 * Return a path if it is a valid dot-separate path to an object.
 * Otherwise, return "never".
 */
type ValidateKeyPath<T, P> = P extends `${infer PX extends keyof T &
  KeyPathComponents}`
  ? PX
  : P extends `${infer P0 extends keyof T & KeyPathComponents}.${infer Rest}`
    ? `${P0}.${ValidateKeyPath<T[P0], Rest>}`
    : never;

// function foo<T, P>(
//   x: T,
//   p: P extends ValidateKeyPath<T, P> ? P : never,
// ): void {}

// foo({x: [0,1,2]}, "x.0");

export interface TransactionUtil {
  notify: (w: WalletNotification) => void;
  scheduleOnCommit(f: () => void): void;
}

export type StoreMap = { [Store: string]: StoreWithIndexes<any, any, any> };
export type StoreNames<Stores extends StoreMap> = keyof Stores;
export type DbReadWriteTransaction<
  Stores extends StoreMap,
  StoresArr extends Array<StoreNames<Stores>>,
> = {
  [X in StoresArr[number]]: StoreReadWriteAccessor<
    Stores[X]["store"]["_dummy"],
    Stores[X]["indexMap"]
  >;
} & {
  _util: TransactionUtil;
  notify: (w: WalletNotification) => void;
};

export type DbReadOnlyTransaction<
  Stores extends StoreMap,
  StoresArr extends Array<StoreNames<Stores>>,
> = {
  [X in StoresArr[number]]: StoreReadOnlyAccessor<
    Stores[X]["store"]["_dummy"],
    Stores[X]["indexMap"]
  >;
} & {
  _util: TransactionUtil;
  notify: (w: WalletNotification) => void;
};

/**
 * Convert the type of an array to a union of the contents.
 *
 * Example:
 * Input ["foo", "bar"]
 * Output "foo" | "bar"
 */
export type UnionFromArray<Arr> = Arr extends {
  [X in keyof Arr]: Arr[X] & string;
}
  ? Arr[keyof Arr & number]
  : unknown;

function runTx<Arg, Res>(
  tx: IDBTransaction,
  arg: Arg,
  f: (t: Arg, t2: IDBTransaction) => Promise<Res>,
  internalContext: InternalTransactionContext,
): Promise<Res> {
  // Create stack trace in case we need to to print later where
  // the transaction was started.
  const stack = Error("Failed transaction was started here.");

  const cancellationToken = internalContext.cancellationToken;

  const unregisterOnCancelled = cancellationToken.onCancelled(() => {
    logger.trace("aborting transaction due to cancellation");
    if (!internalContext.isAborted) {
      internalContext.isAborted = true;
      const abortExn = new CancellationToken.CancellationError(
        cancellationToken.reason,
      );
      internalContext.abortExn = abortExn;
    }
    tx.abort();
  });

  cancellationToken.throwIfCancelled();

  return new Promise((resolve, reject) => {
    let funResult: any = undefined;
    let gotFunResult = false;
    let transactionException: any = undefined;
    tx.oncomplete = () => {
      logger.trace("transaction completed");
      // This is a fatal error: The transaction completed *before*
      // the transaction function returned.  Likely, the transaction
      // function waited on a promise that is *not* resolved in the
      // microtask queue, thus triggering the auto-commit behavior.
      // Unfortunately, the auto-commit behavior of IDB can't be switched
      // off.  There are some proposals to add this functionality in the future.
      if (!gotFunResult) {
        const msg =
          "BUG: transaction closed before transaction function returned";
        logger.error(msg);
        logger.error(`${stack.stack ?? stack}`);
        reject(Error(msg));
      } else {
        resolve(funResult);
      }
      internalContext.handleAfterCommit();
      // Notify here.
      if (
        internalContext.notifications.length > 0 &&
        internalContext.applyNotifications
      ) {
        internalContext.applyNotifications(internalContext.notifications);
      }
      unregisterOnCancelled();
    };
    tx.onerror = () => {
      logger.trace("transaction had error");
      if (cancellationToken.isCancelled) {
        reject(
          new CancellationToken.CancellationError(cancellationToken.reason),
        );
        return;
      }
      logger.error("error in transaction");
      logger.error(`${stack.stack ?? stack}`);
      const txError = tx.error;
      if (txError) {
        reject(txError);
      } else {
        reject(new Error("unknown transaction error"));
      }
    };
    tx.onabort = () => {
      logger.trace("transaction was aborted");
      if (cancellationToken.isCancelled) {
        const abortExn = new CancellationToken.CancellationError(
          cancellationToken.reason,
        );
        reject(abortExn);
        return;
      }
      let msg: string;
      if (tx.error) {
        msg = `Transaction aborted (transaction error): ${tx.error}`;
      } else if (transactionException !== undefined) {
        msg = `Transaction aborted (exception thrown): ${transactionException}`;
      } else {
        msg = "Transaction aborted (no DB error)";
      }
      const abortExn = new TransactionAbortedError(msg);
      internalContext.isAborted = true;
      internalContext.abortExn = abortExn;
      unregisterOnCancelled();
      logger.error(msg);
      logger.error(`${stack.stack ?? stack}`);
      reject(abortExn);
    };
    const resP = Promise.resolve().then(() => f(arg, tx));
    resP
      .then((result) => {
        logger.trace("transaction function returned");
        gotFunResult = true;
        funResult = result;
      })
      .catch((e) => {
        if (cancellationToken.isCancelled) {
          logger.trace("Ignoring failed transaction due to cancellation.");
          return;
        }
        if (e == TransactionAbort) {
          logger.trace("aborting transaction");
          tx.abort();
        } else if ("name" in e && e.name === "AbortError") {
          logger.warn("got AbortError, transaction was aborted");
        } else {
          transactionException = e;
          logger.error(`Transaction failed: ${safeStringifyException(e)}`);
          logger.error(`${stack.stack ?? stack}`);
          tx.abort();
        }
      })
      .catch((e) => {
        logger.error(`aborting failed: ${safeStringifyException(e)}`);
      });
  });
}

/**
 * Create a transaction handle that will be passed
 * to the main handler for the transaction.
 */
function makeTxClientContext(
  tx: IDBTransaction,
  storePick: { [n: string]: StoreWithIndexes<any, any, any> },
  internalContext: InternalTransactionContext,
): any {
  const ctx: {
    [s: string]:
      | StoreReadWriteAccessor<any, any>
      | ((notif: WalletNotification) => void)
      | TransactionUtil;
  } = {
    notify(notif: WalletNotification): void {
      internalContext.scheduleNotification(notif);
    },
    _util: {
      notify(notif: WalletNotification): void {
        internalContext.scheduleNotification(notif);
      },
      scheduleOnCommit(f: () => void): void {
        internalContext.scheduleAfterCommit(f);
      },
    },
  };
  for (const storeAlias in storePick) {
    const indexes: { [s: string]: IndexReadWriteAccessor<any> } = {};
    const swi = storePick[storeAlias];
    const storeName = swi.storeName;
    for (const indexAlias in storePick[storeAlias].indexMap) {
      const indexDescriptor: IndexDescriptor =
        storePick[storeAlias].indexMap[indexAlias];
      const indexName = indexDescriptor.name;
      indexes[indexAlias] = {
        get(key) {
          internalContext.throwIfInactive();
          internalContext.storesAccessed.add(storeName);
          const req = tx.objectStore(storeName).index(indexName).get(key);
          return requestToPromise(req, internalContext);
        },
        iter(query) {
          internalContext.throwIfInactive();
          internalContext.storesAccessed.add(storeName);
          const req = tx
            .objectStore(storeName)
            .index(indexName)
            .openCursor(query);
          return new ResultStream<any>(req);
        },
        getAll(query, count) {
          internalContext.throwIfInactive();
          internalContext.storesAccessed.add(storeName);
          const req = tx
            .objectStore(storeName)
            .index(indexName)
            .getAll(query, count);
          return requestToPromise(req, internalContext);
        },
        getAllKeys(query, count) {
          internalContext.throwIfInactive();
          internalContext.storesAccessed.add(storeName);
          const req = tx
            .objectStore(storeName)
            .index(indexName)
            .getAllKeys(query, count);
          return requestToPromise(req, internalContext);
        },
        count(query) {
          internalContext.throwIfInactive();
          internalContext.storesAccessed.add(storeName);
          const req = tx.objectStore(storeName).index(indexName).count(query);
          return requestToPromise(req, internalContext);
        },
      };
    }
    ctx[storeAlias] = {
      indexes,
      get(key) {
        internalContext.throwIfInactive();
        internalContext.storesAccessed.add(storeName);
        const req = tx.objectStore(storeName).get(key);
        return requestToPromise(req, internalContext);
      },
      getAll(query, count) {
        internalContext.throwIfInactive();
        internalContext.storesAccessed.add(storeName);
        const req = tx.objectStore(storeName).getAll(query, count);
        return requestToPromise(req, internalContext);
      },
      iter(query) {
        internalContext.throwIfInactive();
        internalContext.storesAccessed.add(storeName);
        const req = tx.objectStore(storeName).openCursor(query);
        return new ResultStream<any>(req);
      },
      async add(r, k) {
        internalContext.throwIfInactive();
        if (!internalContext.allowWrite) {
          throw Error("attempting write in a read-only transaction");
        }
        internalContext.storesAccessed.add(storeName);
        internalContext.storesModified.add(storeName);
        const req = tx.objectStore(storeName).add(r, k);
        const key = await requestToPromise(req, internalContext);
        return {
          key: key,
        };
      },
      async put(r, k) {
        internalContext.throwIfInactive();
        if (!internalContext.allowWrite) {
          throw Error("attempting write in a read-only transaction");
        }
        internalContext.storesAccessed.add(storeName);
        internalContext.storesModified.add(storeName);
        const req = tx.objectStore(storeName).put(r, k);
        const key = await requestToPromise(req, internalContext);
        return {
          key: key,
        };
      },
      delete(k) {
        internalContext.throwIfInactive();
        if (!internalContext.allowWrite) {
          throw Error("attempting write in a read-only transaction");
        }
        internalContext.storesAccessed.add(storeName);
        internalContext.storesModified.add(storeName);
        const req = tx.objectStore(storeName).delete(k);
        return requestToPromise(req, internalContext);
      },
    };
  }
  return ctx;
}

/**
 * Handle for typed access to a database.
 */
export interface DbAccess<Stores extends StoreMap> {
  /**
   * The underlying IndexedDB database handle.
   *
   * Use with caution, as using the handle directly will not
   * properly run DB triggers.
   */
  idbHandle(): IDBDatabase;

  /**
   * Run an async function in a "readwrite" transaction on the database, using
   * all object store.
   *
   * The transaction function must run within the microtask queue.
   * Waiting for macrotasks results in an autocommit and
   * a subsequent exception thrown by this function.
   */
  runAllStoresReadWriteTx<T>(
    options: {
      label?: string;
    },
    txf: (
      tx: DbReadWriteTransaction<Stores, Array<StoreNames<Stores>>>,
    ) => Promise<T>,
  ): Promise<T>;

  /**
   * Run an async function in a "readonly" transaction on the database, using
   * all object store.
   *
   * The transaction function must run within the microtask queue.
   * Waiting for macrotasks results in an autocommit and
   * a subsequent exception thrown by this function.
   */
  runAllStoresReadOnlyTx<T>(
    options: {
      label?: string;
    },
    txf: (
      tx: DbReadOnlyTransaction<Stores, Array<StoreNames<Stores>>>,
    ) => Promise<T>,
  ): Promise<T>;

  /**
   * Run an async function in a "readwrite" transaction on the database, using
   * the selected object store.
   *
   * The transaction function must run within the microtask queue.
   * Waiting for macrotasks results in an autocommit and
   * a subsequent exception thrown by this function.
   */
  runReadWriteTx<T, StoreNameArray extends Array<StoreNames<Stores>>>(
    opts: {
      storeNames: StoreNameArray;
      label?: string;
    },
    txf: (tx: DbReadWriteTransaction<Stores, StoreNameArray>) => Promise<T>,
  ): Promise<T>;

  /**
   * Run an async function in a "readonly" transaction on the database, using
   * the selected object store.
   *
   * The transaction function must run within the microtask queue.
   * Waiting for macrotasks results in an autocommit and
   * a subsequent exception thrown by this function.
   */
  runReadOnlyTx<T, StoreNameArray extends Array<StoreNames<Stores>>>(
    opts: {
      storeNames: StoreNameArray;
      label?: string;
    },
    txf: (tx: DbReadOnlyTransaction<Stores, StoreNameArray>) => Promise<T>,
  ): Promise<T>;
}

export interface AfterCommitInfo {
  mode: IDBTransactionMode;
  scope: Set<string>;
  accessedStores: Set<string>;
  modifiedStores: Set<string>;
}

export interface TriggerSpec {
  /**
   * Trigger run after every successful commit, run outside of the transaction.
   */
  afterCommit?: (info: AfterCommitInfo) => void;

  // onRead(store, value)
  // initState<State> () => State
  // beforeCommit<State>? (tx: Transaction, s: State | undefined) => Promise<void>;
}

/**
 * Additional state we store for every IndexedDB transaction opened
 * via the query helper.
 */
class InternalTransactionContext {
  isAborted = false;
  storesScope: Set<string>;
  storesAccessed: Set<string> = new Set();
  storesModified: Set<string> = new Set();
  allowWrite: boolean;
  abortExn: TransactionAbortedError | undefined;
  notifications: WalletNotification[] = [];
  afterCommitHandlers: (() => void)[] = [];

  constructor(
    private readonly triggerSpec: TriggerSpec,
    private readonly mode: IDBTransactionMode,
    readonly scope: string[],
    public readonly cancellationToken: CancellationToken,
    public readonly applyNotifications?: (notifs: WalletNotification[]) => void,
  ) {
    this.storesScope = new Set(scope);
    this.allowWrite = mode === "readwrite" || mode === "versionchange";
  }

  scheduleNotification(notif: WalletNotification): void {
    this.notifications.push(notif);
  }

  scheduleAfterCommit(f: () => void) {
    this.afterCommitHandlers.push(f);
  }

  handleAfterCommit() {
    if (this.triggerSpec.afterCommit) {
      this.triggerSpec.afterCommit({
        mode: this.mode,
        accessedStores: this.storesAccessed,
        modifiedStores: this.storesModified,
        scope: this.storesScope,
      });
    }
    for (const f of this.afterCommitHandlers) {
      f();
    }
  }

  throwIfInactive() {
    this.cancellationToken.throwIfCancelled();
    if (this.isAborted) {
      throw this.abortExn;
    }
  }
}

/**
 * Type-safe access to a database with a particular store map.
 *
 * A store map is the metadata that describes the store.
 */
export class DbAccessImpl<Stores extends StoreMap> implements DbAccess<Stores> {
  constructor(
    private db: IDBDatabase,
    private stores: Stores,
    private triggers: TriggerSpec = {},
    private cancellationToken: CancellationToken,
    private applyNotifications?: (notifs: WalletNotification[]) => void,
  ) {}

  idbHandle(): IDBDatabase {
    return this.db;
  }

  async runAllStoresReadWriteTx<T>(
    options: {
      label?: string;
    },
    txf: (
      tx: DbReadWriteTransaction<Stores, Array<StoreNames<Stores>>>,
    ) => Promise<T>,
  ): Promise<T> {
    this.cancellationToken.throwIfCancelled();
    const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
      {};
    const strStoreNames: string[] = [];
    for (const sn of Object.keys(this.stores as any)) {
      const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
      strStoreNames.push(swi.storeName);
      accessibleStores[swi.storeName] = swi;
    }
    const mode = "readwrite";
    const triggerContext = new InternalTransactionContext(
      this.triggers,
      mode,
      strStoreNames,
      this.cancellationToken,
      this.applyNotifications,
    );
    const tx = this.db.transaction(strStoreNames, mode);
    const writeContext = makeTxClientContext(
      tx,
      accessibleStores,
      triggerContext,
    );
    return await runTx(tx, writeContext, txf, triggerContext);
  }

  async runAllStoresReadOnlyTx<T>(
    options: {
      label?: string;
    },
    txf: (
      tx: DbReadOnlyTransaction<Stores, Array<StoreNames<Stores>>>,
    ) => Promise<T>,
  ): Promise<T> {
    this.cancellationToken.throwIfCancelled();
    const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
      {};
    const strStoreNames: string[] = [];
    for (const sn of Object.keys(this.stores as any)) {
      const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
      strStoreNames.push(swi.storeName);
      accessibleStores[swi.storeName] = swi;
    }
    const mode = "readonly";
    const internalContext = new InternalTransactionContext(
      this.triggers,
      mode,
      strStoreNames,
      this.cancellationToken,
      this.applyNotifications,
    );
    const tx = this.db.transaction(strStoreNames, mode);
    const writeContext = makeTxClientContext(
      tx,
      accessibleStores,
      internalContext,
    );
    const res = await runTx(tx, writeContext, txf, internalContext);
    return res;
  }

  async runReadWriteTx<T, StoreNameArray extends Array<StoreNames<Stores>>>(
    opts: {
      storeNames: StoreNameArray;
    },
    txf: (tx: DbReadWriteTransaction<Stores, StoreNameArray>) => Promise<T>,
  ): Promise<T> {
    this.cancellationToken.throwIfCancelled();
    const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
      {};
    const strStoreNames: string[] = [];
    for (const sn of opts.storeNames) {
      const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
      strStoreNames.push(swi.storeName);
      accessibleStores[swi.storeName] = swi;
    }
    const mode = "readwrite";
    const triggerContext = new InternalTransactionContext(
      this.triggers,
      mode,
      strStoreNames,
      this.cancellationToken,
      this.applyNotifications,
    );
    const tx = this.db.transaction(strStoreNames, mode);
    const writeContext = makeTxClientContext(
      tx,
      accessibleStores,
      triggerContext,
    );
    const res = await runTx(tx, writeContext, txf, triggerContext);
    return res;
  }

  async runReadOnlyTx<T, StoreNameArray extends Array<StoreNames<Stores>>>(
    opts: {
      storeNames: StoreNameArray;
    },
    txf: (tx: DbReadOnlyTransaction<Stores, StoreNameArray>) => Promise<T>,
  ): Promise<T> {
    this.cancellationToken.throwIfCancelled();
    const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
      {};
    const strStoreNames: string[] = [];
    for (const sn of opts.storeNames) {
      const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
      strStoreNames.push(swi.storeName);
      accessibleStores[swi.storeName] = swi;
    }
    const mode = "readonly";
    const triggerContext = new InternalTransactionContext(
      this.triggers,
      mode,
      strStoreNames,
      this.cancellationToken,
      this.applyNotifications,
    );
    const tx = this.db.transaction(strStoreNames, mode);
    const readContext = makeTxClientContext(
      tx,
      accessibleStores,
      triggerContext,
    );
    const res = await runTx(tx, readContext, txf, triggerContext);
    return res;
  }
}
