/* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";

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

const mockAddonRepository = ({
  addons = [],
  expectedBrowserID = null,
  expectedExtensionIDs = null,
  matchedIDs = [],
  unmatchedIDs = [],
}) => {
  return {
    async getMappedAddons(browserID, extensionIDs) {
      if (expectedBrowserID) {
        Assert.equal(browserID, expectedBrowserID, "expected browser ID");
      }
      if (expectedExtensionIDs) {
        Assert.deepEqual(
          extensionIDs,
          expectedExtensionIDs,
          "expected extension IDs"
        );
      }

      return Promise.resolve({
        addons,
        matchedIDs,
        unmatchedIDs,
      });
    },
  };
};

const assertStageInstallsResult = (result, importedAddonIDs) => {
  // Sort the results to always assert the elements in the same order.
  result.importedAddonIDs.sort();
  Assert.deepEqual(result, { importedAddonIDs }, "expected results");
  Assert.ok(
    AMBrowserExtensionsImport.hasPendingImportedAddons,
    "expected pending imported add-ons"
  );
};

const cancelInstalls = async importedAddonIDs => {
  const promiseTopic = TestUtils.topicObserved(
    "webextension-imported-addons-cancelled"
  );
  // We want to verify that we received a `onInstallCancelled` event per
  // (cancelled) install (i.e. per imported add-on).
  const cancelledPromises = importedAddonIDs.map(id =>
    AddonTestUtils.promiseInstallEvent(
      "onInstallCancelled",
      (install, cancelledByUser) => {
        Assert.equal(cancelledByUser, false, "Not user-cancelled");
        return install.addon.id == id;
      }
    )
  );
  await AMBrowserExtensionsImport.cancelInstalls();
  await Promise.all([promiseTopic, ...cancelledPromises]);
  Assert.ok(
    !AMBrowserExtensionsImport.hasPendingImportedAddons,
    "expected no pending imported add-ons"
  );
};

const TEST_SERVER = createHttpServer({ hosts: ["example.com"] });

const ADDONS = {
  ext1: {
    manifest: {
      name: "Ext 1",
      version: "1.0",
      browser_specific_settings: { gecko: { id: "ff@ext-1" } },
    },
  },
  ext2: {
    manifest: {
      name: "Ext 2",
      version: "1.0",
      browser_specific_settings: { gecko: { id: "ff@ext-2" } },
    },
  },
};
// Populated in `setup()`.
const XPIS = {};
// Populated in `setup()`.
const ADDON_SEARCH_RESULTS = {};

AddonTestUtils.init(this);
AddonTestUtils.overrideCertDB();
AddonTestUtils.createAppInfo(
  "xpcshell@tests.mozilla.org",
  "XPCShell",
  "42",
  "42"
);

add_setup(async function setup() {
  for (const [name, data] of Object.entries(ADDONS)) {
    XPIS[name] = AddonTestUtils.createTempWebExtensionFile(data);
    TEST_SERVER.registerFile(`/addons/${name}.xpi`, XPIS[name]);

    ADDON_SEARCH_RESULTS[name] = {
      id: data.manifest.browser_specific_settings.gecko.id,
      name: data.name,
      version: data.version,
      sourceURI: Services.io.newURI(`http://example.com/addons/${name}.xpi`),
      icons: {},
    };
  }

  await AddonTestUtils.promiseStartupManager();

  // FOG needs a profile directory to put its data in.
  const profileDir = do_get_profile();

  // FOG needs to be initialized in order for data to flow.
  Services.fog.initializeFOG();

  // When we stage installs and then cancel them, `XPIInstall` won't be able to
  // remove the staging directory (which is expected to be empty) until the
  // next restart. This causes an `AddonTestUtils` assertion to fail because we
  // don't expect any staging directory at the end of the tests. That's why we
  // remove this directory in the cleanup function defined below.
  //
  // We only remove the staging directory and that will only works if the
  // directory is empty, otherwise an unchaught error will be thrown (on
  // purpose).
  registerCleanupFunction(() => {
    const stagingDir = profileDir.clone();
    stagingDir.append("extensions");
    stagingDir.append("staged");
    stagingDir.exists() && stagingDir.remove(/* recursive */ false);

    // Clear the add-on repository override.
    AMBrowserExtensionsImport._addonRepository = null;
  });
});

