/* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/
 */
/* globals end_test */

/* eslint no-unused-vars: ["error", {vars: "local", caughtErrors: "none", args: "none"}] */

const { TelemetryTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/TelemetryTestUtils.sys.mjs"
);

let { AddonManagerPrivate } = ChromeUtils.importESModule(
  "resource://gre/modules/AddonManager.sys.mjs"
);

var pathParts = gTestPath.split("/");
// Drop the test filename
pathParts.splice(pathParts.length - 1, pathParts.length);

const RELATIVE_DIR = pathParts.slice(4).join("/") + "/";

const TESTROOT = "http://example.com/" + RELATIVE_DIR;
const SECURE_TESTROOT = "https://example.com/" + RELATIVE_DIR;
const TESTROOT2 = "http://example.org/" + RELATIVE_DIR;
const SECURE_TESTROOT2 = "https://example.org/" + RELATIVE_DIR;
const CHROMEROOT = pathParts.join("/") + "/";
const PREF_DISCOVER_ENABLED = "extensions.getAddons.showPane";
const PREF_XPI_ENABLED = "xpinstall.enabled";
const PREF_UPDATEURL = "extensions.update.url";
const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
const PREF_UI_LASTCATEGORY = "extensions.ui.lastCategory";

const MANAGER_URI = "about:addons";
const PREF_LOGGING_ENABLED = "extensions.logging.enabled";
const PREF_STRICT_COMPAT = "extensions.strictCompatibility";

var PREF_CHECK_COMPATIBILITY;
(function () {
  var channel = Services.prefs.getCharPref("app.update.channel", "default");
  if (
    channel != "aurora" &&
    channel != "alpha" &&
    channel != "beta" &&
    channel != "release" &&
    channel != "esr"
  ) {
    var version = "nightly";
  } else {
    version = Services.appinfo.version.replace(
      /^([^\.]+\.[0-9]+[a-z]*).*/gi,
      "$1"
    );
  }
  PREF_CHECK_COMPATIBILITY = "extensions.checkCompatibility." + version;
})();

var gPendingTests = [];
var gTestsRun = 0;
var gTestStart = null;

var gRestorePrefs = [
  { name: PREF_LOGGING_ENABLED },
  { name: "extensions.webservice.discoverURL" },
  { name: "extensions.update.url" },
  { name: "extensions.update.background.url" },
  { name: "extensions.update.enabled" },
  { name: "extensions.update.autoUpdateDefault" },
  { name: "extensions.getAddons.get.url" },
  { name: "extensions.getAddons.getWithPerformance.url" },
  { name: "extensions.getAddons.cache.enabled" },
  { name: "devtools.chrome.enabled" },
  { name: PREF_STRICT_COMPAT },
  { name: PREF_CHECK_COMPATIBILITY },
];

for (let pref of gRestorePrefs) {
  if (!Services.prefs.prefHasUserValue(pref.name)) {
    pref.type = "clear";
    continue;
  }
  pref.type = Services.prefs.getPrefType(pref.name);
  if (pref.type == Services.prefs.PREF_BOOL) {
    pref.value = Services.prefs.getBoolPref(pref.name);
  } else if (pref.type == Services.prefs.PREF_INT) {
    pref.value = Services.prefs.getIntPref(pref.name);
  } else if (pref.type == Services.prefs.PREF_STRING) {
    pref.value = Services.prefs.getCharPref(pref.name);
  }
}

// Turn logging on for all tests
Services.prefs.setBoolPref(PREF_LOGGING_ENABLED, true);

function promiseFocus(window) {
  return new Promise(resolve => waitForFocus(resolve, window));
}

// Tools to disable and re-enable the background update and blocklist timers
// so that tests can protect themselves from unwanted timer events.
var gCatMan = Services.catMan;
// Default value from toolkit/mozapps/extensions/extensions.manifest, but disable*UpdateTimer()
// records the actual value so we can put it back in enable*UpdateTimer()
var backgroundUpdateConfig =
  "@mozilla.org/addons/integration;1,getService,addon-background-update-timer,extensions.update.interval,86400";

var UTIMER = "update-timer";
var AMANAGER = "addonManager";
var BLOCKLIST = "nsBlocklistService";

function disableBackgroundUpdateTimer() {
  info("Disabling " + UTIMER + " " + AMANAGER);
  backgroundUpdateConfig = gCatMan.getCategoryEntry(UTIMER, AMANAGER);
  gCatMan.deleteCategoryEntry(UTIMER, AMANAGER, true);
}

function enableBackgroundUpdateTimer() {
  info("Enabling " + UTIMER + " " + AMANAGER);
  gCatMan.addCategoryEntry(
    UTIMER,
    AMANAGER,
    backgroundUpdateConfig,
    false,
    true
  );
}

registerCleanupFunction(function () {
  // Restore prefs
  for (let pref of gRestorePrefs) {
    if (pref.type == "clear") {
      Services.prefs.clearUserPref(pref.name);
    } else if (pref.type == Services.prefs.PREF_BOOL) {
      Services.prefs.setBoolPref(pref.name, pref.value);
    } else if (pref.type == Services.prefs.PREF_INT) {
      Services.prefs.setIntPref(pref.name, pref.value);
    } else if (pref.type == Services.prefs.PREF_STRING) {
      Services.prefs.setCharPref(pref.name, pref.value);
    }
  }

  return AddonManager.getAllInstalls().then(aInstalls => {
    for (let install of aInstalls) {
      if (install instanceof MockInstall) {
        continue;
      }

      ok(
        false,
        "Should not have seen an install of " +
          install.sourceURI.spec +
          " in state " +
          install.state
      );
      install.cancel();
    }
  });
});

function log_exceptions(aCallback, ...aArgs) {
  try {
    return aCallback.apply(null, aArgs);
  } catch (e) {
    info("Exception thrown: " + e);
    throw e;
  }
}

function log_callback(aPromise, aCallback) {
  aPromise.then(aCallback).catch(e => info("Exception thrown: " + e));
  return aPromise;
}

function add_test(test) {
  gPendingTests.push(test);
}

