/* 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 lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
  ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
  FileUtils: "resource://gre/modules/FileUtils.sys.mjs",

  AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
  error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
  generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
});

// from https://developer.mozilla.org/en-US/Add-ons/Add-on_Manager/AddonManager#AddonInstall_errors
const ERRORS = {
  [-1]: "ERROR_NETWORK_FAILURE: A network error occurred.",
  [-2]: "ERROR_INCORRECT_HASH: The downloaded file did not match the expected hash.",
  [-3]: "ERROR_CORRUPT_FILE: The file appears to be corrupt.",
  [-4]: "ERROR_FILE_ACCESS: There was an error accessing the filesystem.",
  [-5]: "ERROR_SIGNEDSTATE_REQUIRED: The addon must be signed and isn't.",
  [-6]: "ERROR_UNEXPECTED_ADDON_TYPE: The downloaded add-on had a different type than expected (during an update).",
  [-7]: "ERROR_INCORRECT_ID: The addon did not have the expected ID (during an update).",
  [-8]: "ERROR_INVALID_DOMAIN: The addon install_origins does not list the 3rd party domain.",
  [-9]: "ERROR_UNEXPECTED_ADDON_VERSION: The downloaded add-on had a different version than expected (during an update).",
  [-10]: "ERROR_BLOCKLISTED: The add-on is blocklisted.",
  [-11]:
    "ERROR_INCOMPATIBLE: The add-on is incompatible (w.r.t. the compatibility range).",
  [-12]:
    "ERROR_UNSUPPORTED_ADDON_TYPE: The add-on type is not supported by the platform.",
};

// Maps the ID of a temporarily installed add-on to the path of the XPI file
// created for it in `installWithBase64`. The file has to outlive the install
// so that the add-on's content scripts keep working, and is removed once the
// add-on is uninstalled.
const temporaryAddonFilePaths = new Map();

async function removeTemporaryAddonFile(id) {
  if (temporaryAddonFilePaths.has(id)) {
    const path = temporaryAddonFilePaths.get(id);
    temporaryAddonFilePaths.delete(id);
    await IOUtils.remove(path, { ignoreAbsent: true });
  }
}

async function installAddon(file, temporary, allowPrivateBrowsing) {
  let addon;
  try {
    if (temporary) {
      addon = await lazy.AddonManager.installTemporaryAddon(file);
    } else {
      const install = await lazy.AddonManager.getInstallForFile(file, null, {
        source: "internal",
      });

      if (install == null) {
        throw new lazy.error.UnknownError("Unknown error");
      }

      try {
        addon = await install.install();
      } catch {
        throw new lazy.error.UnknownError(ERRORS[install.error]);
      }
    }
  } catch (e) {
    throw new lazy.error.InvalidWebExtensionError(
      `Could not install Add-on: ${e.message}`
    );
  }

  if (allowPrivateBrowsing) {
    const perms = {
      permissions: ["internal:privateBrowsingAllowed"],
      origins: [],
    };
    await lazy.ExtensionPermissions.add(addon.id, perms);
    await addon.reload();
  }

  return addon;
}

/** Installs addons by path and uninstalls by ID. */
export class Addon {
  /**
   * Install a Firefox addon with provided base64 string representation.
   *
   * Temporary addons will automatically be uninstalled on shutdown and
   * do not need to be signed, though they must be restartless.
   *
   * @param {string} base64
   *     Base64 string representation of the extension package archive.
   * @param {boolean=} temporary
   *     True to install the addon temporarily, false (default) otherwise.
   * @param {boolean=} allowPrivateBrowsing
   *     True to install the addon that is enabled in Private Browsing mode,
   *     false (default) otherwise.
   *
   * @returns {Promise.<string>}
   *     Addon ID.
   *
   * @throws {UnknownError}
   *     If there is a problem installing the addon.
   */
  static async installWithBase64(base64, temporary, allowPrivateBrowsing) {
    const decodedString = atob(base64);
    const fileContent = Uint8Array.from(decodedString, m => m.codePointAt(0));

    let path;
    try {
      path = PathUtils.join(
        PathUtils.profileDir,
        `addon-test-${lazy.generateUUID()}.xpi`
      );
      await IOUtils.write(path, fileContent);
    } catch (e) {
      throw new lazy.error.UnknownError(
        `Could not write add-on to file: ${e.message}`,
        e
      );
    }

    let addon;
    try {
      const file = new lazy.FileUtils.File(path);
      addon = await installAddon(file, temporary, allowPrivateBrowsing);
    } catch (e) {
      await IOUtils.remove(path, { ignoreAbsent: true });
      throw e;
    }

    if (temporary) {
      // We have to keep the file for a temporary extension around to make
      // content scripts work. Store it so that we can remove it
      // once the addon is uninstalled.
      // Remove any stale file first in case we're reinstalling the addon.
      await removeTemporaryAddonFile(addon.id);
      temporaryAddonFilePaths.set(addon.id, path);
    } else {
      await IOUtils.remove(path);
    }

    return addon.id;
  }