add_task(async function test_stage_and_complete_installs() {
  const browserID = "some-browser-id";
  const extensionIDs = ["ext-1", "ext-2"];
  AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
    addons: Object.values(ADDON_SEARCH_RESULTS),
    expectedBrowserID: browserID,
    expectedExtensionIDs: extensionIDs,
  });
  const importedAddonIDs = ["ff@ext-1", "ff@ext-2"];

  let promiseTopic = TestUtils.topicObserved(
    "webextension-imported-addons-pending"
  );
  const result = await AMBrowserExtensionsImport.stageInstalls(
    browserID,
    extensionIDs
  );
  await promiseTopic;
  assertStageInstallsResult(result, importedAddonIDs);

  // Make sure the prompt handler is the one from `AMBrowserExtensionsImport`
  // since we don't want to show a permission prompt during an import.
  for (const install of AMBrowserExtensionsImport._pendingInstallsMap.values()) {
    Assert.equal(
      install.promptHandler,
      AMBrowserExtensionsImport._installPromptHandler,
      "expected prompt handler to be the one set by AMBrowserExtensionsImport"
    );
  }

  promiseTopic = TestUtils.topicObserved(
    "webextension-imported-addons-complete"
  );
  const endedPromises = importedAddonIDs.map(id =>
    AddonTestUtils.promiseInstallEvent(
      "onInstallEnded",
      install => install.addon.id == id
    )
  );
  await AMBrowserExtensionsImport.completeInstalls();
  await Promise.all([promiseTopic, ...endedPromises]);

  Assert.ok(
    !AMBrowserExtensionsImport.hasPendingImportedAddons,
    "expected no pending imported add-ons"
  );
  Assert.ok(
    !AMBrowserExtensionsImport._canCompleteOrCancelInstalls &&
      !AMBrowserExtensionsImport._importInProgress,
    "expected internal state to be consistent"
  );

  for (const id of importedAddonIDs) {
    const addon = await AddonManager.getAddonByID(id);
    Assert.ok(addon.isActive, `expected add-on "${id}" to be enabled`);
    await addon.uninstall();
  }
});

add_task(async function test_stage_and_cancel_installs() {
  const browserID = "some-browser-id";
  const extensionIDs = ["ext-1", "ext-2"];
  AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
    addons: Object.values(ADDON_SEARCH_RESULTS),
    expectedBrowserID: browserID,
    expectedExtensionIDs: extensionIDs,
  });
  const importedAddonIDs = ["ff@ext-1", "ff@ext-2"];

  const promiseTopic = TestUtils.topicObserved(
    "webextension-imported-addons-pending"
  );
  const result = await AMBrowserExtensionsImport.stageInstalls(
    browserID,
    extensionIDs
  );
  await promiseTopic;
  assertStageInstallsResult(result, importedAddonIDs);

  await cancelInstalls(importedAddonIDs);
});

add_task(async function test_stageInstalls_telemetry() {
  const browserID = "some-browser-id";
  const extensionIDs = ["ext-1", "ext-2"];
  const unmatchedIDs = ["unmatched-1", "unmatched-2"];
  AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
    addons: Object.values(ADDON_SEARCH_RESULTS),
    expectedBrowserID: browserID,
    expectedExtensionIDs: extensionIDs,
    matchedIDs: ["ext-1", "ext-2"],
    unmatchedIDs,
  });
  const importedAddonIDs = ["ff@ext-1", "ff@ext-2"];

  const promiseTopic = TestUtils.topicObserved(
    "webextension-imported-addons-pending"
  );
  const result = await AMBrowserExtensionsImport.stageInstalls(
    browserID,
    extensionIDs
  );
  await promiseTopic;
  assertStageInstallsResult(result, importedAddonIDs);

  Assert.deepEqual(
    Glean.browserMigration.matchedExtensions.testGetValue(),
    extensionIDs
  );
  Assert.deepEqual(
    Glean.browserMigration.unmatchedExtensions.testGetValue(),
    unmatchedIDs
  );

  await cancelInstalls(importedAddonIDs);
});

