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

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { IndexedDB } from "resource://gre/modules/IndexedDB.sys.mjs";

const lazy = XPCOMUtils.declareLazy({
  ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs",
  ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs",
  getTrimmedString: "resource://gre/modules/ExtensionTelemetry.sys.mjs",

  disabledAutoResetOnCorrupted: {
    // NOTE: this pref is meant to disable the auto reset of the
    // IndexedDB backend database when it is detected as corrupted
    // for debugging purpose.
    pref: "extensions.webextensions.keepStorageOnCorrupted.storageLocal",
    // TODO(Bug 1992973): change the default behavior as part of enabling auto-reset
    // corrupted storage.local IndexedDB databases on all channels.
    default: true,
  },
});

// The userContextID reserved for the extension storage (its purpose is ensuring that the IndexedDB
// storage used by the browser.storage.local API is not directly accessible from the extension code,
// it is defined and reserved as "userContextIdInternal.webextStorageLocal" in ContextualIdentityService.sys.mjs).
const WEBEXT_STORAGE_USER_CONTEXT_ID = -1 >>> 0;

const IDB_NAME = "webExtensions-storage-local";
const IDB_DATA_STORENAME = "storage-local-data";
const IDB_VERSION = 1;

// Whether or not the installed extensions should be migrated to the storage.local IndexedDB backend.
const BACKEND_ENABLED_PREF =
  "extensions.webextensions.ExtensionStorageIDB.enabled";
const IDB_MIGRATED_PREF_BRANCH =
  "extensions.webextensions.ExtensionStorageIDB.migrated";

export const ERROR_OPEN_ON_INACTIVE_POLICY =
  "Failed to open storage.local backend after the extension was already shutdown";

class DataMigrationAbortedError extends Error {
  get name() {
    return "DataMigrationAbortedError";
  }
}

var ErrorsTelemetry = {
  initialized: false,

  lazyInit() {
    if (this.initialized) {
      return;
    }
    this.initialized = true;
  },

  /**
   * Get the DOMException error name for a given error object.
   *
   * @param {Error | undefined} error
   *        The Error object to convert into a string, or undefined if there was no error.
   *
   * @returns {string | undefined}
   *          The DOMException error name (sliced to a maximum of 80 chars),
   *          "OtherError" if the error object is not a DOMException instance,
   *          or `undefined` if there wasn't an error.
   */
  getErrorName(error) {
    if (!error) {
      return undefined;
    }

    if (
      DOMException.isInstance(error) ||
      error instanceof DataMigrationAbortedError
    ) {
      if (error.name.length > 80) {
        return lazy.getTrimmedString(error.name);
      }

      return error.name;
    }

    return "OtherError";
  },

  /**
   * Record telemetry related to a data migration result.
   *
   * @param {object} telemetryData
   * @param {string} telemetryData.backend
   *        The backend selected ("JSONFile" or "IndexedDB").
   * @param {boolean} [telemetryData.dataMigrated]
   *        Old extension data has been migrated successfully.
   * @param {string} telemetryData.extensionId
   *        The id of the extension migrated.
   * @param {Error | undefined} telemetryData.error
   *        The error raised during the data migration, if any.
   * @param {boolean} [telemetryData.hasJSONFile]
   *        The extension has an existing JSONFile to migrate.
   * @param {boolean} [telemetryData.hasOldData]
   *        The extension's JSONFile wasn't empty.
   * @param {string} telemetryData.histogramCategory
   *        The histogram category for the result ("success" or "failure").
   */
  recordDataMigrationResult(telemetryData) {
    try {
      const {
        backend,
        dataMigrated,
        extensionId,
        error,
        hasJSONFile,
        hasOldData,
        histogramCategory,
      } = telemetryData;

      this.lazyInit();
      Glean.extensionsData.migrateResultCount[histogramCategory].add(1);

      const extra = { addon_id: lazy.getTrimmedString(extensionId), backend };

      if (dataMigrated != null) {
        extra.data_migrated = dataMigrated ? "y" : "n";
      }

      if (hasJSONFile != null) {
        extra.has_jsonfile = hasJSONFile ? "y" : "n";
      }

      if (hasOldData != null) {
        extra.has_olddata = hasOldData ? "y" : "n";
      }

      if (error) {
        extra.error_name = this.getErrorName(error);
      }

      Glean.extensionsData.migrateResult.record(extra);
    } catch (err) {
      // Report any telemetry error on the browser console, but
      // we treat it as a non-fatal error and we don't re-throw
      // it to the caller.
      Cu.reportError(err);
    }
  },

  /**
   * Record telemetry related to the unexpected errors raised while executing
   * a storage.local API call.
   *
   * @param {object} options
   * @param {string} options.extensionId
   *        The id of the extension migrated.
   * @param {string} options.storageMethod
   *        The storage.local API method being run.
   * @param {Error}  options.error
   *        The unexpected error raised during the API call.
   */
  recordStorageLocalError({ extensionId, storageMethod, error }) {
    this.lazyInit();

    Glean.extensionsData.storageLocalError.record({
      addon_id: lazy.getTrimmedString(extensionId),
      method: storageMethod,
      error_name: this.getErrorName(error),
    });
  },
};

