"use strict";

ChromeUtils.defineModuleGetter(this, "ExtensionStorage",
                               "resource://gre/modules/ExtensionStorage.jsm");
ChromeUtils.defineModuleGetter(this, "ExtensionStorageIDB",
                               "resource://gre/modules/ExtensionStorageIDB.jsm");
ChromeUtils.defineModuleGetter(this, "TelemetryStopwatch",
                               "resource://gre/modules/TelemetryStopwatch.jsm");

// Telemetry histogram keys for the JSONFile backend.
const storageGetHistogram = "WEBEXT_STORAGE_LOCAL_GET_MS";
const storageSetHistogram = "WEBEXT_STORAGE_LOCAL_SET_MS";
// Telemetry  histogram keys for the IndexedDB backend.
const storageGetIDBHistogram = "WEBEXT_STORAGE_LOCAL_IDB_GET_MS";
const storageSetIDBHistogram = "WEBEXT_STORAGE_LOCAL_IDB_SET_MS";

// Wrap a storage operation in a TelemetryStopWatch.
async function measureOp(histogram, fn) {
  const stopwatchKey = {};
  TelemetryStopwatch.start(histogram, stopwatchKey);
  try {
    let result = await fn();
    TelemetryStopwatch.finish(histogram, stopwatchKey);
    return result;
  } catch (err) {
    TelemetryStopwatch.cancel(histogram, stopwatchKey);
    throw err;
  }
}