function run_next_test() {
  // Make sure we're not calling run_next_test from inside an add_task() test
  // We're inside the browser_test.js 'testScope' here
  if (this.__tasks) {
    throw new Error(
      "run_next_test() called from an add_task() test function. " +
        "run_next_test() should not be called from inside add_task() " +
        "under any circumstances!"
    );
  }
  if (gTestsRun > 0) {
    info("Test " + gTestsRun + " took " + (Date.now() - gTestStart) + "ms");
  }

  if (!gPendingTests.length) {
    executeSoon(end_test);
    return;
  }

  gTestsRun++;
  var test = gPendingTests.shift();
  if (test.name) {
    info("Running test " + gTestsRun + " (" + test.name + ")");
  } else {
    info("Running test " + gTestsRun);
  }

  gTestStart = Date.now();
  executeSoon(() => log_exceptions(test));
}

var get_tooltip_info = async function (addonEl) {
  // Extract from title attribute.
  const { addon } = addonEl;
  const name = addon.name;

  let nameWithVersion = addonEl.addonNameEl.title;
  if (addonEl.addon.userDisabled) {
    // TODO - Bug 1558077: Currently Fluent is clearing the addon title
    // when the addon is disabled, fixing it requires changes to the
    // HTML about:addons localized strings, and then remove this
    // workaround.
    nameWithVersion = `${name} ${addon.version}`;
  }

  return {
    name,
    version: nameWithVersion.substring(name.length + 1),
  };
};

function get_addon_file_url(aFilename) {
  try {
    var cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
      Ci.nsIChromeRegistry
    );
    var fileurl = cr.convertChromeURL(
      makeURI(CHROMEROOT + "addons/" + aFilename)
    );
    return fileurl.QueryInterface(Ci.nsIFileURL);
  } catch (ex) {
    var jar = getJar(CHROMEROOT + "addons/" + aFilename);
    var tmpDir = extractJarToTmp(jar);
    tmpDir.append(aFilename);

    return Services.io.newFileURI(tmpDir).QueryInterface(Ci.nsIFileURL);
  }
}

function check_all_in_list(aManager, aIds, aIgnoreExtras) {
  var doc = aManager.document;
  var list = doc.getElementById("addon-list");

  var inlist = [];
  var node = list.firstChild;
  while (node) {
    if (node.value) {
      inlist.push(node.value);
    }
    node = node.nextSibling;
  }

  for (let id of aIds) {
    if (!inlist.includes(id)) {
      ok(false, "Should find " + id + " in the list");
    }
  }

  if (aIgnoreExtras) {
    return;
  }

  for (let inlistItem of inlist) {
    if (!aIds.includes(inlistItem)) {
      ok(false, "Shouldn't have seen " + inlistItem + " in the list");
    }
  }
}

function getAddonCard(win, id) {
  return win.document.querySelector(`addon-card[addon-id="${id}"]`);
}

async function wait_for_view_load(
  aManagerWindow,
  aCallback,
  aForceWait,
  aLongerTimeout
) {
  // Wait one tick to make sure that the microtask related to an
  // async loadView call originated from outsite about:addons
  // is already executing (otherwise isLoading would be still false
  // and we wouldn't be waiting for that load before resolving
  // the promise returned by this test helper function).
  await Promise.resolve();

  let p = new Promise(resolve => {
    requestLongerTimeout(aLongerTimeout ? aLongerTimeout : 2);

    if (!aForceWait && !aManagerWindow.gViewController.isLoading) {
      resolve(aManagerWindow);
      return;
    }

    aManagerWindow.document.addEventListener(
      "view-loaded",
      function () {
        resolve(aManagerWindow);
      },
      { once: true }
    );
  });

  return log_callback(p, aCallback);
}

function wait_for_manager_load(aManagerWindow, aCallback) {
  info("Waiting for initialization");
  return log_callback(
    aManagerWindow.promiseInitialized.then(() => aManagerWindow),
    aCallback
  );
}

function open_manager(
  aView,
  aCallback,
  aLoadCallback,
  aLongerTimeout,
  aWin = window
) {
  let p = new Promise(resolve => {
    async function setup_manager(aManagerWindow) {
      if (aLoadCallback) {
        log_exceptions(aLoadCallback, aManagerWindow);
      }

      if (aView) {
        aManagerWindow.loadView(aView);
      }

      Assert.notEqual(
        aManagerWindow,
        null,
        "Should have an add-ons manager window"
      );
      is(
        aManagerWindow.location.href,
        MANAGER_URI,
        "Should be displaying the correct UI"
      );

      await promiseFocus(aManagerWindow);
      info("window has focus, waiting for manager load");
      await wait_for_manager_load(aManagerWindow);
      info("Manager waiting for view load");
      await wait_for_view_load(aManagerWindow, null, null, aLongerTimeout);
      resolve(aManagerWindow);
    }

    info("Loading manager window in tab");
    Services.obs.addObserver(function observer(aSubject, aTopic) {
      Services.obs.removeObserver(observer, aTopic);
      if (aSubject.location.href != MANAGER_URI) {
        info("Ignoring load event for " + aSubject.location.href);
        return;
      }
      setup_manager(aSubject);
    }, "EM-loaded");

    aWin.gBrowser.selectedTab = BrowserTestUtils.addTab(aWin.gBrowser);
    aWin.switchToTabHavingURI(MANAGER_URI, true, {
      triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
    });
  });

  // The promise resolves with the manager window, so it is passed to the callback
  return log_callback(p, aCallback);
}

function close_manager(aManagerWindow, aCallback, aLongerTimeout) {
  let p = new Promise((resolve, reject) => {
    requestLongerTimeout(aLongerTimeout ? aLongerTimeout : 2);

    Assert.notEqual(
      aManagerWindow,
      null,
      "Should have an add-ons manager window to close"
    );
    is(
      aManagerWindow.location.href,
      MANAGER_URI,
      "Should be closing window with correct URI"
    );

    aManagerWindow.addEventListener("unload", function listener() {
      try {
        dump("Manager window unload handler\n");
        this.removeEventListener("unload", listener);
        resolve();
      } catch (e) {
        reject(e);
      }
    });
  });

  info("Telling manager window to close");
  aManagerWindow.close();
  info("Manager window close() call returned");

  return log_callback(p, aCallback);
}