export class ExtensionStorageLocalIDB extends IndexedDB {
  #addonId;
  #storagePrincipal;

  onupgradeneeded(event) {
    if (event.oldVersion < 1) {
      this.createObjectStore(IDB_DATA_STORENAME);
    }
  }

  static get disabledAutoResetOnCorrupted() {
    return lazy.disabledAutoResetOnCorrupted;
  }

  static isMissingObjectStore(db) {
    return (
      !db.objectStoreNames.contains(IDB_DATA_STORENAME) &&
      db.version >= IDB_VERSION
    );
  }

  static async openForPrincipal(storagePrincipal) {
    const { addonPolicy, addonId } = storagePrincipal;

    const ensureAddonPolicyIsActive = () => {
      if (addonPolicy?.active) {
        return;
      }
      throw new Error(
        addonId
          ? `${ERROR_OPEN_ON_INACTIVE_POLICY} (${addonId})`
          : ERROR_OPEN_ON_INACTIVE_POLICY
      );
    };

    // Don't even try to open the IndexedDB database if the
    // extension is not active anymore.
    ensureAddonPolicyIsActive();

    // The db is opened using an extension principal isolated in a reserved user context id.
    let result = await super.openForPrincipal(storagePrincipal, IDB_NAME, {
      version: IDB_VERSION,
    });
    result.#storagePrincipal = storagePrincipal;
    result.#addonId = addonId;

    // Delete and recreate the database from scratch if the expected object store
    // isn't found in objectStoreNames DOMStringList.
    //
    // NOTE: the onupgradeneeded handler is expected to be executed before openForPrincipal
    // resolves, and so if at this point the expected object store name isn't found, then
    // it means that the database got corrupted (and if the database version is still
    // set then the onupgradeneeded function would never recreate it).
    if (this.isMissingObjectStore(result.db)) {
      Glean.extensionsData.storageLocalCorruptedReset.record({
        addon_id: addonId,
        reason: "ObjectStoreNotFound",
        is_addon_active: !!addonPolicy.active,
        after_reset: false,
        reset_disabled: lazy.disabledAutoResetOnCorrupted,
      });

      // Don't reset the database if the addon isn't active anymore.
      ensureAddonPolicyIsActive();

      if (!lazy.disabledAutoResetOnCorrupted) {
        let resetErrorName = null;
        try {
          await this.resetForPrincipal(storagePrincipal);
        } catch (err) {
          Cu.reportError(err);
          resetErrorName = ErrorsTelemetry.getErrorName(err);
        }

        // Now try again to open the db, which should create the object store
        // from the onupgradedneeded event listener.
        result = await super.openForPrincipal(storagePrincipal, IDB_NAME, {
          version: IDB_VERSION,
        });
        // throw an error more specific than "An unexpected error occurred" if objectStoreNames
        // doesn't still include the expected object store name.
        if (this.isMissingObjectStore(result.db)) {
          Glean.extensionsData.storageLocalCorruptedReset.record({
            addon_id: storagePrincipal.addonId,
            reason: "ObjectStoreNotFound",
            is_addon_active: !!addonPolicy.active,
            after_reset: true,
            reset_disabled: lazy.disabledAutoResetOnCorrupted,
            reset_error_name: resetErrorName,
          });
          const { ExtensionError } = lazy.ExtensionUtils;
          throw new ExtensionError("Corrupted storage.local backend");
        }
      }
    }

    // Make sure we reject also if the add-on ends up being disabled by the time
    // the call is ready to resolve successfully.
    ensureAddonPolicyIsActive();

    /** @type {Promise<ExtensionStorageLocalIDB>} */
    return result;
  }

