"use strict";

const server = createHttpServer({ hosts: ["example.com", "example.net"] });
server.registerPathHandler("/dummy", () => {});

AddonTestUtils.init(this);
AddonTestUtils.overrideCertDB();

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

async function grantUserScriptsPermission(extensionId) {
  // userScripts is optional-only, and we must grant it. See comment at
  // grantUserScriptsPermission in test_ext_userScripts_mv3_availability.js.
  await ExtensionPermissions.add(extensionId, {
    permissions: ["userScripts"],
    origins: [],
  });
}

async function spawnPage(spawnFunc) {
  let contentPage = await ExtensionTestUtils.loadContentPage(
    "http://example.com/dummy"
  );
  const results = await contentPage.spawn([], spawnFunc);
  await contentPage.close();
  return results;
}

add_setup(async () => {
  Services.prefs.setBoolPref("extensions.userScripts.mv3.enabled", true);
  await ExtensionTestUtils.startAddonManager();
});

add_task(async function default_USER_SCRIPT_world_behavior() {
  const extensionId = "@default_USER_SCRIPT_world_behavior";
  await grantUserScriptsPermission(extensionId);
  let extension = ExtensionTestUtils.loadExtension({
    useAddonManager: "permanent",
    manifest: {
      browser_specific_settings: { gecko: { id: extensionId } },
      manifest_version: 3,
      optional_permissions: ["userScripts"],
      host_permissions: ["*://example.com/*"],
    },
    files: {
      "world_checker.js": () => {
        window.wrappedJSObject.results = [];

        // window.eval is not blocked and runs in MAIN world.
        // eslint-disable-next-line no-eval
        let resultsInMainWorld = window.eval("results");

        // Unlike ISOLATED, USER_SCRIPT world cannot access extension APIs.
        resultsInMainWorld.push(typeof browser === "undefined");

        // Unlike MAIN, USER_SCRIPT world's default CSP blocks eval.
        try {
          // eslint-disable-next-line no-eval
          eval("throw new Error('eval executed unexpectedly???')");
        } catch (e) {
          resultsInMainWorld.push(e.message);
        }
      },
    },
    async background() {
      await browser.userScripts.register([
        {
          id: "world global checker",
          matches: ["*://example.com/dummy"],
          js: [{ file: "world_checker.js" }],
          runAt: "document_start",
          world: "USER_SCRIPT",
        },
      ]);
      browser.test.sendMessage("registered");
    },
  });

  await extension.startup();
  await extension.awaitMessage("registered");

  const results = await spawnPage(() => this.content.wrappedJSObject.results);
  equal(results[0], true, "browser (extension APIs) should be undefined");
  equal(
    results[1],
    "call to eval() blocked by CSP",
    "eval() should be blocked by default in the USER_SCRIPT world"
  );

  await extension.unload();
});

add_task(async function multiple_scripts_share_same_default_world() {
  const extensionId = "@multiple_scripts_share_same_default_world";
  await grantUserScriptsPermission(extensionId);
  let extension = ExtensionTestUtils.loadExtension({
    useAddonManager: "permanent",
    manifest: {
      browser_specific_settings: { gecko: { id: extensionId } },
      manifest_version: 3,
      optional_permissions: ["userScripts"],
      host_permissions: ["*://example.com/*"],
    },
    async background() {
      await browser.userScripts.register([
        // The document_start script always runs first, which initializes the
        // middle string that the document_end scripts will prepend/append to,
        // as a sign of execution.
        // The expected value of wrappedJSObject.r below is independent of the
        // execution order of the two blocks of document_end scripts. Test
        // coverage on actual execution order of separate scripts is provided
        // further below by test_default_and_many_non_default_worldIds.
        {
          id: "first scripts",
          matches: ["*://example.com/dummy"],
          js: [
            // All js within one script are guaranteed to run in order, and
            // they should run in the same default sandbox.
            { code: `var a = "";` },
            { code: `a += "1";` },
            { code: `a += "2";` },
            { code: `a += "3";` },
            { code: `a += "4";` },
            { code: `a += "5";` },
            // Prepends a to x, and expose in web page as variable "r".
            { code: `var x = x || ""; x = a + x; window.wrappedJSObject.r=x` },
          ],
          runAt: "document_end",
        },
        {
          id: "separate scripts",
          matches: ["*://example.com/dummy"],
          js: [
            { code: `var b = "";` },
            { code: `b += "6";` },
            { code: `b += "7";` },
            { code: `b += "8";` },
            { code: `b += "9";` },
            // Appends a to x, and expose in web page as variable "r".
            { code: `var x = x || ""; x = x + b; window.wrappedJSObject.r=x` },
          ],
          runAt: "document_end",
        },
        {
          id: "document_start, runs before other document_end scripts",
          matches: ["*://example.com/dummy"],
          // The other scripts prepend and append, and this becomes the middle.
          js: [{ code: `var x = x || "_"; window.wrappedJSObject.r=x` }],
          runAt: "document_start",
        },
      ]);
      browser.test.sendMessage("registered");
    },
  });

  await extension.startup();
  await extension.awaitMessage("registered");

  const result = await spawnPage(async () => {
    // We might observe page load while the document_end scripts are still
    // compiling. To avoid intermittent failures, wait for it to complete.
    await ContentTaskUtils.waitForCondition(
      // 10 is "12345_6789".length (expected value of r).
      () => this.content.wrappedJSObject.r?.length === 10,
      "Waiting for all user scripts to have completed running"
    );
    let { x, r } = this.content.wrappedJSObject;
    return { x, r };
  });
  equal(result.x, undefined, "Web page cannot see vars from USER_SCRIPT world");
  equal(result.r, "12345_6789", "All user scripts should share the same scope");

  await extension.unload();
});