add_task(async function test_call_stageInstalls_twice() {
  const browserID = "some-browser-id";
  const extensionIDs = ["ext-1"];
  AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
    // Only return one extension.
    addons: Object.values(ADDON_SEARCH_RESULTS).slice(0, 1),
    expectedBrowserID: browserID,
    expectedExtensionIDs: extensionIDs,
  });
  const importedAddonIDs = ["ff@ext-1"];

  const promiseTopic = TestUtils.topicObserved(
    "webextension-imported-addons-pending"
  );
  let result = await AMBrowserExtensionsImport.stageInstalls(
    browserID,
    extensionIDs
  );
  await promiseTopic;
  assertStageInstallsResult(result, importedAddonIDs);

  await Assert.rejects(
    AMBrowserExtensionsImport.stageInstalls(browserID, []),
    /Cannot stage installs because there are pending imported add-ons/,
    "expected rejection because there are pending imported add-ons"
  );

  // Cancel the installs for the previous import.
  await cancelInstalls(importedAddonIDs);

  // We should now be able to stage installs again.
  result = await AMBrowserExtensionsImport.stageInstalls(
    browserID,
    extensionIDs
  );
  assertStageInstallsResult(result, importedAddonIDs);

  await cancelInstalls(importedAddonIDs);
});

add_task(async function test_call_stageInstalls_no_addons() {
  const browserID = "some-browser-id";
  const extensionIDs = ["ext-123456"];
  AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
    // Returns no mapped add-ons.
    addons: [],
    expectedBrowserID: browserID,
    expectedExtensionIDs: extensionIDs,
  });

  const result = await AMBrowserExtensionsImport.stageInstalls(
    browserID,
    extensionIDs
  );

  Assert.deepEqual(result, { importedAddonIDs: [] }, "expected result");
  Assert.ok(
    !AMBrowserExtensionsImport.hasPendingImportedAddons,
    "expected no pending imported add-ons"
  );
  Assert.ok(
    !AMBrowserExtensionsImport._canCompleteOrCancelInstalls &&
      !AMBrowserExtensionsImport._importInProgress,
    "expected internal state to be consistent"
  );
});

add_task(async function test_import_twice() {
  const browserID = "some-browser-id";
  const extensionIDs = ["ext-1", "ext-2"];
  AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
    addons: Object.values(ADDON_SEARCH_RESULTS),
    expectedBrowserID: browserID,
    expectedExtensionIDs: extensionIDs,
  });
  const importedAddonIDs = ["ff@ext-1", "ff@ext-2"];

  let promiseTopic = TestUtils.topicObserved(
    "webextension-imported-addons-pending"
  );
  let result = await AMBrowserExtensionsImport.stageInstalls(
    browserID,
    extensionIDs
  );
  await promiseTopic;
  assertStageInstallsResult(result, importedAddonIDs);

  // Finalize the installs.
  promiseTopic = TestUtils.topicObserved(
    "webextension-imported-addons-complete"
  );
  const endedPromises = importedAddonIDs.map(id =>
    AddonTestUtils.promiseInstallEvent(
      "onInstallEnded",
      install => install.addon.id == id
    )
  );
  await AMBrowserExtensionsImport.completeInstalls();
  await Promise.all([promiseTopic, ...endedPromises]);

  // Try to import the same add-ons again. Because these add-ons are already
  // installed, we shouldn't re-import them again.
  result = await AMBrowserExtensionsImport.stageInstalls(
    browserID,
    extensionIDs
  );
  Assert.deepEqual(result, { importedAddonIDs: [] }, "expected result");
  Assert.ok(
    !AMBrowserExtensionsImport.hasPendingImportedAddons,
    "expected no pending imported add-ons"
  );
  Assert.ok(
    !AMBrowserExtensionsImport._canCompleteOrCancelInstalls &&
      !AMBrowserExtensionsImport._importInProgress,
    "expected internal state to be consistent"
  );

  for (const id of importedAddonIDs) {
    const addon = await AddonManager.getAddonByID(id);
    Assert.ok(addon.isActive, `expected add-on "${id}" to be enabled`);
    await addon.uninstall();
  }
});