  static async resetForPrincipal(storagePrincipal) {
    await new Promise(resolve => {
      // NOTE: using clearStoragesForPrincipal here to make sure we are completely
      // dropping the corrupted indexeddb (storagePrincipal is only used for the storage.local
      // IndexedDB backend and so the call that follows will not be clearing other storage
      // backends that belongs to the API).
      let req = Services.qms.clearStoragesForPrincipal(storagePrincipal);
      req.callback = resolve;
    });
  }

  async dropAndReopen() {
    // Do not reset the database if the addon isn't active anymore.
    if (!this.#storagePrincipal.addonPolicy?.active) {
      throw new Error(`${ERROR_OPEN_ON_INACTIVE_POLICY} (${this.#addonId})`);
    }
    // Forcefully drop the corrupted IndexedDB database.
    await ExtensionStorageLocalIDB.resetForPrincipal(this.#storagePrincipal);
    // Reopen the database after it has been reset and retrive the
    // underlying wrapped IndexedDB database instance to become
    // the active one for the current IndexedDB database wrapper
    // instance.
    const newInstance = await ExtensionStorageLocalIDB.openForPrincipal(
      this.#storagePrincipal
    );
    this.db = newInstance.db;
  }

  async isEmpty() {
    const cursor = await this.objectStore(
      IDB_DATA_STORENAME,
      "readonly"
    ).openKeyCursor();
    return cursor.done;
  }

  /**
   * Asynchronously sets the values of the given storage items.
   *
   * @param {object} items
   *        The storage items to set. For each property in the object,
   *        the storage value for that property is set to its value in
   *        said object. Any values which are StructuredCloneHolder
   *        instances are deserialized before being stored.
   * @param {object}  options
   * @param {callback} [options.serialize]
   *        Set to a function which will be used to serialize the values into
   *        a StructuredCloneHolder object (if appropriate) and being sent
   *        across the processes (it is also used to detect data cloning errors
   *        and raise an appropriate error to the caller).
   *
   * @returns {Promise<null|object>}
   *        Return a promise which resolves to the computed "changes" object
   *        or null.
   */
  async set(items, { serialize } = {}) {
    const changes = {};
    let changed = false;

    // Explicitly create a transaction, so that we can explicitly abort it
    // as soon as one of the put requests fails.
    const transaction = this.transaction(IDB_DATA_STORENAME, "readwrite");
    const objectStore = transaction.objectStore(IDB_DATA_STORENAME);
    const transactionCompleted = transaction.promiseComplete();

    if (!serialize) {
      serialize = (name, anonymizedName, value) => value;
    }

    for (let key of Object.keys(items)) {
      try {
        let oldValue = await objectStore.get(key);

        await objectStore.put(items[key], key);

        changes[key] = {
          oldValue:
            oldValue && serialize(`old/${key}`, `old/<anonymized>`, oldValue),
          newValue: serialize(`new/${key}`, `new/<anonymized>`, items[key]),
        };
        changed = true;
      } catch (err) {
        transactionCompleted.catch(() => {
          // We ignore this rejection because we are explicitly aborting the transaction,
          // the transaction.error will be null, and we throw the original error below.
        });
        transaction.abort();

        throw err;
      }
    }

    await transactionCompleted;

    return changed ? changes : null;
  }

  /**
   * Asynchronously retrieves the values for the given storage items.
   *
   * @param {Array<string>|object|null} [keysOrItems]
   *        The storage items to get. If an array, the value of each key
   *        in the array is returned. If null, the values of all items
   *        are returned. If an object, the value for each key in the
   *        object is returned, or that key's value if the item is not
   *        set.
   * @returns {Promise<object>}
   *        An object which has a property for each requested key,
   *        containing that key's value as stored in the IndexedDB
   *        storage.
   */
  async get(keysOrItems) {
    let keys;
    let defaultValues;

    if (typeof keysOrItems === "string") {
      keys = [keysOrItems];
    } else if (Array.isArray(keysOrItems)) {
      keys = keysOrItems;
    } else if (keysOrItems && typeof keysOrItems === "object") {
      keys = Object.keys(keysOrItems);
      defaultValues = keysOrItems;
    }

    const result = {};

    // Retrieve all the stored data using a cursor when browser.storage.local.get()
    // has been called with no keys.
    if (keys == null) {
      const cursor = await this.objectStore(
        IDB_DATA_STORENAME,
        "readonly"
      ).openCursor();
      while (!cursor.done) {
        result[cursor.key] = cursor.value;
        await cursor.continue();
      }
    } else {
      const objectStore = this.objectStore(IDB_DATA_STORENAME);
      for (let key of keys) {
        const storedValue = await objectStore.get(key);
        if (storedValue === undefined) {
          if (defaultValues && defaultValues[key] !== undefined) {
            result[key] = defaultValues[key];
          }
        } else {
          result[key] = storedValue;
        }
      }
    }

    return result;
  }

  /**
   * Asynchronously retrieves the bytes in use for the given storage items.
   *
   * @param {Array<string>|string|null} [keys]
   * @returns {Promise<number>}
   */
  async getBytesInUse(keys) {
    const data = await this.get(keys);
    let bytesInUse = 0;
    for (let key in data) {
      const clone = new StructuredCloneHolder(key, null, data[key]);
      bytesInUse += key.length + clone.dataSize;
    }
    return bytesInUse;
  }

  /**
   * Asynchronously retrieves all keys.
   *
   * @returns {Promise<Array<string>>}
   *          Returns an array of keys.
   */
  async getKeys() {
    const cursor = await this.objectStore(
      IDB_DATA_STORENAME,
      "readonly"
    ).openCursor();
    const keys = [];
    while (!cursor.done) {
      keys.push(cursor.key);
      await cursor.continue();
    }
    return keys;
  }

  /**
   * Asynchronously removes the given storage items.
   *
   * @param {string|Array<string>} keys
   *        A string key of a list of storage items keys to remove.
   * @returns {Promise<object>}
   *          Returns an object which contains applied changes.
   */
  async remove(keys) {
    // Ensure that keys is an array of strings.
    keys = [].concat(keys);

    if (keys.length === 0) {
      // Early exit if there is nothing to remove.
      return null;
    }

    const changes = {};
    let changed = false;

    const objectStore = this.objectStore(IDB_DATA_STORENAME, "readwrite");

    let promises = [];

    for (let key of keys) {
      promises.push(
        objectStore.getKey(key).then(async foundKey => {
          if (foundKey === key) {
            changed = true;
            changes[key] = { oldValue: await objectStore.get(key) };
            return objectStore.delete(key);
          }
        })
      );
    }

    await Promise.all(promises);

    return changed ? changes : null;
  }

  /**
   * Asynchronously clears all storage entries.
   *
   * @returns {Promise<object>}
   *          Returns an object which contains applied changes.
   */
  async clear() {
    const changes = {};
    let changed = false;

    const objectStore = this.objectStore(IDB_DATA_STORENAME, "readwrite");

    try {
      const cursor = await objectStore.openCursor();
      while (!cursor.done) {
        changes[cursor.key] = { oldValue: cursor.value };
        changed = true;
        await cursor.continue();
      }
      await objectStore.clear();
    } catch (err) {
      // Error names expected to be raised on known corrupted storage
      // issues that storage.local.clear method may be hitting.
      const KNOWN_CORRUPTED_ERROR_NAMES = ["UnknownError"];
      const errorName = ErrorsTelemetry.getErrorName(err);
      if (
        lazy.disabledAutoResetOnCorrupted ||
        !KNOWN_CORRUPTED_ERROR_NAMES.includes(errorName)
      ) {
        throw err;
      } else {
        // Drop and reopen the database if iterating over the
        // IDB objectStore keys or clearing the objectStore
        // has hit unexpected rejections.
        Cu.reportError(err);
        Glean.extensionsData.storageLocalCorruptedReset.record({
          addon_id: this.#addonId,
          reason: `RejectedClear:${errorName}`,
          is_addon_active: !!this.#storagePrincipal.addonPolicy?.active,
        });
        await this.dropAndReopen();
      }
    }

    return changed ? changes : null;
  }
}

/**
 * Migrate the data stored in the JSONFile backend to the IDB Backend.
 *
 * Returns a promise which is resolved once the data migration has been
 * completed and the new IDB backend can be enabled.
 * Rejects if the data has been read successfully from the JSONFile backend
 * but it failed to be saved in the new IDB backend.
 *
 * This method is called only from the main process (where the file
 * can be opened).
 *
 * @param {Extension} extension
 *        The extension to migrate to the new IDB backend.
 * @param {nsIPrincipal} storagePrincipal
 *        The "internally reserved" extension storagePrincipal to be used to create
 *        the ExtensionStorageLocalIDB instance.
 */
async function migrateJSONFileData(extension, storagePrincipal) {
  let oldStoragePath;
  let oldStorageExists;
  let idbConn;
  let jsonFile;
  let hasEmptyIDB;
  let nonFatalError;
  let dataMigrateCompleted = false;
  let hasOldData = false;

  function abortIfShuttingDown() {
    if (extension.hasShutdown || Services.startup.shuttingDown) {
      throw new DataMigrationAbortedError("extension or app is shutting down");
    }
  }

  if (ExtensionStorageIDB.isMigratedExtension(extension)) {
    return;
  }

  try {
    abortIfShuttingDown();
    idbConn = await ExtensionStorageIDB.open(
      storagePrincipal,
      extension.hasPermission("unlimitedStorage")
    );
    abortIfShuttingDown();

    hasEmptyIDB = await idbConn.isEmpty();

    if (!hasEmptyIDB) {
      // If the IDB backend is enabled and there is data already stored in the IDB backend,
      // there is no "going back": any data that has not been migrated will be still on disk
      // but it is not going to be migrated anymore, it could be eventually used to allow
      // a user to manually retrieve the old data file).
      ExtensionStorageIDB.setMigratedExtensionPref(extension, true);
      return;
    }
  } catch (err) {
    extension.logWarning(
      `storage.local data migration cancelled, unable to open IDB connection: ${err.message}::${err.stack}`
    );

    ErrorsTelemetry.recordDataMigrationResult({
      backend: "JSONFile",
      extensionId: extension.id,
      error: err,
      histogramCategory: "failure",
    });

    throw err;
  }

  try {
    abortIfShuttingDown();

    oldStoragePath = lazy.ExtensionStorage.getStorageFile(extension.id);
    oldStorageExists = await IOUtils.exists(oldStoragePath).catch(fileErr => {
      // If we can't access the oldStoragePath here, then extension is also going to be unable to
      // access it, and so we log the error but we don't stop the extension from switching to
      // the IndexedDB backend.
      extension.logWarning(
        `Unable to access extension storage.local data file: ${fileErr.message}::${fileErr.stack}`
      );
      return false;
    });

    // Migrate any data stored in the JSONFile backend (if any), and remove the old data file
    // if the migration has been completed successfully.
    if (oldStorageExists) {
      // Do not load the old JSON file content if shutting down is already in progress.
      abortIfShuttingDown();

      Services.console.logStringMessage(
        `Migrating storage.local data for ${extension.policy.debugName}...`
      );

      jsonFile = await lazy.ExtensionStorage.getFile(extension.id);

      abortIfShuttingDown();

      const data = {};
      for (let [key, value] of jsonFile.data.entries()) {
        data[key] = value;
        hasOldData = true;
      }

      await idbConn.set(data);
      Services.console.logStringMessage(
        `storage.local data successfully migrated to IDB Backend for ${extension.policy.debugName}.`
      );
    }

    dataMigrateCompleted = true;
  } catch (err) {
    extension.logWarning(
      `Error on migrating storage.local data file: ${err.message}::${err.stack}`
    );

    if (oldStorageExists && !dataMigrateCompleted) {
      ErrorsTelemetry.recordDataMigrationResult({
        backend: "JSONFile",
        dataMigrated: dataMigrateCompleted,
        extensionId: extension.id,
        error: err,
        hasJSONFile: oldStorageExists,
        hasOldData,
        histogramCategory: "failure",
      });

      // If the data failed to be stored into the IndexedDB backend, then we clear the IndexedDB
      // backend to allow the extension to retry the migration on its next startup, and reject
      // the data migration promise explicitly (which would prevent the new backend
      // from being enabled for this session).
      await new Promise(resolve => {
        let req = Services.qms.clearStoragesForPrincipal(storagePrincipal);
        req.callback = resolve;
      });

      throw err;
    }

    // This error is not preventing the extension from switching to the IndexedDB backend,
    // but we may still want to know that it has been triggered and include it into the
    // telemetry data collected for the extension.
    nonFatalError = err;
  } finally {
    // Clear the jsonFilePromise cached by the ExtensionStorage.
    await lazy.ExtensionStorage.clearCachedFile(extension.id).catch(err => {
      extension.logWarning(err.message);
    });
  }

  // If the IDB backend has been enabled, rename the old storage.local data file, but
  // do not prevent the extension from switching to the IndexedDB backend if it fails.
  if (oldStorageExists && dataMigrateCompleted) {
    try {
      // Only migrate the file when it actually exists (e.g. the file name is not going to exist
      // when it is corrupted, because JSONFile internally rename it to `.corrupt`.
      if (await IOUtils.exists(oldStoragePath)) {
        const uniquePath = await IOUtils.createUniqueFile(
          PathUtils.parent(oldStoragePath),
          `${PathUtils.filename(oldStoragePath)}.migrated`
        );
        await IOUtils.move(oldStoragePath, uniquePath);
      }
    } catch (err) {
      nonFatalError = err;
      extension.logWarning(err.message);
    }
  }

  ExtensionStorageIDB.setMigratedExtensionPref(extension, true);

  ErrorsTelemetry.recordDataMigrationResult({
    backend: "IndexedDB",
    dataMigrated: dataMigrateCompleted,
    extensionId: extension.id,
    error: nonFatalError,
    hasJSONFile: oldStorageExists,
    hasOldData,
    histogramCategory: "success",
  });
}

/**
 * This ExtensionStorage class implements a backend for the storage.local API which
 * uses IndexedDB to store the data.
 */
export var ExtensionStorageIDB = {
  BACKEND_ENABLED_PREF,
  IDB_MIGRATED_PREF_BRANCH,

  // Map<extension-id, Set<Function>>
  listeners: new Map(),

  // Keep track if the IDB backend has been selected or not for a running extension
  // (the selected backend should never change while the extension is running, even if the
  // related preference has been changed in the meantime):
  //
  //   WeakMap<extension -> Promise<boolean>
  selectedBackendPromises: new WeakMap(),

  init() {
    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "isBackendEnabled",
      BACKEND_ENABLED_PREF,
      false
    );
  },