function restart_manager(aManagerWindow, aView, aCallback, aLoadCallback) {
  if (!aManagerWindow) {
    return open_manager(aView, aCallback, aLoadCallback);
  }

  return close_manager(aManagerWindow).then(() =>
    open_manager(aView, aCallback, aLoadCallback)
  );
}

function wait_for_window_open(aCallback) {
  let p = new Promise(resolve => {
    Services.wm.addListener({
      onOpenWindow(aXulWin) {
        Services.wm.removeListener(this);

        let domwindow = aXulWin.docShell.domWindow;
        domwindow.addEventListener(
          "load",
          function () {
            executeSoon(function () {
              resolve(domwindow);
            });
          },
          { once: true }
        );
      },

      onCloseWindow() {},
    });
  });

  return log_callback(p, aCallback);
}

function formatDate(aDate) {
  const dtOptions = { year: "numeric", month: "long", day: "numeric" };
  return aDate.toLocaleDateString(undefined, dtOptions);
}

function is_hidden(aElement) {
  var style = aElement.ownerGlobal.getComputedStyle(aElement);
  if (style.display == "none") {
    return true;
  }
  if (style.visibility != "visible") {
    return true;
  }

  // Hiding a parent element will hide all its children
  if (aElement.parentNode != aElement.ownerDocument) {
    return is_hidden(aElement.parentNode);
  }

  return false;
}

function is_element_visible(aElement, aMsg) {
  isnot(aElement, null, "Element should not be null, when checking visibility");
  ok(!is_hidden(aElement), aMsg || aElement + " should be visible");
}

function is_element_hidden(aElement, aMsg) {
  isnot(aElement, null, "Element should not be null, when checking visibility");
  ok(is_hidden(aElement), aMsg || aElement + " should be hidden");
}

function promiseAddonByID(aId) {
  return AddonManager.getAddonByID(aId);
}

function promiseAddonsByIDs(aIDs) {
  return AddonManager.getAddonsByIDs(aIDs);
}
/**
 * Install an add-on and call a callback when complete.
 *
 * The callback will receive the Addon for the installed add-on.
 */
async function install_addon(path, cb, pathPrefix = TESTROOT) {
  let install = await AddonManager.getInstallForURL(pathPrefix + path);
  let p = new Promise(resolve => {
    install.addListener({
      onInstallEnded: () => resolve(install.addon),
    });

    install.install();
  });

  return log_callback(p, cb);
}

function CategoryUtilities(aManagerWindow) {
  this.window = aManagerWindow;
  this.window.addEventListener("unload", () => (this.window = null), {
    once: true,
  });
}

CategoryUtilities.prototype = {
  window: null,

  get _categoriesBox() {
    return this.window.document.querySelector("categories-box");
  },

  getSelectedViewId() {
    let selectedItem = this._categoriesBox.querySelector("[selected]");
    isnot(selectedItem, null, "A category should be selected");
    return selectedItem.getAttribute("viewid");
  },

  get selectedCategory() {
    isnot(
      this.window,
      null,
      "Should not get selected category when manager window is not loaded"
    );
    let viewId = this.getSelectedViewId();
    let view = this.window.gViewController.parseViewId(viewId);
    return view.type == "list" ? view.param : view.type;
  },

  get(categoryType) {
    isnot(
      this.window,
      null,
      "Should not get category when manager window is not loaded"
    );

    let button = this._categoriesBox.querySelector(`[name="${categoryType}"]`);
    if (button) {
      return button;
    }

    ok(false, "Should have found a category with type " + categoryType);
    return null;
  },

  isVisible(categoryButton) {
    isnot(
      this.window,
      null,
      "Should not check visible state when manager window is not loaded"
    );

    // There are some tests checking this before the categories have loaded.
    if (!categoryButton) {
      return false;
    }

    if (categoryButton.disabled || categoryButton.hidden) {
      return false;
    }

    return !is_hidden(categoryButton);
  },

  isTypeVisible(categoryType) {
    return this.isVisible(this.get(categoryType));
  },

  open(categoryButton) {
    isnot(
      this.window,
      null,
      "Should not open category when manager window is not loaded"
    );
    ok(
      this.isVisible(categoryButton),
      "Category should be visible if attempting to open it"
    );

    EventUtils.synthesizeMouseAtCenter(categoryButton, {}, this.window);

    // Use wait_for_view_load until all open_manager calls are gone.
    return wait_for_view_load(this.window);
  },

  openType(categoryType) {
    return this.open(this.get(categoryType));
  },
};

// Returns a promise that will resolve when the certificate error override has been added, or reject
// if there is some failure.
function addCertOverride(host) {
  return new Promise((resolve, reject) => {
    let req = new XMLHttpRequest();
    req.open("GET", "https://" + host + "/");
    req.onload = reject;
    req.onerror = () => {
      if (req.channel && req.channel.securityInfo) {
        let securityInfo = req.channel.securityInfo;
        if (securityInfo.serverCert) {
          let cos = Cc["@mozilla.org/security/certoverride;1"].getService(
            Ci.nsICertOverrideService
          );
          cos.rememberValidityOverride(
            host,
            -1,
            {},
            securityInfo.serverCert,
            false
          );
          resolve();
          return;
        }
      }
      reject();
    };
    req.send(null);
  });
}

// Returns a promise that will resolve when the necessary certificate overrides have been added.
function addCertOverrides() {
  return Promise.all([
    addCertOverride("nocert.example.com"),
    addCertOverride("self-signed.example.com"),
    addCertOverride("untrusted.example.com"),
    addCertOverride("expired.example.com"),
  ]);
}

/***** Mock Provider *****/

function MockProvider(
  addonTypes,
  { supportsOperationsRequiringRestart = false } = {}
) {
  this.addons = [];
  this.installs = [];
  this.addonTypes = addonTypes ?? ["extension"];
  // NOTE: operationsRequiringRestart is an historical feature of the
  // XPIProvider, which is not supported anymore, there may still be
  // tests making assumptions about MockProvider behaviors related to
  // it, and so this is a temporary measure to gradually remove the
  // remaining bits of the deprecated feature from the MockProvider
  // test helpers.
  //
  // TODO: (Bug 1921875) Remove operationsRequiringRestart-related
  // behaviors from MockProvider.
  this.supportsOperationsRequiringRestart = supportsOperationsRequiringRestart;

  var self = this;
  registerCleanupFunction(function () {
    if (self.started) {
      self.unregister();
    }
  });

  this.register();
}