add_task(async function test_worldId_validation() {
  const extensionId = "@test_worldId_validation";
  await grantUserScriptsPermission(extensionId);
  async function background() {
    const id = "single user script id";
    function testRegister(props) {
      // ^ Not async, so that callers can test the difference between sync vs
      // async errors from userScripts.register().
      return browser.userScripts.register([
        { id, includeGlobs: ["*"], js: [{ code: "// ..." }], ...props },
      ]);
    }
    async function doUnregister() {
      await browser.userScripts.unregister({ ids: [id] });
    }

    try {
      await browser.test.assertRejects(
        testRegister({ worldId: "_" }),
        "Invalid worldId: _",
        "worldId starting with underscore are reserved"
      );
      browser.test.log("worldId containing underscore after start is OK");
      await testRegister({ worldId: "x_" });
      await doUnregister();

      await browser.test.assertRejects(
        testRegister({ worldId: "x".repeat(257) }),
        /^Invalid worldId: x{257}$/,
        "Too long worldId is rejected"
      );
      browser.test.log("worldId length of 256 characters is OK");
      await testRegister({ worldId: "x".repeat(256) });
      await doUnregister();
      browser.test.log("worldId length of 256 double-byte characters is OK");
      await testRegister({ worldId: "\u{1234}".repeat(256) });
      await doUnregister();
      // The above shows that we do not count by the number of bytes, but by
      // the JS string length. The following assertion shows that we do not
      // somehow count by the number of Unicode characters.
      await browser.test.assertRejects(
        testRegister({ worldId: "\u{1f00d}".repeat(256) }),
        /^Invalid worldId: \u{1f00d}{256}$/u,
        "worldId length of 256 multi-code unit characters is rejected."
      );

      browser.test.assertThrows(
        () => testRegister({ worldId: 123 }),
        /worldId: Expected string instead of 123/,
        "Non-string worldId is rejected."
      );

      // Now test that worldId cannot be used with world "MAIN".
      await browser.test.assertRejects(
        testRegister({ world: "MAIN", worldId: "i" }),
        "worldId cannot be used with MAIN world.",
        "Should not support worldId with MAIN world"
      );

      await browser.test.assertRejects(
        testRegister({ world: "MAIN", worldId: "i" }),
        "worldId cannot be used with MAIN world.",
        "Should not support worldId with MAIN world"
      );

      // And not even with update().
      await testRegister({ world: "MAIN" });
      await browser.test.assertRejects(
        browser.userScripts.update([{ id, worldId: "y" }]),
        "worldId cannot be used with MAIN world.",
        "Should not update worldId to non-default world for world MAIN"
      );
      browser.test.log("Updating worldId + world=USER_SCRIPT at once is OK");
      await browser.userScripts.update([
        { id, world: "USER_SCRIPT", worldId: "y" },
      ]);
      await browser.test.assertRejects(
        browser.userScripts.update([{ id, world: "MAIN" }]),
        "worldId cannot be used with MAIN world.",
        "Should not change world to MAIN when worldId is non-default worldId"
      );
      browser.test.log("Update can set world=MAIN and clear worldId at once");
      await browser.userScripts.update([{ id, world: "MAIN", worldId: "" }]);
      await doUnregister();
    } catch (e) {
      browser.test.fail(`Unexpected error: ${e}`);
    }
    browser.test.sendMessage("done");
  }
  let extension = ExtensionTestUtils.loadExtension({
    manifest: {
      browser_specific_settings: { gecko: { id: extensionId } },
      manifest_version: 3,
      optional_permissions: ["userScripts"],
    },
    background,
  });
  await extension.startup();
  await extension.awaitMessage("done");
  await extension.unload();
});