  isMigratedExtension(extension) {
    return Services.prefs.getBoolPref(
      `${IDB_MIGRATED_PREF_BRANCH}.${extension.id}`,
      false
    );
  },

  setMigratedExtensionPref(extension, val) {
    Services.prefs.setBoolPref(
      `${IDB_MIGRATED_PREF_BRANCH}.${extension.id}`,
      !!val
    );
  },

  clearMigratedExtensionPref(extensionId) {
    Services.prefs.clearUserPref(`${IDB_MIGRATED_PREF_BRANCH}.${extensionId}`);
  },

  getStoragePrincipal(extension) {
    return extension.createPrincipal(extension.baseURI, {
      userContextId: WEBEXT_STORAGE_USER_CONTEXT_ID,
    });
  },

  /**
   * Select the preferred backend and return a promise which is resolved once the
   * selected backend is ready to be used (e.g. if the extension is switching from
   * the old JSONFile storage to the new IDB backend, any previously stored data will
   * be migrated to the backend before the promise is resolved).
   *
   * This method is called from both the main and child (content or extension) processes:
   * - an extension child context will call this method lazily, when the browser.storage.local
   *   is being used for the first time, and it will result into asking the main process
   *   to call the same method in the main process
   * - on the main process side, it will check if the new IDB backend can be used (and if it can,
   *   it will migrate any existing data into the new backend, which needs to happen in the
   *   main process where the file can directly be accessed)
   *
   * The result will be cached while the extension is still running, and so an extension
   * child context is going to ask the main process only once per child process, and on the
   * main process side the backend selection and data migration will happen only once.
   *
   * @param {import("ExtensionPageChild.sys.mjs").ExtensionBaseContextChild} context
   *        The extension context that is selecting the storage backend.
   *
   * @returns {Promise<object>}
   *          Returns a promise which resolves to an object which provides a
   *          `backendEnabled` boolean property, and if it is true the extension should use
   *          the IDB backend and the object also includes a `storagePrincipal` property
   *          of type nsIPrincipal, otherwise `backendEnabled` will be false when the
   *          extension should use the old JSONFile backend (e.g. because the IDB backend has
   *          not been enabled from the preference).
   */
  selectBackend(context) {
    const { extension } = context;

    if (!this.selectedBackendPromises.has(extension)) {
      let promise;

      if (context.childManager) {
        return context.childManager
          .callParentAsyncFunction("storage.local.IDBBackend.selectBackend", [])
          .then(parentResult => {
            let result;

            if (!parentResult.backendEnabled) {
              result = { backendEnabled: false };
            } else {
              result = {
                ...parentResult,
                // In the child process, we need to deserialize the storagePrincipal
                // from the StructuredCloneHolder used to send it across the processes.
                storagePrincipal: parentResult.storagePrincipal.deserialize(
                  this,
                  true
                ),
              };
            }

            // Cache the result once we know that it has been resolved. The promise returned by
            // context.childManager.callParentAsyncFunction will be dead when context.cloneScope
            // is destroyed. To keep a promise alive in the cache, we wrap the result in an
            // independent promise.
            this.selectedBackendPromises.set(
              extension,
              Promise.resolve(result)
            );

            return result;
          });
      }

      // If migrating to the IDB backend is not enabled by the preference, then we
      // don't need to migrate any data and the new backend is not enabled.
      if (!this.isBackendEnabled) {
        promise = Promise.resolve({ backendEnabled: false });
      } else {
        // In the main process, lazily create a storagePrincipal isolated in a
        // reserved user context id (its purpose is ensuring that the IndexedDB storage used
        // by the browser.storage.local API is not directly accessible from the extension code).
        const storagePrincipal = this.getStoragePrincipal(extension);

        // Serialize the nsIPrincipal object into a StructuredCloneHolder related to the privileged
        // js global, ready to be sent to the child processes.
        const serializedPrincipal = new StructuredCloneHolder(
          "ExtensionStorageIDB/selectBackend/serializedPrincipal",
          null,
          storagePrincipal,
          this
        );

        promise = migrateJSONFileData(extension, storagePrincipal)
          .then(() => {
            extension.setSharedData("storageIDBBackend", true);
            extension.setSharedData("storageIDBPrincipal", storagePrincipal);
            Services.ppmm.sharedData.flush();
            return {
              backendEnabled: true,
              storagePrincipal: serializedPrincipal,
            };
          })
          .catch(err => {
            // If the data migration promise is rejected, the old data has been read
            // successfully from the old JSONFile backend but it failed to be saved
            // into the IndexedDB backend (which is likely unrelated to the kind of
            // data stored and more likely a general issue with the IndexedDB backend)
            // In this case we keep the JSONFile backend enabled for this session
            // and we will retry to migrate to the IDB Backend the next time the
            // extension is being started.
            // TODO Bug 1465129: This should be a very unlikely scenario, some telemetry
            // data about it may be useful.
            extension.logWarning(
              "JSONFile backend is being kept enabled by an unexpected " +
                `IDBBackend failure: ${err.message}::${err.stack}`
            );
            extension.setSharedData("storageIDBBackend", false);
            Services.ppmm.sharedData.flush();

            return { backendEnabled: false };
          });
      }

      this.selectedBackendPromises.set(extension, promise);
    }

    return this.selectedBackendPromises.get(extension);
  },

