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

import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
import { ExtensionTaskScheduler } from "resource://gre/modules/ExtensionTaskScheduler.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  SQLiteKeyValueService: "resource://gre/modules/kvstore.sys.mjs",
});

/**
 * User scripts are internally represented as the options to pass to the
 * WebExtensionContentScript constructor in the child.
 *
 * @typedef {WebExtensionContentScriptInit} InternalUserScript
 *
 * The internal representation is derived from the public representation as
 * defined in user_scripts.json.
 *
 * @typedef {object} RegisteredUserScript
 */

/**
 * WorldProperties as received from userScripts.configureWorld.
 *
 * @typedef {object} WorldProperties
 * @property {string} worldId
 * @property {string|null} csp
 */

/**
 * User scripts are stored in the following format in a SKV database. A SKV
 * database is a KeyValue store where the keys are ordered lexicographically.
 *
 * Common operations are querying/removing from extensions.
 * UserScript IDs are arbitrary strings that do not start with _.
 *
 * <extensionId>/_script_/<script_id>
 */
class Store {
  async _init() {
    const storePath = PathUtils.join(PathUtils.profileDir, "extension-store");
    await IOUtils.makeDirectory(storePath);
    // TODO: Add recovery (rename) when implemented in skv (bug 1913238).
    this._store = await lazy.SQLiteKeyValueService.getOrCreate(
      storePath,
      "userScripts"
    );
  }

  lazyInit() {
    if (!this._initPromise) {
      this._initPromise = this._init();
    }

    return this._initPromise;
  }

  _uninitForTesting() {
    this._store = null;
    this._initPromise = null;
  }

  /**
   * Retrieve all pairs, optionally with a range to select only the key-value
   * pairs from keys between fromKey (inclusive) and toKey (exclusive), with
   * the keys in lexicographical order.
   *
   * @param {string} [fromKey]
   * @param {string} [toKey]
   * @returns {Promise<Array<[string,*]>>}
   */
  async getAllEntries(fromKey, toKey) {
    await this.lazyInit();
    const pairs = [];
    const enumerator = await this._store.enumerate(fromKey, toKey);
    while (enumerator.hasMoreElements()) {
      const { key, value } = enumerator.getNext();
      pairs.push([key, JSON.parse(value)]);
    }
    return pairs;
  }

  /**
   * Write all pairs. If the value is the null value, the key is deleted
   * Otherwise the values must be JSON-serializable and are written.
   *
   * TODO: Drop when skv supports JSON (bug 1919618).
   *
   * @param {Array<[string,*]>} pairs
   */
  async writeMany(pairs) {
    pairs = pairs.map(([k, v]) => {
      return [k, v == null ? null : JSON.stringify(v)];
    });
    await this.lazyInit();
    return this._store.writeMany(pairs);
  }

  async deleteRange(fromKey, toKey) {
    await this.lazyInit();
    return this._store.deleteRange(fromKey, toKey);
  }
}

const store = new Store();

const userScriptTaskQueues = new ExtensionTaskScheduler();

/**
 * Manages registered user scripts. At initialization, user scripts are loaded
 * from a database on disk. They are converted to an internal format and shared
 * with content processes with sharedData IPC (see makeInternalContentScript),
 * stored in extension.registeredContentScripts.
 *
 * The in-memory registration in extension.registeredContentScripts is the main
 * representation of registered user scripts. The database is only accessed at
 * startup (to load previous registrations) and on modifications.
 *
 * User script code strings are converted to blob:-URLs for use by content
 * processes, and also managed here.
 */
class UserScriptsManager {
  constructor(extension) {
    extension.callOnClose(this);

    this.extension = extension;
    this.blobUrls = new Set();

    // Mapping from public script ID to the internal scriptId. This scriptId
    // is used by the content process to identify scripts when updating the
    // internal representation of the script via IPC, and also used as the key
    // in the extension.registeredContentScripts map.
    this.scriptIdsMap = new Map();

    this.worldConfigs = new Map();
  }

  runReadTask(callback) {
    return userScriptTaskQueues.runReadTask(this.extension.id, callback);
  }

  runWriteTask(callback) {
    return userScriptTaskQueues.runWriteTask(this.extension.id, callback);
  }