add_task(async function test_default_and_many_non_default_worldIds() {
  const extensionId = "@test_default_and_many_non_default_worldIds";
  await grantUserScriptsPermission(extensionId);
  let extension = ExtensionTestUtils.loadExtension({
    useAddonManager: "permanent",
    manifest: {
      browser_specific_settings: { gecko: { id: extensionId } },
      manifest_version: 3,
      optional_permissions: ["userScripts"],
      host_permissions: ["*://example.com/*"],
    },
    async background() {
      const matches = ["*://example.com/dummy"];
      let scripts = [
        {
          id: "define res, so other scripts can push results",
          matches,
          js: [{ code: `window.res = [];` }],
          runAt: "document_start",
          world: "MAIN",
          // worldId not specified - not meaningful for world "MAIN".
        },
        {
          id: "default world (worldId not specified)",
          matches,
          js: [{ code: `var x = x ?? []; x.push(-3)` }],
          runAt: "document_start",
          world: "USER_SCRIPT",
          // worldId not specified = default world.
        },
        {
          id: "default world (worldId is empty string)",
          matches,
          js: [{ code: `var x = x ?? []; x.push(-2)` }],
          runAt: "document_start",
          world: "USER_SCRIPT",
          worldId: "", // worldId "" is the default world.
        },
        {
          id: "default world (worldId is null)",
          matches,
          js: [{ code: `var x = x ?? []; x.push(-1)` }],
          runAt: "document_start",
          world: "USER_SCRIPT",
          worldId: null, // worldId null defaults to default world.
        },
        {
          id: "default world (export result from previous scripts)",
          matches,
          js: [{ code: `window.wrappedJSObject.res.push(...x)` }],
          runAt: "document_end", // Runs after document_start in default world.
          world: "USER_SCRIPT",
          // worldId not specified = default world.
        },
      ];
      // expected result is [-3, -2, -1] is from default world above.
      const expectedResults = [-3, -2, -1]; // plus 1...50 from loop below.
      for (let i = 1; i <= 50; ++i) {
        expectedResults.push(i);
        // The first script initializes "x" if not done so before, the second
        // script exports it to the main world.
        scripts.push({
          id: `user script ${i} at document_start`,
          matches,
          js: [{ code: `var x = x ?? ${i};` }],
          runAt: "document_start",
          world: "USER_SCRIPT",
          worldId: `worldId ${i}`,
        });
        scripts.push({
          id: `user script ${i} at document_end`,
          matches,
          // If worlds were unexpectedly shared, x would be from another script
          // and false would be added to res instead of the number i.
          js: [{ code: `window.wrappedJSObject.res.push(x === ${i} && ${i})` }],
          runAt: "document_end",
          world: "USER_SCRIPT",
          worldId: `worldId ${i}`,
        });
      }
      // If the page loads very fast, it is possible for document_end scripts
      // to still be compiling and not having been executed yet. To ensure that
      // we only check the results after all scripts have run, schedule a
      // document_idle script that runs after everything else.
      scripts.push({
        id: "document_idle, runs after everything else",
        matches: ["*://example.com/dummy"],
        js: [{ code: `window.wrappedJSObject.allTestScriptsRan = true;` }],
        runAt: "document_idle",
      });
      await browser.userScripts.register(scripts);
      browser.test.sendMessage("registered_and_expected", expectedResults);
    },
  });

  await extension.startup();
  let expectedRes = await extension.awaitMessage("registered_and_expected");

  const actualRes = await spawnPage(async () => {
    await ContentTaskUtils.waitForCondition(
      () => this.content.wrappedJSObject.allTestScriptsRan,
      "Waiting for all user scripts to have completed running"
    );
    return this.content.wrappedJSObject.res;
  });
  // Script execution order is guaranteed to be some defined order. It is
  // currently the order of registration, but may change, see bug 1963072.

  Assert.deepEqual(
    actualRes,
    expectedRes,
    "Every script should execute in the world specified by worldId"
  );

  await extension.unload();
});
