/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/**
 * @file
 *
 * This module provides Promise-based wrappers around ordinarily
 * IDBRequest-based IndexedDB methods and classes.
 */

/**
 * Wraps the given request object, and returns a Promise which resolves when
 * the requests succeeds or rejects when it fails.
 *
 * @param {IDBRequest} request
 *        An IndexedDB request object to wrap.
 * @returns {Promise}
 */
function wrapRequest(request) {
  return new Promise((resolve, reject) => {
    request.onsuccess = () => {
      resolve(request.result);
    };
    request.onerror = () => {
      reject(request.error);
    };
  });
}

/**
 * Forwards a set of getter properties from a wrapper class to the wrapped
 * object.
 *
 * @param {function} cls
 *        The class constructor for which to forward the getters.
 * @param {string} target
 *        The name of the property which contains the wrapped object to which
 *        to forward the getters.
 * @param {Array<string>} props
 *        A list of property names to forward.
 */
function forwardGetters(cls, target, props) {
  for (let prop of props) {
    Object.defineProperty(cls.prototype, prop, {
      get() {
        return this[target][prop];
      },
    });
  }
}

/**
 * Forwards a set of getter and setter properties from a wrapper class to the
 * wrapped object.
 *
 * @param {function} cls
 *        The class constructor for which to forward the properties.
 * @param {string} target
 *        The name of the property which contains the wrapped object to which
 *        to forward the properties.
 * @param {Array<string>} props
 *        A list of property names to forward.
 */
function forwardProps(cls, target, props) {
  for (let prop of props) {
    Object.defineProperty(cls.prototype, prop, {
      get() {
        return this[target][prop];
      },
      set(value) {
        this[target][prop] = value;
      },
    });
  }
}

/**
 * Wraps a set of IDBRequest-based methods via {@link wrapRequest} and
 * forwards them to the equivalent methods on the wrapped object.
 *
 * @param {function} cls
 *        The class constructor for which to forward the methods.
 * @param {string} target
 *        The name of the property which contains the wrapped object to which
 *        to forward the methods.
 * @param {Array<string>} methods
 *        A list of method names to forward.
 */
function wrapMethods(cls, target, methods) {
  for (let method of methods) {
    cls.prototype[method] = function (...args) {
      return wrapRequest(this[target][method](...args));
    };
  }
}

/**
 * Forwards a set of methods from a wrapper class to the wrapped object.
 *
 * @param {function} cls
 *        The class constructor for which to forward the getters.
 * @param {string} target
 *        The name of the property which contains the wrapped object to which
 *        to forward the methods.
 * @param {Array<string>} methods
 *        A list of method names to forward.
 */
function forwardMethods(cls, target, methods) {
  for (let method of methods) {
    cls.prototype[method] = function (...args) {
      return this[target][method](...args);
    };
  }
}

class Cursor {
  constructor(cursorRequest, source) {
    this.cursorRequest = cursorRequest;
    this.source = source;
    this.cursor = null;
  }

  get done() {
    return !this.cursor;
  }

  // This method is used internally to wait the cursor's IDBRequest to have been
  // completed and the internal cursor has been updated (used when we initially
  // create the cursor from Cursed.openCursor/openKeyCursor, and in the method
  // of this class defined by defineCursorUpdateMethods).
  async awaitRequest() {
    this.cursor = await wrapRequest(this.cursorRequest);
    return this;
  }
}

/**
 * Define the Cursor class methods that update the cursor (continue, continuePrimaryKey
 * and advance) as async functions that call the related IDBCursor methods and
 * await the cursor's IDBRequest to be completed.
 *
 * @param {function} cls
 *        The class constructor for which to define the cursor update methods.
 * @param {Array<string>} methods
 *        A list of "cursor update" method names to define.
 */
function defineCursorUpdateMethods(cls, methods) {
  for (let method of methods) {
    cls.prototype[method] = async function (...args) {
      const promise = this.awaitRequest();
      this.cursor[method](...args);
      await promise;
    };
  }
}

defineCursorUpdateMethods(Cursor, [
  "advance",
  "continue",
  "continuePrimaryKey",
]);

forwardGetters(Cursor, "cursor", ["direction", "key", "primaryKey"]);
wrapMethods(Cursor, "cursor", ["delete", "update"]);

class CursorWithValue extends Cursor {}

forwardGetters(CursorWithValue, "cursor", ["value"]);

class Cursed {
  constructor(cursed) {
    this.cursed = cursed;
  }

  openCursor(...args) {
    const cursor = new CursorWithValue(this.cursed.openCursor(...args), this);
    return cursor.awaitRequest();
  }

  openKeyCursor(...args) {
    const cursor = new Cursor(this.cursed.openKeyCursor(...args), this);
    return cursor.awaitRequest();
  }
}

wrapMethods(Cursed, "cursed", [
  "count",
  "get",
  "getAll",
  "getAllKeys",
  "getKey",
]);

class Index extends Cursed {
  constructor(index, objectStore) {
    super(index);

    this.objectStore = objectStore;
    this.index = index;
  }
}

forwardGetters(Index, "index", [
  "isAutoLocale",
  "keyPath",
  "locale",
  "multiEntry",
  "name",
  "unique",
]);

class ObjectStore extends Cursed {
  constructor(store) {
    super(store);

    this.store = store;
  }

  createIndex(...args) {
    return new Index(this.store.createIndex(...args), this);
  }

  index(...args) {
    return new Index(this.store.index(...args), this);
  }
}

wrapMethods(ObjectStore, "store", ["add", "clear", "delete", "put"]);

