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

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

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

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

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

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

// Helper to run tests for specific regions
function setupRegions(home, current) {
  Region._setHomeRegion(home || "");
  Region._setCurrentRegion(current || "");
}

function setLanguage(language) {
  Services.locale.availableLocales = [language];
  Services.locale.requestedLocales = [language];
}

/**
 * Calls to this need to revert these changes by undoing them at the end of the test,
 * using:
 *
 *  await EnterprisePolicyTesting.setupPolicyEngineWithJson("");
 */
async function setupEnterprisePolicy() {
  // set app info name as it's needed to set a policy and is not defined by default
  // in xpcshell tests
  updateAppInfo({
    name: "XPCShell",
  });

  // set up an arbitrary enterprise policy
  await EnterprisePolicyTesting.setupPolicyEngineWithJson({
    policies: {
      EnableTrackingProtection: {
        Value: true,
      },
    },
  });
}

add_task(async function test_shouldShowVPNPromo() {
  function setPromoEnabled(enabled) {
    Services.prefs.setBoolPref("browser.vpn_promo.enabled", enabled);
  }

  const allowedRegion = "US";
  const disallowedRegion = "SY";
  const illegalRegion = "CN";
  const unsupportedRegion = "LY";
  const regionNotInDefaultPref = "QQ";

  // Show promo when enabled in allowed regions
  setupRegions(allowedRegion, allowedRegion);
  Assert.ok(BrowserUtils.shouldShowVPNPromo());

  // Don't show when not enabled
  setPromoEnabled(false);
  Assert.ok(!BrowserUtils.shouldShowVPNPromo());

  // Don't show in disallowed home regions, even when enabled
  setPromoEnabled(true);
  setupRegions(disallowedRegion);
  Assert.ok(!BrowserUtils.shouldShowVPNPromo());

  // Don't show in illegal home regions, even when enabled
  setupRegions(illegalRegion);
  Assert.ok(!BrowserUtils.shouldShowVPNPromo());

  // Don't show in disallowed current regions, even when enabled
  setupRegions(allowedRegion, disallowedRegion);
  Assert.ok(!BrowserUtils.shouldShowVPNPromo());

  // Don't show in illegal current regions, even when enabled
  setupRegions(allowedRegion, illegalRegion);
  Assert.ok(!BrowserUtils.shouldShowVPNPromo());

  // Show if home region is supported, even if current region is not supported (but isn't disallowed or illegal)
  setupRegions(allowedRegion, unsupportedRegion);
  Assert.ok(BrowserUtils.shouldShowVPNPromo());

  // Show VPN if current region is supported, even if home region is unsupported (but isn't disallowed or illegal)
  setupRegions(unsupportedRegion, allowedRegion); // revert changes to regions
  Assert.ok(BrowserUtils.shouldShowVPNPromo());

  // Make sure we are getting the list of allowed regions from the right
  // place.
  setupRegions(regionNotInDefaultPref);
  Services.prefs.setStringPref(
    "browser.contentblocking.report.vpn_regions",
    "qq"
  );
  Assert.ok(BrowserUtils.shouldShowVPNPromo());
  Services.prefs.clearUserPref("browser.contentblocking.report.vpn_regions");

  if (AppConstants.platform !== "android") {
    // Services.policies isn't shipped on Android
    // Don't show VPN if there's an active enterprise policy
    setupRegions(allowedRegion, allowedRegion);
    await setupEnterprisePolicy();

    Assert.ok(!BrowserUtils.shouldShowVPNPromo());

    // revert policy changes made earlier
    await EnterprisePolicyTesting.setupPolicyEngineWithJson("");
  }
});

add_task(async function test_sendToDeviceEmailsSupported() {
  const allowedLanguage = "en-US";
  const disallowedLanguage = "ar";

  // Return true if language is en-US
  setLanguage(allowedLanguage);
  Assert.ok(BrowserUtils.sendToDeviceEmailsSupported());

  // Return false if language is ar
  setLanguage(disallowedLanguage);
  Assert.ok(!BrowserUtils.sendToDeviceEmailsSupported());
});