add_task(async function test_call_cancelInstalls_without_pending_import() {
  await Assert.rejects(
    AMBrowserExtensionsImport.cancelInstalls(),
    /No import in progress/,
    "expected an error"
  );
});

add_task(async function test_call_completeInstalls_without_pending_import() {
  await Assert.rejects(
    AMBrowserExtensionsImport.completeInstalls(),
    /No import in progress/,
    "expected an error"
  );
});

add_task(async function test_stage_installs_with_download_aborted() {
  const browserID = "some-browser-id";
  const extensionIDs = ["ext-1", "ext-2"];
  AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
    addons: Object.values(ADDON_SEARCH_RESULTS),
    expectedBrowserID: browserID,
    expectedExtensionIDs: extensionIDs,
  });
  const importedAddonIDs = ["ff@ext-2"];

  // This listener will be triggered once (for the first imported add-on). Its
  // goal is to cancel the download of an imported add-on and make sure it
  // doesn't break everything. We still expect the second add-on to import to
  // be staged for install.
  const onNewInstall = AddonTestUtils.promiseInstallEvent(
    "onNewInstall",
    install => {
      install.addListener({
        onDownloadStarted: () => false,
      });
      return true;
    }
  );
  const promiseTopic = TestUtils.topicObserved(
    "webextension-imported-addons-pending"
  );
  const result = await AMBrowserExtensionsImport.stageInstalls(
    browserID,
    extensionIDs
  );
  await Promise.all([onNewInstall, promiseTopic]);
  assertStageInstallsResult(result, importedAddonIDs);

  Assert.ok(
    AMBrowserExtensionsImport.hasPendingImportedAddons,
    "expected pending imported add-ons"
  );
  Assert.ok(
    AMBrowserExtensionsImport._canCompleteOrCancelInstalls &&
      AMBrowserExtensionsImport._importInProgress,
    "expected internal state to be consistent"
  );

  // Let's cancel the pending installs.
  await cancelInstalls(importedAddonIDs);
});

add_task(async function test_stageInstalls_then_restart_addonManager() {
  const browserID = "some-browser-id";
  const extensionIDs = ["ext-1", "ext-2"];
  const EXPECTED_SOURCE_URI_SPECS = {
    ["ff@ext-1"]: "http://example.com/addons/ext1.xpi",
    ["ff@ext-2"]: "http://example.com/addons/ext2.xpi",
  };
  AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
    addons: Object.values(ADDON_SEARCH_RESULTS),
    expectedBrowserID: browserID,
    expectedExtensionIDs: extensionIDs,
  });
  const importedAddonIDs = ["ff@ext-1", "ff@ext-2"];

  let promiseTopic = TestUtils.topicObserved(
    "webextension-imported-addons-pending"
  );
  let result = await AMBrowserExtensionsImport.stageInstalls(
    browserID,
    extensionIDs
  );
  await promiseTopic;
  assertStageInstallsResult(result, importedAddonIDs);

  // We restart the add-ons manager to simulate a browser restart. It isn't
  // quite the same but that should be enough.
  await AddonTestUtils.promiseRestartManager();

  for (const id of importedAddonIDs) {
    const addon = await AddonManager.getAddonByID(id);
    Assert.ok(addon.isActive, `expected add-on "${id}" to be enabled`);
    // Verify that the sourceURI and installTelemetryInfo also match
    // the values expected for the addons installed from the browser
    // imports install flow.
    Assert.deepEqual(
      {
        id: addon.id,
        sourceURI: addon.sourceURI?.spec,
        installTelemetryInfo: addon.installTelemetryInfo,
      },
      {
        id,
        sourceURI: EXPECTED_SOURCE_URI_SPECS[id],
        installTelemetryInfo: {
          source: AMBrowserExtensionsImport.TELEMETRY_SOURCE,
        },
      },
      "Got the expected AddonWrapper properties"
    );
    await addon.uninstall();
  }
});