MockProvider.prototype = {
  addons: null,
  installs: null,
  addonTypes: null,
  started: null,
  queryDelayPromise: Promise.resolve(),

  blockQueryResponses() {
    this.queryDelayPromise = new Promise(resolve => {
      this._unblockQueries = resolve;
    });
  },

  unblockQueryResponses() {
    if (this._unblockQueries) {
      this._unblockQueries();
      this._unblockQueries = null;
    } else {
      throw new Error("Queries are not blocked");
    }
  },

  /***** Utility functions *****/

  /**
   * Register this provider with the AddonManager
   */
  register: function MP_register() {
    info("Registering mock add-on provider");
    // addonTypes is supposedly the full set of types supported by the provider.
    // The current list is not complete (there are tests that mock add-on types
    // other than "extension"), but it doesn't affect tests since addonTypes is
    // mainly used to determine whether any of the AddonManager's providers
    // support a type, and XPIProvider already defines the types of interest.
    AddonManagerPrivate.registerProvider(this, this.addonTypes);
  },

  /**
   * Unregister this provider with the AddonManager
   */
  unregister: function MP_unregister() {
    info("Unregistering mock add-on provider");
    AddonManagerPrivate.unregisterProvider(this);
  },

  /**
   * Adds an add-on to the list of add-ons that this provider exposes to the
   * AddonManager, dispatching appropriate events in the process.
   *
   * @param  aAddon
   *         The add-on to add
   */
  addAddon: function MP_addAddon(aAddon) {
    var oldAddons = this.addons.filter(aOldAddon => aOldAddon.id == aAddon.id);
    var oldAddon = oldAddons.length ? oldAddons[0] : null;

    this.addons = this.addons.filter(aOldAddon => aOldAddon.id != aAddon.id);

    this.addons.push(aAddon);
    aAddon._provider = this;

    if (!this.started) {
      return;
    }

    let requiresRestart = this.supportsOperationsRequiringRestart
      ? (aAddon.operationsRequiringRestart &
          AddonManager.OP_NEEDS_RESTART_INSTALL) !=
        0
      : false;
    AddonManagerPrivate.callInstallListeners(
      "onExternalInstall",
      null,
      aAddon,
      oldAddon,
      requiresRestart
    );
  },

  /**
   * Removes an add-on from the list of add-ons that this provider exposes to
   * the AddonManager, dispatching the onUninstalled event in the process.
   *
   * @param  aAddon
   *         The add-on to add
   */
  removeAddon: function MP_removeAddon(aAddon) {
    var pos = this.addons.indexOf(aAddon);
    if (pos == -1) {
      ok(
        false,
        "Tried to remove an add-on that wasn't registered with the mock provider"
      );
      return;
    }

    this.addons.splice(pos, 1);

    if (!this.started) {
      return;
    }

    AddonManagerPrivate.callAddonListeners("onUninstalled", aAddon);
  },

  /**
   * Adds an add-on install to the list of installs that this provider exposes
   * to the AddonManager, dispatching appropriate events in the process.
   *
   * @param  aInstall
   *         The add-on install to add
   */
  addInstall: function MP_addInstall(aInstall) {
    this.installs.push(aInstall);
    aInstall._provider = this;

    if (!this.started) {
      return;
    }

    aInstall.callListeners("onNewInstall");
  },

  removeInstall: function MP_removeInstall(aInstall) {
    var pos = this.installs.indexOf(aInstall);
    if (pos == -1) {
      ok(
        false,
        "Tried to remove an install that wasn't registered with the mock provider"
      );
      return;
    }

    this.installs.splice(pos, 1);
  },

  /**
   * Creates a set of mock add-on objects and adds them to the list of add-ons
   * managed by this provider.
   *
   * @param  aAddonProperties
   *         An array of objects containing properties describing the add-ons
   * @return Array of the new MockAddons
   */
  createAddons: function MP_createAddons(aAddonProperties) {
    var newAddons = [];
    for (let addonProp of aAddonProperties) {
      if (!this.supportsOperationsRequiringRestart) {
        if (addonProp.operationsRequiringRestart !== undefined) {
          throw new Error(
            `Unexpected operationsRequiringRestart set on MockAddon ${addonProp.id}. MockProvider instance does not support operationsRequiringRestart.`
          );
        }
        addonProp.operationsRequiringRestart = 0;
      }
      let addon = new MockAddon(addonProp.id);
      for (let prop in addonProp) {
        if (prop == "id") {
          continue;
        }
        if (prop == "applyBackgroundUpdates") {
          addon._applyBackgroundUpdates = addonProp[prop];
        } else if (prop == "appDisabled") {
          addon._appDisabled = addonProp[prop];
        } else if (prop == "userDisabled") {
          addon.setUserDisabled(addonProp[prop]);
        } else if (prop == "softDisabled") {
          addon.setSoftDisabled(addonProp[prop]);
        } else {
          addon[prop] = addonProp[prop];
        }
      }
      if (!addon.optionsType && !!addon.optionsURL) {
        addon.optionsType = AddonManager.OPTIONS_TYPE_DIALOG;
      }

      // Make sure the active state matches the passed in properties
      addon.isActive = addon.shouldBeActive;

      this.addAddon(addon);
      newAddons.push(addon);
    }

    return newAddons;
  },

  /**
   * Creates a set of mock add-on install objects and adds them to the list
   * of installs managed by this provider.
   *
   * @param  aInstallProperties
   *         An array of objects containing properties describing the installs
   * @return Array of the new MockInstalls
   */
  createInstalls: function MP_createInstalls(aInstallProperties) {
    var newInstalls = [];
    for (let installProp of aInstallProperties) {
      let install = new MockInstall(
        installProp.name || null,
        installProp.type || null,
        null
      );
      for (let prop in installProp) {
        switch (prop) {
          case "name":
          case "type":
            break;
          case "sourceURI":
            install[prop] = NetUtil.newURI(installProp[prop]);
            break;
          default:
            install[prop] = installProp[prop];
        }
      }
      this.addInstall(install);
      newInstalls.push(install);
    }

    return newInstalls;
  },

  /***** AddonProvider implementation *****/

  /**
   * Called to initialize the provider.
   */
  startup: function MP_startup() {
    this.started = true;
  },

  /**
   * Called when the provider should shutdown.
   */
  shutdown: function MP_shutdown() {
    this.started = false;
  },

  /**
   * Called to get an Addon with a particular ID.
   *
   * @param  aId
   *         The ID of the add-on to retrieve
   */
  async getAddonByID(aId) {
    await this.queryDelayPromise;

    for (let addon of this.addons) {
      if (addon.id == aId) {
        return addon;
      }
    }

    return null;
  },

  /**
   * Called to get Addons of a particular type.
   *
   * @param  aTypes
   *         An array of types to fetch. Can be null to get all types.
   */
  async getAddonsByTypes(aTypes) {
    await this.queryDelayPromise;

    var addons = this.addons.filter(function (aAddon) {
      if (aTypes && !!aTypes.length && !aTypes.includes(aAddon.type)) {
        return false;
      }
      return true;
    });
    return addons;
  },

  /**
   * Called to get the current AddonInstalls, optionally restricting by type.
   *
   * @param  aTypes
   *         An array of types or null to get all types
   */
  async getInstallsByTypes(aTypes) {
    await this.queryDelayPromise;

    var installs = this.installs.filter(function (aInstall) {
      // Appear to have actually removed cancelled installs from the provider
      if (aInstall.state == AddonManager.STATE_CANCELLED) {
        return false;
      }

      if (aTypes && !!aTypes.length && !aTypes.includes(aInstall.type)) {
        return false;
      }

      return true;
    });
    return installs;
  },

  /**
   * Called when a new add-on has been enabled when only one add-on of that type
   * can be enabled.
   *
   * @param  aId
   *         The ID of the newly enabled add-on
   * @param  aType
   *         The type of the newly enabled add-on
   * @param  aPendingRestart
   *         true if the newly enabled add-on will only become enabled after a
   *         restart
   */
  addonChanged: function MP_addonChanged() {
    // Not implemented
  },

  /**
   * Update the appDisabled property for all add-ons.
   */
  updateAddonAppDisabledStates: function MP_updateAddonAppDisabledStates() {
    // Not needed
  },

  /**
   * Called to get an AddonInstall to download and install an add-on from a URL.
   *
   * @param  {string} aUrl
   *         The URL to be installed
   * @param  {object} aOptions
   *         Options for the install
   */
  getInstallForURL: function MP_getInstallForURL() {
    // Not yet implemented
  },

  /**
   * Called to get an AddonInstall to install an add-on from a local file.
   *
   * @param  aFile
   *         The file to be installed
   */
  getInstallForFile: function MP_getInstallForFile() {
    // Not yet implemented
  },

  /**
   * Called to test whether installing add-ons is enabled.
   *
   * @return true if installing is enabled
   */
  isInstallEnabled: function MP_isInstallEnabled() {
    return false;
  },

  /**
   * Called to test whether this provider supports installing a particular
   * mimetype.
   *
   * @param  aMimetype
   *         The mimetype to check for
   * @return true if the mimetype is supported
   */
  supportsMimetype: function MP_supportsMimetype() {
    return false;
  },

  /**
   * Called to test whether installing add-ons from a URI is allowed.
   *
   * @param  aUri
   *         The URI being installed from
   * @return true if installing is allowed
   */
  isInstallAllowed: function MP_isInstallAllowed() {
    return false;
  },
};