add_task(async function test_shouldShowFocusPromo() {
  const allowedRegion = "US";
  const disallowedRegion = "CN";

  // Show promo when neither region is disallowed
  setupRegions(allowedRegion, allowedRegion);
  Assert.ok(BrowserUtils.shouldShowPromo(BrowserUtils.PromoType.FOCUS));

  // Don't show when home region is disallowed
  setupRegions(disallowedRegion);
  Assert.ok(!BrowserUtils.shouldShowPromo(BrowserUtils.PromoType.FOCUS));

  setupRegions(allowedRegion, allowedRegion);

  // Don't show when there is an enterprise policy active
  if (AppConstants.platform !== "android") {
    // Services.policies isn't shipped on Android
    await setupEnterprisePolicy();

    Assert.ok(!BrowserUtils.shouldShowPromo(BrowserUtils.PromoType.FOCUS));

    // revert policy changes made earlier
    await EnterprisePolicyTesting.setupPolicyEngineWithJson("");
  }

  // Don't show when promo disabled by pref
  Preferences.set("browser.promo.focus.enabled", false);
  Assert.ok(!BrowserUtils.shouldShowPromo(BrowserUtils.PromoType.FOCUS));

  Preferences.resetBranch("browser.promo.focus");
});

add_task(async function test_shouldShowPinPromo() {
  Preferences.set("browser.promo.pin.enabled", true);
  // Show pin promo type by default when promo is enabled
  Assert.ok(BrowserUtils.shouldShowPromo(BrowserUtils.PromoType.PIN));

  // Don't show when there is an enterprise policy active
  if (AppConstants.platform !== "android") {
    // Services.policies isn't shipped on Android
    await setupEnterprisePolicy();

    Assert.ok(!BrowserUtils.shouldShowPromo(BrowserUtils.PromoType.PIN));

    // revert policy changes made earlier
    await EnterprisePolicyTesting.setupPolicyEngineWithJson("");
  }

  // Don't show when promo disabled by pref
  Preferences.set("browser.promo.pin.enabled", false);
  Assert.ok(!BrowserUtils.shouldShowPromo(BrowserUtils.PromoType.PIN));

  Preferences.resetBranch("browser.promo.pin");
});

add_task(async function test_shouldShowRelayPromo() {
  // This test assumes by default no uri is configured.
  Preferences.set("identity.fxaccounts.autoconfig.uri", "");
  Assert.ok(BrowserUtils.shouldShowPromo(BrowserUtils.PromoType.RELAY));

  // Don't show when there is an enterprise policy active
  if (AppConstants.platform !== "android") {
    // Services.policies isn't shipped on Android
    await setupEnterprisePolicy();

    Assert.ok(!BrowserUtils.shouldShowPromo(BrowserUtils.PromoType.RELAY));

    // revert policy changes made earlier
    await EnterprisePolicyTesting.setupPolicyEngineWithJson("");
  }

  // Don't show if a custom FxA instance is configured
  Preferences.set("identity.fxaccounts.autoconfig.uri", "https://x");
  Assert.ok(!BrowserUtils.shouldShowPromo(BrowserUtils.PromoType.RELAY));

  Preferences.reset("identity.fxaccounts.autoconfig.uri");
});

add_task(async function test_shouldShowCookieBannersPromo() {
  Preferences.set("browser.promo.cookiebanners.enabled", true);
  // Show cookie banners promo type by default when promo is enabled
  Assert.ok(
    BrowserUtils.shouldShowPromo(BrowserUtils.PromoType.COOKIE_BANNERS)
  );

  // Don't show when promo disabled by pref
  Preferences.set("browser.promo.cookiebanners.enabled", false);
  Assert.ok(
    !BrowserUtils.shouldShowPromo(BrowserUtils.PromoType.COOKIE_BANNERS)
  );

  Preferences.resetBranch("browser.promo.cookiebanners");
});

add_task(function test_getShareableURL() {
  // Some test suites, specifically android, don't have this setup properly -- so we add it manually
  if (!Preferences.get("services.sync.engine.tabs.filteredSchemes")) {
    Preferences.set(
      "services.sync.engine.tabs.filteredSchemes",
      "about|resource|chrome|file|blob|moz-extension|data"
    );
  }
  // Empty shouldn't be sendable
  Assert.ok(!BrowserUtils.getShareableURL(""));
  // Valid
  let good = Services.io.newURI("https://mozilla.org");
  Assert.ok(BrowserUtils.getShareableURL(good).equals(good));
  // Invalid
  Assert.ok(
    !BrowserUtils.getShareableURL(Services.io.newURI("file://path/to/pdf.pdf"))
  );

  // Invalid
  Assert.ok(
    !BrowserUtils.getShareableURL(
      Services.io.newURI(
        "data:application/json;base64,ewogICJ0eXBlIjogIm1haW4i=="
      )
    )
  );

  // Reader mode:
  if (AppConstants.platform !== "android") {
    let readerUrl = Services.io.newURI(
      "about:reader?url=" + encodeURIComponent("http://foo.com/")
    );
    Assert.equal(
      BrowserUtils.getShareableURL(readerUrl).spec,
      "http://foo.com/"
    );
  }
});