  persist(storagePrincipal) {
    return new Promise((resolve, reject) => {
      const request = Services.qms.persist(storagePrincipal);
      request.callback = () => {
        if (request.resultCode === Cr.NS_OK) {
          resolve();
        } else {
          reject(
            new Error(
              `Failed to persist storage for principal: ${storagePrincipal.originNoSuffix}`
            )
          );
        }
      };
    });
  },

  /**
   * Open a connection to the IDB storage.local db for a given extension.
   * given extension.
   *
   * @param {nsIPrincipal} storagePrincipal
   *        The "internally reserved" extension storagePrincipal to be used to create
   *        the ExtensionStorageLocalIDB instance.
   * @param {boolean} persisted
   *        A boolean which indicates if the storage should be set into persistent mode.
   *
   * @returns {Promise<ExtensionStorageLocalIDB>}
   *          Return a promise which resolves to the opened IDB connection.
   */
  open(storagePrincipal, persisted) {
    if (!storagePrincipal) {
      return Promise.reject(new Error("Unexpected empty principal"));
    }
    let setPersistentMode = persisted
      ? this.persist(storagePrincipal)
      : Promise.resolve();
    return setPersistentMode.then(() =>
      ExtensionStorageLocalIDB.openForPrincipal(storagePrincipal)
    );
  },