  close() {
    // Note: when an extension unloads, the content process clears registered
    // scripts. The parent process will GC extension.registeredContentScripts
    // eventually.
    this.scriptIdsMap.clear();
    this.#revokeUnusedBlobUrls();
    this.extension.userScriptsManager = null;
  }

  #makeDbKey(publicId = "", type = "_script_") {
    return `${this.extension.id}/${type}/${publicId}`;
  }

  async initializeFromDatabase() {
    let worldConfigInitPromise = this.#initializeWorldsFromDatabase();
    const dbScriptEntries = await store.getAllEntries(
      `${this.extension.id}/_script_/`,
      `${this.extension.id}/_script_0`
    );

    // Init worlds before registering scripts, to make sure that if the scripts
    // are injected, that thet have the expected world configuration.
    await worldConfigInitPromise;

    // The database returns them in lexicographical order, which enables
    // extensions to customize the order, as desired:
    // https://github.com/w3c/webextensions/issues/606
    const publicScripts = dbScriptEntries.map(([_m, script]) => script);
    await this.registerNewScripts(publicScripts, /* isReadFromDB */ true);
  }

  /**
   * Register user scripts internally. Also updates the database, unless isAPI
   * is false. Caller should make sure that each script has a unique ID.
   *
   * @param {RegisteredUserScript[]} publicScripts
   * @param {boolean} [isReadFromDB=false]
   * @throws {ExtensionError} if any of the scripts are invalid.
   */
  async registerNewScripts(publicScripts, isReadFromDB = false) {
    if (!isReadFromDB) {
      // Additional validation when userScripts.register() was called.
      // (Presumably the data read from the database is valid.)
      for (let publicScript of publicScripts) {
        if (this.scriptIdsMap.has(publicScript.id)) {
          throw new ExtensionUtils.ExtensionError(
            `User script with id "${publicScript.id}" is already registered.`
          );
        }
        this.#ensureValidUserScript(publicScript);
      }
    }

    // All valid, we don't expect code below to throw.

    let newInternalScripts = [];
    for (let publicScript of publicScripts) {
      let script = this.#makeInternalUserScript(publicScript);
      let scriptId = ExtensionUtils.getUniqueId();
      this.scriptIdsMap.set(publicScript.id, scriptId);
      this.extension.registeredContentScripts.set(scriptId, script);
      newInternalScripts.push({ scriptId, options: script });
    }

    this.extension.updateContentScripts();

    let promise;
    if (this.extension.shouldSendSharedData()) {
      // Broadcast changes to existing processes if we are past startup.
      promise = this.extension.broadcast("Extension:RegisterContentScripts", {
        id: this.extension.id,
        scripts: newInternalScripts,
      });
    }

    if (!isReadFromDB) {
      await store.writeMany(
        publicScripts.map(script => [this.#makeDbKey(script.id), script])
      );
    }

    await promise;
  }

  /**
   * Fully or partially update an existing script. Partial refers to the script
   * description; when this method returns either all scripts have been updated,
   * or none. Caller should make sure that each script has a unique ID.
   *
   * @param {RegisteredUserScript[]} partialPublicScripts
   * @throws {ExtensionError} if any of the updated script are invalid.
   */
  async updateScripts(partialPublicScripts) {
    let scriptsToUpdate = [];
    try {
      for (let partialPublicScript of partialPublicScripts) {
        let publicId = partialPublicScript.id;
        let scriptId = this.scriptIdsMap.get(publicId);
        let oldScript = this.extension.registeredContentScripts.get(scriptId);
        if (!oldScript) {
          throw new ExtensionUtils.ExtensionError(
            `User script with id "${publicId}" does not exist.`
          );
        }
        let newScript = this.#makeInternalUserScript(
          partialPublicScript,
          oldScript
        );
        this.#ensureValidUserScript(newScript);
        scriptsToUpdate.push({ scriptId, options: newScript });
      }
    } catch (e) {
      // The above #makeInternalUserScript() call may create new blob:-URLs,
      // but if a validation error occurred, they are never going to be used,
      // so we can revoke them.
      this.#revokeUnusedBlobUrls();
      throw e;
    }

    // All valid, we don't expect code below to throw.

    for (const { scriptId, options } of scriptsToUpdate) {
      this.extension.registeredContentScripts.set(scriptId, options);
    }
    this.extension.updateContentScripts();
    let promise = this.extension.broadcast("Extension:UpdateContentScripts", {
      id: this.extension.id,
      scripts: scriptsToUpdate,
    });

    // To save in the database, we need the stable public representation. This
    // is async because the code URLs have to be resolved to code strings.
    let publicScripts = await Promise.all(
      scriptsToUpdate.map(({ options }, i) => {
        let publicId = partialPublicScripts[i].id;
        return this.#makePublicUserScript(publicId, options);
      })
    );
    await store.writeMany(
      publicScripts.map(script => [this.#makeDbKey(script.id), script])
    );
    await promise;

    // When succeeded, we may have to revoke blob:-URLs of old scripts. Do this
    // last, after the content processes have processed the update, so that we
    // know for sure that nothing is going to use the old blob:-URLs any more.
    this.#revokeUnusedBlobUrls();
  }

  /**
   * @param {string[]} [publicScriptIds]
   */
  async unregisterScripts(publicScriptIds) {
    publicScriptIds = this.#filterExistingPublicScriptIds(publicScriptIds);
    let scriptIds = [];
    for (let publicId of publicScriptIds) {
      let scriptId = this.scriptIdsMap.get(publicId);
      this.extension.registeredContentScripts.delete(scriptId);
      this.scriptIdsMap.delete(publicId);
      scriptIds.push(scriptId);
    }
    this.extension.updateContentScripts();
    let promise = this.extension.broadcast(
      "Extension:UnregisterContentScripts",
      { id: this.extension.id, scriptIds }
    );
    await store.writeMany(
      publicScriptIds.map(id => [this.#makeDbKey(id), null])
    );
    await promise;
    // Revoke once the content process has acknowledged unregistration, so that
    // they are not going to use the blob:-URL any more.
    this.#revokeUnusedBlobUrls();
  }

  /**
   * @param {string[]} [publicScriptIds]
   */
  async getScripts(publicScriptIds) {
    publicScriptIds = this.#filterExistingPublicScriptIds(publicScriptIds);
    return Promise.all(
      publicScriptIds.map(publicId => {
        let scriptId = this.scriptIdsMap.get(publicId);
        return this.#makePublicUserScript(
          publicId,
          this.extension.registeredContentScripts.get(scriptId)
        );
      })
    );
  }

  async #initializeWorldsFromDatabase() {
    const dbWorldEntries = await store.getAllEntries(
      `${this.extension.id}/_world_/`,
      `${this.extension.id}/_world_0`
    );

    const allProperties = dbWorldEntries.map(([, properties]) => properties);
    for (let properties of allProperties) {
      this.worldConfigs.set(properties.worldId, properties);
    }
    this.extension.setSharedData("userScriptsWorldConfigs", this.worldConfigs);
    if (this.extension.shouldSendSharedData()) {
      // Broadcast changes to existing processes if we are past startup.
      await this.extension.broadcast("Extension:UpdateUserScriptWorlds", {
        id: this.extension.id,
        reset: null,
        update: allProperties,
      });
    }
  }

  /**
   * @param {WorldProperties} properties
   */
  async configureWorld(properties) {
    const worldId = properties.worldId;
    this.worldConfigs.set(worldId, properties);

    this.extension.setSharedData("userScriptsWorldConfigs", this.worldConfigs);
    await this.extension.broadcast("Extension:UpdateUserScriptWorlds", {
      id: this.extension.id,
      reset: null,
      update: [properties],
    });
    await store.writeMany([[this.#makeDbKey(worldId, "_world_"), properties]]);
  }

  /**
   * @param {string} worldId
   */
  async resetWorldConfiguration(worldId) {
    if (!this.worldConfigs.delete(worldId)) {
      return;
    }

    this.extension.setSharedData("userScriptsWorldConfigs", this.worldConfigs);
    await this.extension.broadcast("Extension:UpdateUserScriptWorlds", {
      id: this.extension.id,
      reset: [worldId],
      update: null,
    });
    await store.writeMany([[this.#makeDbKey(worldId, "_world_"), null]]);
  }

  /** @returns {WorldProperties[]} */
  async getWorldConfigurations() {
    return Array.from(this.worldConfigs.values());
  }

  // userScripts.getScripts & userScripts.unregister accepts an ids filter.
  // It may contain non-existing IDs..
  #filterExistingPublicScriptIds(publicScriptIds) {
    if (publicScriptIds) {
      return publicScriptIds.filter(id => this.scriptIdsMap.has(id));
    }
    return Array.from(this.scriptIdsMap.keys());
  }

  #getJsPathUrlForCode(code) {
    // TODO: Consider data:-URLs for small chunks of code?
    let blobUrl = URL.createObjectURL(
      new Blob([code], { type: "text/javascript" })
    );
    this.blobUrls.add(blobUrl);
    return blobUrl;
  }

  #revokeUnusedBlobUrls() {
    let urlsToKeep = new Set();
    for (let scriptId of this.scriptIdsMap.values()) {
      let internalScript =
        this.extension.registeredContentScripts.get(scriptId);
      for (let jsPath of internalScript.jsPaths) {
        if (jsPath.startsWith("blob:")) {
          urlsToKeep.add(jsPath);
        }
      }
    }
    for (let blobUrl of this.blobUrls) {
      if (!urlsToKeep.has(blobUrl)) {
        URL.revokeObjectURL(blobUrl);
        this.blobUrls.delete(blobUrl);
      }
    }
  }

  // static because the functionality may be needed even without an Extension,
  // e.g. from clearOnUninstall.
  static async deleteAll(extensionId) {
    await store.deleteRange(`${extensionId}/`, `${extensionId}0`);
  }

  /**
   * Converts the public representation of a user script to the internal format
   * as expected by the WebExtensionContentScript constructor, and shared with
   * all content processes via sharedData IPC. The public representation can be
   * from the userScripts.register or userScripts.update APIs, or the database.
   *
   * In case of userScripts.update, the previous representaton of the internal
   * script can be passed via the oldScript parameter to allow for the object
   * to be completed.
   *
   * @param {RegisteredUserScript | object} publicScript
   *        A complete or partial representation of a user script, in the
   *        RegisteredUserScript format. Must be a complete representation if
   *        oldScript is not specified.
   * @param {InternalUserScript} [oldScript]
   *        The existing internal representation of a script, if |publicScript|
   *        is a partial update.
   *
   * @returns {InternalUserScript}
   */
  #makeInternalUserScript(publicScript, oldScript = null) {
    let jsPaths = oldScript?.jsPaths;
    if (publicScript.js) {
      jsPaths = publicScript.js.map(({ file, code }) => {
        if (file != null) {
          // file is a relative path, whether from userScripts.register/update,
          // which is enforced by the unresolvedRelativeUrl schema type, or from
          // the database. Turn into absolute moz-extension:-URL so that the URL
          // can be resolved to its actual source at injection time.
          return this.extension.getURL(file);
        }
        return this.#getJsPathUrlForCode(code);
      });
    }
    const nonEmptyOrNull = arr => (arr?.length ? arr : null);
    return {
      isUserScript: true,
      // Note: id not set because we don't need it internally. The absence of
      // the "id" property is also needed to hide this internal script from the
      // scripting API, in ExtensionScriptingStore.getInitialScriptIdsMap.
      allFrames: publicScript.allFrames ?? oldScript?.allFrames ?? false,
      jsPaths,
      // WebExtensionContentScript requires matches to be set to an Array.
      // Although "matches" is optional in the userScripts API (because
      // includeGlobs can be used instead with OR semantics), it is required
      // for most other use cases (content scripts and MozDocumentMatcher). For
      // clarity, WebExtensionContentScript.webidl therefore marks "matches" as
      // a required array, and we fall back to an empty array if needed.
      matches: publicScript.matches || oldScript?.matches || [],
      excludeMatches: nonEmptyOrNull(
        publicScript.excludeMatches || oldScript?.excludeMatches
      ),
      includeGlobs: nonEmptyOrNull(
        publicScript.includeGlobs || oldScript?.includeGlobs
      ),
      excludeGlobs: nonEmptyOrNull(
        publicScript.excludeGlobs || oldScript?.excludeGlobs
      ),
      runAt: publicScript.runAt || oldScript?.runAt || "document_idle",
      world: publicScript.world || oldScript?.world || "USER_SCRIPT",
      worldId: publicScript.worldId ?? oldScript?.worldId ?? "",
    };
  }

  /**
   * Converts the internal in-memory representation of a registered user script
   * to the public RegisteredUserScript type as defined in the userScripts API.
   *
   * @param {string} publicId
   *        The public script ID chosen by the extension (not used internally).
   * @param {InternalUserScript} internalScript
   *        The internal representation, see #makeInternalUserScript().
   *
   * @returns {Promise<object>}
   */
  async #makePublicUserScript(publicId, internalScript) {
    let hasCodePromise = false;
    let js = internalScript.jsPaths.map(jsPath => {
      if (jsPath.startsWith(this.extension.baseURL)) {
        // Return path without leading origin & /.
        return { file: jsPath.slice(this.extension.baseURL.length) };
      }
      hasCodePromise = true;
      // blob:-URL generated via #makeInternalUserScript() from "code" option.
      return fetch(jsPath)
        .then(res => res.text())
        .then(code => ({ code }));
    });

    if (hasCodePromise) {
      js = await Promise.all(js);
    }

    // Properties match order of RegisteredUserScript in user_scripts.json.
    let script = {
      id: publicId,
      allFrames: internalScript.allFrames,
      js,
      // See #makeInternalUserScript - "matches" is internally required, but if
      // it is an empty array, it is semantically equivalent to null.
      matches: internalScript.matches.length ? internalScript.matches : null,
      excludeMatches: internalScript.excludeMatches,
      includeGlobs: internalScript.includeGlobs,
      excludeGlobs: internalScript.excludeGlobs,
      runAt: internalScript.runAt,
      world: internalScript.world,
      worldId: internalScript.worldId,
    };

    return script;
  }

  /**
   * Some requirements of the RegisteredUserScript type are not validated
   * by the schema, because that is not feasible. E.g. to determine whether
   * an update is valid, we need to apply the update and check the final
   * result.
   *
   * @param {RegisteredUserScript|InternalUserScript} script
   */
  #ensureValidUserScript(script) {
    // Due to similarities between RegisteredUserScript and InternalUserScript,
    // we can re-use the same logic for both types.

    // Note: unlike similar logic in the scripting API, the userScripts API
    // permits an empty js array. The "js" property is guaranteed to exist
    // because it is a required key in userScripts.register.

    if (!script.matches?.length && !script.includeGlobs?.length) {
      throw new ExtensionUtils.ExtensionError(
        "matches or includeGlobs must be specified."
      );
    }

    if (script.matches) {
      // This will throw if a match pattern is invalid.
      ExtensionUtils.parseMatchPatterns(script.matches);
    }

    if (script.excludeMatches) {
      // This will throw if a match pattern is invalid.
      ExtensionUtils.parseMatchPatterns(script.excludeMatches);
    }

    if (script.worldId && script.world === "MAIN") {
      // worldId is only supported with world USER_SCRIPT (default).
      throw new ExtensionUtils.ExtensionError(
        "worldId cannot be used with MAIN world."
      );
    }
  }
}

export const ExtensionUserScripts = {
  async initExtension(extension) {
    if (extension.userScriptsManager) {
      throw new Error(`UserScriptsManager already exists for ${extension.id}`);
    }
    extension.userScriptsManager = new UserScriptsManager(extension);

    // userScripts API data persists across browser updates, but is cleared
    // whenever extensions update.
    switch (extension.startupReason) {
      case "ADDON_INSTALL":
      case "ADDON_UPGRADE":
      case "ADDON_DOWNGRADE":
        // Since we start with an empty state, and deletion will also result in
        // an empty state, we do not need to await the completion of deleteAll.
        // When a userScripts API call happens, it will be queued as desired.
        extension.userScriptsManager.runWriteTask(() =>
          UserScriptsManager.deleteAll(extension.id)
        );
        return;
    }

    // We consider initialization from database a write task because it should
    // block any other read/write tasks until the initialization has completed.
    await extension.userScriptsManager.runWriteTask(() =>
      extension.userScriptsManager.initializeFromDatabase()
    );
  },

  async clearOnUninstall(extensionId) {
    await userScriptTaskQueues.runWriteTask(extensionId, () =>
      UserScriptsManager.deleteAll(extensionId)
    );
  },

  // As its name implies, don't use this method for anything but an easy access
  // to the internal store for testing purposes.
  _getStoreForTesting() {
    return store;
  },
};