/***** Mock Addon object for the Mock Provider *****/

function MockAddon(aId, aName, aType, aOperationsRequiringRestart) {
  // Only set required attributes.
  this.id = aId || "";
  this.name = aName || "";
  this.type = aType || "extension";
  this.version = "";
  this.isCompatible = true;
  this.providesUpdatesSecurely = true;
  this.blocklistState = 0;
  this._appDisabled = false;
  this._userDisabled = false;
  this._applyBackgroundUpdates = AddonManager.AUTOUPDATE_ENABLE;
  this.scope = AddonManager.SCOPE_PROFILE;
  this.isActive = true;
  this.creator = "";
  this.pendingOperations = 0;
  this._permissions =
    AddonManager.PERM_CAN_UNINSTALL |
    AddonManager.PERM_CAN_ENABLE |
    AddonManager.PERM_CAN_DISABLE |
    AddonManager.PERM_CAN_UPGRADE |
    AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS;
  this.operationsRequiringRestart =
    aOperationsRequiringRestart != undefined
      ? aOperationsRequiringRestart
      : AddonManager.OP_NEEDS_RESTART_INSTALL |
        AddonManager.OP_NEEDS_RESTART_UNINSTALL |
        AddonManager.OP_NEEDS_RESTART_ENABLE |
        AddonManager.OP_NEEDS_RESTART_DISABLE;
}

