/* 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/. */

const DB_NAME = "remote-settings";
const DB_VERSION = 3;

/**
 * Wrap IndexedDB errors to catch them more easily.
 */
class IndexedDBError extends Error {
  constructor(error, method = "", identifier = "") {
    if (typeof error == "string") {
      error = new Error(error);
    }
    super(`IndexedDB: ${identifier} ${method} ${error && error.message}`);
    this.name = error.name;
    this.stack = error.stack;
  }
}

class ShutdownError extends IndexedDBError {
  constructor(error, method = "", identifier = "") {
    super(error, method, identifier);
  }
}

// We batch operations in order to reduce round-trip latency to the IndexedDB
// database thread. The trade-offs are that the more records in the batch, the
// more time we spend on this thread in structured serialization, and the
// greater the chance to jank PBackground and this thread when the responses
// come back. The initial choice of 250 was made targeting 2-3ms on a fast
// machine and 10-15ms on a slow machine.
// Every chunk waits for success before starting the next, and
// the final chunk's completion will fire transaction.oncomplete .
function bulkOperationHelper(
  store,
  { reject, completion },
  operation,
  list,
  listIndex = 0
) {
  try {
    const CHUNK_LENGTH = 250;
    const max = Math.min(listIndex + CHUNK_LENGTH, list.length);
    let request;
    for (; listIndex < max; listIndex++) {
      request = store[operation](list[listIndex]);
    }
    if (listIndex < list.length) {
      // On error, `transaction.onerror` is called.
      request.onsuccess = bulkOperationHelper.bind(
        null,
        store,
        { reject, completion },
        operation,
        list,
        listIndex
      );
    } else if (completion) {
      completion();
    }
    // otherwise, we're done, and the transaction will complete on its own.
  } catch (e) {
    // The executeIDB callsite has a try... catch, but it will not catch
    // errors in subsequent bulkOperationHelper calls chained through
    // request.onsuccess above. We do want to catch those, so we have to
    // feed them through manually. We cannot use an async function with
    // promises, because if we wait a microtask after onsuccess fires to
    // put more requests on the transaction, the transaction will auto-commit
    // before we can add more requests.
    reject(e);
  }
}

/**
 * Helper to wrap some IDBObjectStore operations into a promise.
 *
 * @param {IDBDatabase} db
 * @param {String|String[]} storeNames - either a string or an array of strings.
 * @param {String} mode
 * @param {function} callback
 * @param {String} description of the operation for error handling purposes.
 */
function executeIDB(db, storeNames, mode, callback, desc) {
  if (!Array.isArray(storeNames)) {
    storeNames = [storeNames];
  }
  const transaction = db.transaction(storeNames, mode);
  let promise = new Promise((resolve, reject) => {
    let stores = storeNames.map(name => transaction.objectStore(name));
    let result;
    let rejectWrapper = e => {
      reject(new IndexedDBError(e, desc || "execute()", storeNames.join(", ")));
      try {
        transaction.abort();
      } catch (ex) {
        console.error(ex);
      }
    };
    // Add all the handlers before using the stores.
    transaction.onerror = event =>
      reject(new IndexedDBError(event.target.error, desc || "execute()"));
    transaction.onabort = event =>
      reject(
        new IndexedDBError(
          event.target.error || transaction.error || "IDBTransaction aborted",
          desc || "execute()"
        )
      );
    transaction.oncomplete = event => resolve(result);
    // Simplify access to a single datastore:
    if (stores.length == 1) {
      stores = stores[0];
    }
    try {
      // Although this looks sync, once the callback places requests
      // on the datastore, it can independently keep the transaction alive and
      // keep adding requests. Even once we exit this try.. catch, we may
      // therefore experience errors which should abort the transaction.
      // This is why we pass the rejection handler - then the consumer can
      // continue to ensure that errors are handled appropriately.
      // In theory, exceptions thrown from onsuccess handlers should also
      // cause IndexedDB to abort the transaction, so this is a belt-and-braces
      // approach.
      result = callback(stores, rejectWrapper);
    } catch (e) {
      rejectWrapper(e);
    }
  });
  return { promise, transaction };
}

/**
 * Helper to wrap indexedDB.open() into a promise.
 */
async function openIDB(allowUpgrades = true) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);
    request.onupgradeneeded = event => {
      if (!allowUpgrades) {
        reject(
          new Error(
            `IndexedDB: Error accessing ${DB_NAME} IDB at version ${DB_VERSION}`
          )
        );
        return;
      }
      // When an upgrade is needed, a transaction is started.
      const transaction = event.target.transaction;
      transaction.onabort = event => {
        const error =
          event.target.error ||
          transaction.error ||
          new DOMException("The operation has been aborted", "AbortError");
        reject(new IndexedDBError(error, "open()"));
      };

      const db = event.target.result;
      db.onerror = event => reject(new IndexedDBError(event.target.error));

      if (event.oldVersion < 1) {
        // Records store
        const recordsStore = db.createObjectStore("records", {
          keyPath: ["_cid", "id"],
        });
        // An index to obtain all the records in a collection.
        recordsStore.createIndex("cid", "_cid");
        // Last modified field
        recordsStore.createIndex("last_modified", ["_cid", "last_modified"]);
        // Timestamps store
        db.createObjectStore("timestamps", {
          keyPath: "cid",
        });
      }
      if (event.oldVersion < 2) {
        // Collections store
        db.createObjectStore("collections", {
          keyPath: "cid",
        });
      }
      if (event.oldVersion < 3) {
        // Clear existing stores for a fresh start
        transaction.objectStore("records").clear();
        transaction.objectStore("timestamps").clear();
        transaction.objectStore("collections").clear();
        // Attachment store
        db.createObjectStore("attachments", {
          keyPath: ["cid", "attachmentId"],
        });
      }
    };
    request.onerror = event => reject(new IndexedDBError(event.target.error));
    request.onsuccess = event => {
      const db = event.target.result;
      resolve(db);
    };
  });
}

function destroyIDB() {
  const request = indexedDB.deleteDatabase(DB_NAME);
  return new Promise((resolve, reject) => {
    request.onerror = event => reject(new IndexedDBError(event.target.error));
    request.onsuccess = () => resolve();
  });
}

export var IDBHelpers = {
  bulkOperationHelper,
  executeIDB,
  openIDB,
  destroyIDB,
  IndexedDBError,
  ShutdownError,
};