this.storage = class extends ExtensionAPI {
  getLocalFileBackend(context, {deserialize, serialize}) {
    return {
      get(keys) {
        return measureOp(storageGetHistogram, () => {
          return context.childManager.callParentAsyncFunction(
            "storage.local.JSONFileBackend.get",
            [serialize(keys)]).then(deserialize);
        });
      },
      set(items) {
        return measureOp(storageSetHistogram, () => {
          return context.childManager.callParentAsyncFunction(
            "storage.local.JSONFileBackend.set", [serialize(items)]);
        });
      },
      remove(keys) {
        return context.childManager.callParentAsyncFunction(
          "storage.local.JSONFileBackend.remove", [serialize(keys)]);
      },
      clear() {
        return context.childManager.callParentAsyncFunction(
          "storage.local.JSONFileBackend.clear", []);
      },
    };
  }

  getLocalIDBBackend(context, {hasParentListeners, serialize, storagePrincipal}) {
    let dbPromise;
    async function getDB() {
      if (dbPromise) {
        return dbPromise;
      }

      dbPromise = ExtensionStorageIDB.open(storagePrincipal).catch(err => {
        // Reset the cached promise if it has been rejected, so that the next
        // API call is going to retry to open the DB.
        dbPromise = null;
        throw err;
      });

      return dbPromise;
    }

    return {
      get(keys) {
        return measureOp(storageGetIDBHistogram, async () => {
          const db = await getDB();
          return db.get(keys);
        });
      },
      set(items) {
        return measureOp(storageSetIDBHistogram, async () => {
          const db = await getDB();
          const changes = await db.set(items, {
            serialize: ExtensionStorage.serialize,
          });

          if (!changes) {
            return;
          }

          const hasListeners = await hasParentListeners();
          if (hasListeners) {
            await context.childManager.callParentAsyncFunction(
              "storage.local.IDBBackend.fireOnChanged", [changes]);
          }
        });
      },
      async remove(keys) {
        const db = await getDB();
        const changes = await db.remove(keys);

        if (!changes) {
          return;
        }

        const hasListeners = await hasParentListeners();
        if (hasListeners) {
          await context.childManager.callParentAsyncFunction(
            "storage.local.IDBBackend.fireOnChanged", [changes]);
        }
      },
      async clear() {
        const db = await getDB();
        const changes = await db.clear(context.extension);

        if (!changes) {
          return;
        }

        const hasListeners = await hasParentListeners();
        if (hasListeners) {
          await context.childManager.callParentAsyncFunction(
            "storage.local.IDBBackend.fireOnChanged", [changes]);
        }
      },
    };
  }

  getAPI(context) {
    const {extension} = context;
    const serialize = ExtensionStorage.serializeForContext.bind(null, context);
    const deserialize = ExtensionStorage.deserializeForContext.bind(null, context);

    function sanitize(items) {
      // The schema validator already takes care of arrays (which are only allowed
      // to contain strings). Strings and null are safe values.
      if (typeof items != "object" || items === null || Array.isArray(items)) {
        return items;
      }
      // If we got here, then `items` is an object generated by `ObjectType`'s
      // `normalize` method from Schemas.jsm. The object returned by `normalize`
      // lives in this compartment, while the values live in compartment of
      // `context.contentWindow`. The `sanitize` method runs with the principal
      // of `context`, so we cannot just use `ExtensionStorage.sanitize` because
      // it is not allowed to access properties of `items`.
      // So we enumerate all properties and sanitize each value individually.
      let sanitized = {};
      for (let [key, value] of Object.entries(items)) {
        sanitized[key] = ExtensionStorage.sanitize(value, context);
      }
      return sanitized;
    }

    // Detect the actual storage.local enabled backend for the extension (as soon as the
    // storage.local API has been accessed for the first time).
    let promiseStorageLocalBackend;
    const getStorageLocalBackend = async () => {
      const {
        backendEnabled,
        storagePrincipal,
      } = await ExtensionStorageIDB.selectBackend(context);

      if (!backendEnabled) {
        return this.getLocalFileBackend(context, {deserialize, serialize});
      }

      return this.getLocalIDBBackend(context, {
        storagePrincipal,
        hasParentListeners() {
          // We spare a good amount of memory if there are no listeners around
          // (e.g. because they have never been subscribed or they have been removed
          // in the meantime).
          return context.childManager.callParentAsyncFunction(
            "storage.local.IDBBackend.hasListeners", []);
        },
        serialize,
      });
    };

    // Synchronously select the backend if the IndexedDB backend is not enabled.
    let selectedBackend;
    if (extension.storageIDBBackend === false) {
      selectedBackend = this.getLocalFileBackend(context, {deserialize, serialize});
    }

    // Generate the backend-agnostic local API wrapped methods.
    const local = {};
    for (let method of ["get", "set", "remove", "clear"]) {
      local[method] = async function(...args) {
        // Discover the selected backend if it is not known yet.
        if (!selectedBackend) {
          if (!promiseStorageLocalBackend) {
            promiseStorageLocalBackend = getStorageLocalBackend().catch(err => {
              // Clear the cached promise if it has been rejected.
              promiseStorageLocalBackend = null;
              throw err;
            });
          }

          // Get the selected backend and cache it for the next API calls from this context.
          selectedBackend = await promiseStorageLocalBackend;
        }

        return selectedBackend[method](...args);
      };
    }

    return {
      storage: {
        local,

        sync: {
          get(keys) {
            keys = sanitize(keys);
            return context.childManager.callParentAsyncFunction("storage.sync.get", [
              keys,
            ]);
          },
          set(items) {
            items = sanitize(items);
            return context.childManager.callParentAsyncFunction("storage.sync.set", [
              items,
            ]);
          },
        },

        managed: {
          get(keys) {
            return context.childManager.callParentAsyncFunction("storage.managed.get", [
              serialize(keys),
            ]).then(deserialize);
          },
          set(items) {
            return Promise.reject({message: "storage.managed is read-only"});
          },
          remove(keys) {
            return Promise.reject({message: "storage.managed is read-only"});
          },
          clear() {
            return Promise.reject({message: "storage.managed is read-only"});
          },
        },

        onChanged: new EventManager({
          context,
          name: "storage.onChanged",
          register: fire => {
            let onChanged = (data, area) => {
              let changes = new context.cloneScope.Object();
              for (let [key, value] of Object.entries(data)) {
                changes[key] = deserialize(value);
              }
              fire.raw(changes, area);
            };

            let parent = context.childManager.getParentEvent("storage.onChanged");
            parent.addListener(onChanged);
            return () => {
              parent.removeListener(onChanged);
            };
          },
        }).api(),
      },
    };
  }
};