MockAddon.prototype = {
  get isCorrectlySigned() {
    if (this.signedState === AddonManager.SIGNEDSTATE_NOT_REQUIRED) {
      return true;
    }
    return this.signedState > AddonManager.SIGNEDSTATE_MISSING;
  },

  get shouldBeActive() {
    return (
      !this.appDisabled &&
      !this._userDisabled &&
      !this._softDisabled &&
      !(this.pendingOperations & AddonManager.PENDING_UNINSTALL)
    );
  },

  get appDisabled() {
    return this._appDisabled;
  },

  set appDisabled(val) {
    if (val == this._appDisabled) {
      return;
    }

    AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, [
      "appDisabled",
    ]);

    var currentActive = this.shouldBeActive;
    this._appDisabled = val;
    var newActive = this.shouldBeActive;
    this._updateActiveState(currentActive, newActive);
  },

  get userDisabled() {
    // NOTE: the logic here should reseamble the logic
    // from the AddonInstall getter with the same name.
    return this._softDisabled || this._userDisabled;
  },

  set userDisabled(val) {
    throw new Error("No. Bad.");
  },

  get softDisabled() {
    return this._softDisabled;
  },

  set softDisabled(val) {
    throw new Error("No. Bad.");
  },

  setUserDisabled(val) {
    // NOTE: the logic here should reseamble the logic
    // from the AddonInstall method with the same name.
    if (val == (this._userDisabled || this._softDisabled)) {
      return;
    }

    var currentActive = this.shouldBeActive;
    this._userDisabled = val;
    var newActive = this.shouldBeActive;
    this._updateActiveState(currentActive, newActive);
  },

  setSoftDisabled(val) {
    // NOTE: the logic here should reseamble the logic
    // from the AddonInstall method with the same name.
    if (val == this._softDisabled) {
      return;
    }

    var currentActive = this.shouldBeActive;
    if (!this._userDisabled) {
      this._softDisabled = val;
    }
    var newActive = this.shouldBeActive;
    this._updateActiveState(currentActive, newActive);
  },

  async enable() {
    await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));

    this.setUserDisabled(false);
    this.setSoftDisabled(false);
  },
  async disable() {
    await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));

    this.setUserDisabled(true);
  },

  get permissions() {
    let permissions = this._permissions;
    if (this.appDisabled) {
      permissions &= ~AddonManager.PERM_CAN_ENABLE;
      permissions &= ~AddonManager.PERM_CAN_DISABLE;
    }
    return permissions;
  },

  set permissions(val) {
    this._permissions = val;
  },

  get applyBackgroundUpdates() {
    return this._applyBackgroundUpdates;
  },

  set applyBackgroundUpdates(val) {
    if (
      val != AddonManager.AUTOUPDATE_DEFAULT &&
      val != AddonManager.AUTOUPDATE_DISABLE &&
      val != AddonManager.AUTOUPDATE_ENABLE
    ) {
      ok(false, "addon.applyBackgroundUpdates set to an invalid value: " + val);
    }
    this._applyBackgroundUpdates = val;
    AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, [
      "applyBackgroundUpdates",
    ]);
  },

  isCompatibleWith() {
    return true;
  },

  findUpdates() {
    // Tests can implement this if they need to
  },

  async getBlocklistURL() {
    return this.blocklistURL;
  },

  uninstall(aAlwaysAllowUndo = false) {
    if (
      this.operationsRequiringRestart &
        AddonManager.OP_NEED_RESTART_UNINSTALL &&
      this.pendingOperations & AddonManager.PENDING_UNINSTALL
    ) {
      throw Components.Exception("Add-on is already pending uninstall");
    }

    var needsRestart =
      aAlwaysAllowUndo ||
      !!(
        this.operationsRequiringRestart &
        AddonManager.OP_NEEDS_RESTART_UNINSTALL
      );
    this.pendingOperations |= AddonManager.PENDING_UNINSTALL;
    AddonManagerPrivate.callAddonListeners(
      "onUninstalling",
      this,
      needsRestart
    );
    if (!needsRestart) {
      this.pendingOperations -= AddonManager.PENDING_UNINSTALL;
      this._provider.removeAddon(this);
    } else if (
      !(this.operationsRequiringRestart & AddonManager.OP_NEEDS_RESTART_DISABLE)
    ) {
      this.isActive = false;
    }
  },

  cancelUninstall() {
    if (!(this.pendingOperations & AddonManager.PENDING_UNINSTALL)) {
      throw Components.Exception("Add-on is not pending uninstall");
    }

    this.pendingOperations -= AddonManager.PENDING_UNINSTALL;
    this.isActive = this.shouldBeActive;
    AddonManagerPrivate.callAddonListeners("onOperationCancelled", this);
  },

  markAsSeen() {
    this.seen = true;
  },

  updateBlocklistState() {
    // NOTE: this is currently a no-op meant to just prevent MockProvider
    // addons to trigger an unexpected "addon.updateBlockistState is not a function"
    // error in tests covering the blocklist (while there are also MockProvider
    // installed addons).
  },

  _updateActiveState(currentActive, newActive) {
    if (currentActive == newActive) {
      return;
    }

    if (newActive == this.isActive) {
      this.pendingOperations -= newActive
        ? AddonManager.PENDING_DISABLE
        : AddonManager.PENDING_ENABLE;
      AddonManagerPrivate.callAddonListeners("onOperationCancelled", this);
    } else if (newActive) {
      let needsRestart = !!(
        this.operationsRequiringRestart & AddonManager.OP_NEEDS_RESTART_ENABLE
      );
      this.pendingOperations |= AddonManager.PENDING_ENABLE;
      AddonManagerPrivate.callAddonListeners("onEnabling", this, needsRestart);
      if (!needsRestart) {
        this.isActive = newActive;
        this.pendingOperations -= AddonManager.PENDING_ENABLE;
        AddonManagerPrivate.callAddonListeners("onEnabled", this);
      }
    } else {
      let needsRestart = !!(
        this.operationsRequiringRestart & AddonManager.OP_NEEDS_RESTART_DISABLE
      );
      this.pendingOperations |= AddonManager.PENDING_DISABLE;
      AddonManagerPrivate.callAddonListeners("onDisabling", this, needsRestart);
      if (!needsRestart) {
        this.isActive = newActive;
        this.pendingOperations -= AddonManager.PENDING_DISABLE;
        AddonManagerPrivate.callAddonListeners("onDisabled", this);
      }
    }
  },
};

/***** Mock AddonInstall object for the Mock Provider *****/

function MockInstall(aName, aType, aAddonToInstall) {
  this.name = aName || "";
  // Don't expose type until download completed
  this._type = aType || "extension";
  this.type = null;
  this.version = "1.0";
  this.iconURL = "";
  this.infoURL = "";
  this.state = AddonManager.STATE_AVAILABLE;
  this.error = 0;
  this.sourceURI = null;
  this.file = null;
  this.progress = 0;
  this.maxProgress = -1;
  this.certificate = null;
  this.certName = "";
  this.existingAddon = null;
  this.addon = null;
  this._addonToInstall = aAddonToInstall;
  this.listeners = [];

  // Another type of install listener for tests that want to check the results
  // of code run from standard install listeners
  this.testListeners = [];
}