  /**
   * Install a Firefox addon with provided path.
   *
   * Temporary addons will automatically be uninstalled on shutdown and
   * do not need to be signed, though they must be restartless.
   *
   * @param {string} path
   *     Full path to the extension package archive.
   * @param {boolean=} temporary
   *     True to install the addon temporarily, false (default) otherwise.
   * @param {boolean=} allowPrivateBrowsing
   *     True to install the addon that is enabled in Private Browsing mode,
   *     false (default) otherwise.
   *
   * @returns {Promise.<string>}
   *     Addon ID.
   *
   * @throws {UnknownError}
   *     If there is a problem installing the addon.
   */
  static async installWithPath(path, temporary, allowPrivateBrowsing) {
    let file;

    // On Windows we can end up with a path with mixed \ and /
    // which doesn't work in Firefox.
    if (lazy.AppInfo.isWindows) {
      path = path.replace(/\//g, "\\");
    }

    try {
      file = new lazy.FileUtils.File(path);
    } catch (e) {
      throw new lazy.error.UnknownError(`Expected absolute path: ${e}`, e);
    }

    if (!file.exists()) {
      throw new lazy.error.UnknownError(`No such file or directory: ${path}`);
    }

    const addon = await installAddon(file, temporary, allowPrivateBrowsing);

    return addon.id;
  }

  static async cleanupTemporaryAddonFiles() {
    await Promise.all(
      Array.from(temporaryAddonFilePaths.keys(), id =>
        removeTemporaryAddonFile(id)
      )
    );
  }

  /**
   * Uninstall a Firefox addon.
   *
   * If the addon is restartless it will be uninstalled right away.
   * Otherwise, Firefox must be restarted for the change to take effect.
   *
   * @param {string} id
   *     ID of the addon to uninstall.
   *
   * @returns {Promise}
   *
   * @throws {UnknownError}
   *     If there is a problem uninstalling the addon.
   * @throws {NoSuchWebExtensionError}
   *     Raised if the WebExtension with provided id could not be found.
   */
  static async uninstall(id) {
    const candidate = await lazy.AddonManager.getAddonByID(id);
    if (candidate === null) {
      // `AddonManager.getAddonByID` never rejects but instead
      // returns `null` if the requested addon cannot be found.
      throw new lazy.error.NoSuchWebExtensionError(
        `Add-on with ID "${id}" is not installed.`
      );
    }

    return new Promise((resolve, reject) => {
      let listener = {
        onOperationCancelled: addon => {
          if (addon.id === candidate.id) {
            lazy.AddonManager.removeAddonListener(listener);
            throw new lazy.error.UnknownError(
              `Uninstall of Add-on with ID "${candidate.id}" was canceled.`
            );
          }
        },

        onUninstalled: async addon => {
          if (addon.id === candidate.id) {
            lazy.AddonManager.removeAddonListener(listener);
            await removeTemporaryAddonFile(candidate.id);
            resolve();
          }
        },
      };

      lazy.AddonManager.addAddonListener(listener);
      candidate.uninstall().catch(e => {
        lazy.AddonManager.removeAddonListener(listener);
        reject(
          new lazy.error.UnknownError(
            `Failed to uninstall Add-on with ID "${id}": ${e.message}`
          )
        );
      });
    });
  }
}