/**
 * Verify that category manager calling modules are loaded on-demand,
 * and that caching doesn't break adding more modules as category entries
 * at runtime.
 */
add_task(async function test_callModulesFromCategory() {
  const MODULE1 = "resource://test/my_catman_1.sys.mjs";
  const MODULE2 = "resource://test/my_catman_2.sys.mjs";
  const CATEGORY = "test-modules-from-catman";
  const OBSTOPIC1 = CATEGORY + "-notification";
  const OBSTOPIC2 = CATEGORY + "-other-notification";

  // The two modules both fire different observer topics to allow us to ensure
  // they have been called. This helper just makes it easier to get only
  // that return value as a result of a promise, as `topicObserved` also
  // returns the "subject" of the observer notification, which we don't care about.
  let rvFromModule = topic =>
    TestUtils.topicObserved(topic).then(([_subj, data]) => data);

  // Start off with nothing in a category:
  Assert.equal(
    Cu.isESModuleLoaded(MODULE1),
    false,
    "First module should not be loaded."
  );
  let catEntries = Array.from(Services.catMan.enumerateCategory(CATEGORY));
  Assert.deepEqual(catEntries, [], "Should be no entries for this category.");

  try {
    // There's nothing in this category right now so this should be a no-op.
    BrowserUtils.callModulesFromCategory({ categoryName: CATEGORY }, "Hello");
  } catch (ex) {
    Assert.ok(false, `Should not have thrown but received an exception ${ex}`);
  }

  // Now add an item, check that calling it now works.
  //
  // Note that category manager observer notifications are async (they get
  // dispatched as runnables) and so we have to wait for it to make sure that
  // BrowserUtils has had a chance of being told new entries have arrived.
  let catManUpdated = TestUtils.topicObserved("xpcom-category-entry-added");

  Services.catMan.addCategoryEntry(
    CATEGORY,
    MODULE1,
    `Module1.test`,
    false,
    false
  );
  catEntries = Array.from(Services.catMan.enumerateCategory(CATEGORY));
  Assert.equal(catEntries.length, 1);

  // See note above.
  await catManUpdated;

  Assert.equal(
    Cu.isESModuleLoaded(MODULE1),
    false,
    "First module should still not be loaded."
  );

  // This entry will cause an observer topic to notify, so ensure that happens.
  let moduleResult = rvFromModule(OBSTOPIC1);
  BrowserUtils.callModulesFromCategory({ categoryName: CATEGORY }, "Hello");
  Assert.equal(
    Cu.isESModuleLoaded(MODULE1),
    true,
    "First module should be loaded sync."
  );
  Assert.equal("Hello", await moduleResult, "Should have been called.");

  // Now add another item, check that both are called.
  catManUpdated = TestUtils.topicObserved("xpcom-category-entry-added");
  Services.catMan.addCategoryEntry(
    CATEGORY,
    MODULE2,
    `Module2.othertest`,
    false,
    false
  );
  await catManUpdated;

  moduleResult = Promise.all([
    rvFromModule(OBSTOPIC1),
    rvFromModule(OBSTOPIC2),
  ]);

  BrowserUtils.callModulesFromCategory({ categoryName: CATEGORY }, "Hello");
  Assert.deepEqual(
    ["Hello", "Hello"],
    await moduleResult,
    "Both modules should have been called."
  );

  // Now remove the first module again, check that only the second one notifies.
  catManUpdated = TestUtils.topicObserved("xpcom-category-entry-removed");
  Services.catMan.deleteCategoryEntry(CATEGORY, MODULE1, false);
  await catManUpdated;
  let ob = () => Assert.ok(false, "I shouldn't be called.");
  Services.obs.addObserver(ob, OBSTOPIC1);

  moduleResult = rvFromModule(OBSTOPIC2);
  BrowserUtils.callModulesFromCategory({ categoryName: CATEGORY }, "Hello");
  Assert.equal(
    "Hello",
    await moduleResult,
    "Second module should still be called."
  );

  let idleResult = null;
  let idlePromise = TestUtils.topicObserved(OBSTOPIC2).then(([_subj, data]) => {
    idleResult = data;
    return data;
  });
  BrowserUtils.callModulesFromCategory(
    { categoryName: CATEGORY, idleDispatch: true },
    "Hello"
  );
  Assert.equal(idleResult, null, "Idle calls should not happen immediately.");
  Assert.equal("Hello", await idlePromise, "Idle calls should run eventually.");

  Services.obs.removeObserver(ob, OBSTOPIC1);

  // Now clean up our category for later tests.
  Services.catMan.deleteCategory(CATEGORY);
});