MockInstall.prototype = {
  install() {
    switch (this.state) {
      case AddonManager.STATE_AVAILABLE:
        this.state = AddonManager.STATE_DOWNLOADING;
        if (!this.callListeners("onDownloadStarted")) {
          this.state = AddonManager.STATE_CANCELLED;
          this.callListeners("onDownloadCancelled");
          return;
        }

        this.type = this._type;

        // Adding addon to MockProvider to be implemented when needed
        if (this._addonToInstall) {
          this.addon = this._addonToInstall;
        } else {
          this.addon = new MockAddon("", this.name, this.type);
          this.addon.version = this.version;
          this.addon.pendingOperations = AddonManager.PENDING_INSTALL;
        }
        this.addon.install = this;
        if (this.existingAddon) {
          if (!this.addon.id) {
            this.addon.id = this.existingAddon.id;
          }
          this.existingAddon.pendingUpgrade = this.addon;
          this.existingAddon.pendingOperations |= AddonManager.PENDING_UPGRADE;
        }

        this.state = AddonManager.STATE_DOWNLOADED;
        this.callListeners("onDownloadEnded");
      // fall through
      case AddonManager.STATE_DOWNLOADED: {
        this.state = AddonManager.STATE_INSTALLING;
        if (!this.callListeners("onInstallStarted")) {
          this.state = AddonManager.STATE_CANCELLED;
          this.callListeners("onInstallCancelled");
          return;
        }

        let needsRestart = this._provider?.supportsOperationsRequiringRestart
          ? this.operationsRequiringRestart &
            AddonManager.OP_NEEDS_RESTART_INSTALL
          : false;
        AddonManagerPrivate.callAddonListeners(
          "onInstalling",
          this.addon,
          needsRestart
        );
        if (!needsRestart) {
          AddonManagerPrivate.callAddonListeners("onInstalled", this.addon);
        }

        this.state = AddonManager.STATE_INSTALLED;
        this.callListeners("onInstallEnded");
        break;
      }
      case AddonManager.STATE_DOWNLOADING:
      case AddonManager.STATE_CHECKING_UPDATE:
      case AddonManager.STATE_INSTALLING:
        // Installation is already running
        return;
      default:
        ok(false, "Cannot start installing when state = " + this.state);
    }
  },

  cancel() {
    switch (this.state) {
      case AddonManager.STATE_AVAILABLE:
        this.state = AddonManager.STATE_CANCELLED;
        break;
      case AddonManager.STATE_INSTALLED:
        this.state = AddonManager.STATE_CANCELLED;
        this._provider.removeInstall(this);
        this.callListeners("onInstallCancelled");
        break;
      default:
        // Handling cancelling when downloading to be implemented when needed
        ok(false, "Cannot cancel when state = " + this.state);
    }
  },

  addListener(aListener) {
    if (!this.listeners.some(i => i == aListener)) {
      this.listeners.push(aListener);
    }
  },

  removeListener(aListener) {
    this.listeners = this.listeners.filter(i => i != aListener);
  },

  addTestListener(aListener) {
    if (!this.testListeners.some(i => i == aListener)) {
      this.testListeners.push(aListener);
    }
  },

  removeTestListener(aListener) {
    this.testListeners = this.testListeners.filter(i => i != aListener);
  },

  callListeners(aMethod) {
    var result = AddonManagerPrivate.callInstallListeners(
      aMethod,
      this.listeners,
      this,
      this.addon
    );

    // Call test listeners after standard listeners to remove race condition
    // between standard and test listeners
    for (let listener of this.testListeners) {
      try {
        if (aMethod in listener) {
          if (listener[aMethod](this, this.addon) === false) {
            result = false;
          }
        }
      } catch (e) {
        ok(false, "Test listener threw exception: " + e);
      }
    }

    return result;
  },
};

function waitForCondition(condition, nextTest, errorMsg) {
  let tries = 0;
  let interval = setInterval(function () {
    if (tries >= 30) {
      ok(false, errorMsg);
      moveOn();
    }
    var conditionPassed;
    try {
      conditionPassed = condition();
    } catch (e) {
      ok(false, e + "\n" + e.stack);
      conditionPassed = false;
    }
    if (conditionPassed) {
      moveOn();
    }
    tries++;
  }, 100);
  let moveOn = function () {
    clearInterval(interval);
    nextTest();
  };
}

// Wait for and then acknowledge (by pressing the primary button) the
// given notification.
function promiseNotification(id = "addon-webext-permissions") {
  return new Promise(resolve => {
    function popupshown() {
      let notification = PopupNotifications.getNotification(id);
      if (notification) {
        PopupNotifications.panel.removeEventListener("popupshown", popupshown);
        PopupNotifications.panel.firstElementChild.button.click();
        resolve();
      }
    }
    PopupNotifications.panel.addEventListener("popupshown", popupshown);
  });
}

/**
 * Wait for the given PopupNotification to display
 *
 * @param {string} name
 *        The name of the notification to wait for.
 *
 * @returns {Promise}
 *          Resolves with the notification window.
 */
function promisePopupNotificationShown(name = "addon-webext-permissions") {
  return new Promise(resolve => {
    function popupshown() {
      let notification = PopupNotifications.getNotification(name);
      if (!notification) {
        return;
      }

      ok(notification, `${name} notification shown`);
      ok(PopupNotifications.isPanelOpen, "notification panel open");

      PopupNotifications.panel.removeEventListener("popupshown", popupshown);
      resolve(PopupNotifications.panel.firstChild);
    }
    PopupNotifications.panel.addEventListener("popupshown", popupshown);
  });
}

function waitAppMenuNotificationShown(
  id,
  addonId,
  accept = false,
  win = window
) {
  const { AppMenuNotifications } = ChromeUtils.importESModule(
    "resource://gre/modules/AppMenuNotifications.sys.mjs"
  );
  return new Promise(resolve => {
    let { document, PanelUI } = win;

    async function popupshown() {
      let notification = AppMenuNotifications.activeNotification;
      if (!notification) {
        return;
      }

      is(notification.id, id, `${id} notification shown`);
      ok(PanelUI.isNotificationPanelOpen, "notification panel open");

      PanelUI.notificationPanel.removeEventListener("popupshown", popupshown);

      if (id == "addon-installed" && addonId) {
        let addon = await AddonManager.getAddonByID(addonId);
        if (!addon) {
          ok(false, `Addon with id "${addonId}" not found`);
        }
      }

      let popupnotificationID = PanelUI._getPopupId(notification);
      let popupnotification = document.getElementById(popupnotificationID);

      if (accept) {
        popupnotification.button.click();
      }

      resolve(popupnotification);
    }
    // If it's already open just run the test.
    let notification = AppMenuNotifications.activeNotification;
    if (notification && PanelUI.isNotificationPanelOpen) {
      popupshown();
      return;
    }
    PanelUI.notificationPanel.addEventListener("popupshown", popupshown);
  });
}