  /**
   * Ensure that an error originated from the ExtensionStorageIDB methods is normalized
   * into an ExtensionError (e.g. DataCloneError and QuotaExceededError instances raised
   * from the internal IndexedDB operations have to be converted into an ExtensionError
   * to be accessible to the extension code).
   *
   * @typedef {import("ExtensionUtils.sys.mjs").ExtensionError} ExtensionError
   *
   * @param {object} params
   * @param {Error|ExtensionError|DOMException} params.error
   *        The error object to normalize.
   * @param {string} params.extensionId
   *        The id of the extension that was executing the storage.local method.
   * @param {string} params.storageMethod
   *        The storage method being executed when the error has been thrown
   *        (used to keep track of the unexpected error incidence in telemetry).
   *
   * @returns {ExtensionError}
   *          Return an ExtensionError error instance.
   */
  normalizeStorageError({ error, extensionId, storageMethod }) {
    const { ExtensionError } = lazy.ExtensionUtils;

    if (error instanceof ExtensionError) {
      // @ts-ignore (will go away after `lazy` is properly typed)
      return error;
    }

    let errorMessage;

    if (DOMException.isInstance(error)) {
      switch (error.name) {
        case "DataCloneError":
          errorMessage = String(error);
          break;
        case "QuotaExceededError":
          errorMessage = `${error.name}: storage.local API call exceeded its quota limitations.`;
          break;
      }
    }

    if (!errorMessage) {
      Cu.reportError(error);

      errorMessage = "An unexpected error occurred";

      ErrorsTelemetry.recordStorageLocalError({
        error,
        extensionId,
        storageMethod,
      });
    }

    return new ExtensionError(errorMessage);
  },

  addOnChangedListener(extensionId, listener) {
    let listeners = this.listeners.get(extensionId) || new Set();
    listeners.add(listener);
    this.listeners.set(extensionId, listeners);
  },

  removeOnChangedListener(extensionId, listener) {
    let listeners = this.listeners.get(extensionId);
    listeners.delete(listener);
  },

  notifyListeners(extensionId, changes) {
    let listeners = this.listeners.get(extensionId);
    if (listeners) {
      for (let listener of listeners) {
        listener(changes);
      }
    }
  },

  hasListeners(extensionId) {
    let listeners = this.listeners.get(extensionId);
    return listeners && listeners.size > 0;
  },
};

ExtensionStorageIDB.init();