// Test that errors are reported but do not throw at the callsite,
// and that custom error handlers are invoked.
add_task(async function test_callModulesFromCategory_errors() {
  const OTHER_CAT = "someothercat";
  const MODULE1 = "resource://test/my_catman_1.sys.mjs";

  // Add an item that doesn't exist, and check that although we report errors,
  // the callsite doesn't throw.
  let catManUpdated = TestUtils.topicObserved("xpcom-category-entry-added");
  Services.catMan.addCategoryEntry(
    OTHER_CAT,
    MODULE1,
    `Module1.nonExistantFunction`,
    false,
    false
  );
  await catManUpdated;
  let catEntries = Array.from(Services.catMan.enumerateCategory(OTHER_CAT));
  Assert.equal(catEntries.length, 1);

  let consolePromise = TestUtils.consoleMessageObserved(m => {
    let firstArg = m.wrappedJSObject.arguments?.[0]?.message;
    return typeof firstArg == "string" && firstArg.includes("not a function");
  });
  BrowserUtils.callModulesFromCategory(
    {
      categoryName: OTHER_CAT,
    },
    "Hello"
  );
  let reportedError = await consolePromise;
  let firstArg = reportedError.wrappedJSObject.arguments?.[0]?.message;
  Assert.stringContains(
    firstArg,
    MODULE1,
    "Error message should include module URL."
  );
  Services.catMan.deleteCategoryEntry(OTHER_CAT, MODULE1, false);

  // Check that custom exception handling from extant methods works:
  catManUpdated = TestUtils.topicObserved("xpcom-category-entry-added");
  Services.catMan.addCategoryEntry(
    OTHER_CAT,
    MODULE1,
    `Module1.throwingFunction`,
    false,
    false
  );
  await catManUpdated;
  Assert.equal(catEntries.length, 1);
  let exHandler = Promise.withResolvers();
  BrowserUtils.callModulesFromCategory({
    categoryName: OTHER_CAT,
    failureHandler: exHandler.resolve,
  });
  let caughtException = await exHandler.promise;
  Assert.stringContains(
    caughtException.message,
    "Uh oh",
    "Exceptions should be handled."
  );

  // Now clean up our category for later tests.
  Services.catMan.deleteCategory(OTHER_CAT);
});

/**
 * Test that callModulesFromCategory returns a Promise that resolves when all
 * category tasks have settled.
 */
add_task(async function test_callModulesFromCategory_returns_promise() {
  const CATEGORY = "test-modules-from-catman";
  const MODULE1 = "resource://test/my_catman_1.sys.mjs";
  const OBSTOPIC1 = CATEGORY + "-notification";

  let catManUpdated = TestUtils.topicObserved("xpcom-category-entry-added");
  Services.catMan.addCategoryEntry(
    CATEGORY,
    MODULE1,
    `Module1.test`,
    false,
    false
  );
  await catManUpdated;

  let moduleResult = TestUtils.topicObserved(OBSTOPIC1).then(
    ([_subj, data]) => data
  );

  let result = BrowserUtils.callModulesFromCategory(
    { categoryName: CATEGORY },
    "Hello"
  );

  Assert.ok(result.then, "Should return a Promise");

  let settledResults = await result;
  Assert.ok(Array.isArray(settledResults), "Should return an array of results");
  Assert.equal(settledResults.length, 1, "Should have one result");
  Assert.equal(
    settledResults[0].status,
    "fulfilled",
    "Task should have been fulfilled"
  );

  Assert.equal(await moduleResult, "Hello", "Module should have been called");

  // Now clean up our category for later tests.
  Services.catMan.deleteCategory(CATEGORY);
});