forwardMethods(ObjectStore, "store", ["deleteIndex"]);

class Transaction {
  constructor(transaction) {
    this.transaction = transaction;

    this._completionPromise = new Promise((resolve, reject) => {
      transaction.oncomplete = resolve;
      transaction.onerror = () => {
        reject(transaction.error);
      };
      transaction.onabort = () => {
        const error =
          transaction.error ||
          new DOMException("The operation has been aborted", "AbortError");
        reject(error);
      };
    });
  }

  objectStore(name) {
    return new ObjectStore(this.transaction.objectStore(name));
  }

  /**
   * Returns a Promise which resolves when the transaction completes, or
   * rejects when a transaction error or abort occurs.
   *
   * @returns {Promise}
   */
  promiseComplete() {
    return this._completionPromise;
  }
}

forwardGetters(Transaction, "transaction", [
  "db",
  "mode",
  "error",
  "objectStoreNames",
]);

forwardMethods(Transaction, "transaction", ["abort"]);

export class IndexedDB {
  /**
   * Opens the database with the given name, and returns a Promise which
   * resolves to an IndexedDB instance when the operation completes.
   *
   * @param {string} dbName
   *        The name of the database to open.
   * @param {integer} version
   *        The schema version with which the database needs to be opened. If
   *        the database does not exist, or its current schema version does
   *        not match, the `onupgradeneeded` function will be called.
   * @param {function} [onupgradeneeded]
   *        A function which will be called with an IndexedDB object as its
   *        first parameter when the database needs to be created, or its
   *        schema needs to be upgraded. If this function is not provided, the
   *        {@link #onupgradeneeded} method will be called instead.
   *
   * @returns {Promise<IndexedDB>}
   */
  static open(dbName, version, onupgradeneeded = null) {
    let request = indexedDB.open(dbName, version);
    return this._wrapOpenRequest(request, onupgradeneeded);
  }

  /**
   * Opens the database for a given principal and with the given name, returns
   * a Promise which resolves to an IndexedDB instance when the operation completes.
   *
   * @param {nsIPrincipal} principal
   *        The principal to open the database for.
   * @param {string} dbName
   *        The name of the database to open.
   * @param {object} options
   *        The options with which to open the database.
   * @param {integer} options.version
   *        The schema version with which the database needs to be opened. If
   *        the database does not exist, or its current schema version does
   *        not match, the `onupgradeneeded` function will be called.
   * @param {function} [onupgradeneeded]
   *        A function which will be called with an IndexedDB object as its
   *        first parameter when the database needs to be created, or its
   *        schema needs to be upgraded. If this function is not provided, the
   *        {@link #onupgradeneeded} method will be called instead.
   *
   * @returns {Promise<IndexedDB>}
   */
  static openForPrincipal(principal, dbName, options, onupgradeneeded = null) {
    const request = indexedDB.openForPrincipal(principal, dbName, options);
    return this._wrapOpenRequest(request, onupgradeneeded);
  }

  static _wrapOpenRequest(request, onupgradeneeded = null) {
    request.onupgradeneeded = event => {
      let db = new this(request.result);
      if (onupgradeneeded) {
        onupgradeneeded(db, event);
      } else {
        db.onupgradeneeded(event);
      }
    };

    return wrapRequest(request).then(db => new this(db));
  }

  constructor(db) {
    this.db = db;
  }

  onupgradeneeded() {}

  /**
   * Opens a transaction for the given object stores.
   *
   * @param {Array<string>} storeNames
   *        The names of the object stores for which to open a transaction.
   * @param {string} [mode = "readonly"]
   *        The mode in which to open the transaction.
   * @param {function} [callback]
   *        An optional callback function. If provided, the function will be
   *        called with the Transaction, and a Promise will be returned, which
   *        will resolve to the callback's return value when the transaction
   *        completes.
   * @returns {Transaction|Promise}
   */
  transaction(storeNames, mode, callback = null) {
    let transaction = new Transaction(this.db.transaction(storeNames, mode));

    if (callback) {
      let result = new Promise(resolve => {
        resolve(callback(transaction));
      });
      return transaction.promiseComplete().then(() => result);
    }

    return transaction;
  }

  /**
   * Opens a transaction for a single object store, and returns that object
   * store.
   *
   * @param {string} storeName
   *        The name of the object store to open.
   * @param {string} [mode = "readonly"]
   *        The mode in which to open the transaction.
   * @param {function} [callback]
   *        An optional callback function. If provided, the function will be
   *        called with the ObjectStore, and a Promise will be returned, which
   *        will resolve to the callback's return value when the transaction
   *        completes.
   * @returns {ObjectStore|Promise}
   */
  objectStore(storeName, mode, callback = null) {
    let transaction = this.transaction([storeName], mode);
    let objectStore = transaction.objectStore(storeName);

    if (callback) {
      let result = new Promise(resolve => {
        resolve(callback(objectStore));
      });
      return transaction.promiseComplete().then(() => result);
    }

    return objectStore;
  }

  createObjectStore(...args) {
    return new ObjectStore(this.db.createObjectStore(...args));
  }
}

for (let method of ["cmp", "deleteDatabase"]) {
  IndexedDB[method] = function (...args) {
    return indexedDB[method](...args);
  };
}

forwardMethods(IndexedDB, "db", [
  "addEventListener",
  "close",
  "deleteObjectStore",
  "hasEventListener",
  "removeEventListener",
]);

forwardGetters(IndexedDB, "db", ["name", "objectStoreNames", "version"]);

forwardProps(IndexedDB, "db", [
  "onabort",
  "onclose",
  "onerror",
  "onversionchange",
]);
