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

"use strict";

ChromeUtils.defineESModuleGetters(this, {
  ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs",
  ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs",
  ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs",
});

// Wrap a storage operation in a TelemetryStopWatch.
async function measureOp(telemetryMetric, extension, fn) {
  const stopwatchKey = {};
  telemetryMetric.stopwatchStart(extension, stopwatchKey);
  try {
    let result = await fn();
    telemetryMetric.stopwatchFinish(extension, stopwatchKey);
    return result;
  } catch (err) {
    telemetryMetric.stopwatchCancel(extension, stopwatchKey);
    throw err;
  }
}

this.storage = class extends ExtensionAPI {
  getLocalFileBackend(context, { deserialize, serialize }) {
    return {
      get(keys) {
        return context.childManager
          .callParentAsyncFunction("storage.local.JSONFileBackend.get", [
            serialize(keys),
          ])
          .then(deserialize);
      },
      set(items) {
        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, { fireOnChanged, storagePrincipal }) {
    let dbPromise;
    async function getDB() {
      if (dbPromise) {
        return dbPromise;
      }

      const persisted = context.extension.hasPermission("unlimitedStorage");
      dbPromise = ExtensionStorageIDB.open(storagePrincipal, persisted).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(
          ExtensionTelemetry.storageLocalGetIdb,
          context.extension,
          async () => {
            const db = await getDB();
            return db.get(keys);
          }
        );
      },
      set(items) {
        function serialize(name, anonymizedName, value) {
          return ExtensionStorage.serialize(
            `set/${context.extension.id}/${name}`,
            `set/${context.extension.id}/${anonymizedName}`,
            value
          );
        }

        return measureOp(
          ExtensionTelemetry.storageLocalSetIdb,
          context.extension,
          async () => {
            const db = await getDB();
            const changes = await db.set(items, {
              serialize,
            });

            if (changes) {
              fireOnChanged(changes);
            }
          }
        );
      },
      async remove(keys) {
        const db = await getDB();
        const changes = await db.remove(keys);

        if (changes) {
          fireOnChanged(changes);
        }
      },
      async clear() {
        const db = await getDB();
        const changes = await db.clear(context.extension);

        if (changes) {
          fireOnChanged(changes);
        }
      },
    };
  }

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

    // onChangedName is "storage.onChanged", "storage.sync.onChanged", etc.
    function makeOnChangedEventTarget(onChangedName) {
      return new EventManager({
        context,
        name: onChangedName,
        // Parent event already resets idle if needed, no need to do it here.
        resetIdleOnEvent: false,
        register: fire => {
          let onChanged = (data, area) => {
            let changes = new context.cloneScope.Object();
            for (let [key, value] of Object.entries(data)) {
              changes[key] = deserialize(value);
            }
            if (area) {
              // storage.onChanged includes the area.
              fire.raw(changes, area);
            } else {
              // StorageArea.onChanged doesn't include the area.
              fire.raw(changes);
            }
          };

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

    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.sys.mjs. 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;
    }

    function fireOnChanged(changes) {
      // This call is used (by the storage.local API methods for the IndexedDB backend) to fire a storage.onChanged event,
      // it uses the underlying message manager since the child context (or its ProxyContentParent counterpart
      // running in the main process) may be gone by the time we call this, and so we can't use the childManager
      // abstractions (e.g. callParentAsyncFunction or callParentFunctionNoReturn).
      Services.cpmm.sendAsyncMessage(
        `Extension:StorageLocalOnChanged:${extension.uuid}`,
        changes
      );
    }

    // If the selected backend for the extension is not known yet, we have to lazily detect it
    // by asking to the main process (as soon as the storage.local API has been accessed for
    // the first time).
    const getStorageLocalBackend = async () => {
      const { backendEnabled, storagePrincipal } =
        await ExtensionStorageIDB.selectBackend(context);

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

      return this.getLocalIDBBackend(context, {
        storagePrincipal,
        fireOnChanged,
        serialize,
      });
    };

    // Synchronously select the backend if it is already known.
    let selectedBackend;

    const useStorageIDBBackend = extension.getSharedData("storageIDBBackend");
    if (useStorageIDBBackend === false) {
      selectedBackend = this.getLocalFileBackend(context, {
        deserialize,
        serialize,
      });
    } else if (useStorageIDBBackend === true) {
      selectedBackend = this.getLocalIDBBackend(context, {
        storagePrincipal: extension.getSharedData("storageIDBPrincipal"),
        fireOnChanged,
        serialize,
      });
    }

    let promiseStorageLocalBackend;

    // Generate the backend-agnostic local API wrapped methods.
    const local = {
      onChanged: makeOnChangedEventTarget("storage.local.onChanged"),
    };
    for (let method of ["get", "set", "remove", "clear"]) {
      local[method] = async function (...args) {
        try {
          // 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;
                }
              );
            }

            // If the storage.local method is not 'get' (which doesn't change any of the stored data),
            // fall back to call the method in the parent process, so that it can be completed even
            // if this context has been destroyed in the meantime.
            if (method !== "get") {
              // Let the outer try to catch rejections returned by the backend methods.
              try {
                const result =
                  await context.childManager.callParentAsyncFunction(
                    "storage.local.callMethodInParentProcess",
                    [method, args]
                  );
                return result;
              } catch (err) {
                // Just return the rejection as is, the error has been normalized in the
                // parent process by callMethodInParentProcess and the original error
                // logged in the browser console.
                return Promise.reject(err);
              }
            }

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

          // Let the outer try to catch rejections returned by the backend methods.
          const result = await selectedBackend[method](...args);
          return result;
        } catch (err) {
          throw ExtensionStorageIDB.normalizeStorageError({
            error: err,
            extensionId: extension.id,
            storageMethod: method,
          });
        }
      };
    }

    return {
      storage: {
        local,

        session: {
          async get(keys) {
            return deserialize(
              await context.childManager.callParentAsyncFunction(
                "storage.session.get",
                [serialize(keys)]
              )
            );
          },
          set(items) {
            return context.childManager.callParentAsyncFunction(
              "storage.session.set",
              [serialize(items), { callerLocation: context.getCaller() }]
            );
          },
          onChanged: makeOnChangedEventTarget("storage.session.onChanged"),
        },

        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]
            );
          },
          onChanged: makeOnChangedEventTarget("storage.sync.onChanged"),
        },

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

          onChanged: makeOnChangedEventTarget("storage.managed.onChanged"),
        },

        onChanged: makeOnChangedEventTarget("storage.onChanged"),
      },
    };
  }
};