// Test a category with both a plain .js entry and an ESM entry, for both
// single-window and multi-window scenarios.
add_task(async function test_callModulesFromCategory_multiple_window() {
  const CATEGORY = "test-js-global-catman";
  // A fake .js URL — the file doesn't need to exist since we rely on
  // jsGlobal having the object already (via lazy getter in production).
  const MODULE_JS = "chrome://browser/content/fake-catman-test.js";
  const MODULE_ESM = "resource://test/my_catman_1.sys.mjs";
  const OBSTOPIC = "test-modules-from-catman-notification";

  let catManUpdated = TestUtils.topicObserved("xpcom-category-entry-added");
  Services.catMan.addCategoryEntry(
    CATEGORY,
    MODULE_JS,
    "FakeObj.doThing",
    false,
    false
  );
  await catManUpdated;

  catManUpdated = TestUtils.topicObserved("xpcom-category-entry-added");
  Services.catMan.addCategoryEntry(
    CATEGORY,
    MODULE_ESM,
    "Module1.test",
    false,
    false
  );
  await catManUpdated;

  let sandbox = sinon.createSandbox();

  // Window 1: both the .js and ESM entries should be called.
  let fakeGlobal1 = { FakeObj: { doThing: sandbox.spy() } };

  let esmResult = TestUtils.topicObserved(OBSTOPIC).then(([, data]) => data);
  BrowserUtils.callModulesFromCategory(
    { categoryName: CATEGORY, jsGlobal: fakeGlobal1 },
    "window1"
  );
  sinon.assert.calledOnce(fakeGlobal1.FakeObj.doThing);
  sinon.assert.calledWithExactly(fakeGlobal1.FakeObj.doThing, "window1");
  Assert.equal(await esmResult, "window1", "ESM entry called for window 1.");

  // Window 2 (multi-window): the .js entry must use the new jsGlobal, while
  // the ESM entry continues to use the same singleton module instance.
  let fakeGlobal2 = { FakeObj: { doThing: sandbox.spy() } };

  esmResult = TestUtils.topicObserved(OBSTOPIC).then(([, data]) => data);
  BrowserUtils.callModulesFromCategory(
    { categoryName: CATEGORY, jsGlobal: fakeGlobal2 },
    "window2"
  );
  sinon.assert.calledOnce(fakeGlobal2.FakeObj.doThing);
  sinon.assert.calledWithExactly(fakeGlobal2.FakeObj.doThing, "window2");
  sinon.assert.calledOnce(fakeGlobal1.FakeObj.doThing); // not called again
  Assert.equal(await esmResult, "window2", "ESM entry called for window 2.");

  sandbox.restore();
  Services.catMan.deleteCategory(CATEGORY);
});

// Test error paths for the jsGlobal / plain-.js-script code path.
add_task(async function test_callModulesFromCategory_jsGlobal_errors() {
  const CATEGORY = "test-js-global-catman-errors";
  const MODULE = "chrome://browser/content/fake-catman-test.js";

  let catManUpdated = TestUtils.topicObserved("xpcom-category-entry-added");
  Services.catMan.addCategoryEntry(
    CATEGORY,
    MODULE,
    "FakeObj.doThing",
    false,
    false
  );
  await catManUpdated;

  // Omitting jsGlobal for a .js entry should log an error.
  let consolePromise = TestUtils.consoleMessageObserved(m => {
    let firstArg = m.wrappedJSObject.arguments?.[0];
    return typeof firstArg == "string" && firstArg.includes(CATEGORY);
  });
  BrowserUtils.callModulesFromCategory({ categoryName: CATEGORY }, "hello");
  await consolePromise;

  // Providing a jsGlobal that lacks the expected object should log an error.
  consolePromise = TestUtils.consoleMessageObserved(m => {
    let firstArg = m.wrappedJSObject.arguments?.[0];
    return typeof firstArg == "string" && firstArg.includes(CATEGORY);
  });
  BrowserUtils.callModulesFromCategory(
    { categoryName: CATEGORY, jsGlobal: {} },
    "hello"
  );
  await consolePromise;

  Services.catMan.deleteCategory(CATEGORY);
});