function acceptAppMenuNotificationWhenShown(id, addonId) {
  return waitAppMenuNotificationShown(id, addonId, true);
}

/* HTML view helpers */
async function loadInitialView(type, opts) {
  if (type) {
    // Force the first page load to be the view we want.
    let viewId;
    if (type.startsWith("addons://")) {
      viewId = type;
    } else {
      viewId =
        type == "discover" ? "addons://discover/" : `addons://list/${type}`;
    }
    Services.prefs.setCharPref(PREF_UI_LASTCATEGORY, viewId);
  }

  let loadCallback;
  let loadCallbackDone = Promise.resolve();

  if (opts && opts.loadCallback) {
    loadCallback = win => {
      loadCallbackDone = (async () => {
        // Wait for the test code to finish running before proceeding.
        await opts.loadCallback(win);
      })();
    };
  }

  let win = await open_manager(null, null, loadCallback);
  if (!opts || !opts.withAnimations) {
    win.document.body.setAttribute("skip-animations", "");
  }

  // Let any load callback code to run before the rest of the test continues.
  await loadCallbackDone;

  return win;
}

function getSection(doc, className) {
  return doc.querySelector(`section.${className}`);
}

function waitForViewLoad(win) {
  return wait_for_view_load(win, undefined, true);
}

function closeView(win) {
  return close_manager(win);
}

function switchView(win, type) {
  return new CategoryUtilities(win).openType(type);
}

function isCategoryVisible(win, type) {
  return new CategoryUtilities(win).isTypeVisible(type);
}

function mockPromptService() {
  let { prompt } = Services;
  let promptService = {
    // The prompt returns 1 for cancelled and 0 for accepted.
    _response: 1,
    QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
    confirmEx: () => promptService._response,
  };
  Services.prompt = promptService;
  registerCleanupFunction(() => {
    Services.prompt = prompt;
  });
  return promptService;
}

function assertHasPendingUninstalls(addonList, expectedPendingUninstallsCount) {
  const pendingUninstalls = addonList.querySelector(
    "message-bar-stack.pending-uninstall"
  );
  ok(pendingUninstalls, "Got a pending-uninstall message-bar-stack");
  is(
    pendingUninstalls.childElementCount,
    expectedPendingUninstallsCount,
    "Got a message bar in the pending-uninstall message-bar-stack"
  );
}

function assertHasPendingUninstallAddon(addonList, addon) {
  const pendingUninstalls = addonList.querySelector(
    "message-bar-stack.pending-uninstall"
  );
  const addonPendingUninstall = addonList.getPendingUninstallBar(addon);
  ok(
    addonPendingUninstall,
    "Got expected message-bar for the pending uninstall test extension"
  );
  is(
    addonPendingUninstall.parentNode,
    pendingUninstalls,
    "pending uninstall bar should be part of the message-bar-stack"
  );
  is(
    addonPendingUninstall.getAttribute("addon-id"),
    addon.id,
    "Got expected addon-id attribute on the pending uninstall message-bar"
  );
}

async function testUndoPendingUninstall(addonList, addon) {
  const addonPendingUninstall = addonList.getPendingUninstallBar(addon);
  const undoButton = addonPendingUninstall.querySelector("button[action=undo]");
  ok(undoButton, "Got undo action button in the pending uninstall message-bar");

  info(
    "Clicking the pending uninstall undo button and wait for addon card rendered"
  );
  const updated = BrowserTestUtils.waitForEvent(addonList, "add");
  undoButton.click();
  await updated;

  ok(
    addon && !(addon.pendingOperations & AddonManager.PENDING_UNINSTALL),
    "The addon pending uninstall cancelled"
  );
}

function loadTestSubscript(filePath) {
  Services.scriptloader.loadSubScript(new URL(filePath, gTestPath).href, this);
}

function cleanupPendingNotifications() {
  const { ExtensionsUI } = ChromeUtils.importESModule(
    "resource:///modules/ExtensionsUI.sys.mjs"
  );
  info("Cleanup any pending notification before exiting the test");
  const keys = ChromeUtils.nondeterministicGetWeakSetKeys(
    ExtensionsUI.pendingNotifications
  );
  if (keys) {
    keys.forEach(key => ExtensionsUI.pendingNotifications.delete(key));
  }
}

function promisePermissionPrompt(addonId) {
  return BrowserUtils.promiseObserved(
    "webextension-permission-prompt",
    subject => {
      const { info } = subject.wrappedJSObject || {};
      return !addonId || (info.addon && info.addon.id === addonId);
    }
  ).then(({ subject }) => {
    return subject.wrappedJSObject.info;
  });
}

async function handlePermissionPrompt({
  addonId,
  reject = false,
  assertIcon = true,
} = {}) {
  const info = await promisePermissionPrompt(addonId);
  // Assert that info.addon and info.icon are defined as expected.
  is(
    info.addon && info.addon.id,
    addonId,
    "Got the AddonWrapper in the permission prompt info"
  );

  if (assertIcon) {
    Assert.notEqual(
      info.icon,
      null,
      "Got an addon icon in the permission prompt info"
    );
  }

  if (reject) {
    info.reject();
  } else {
    info.resolve();
  }
}

async function switchToDetailView({ id, win }) {
  let card = getAddonCard(win, id);
  ok(card, `Addon card found for ${id}`);
  ok(!card.querySelector("addon-details"), "The card doesn't have details");
  let loaded = waitForViewLoad(win);
  EventUtils.synthesizeMouseAtCenter(
    card.querySelector(".addon-name-link"),
    { clickCount: 1 },
    win
  );
  await loaded;
  card = getAddonCard(win, id);
  ok(card.querySelector("addon-details"), "The card does have details");
  return card;
}
