"use strict";

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

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

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

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

function loadTestExtension({ background }) {
  const id = Services.uuid.generateUUID().number;
  // "userScripts" is an optional-only permission, so we need to grant it.
  // ExtensionPermissions.add() is async, but not waiting here is OK for us:
  // Extension startup is blocked on reading permissions, which in turn awaits
  // any previous ExtensionPermissions calls in FIFO order.
  ExtensionPermissions.add(id, { permissions: ["userScripts"], origins: [] });
  return ExtensionTestUtils.loadExtension({
    useAddonManager: "permanent",
    manifest: {
      browser_specific_settings: { gecko: { id } },
      manifest_version: 3,
      optional_permissions: ["userScripts"],
    },
    background,
    files: {
      "file1.js": "",
      "file2.js": "",
    },
  });
}

add_setup(async () => {
  await ExtensionTestUtils.startAddonManager();
});

let _contentPage;
// Helper to verify that user script registrations are propagated as expected.
async function lookupScriptsInChild(extensionId, keys) {
  if (!_contentPage) {
    // We just want a content process, don't really care where it is.
    _contentPage = await ExtensionTestUtils.loadContentPage("about:blank");
    registerCleanupFunction(() => _contentPage.close());
  }
  return _contentPage.spawn([extensionId, keys], (extensionId, keys) => {
    const { WebExtensionPolicy, MatchPatternSet } = this.content;

    // User scripts are only considered for scheduling when they have been
    // registered as a WebExtensionContentScript with WebExtensionPolicy.
    // We summarize their values for the test assertions, here.
    let policy = WebExtensionPolicy.getByID(extensionId);
    return policy?.contentScripts.map(script => {
      let out = {};
      for (let k of keys) {
        let v = script[k];
        if (MatchPatternSet.isInstance(v)) {
          v = v.patterns.map(p => p.pattern);
        }
        out[k] = v;
      }
      return out;
    });
  });
}

// Test the documented flow: register user scripts at runtime.onInstalled, and
// expect them to still be registered after a restart. Verifies that the
// registration is both visible through the API and in the content process.
add_task(async function register_and_restart() {
  let extension = loadTestExtension({
    background() {
      const scriptIn = {
        id: "test1",
        matches: ["https://example.com/*"],
        js: [{ file: "file1.js" }, { file: "file2.js" }],
      };
      const expectedScriptOut = {
        ...scriptIn,
        allFrames: false,
        excludeMatches: null,
        includeGlobs: null,
        excludeGlobs: null,
        runAt: "document_idle",
        // Notable call-out: All other content script APIs default to "ISOLATED"
        // as the default world. In the userScripts API, we want to default to
        // "USER_SCRIPT" when the world is not specified.
        world: "USER_SCRIPT",
        worldId: "",
      };
      browser.runtime.onInstalled.addListener(async ({ reason }) => {
        browser.test.assertEq("install", reason, "onInstalled reason");
        await browser.userScripts.register([scriptIn]);
        browser.test.assertDeepEq(
          [expectedScriptOut],
          await browser.userScripts.getScripts(),
          "getScripts() after register"
        );
        browser.test.sendMessage("onInstalled");
      });
      browser.test.onMessage.addListener(async msg => {
        browser.test.assertEq("testStillRegistered", msg, "Expected message");
        browser.test.assertDeepEq(
          [expectedScriptOut],
          await browser.userScripts.getScripts(),
          "getScripts() still finds scripts"
        );
        browser.test.sendMessage("testStillRegistered:done");
      });
    },
  });

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

  const extensionId = extension.id;
  const expectedScriptsInContent = [
    {
      matches: ["https://example.com/*"],
      jsPaths: [
        `moz-extension://${extension.uuid}/file1.js`,
        `moz-extension://${extension.uuid}/file2.js`,
      ],
    },
  ];
  Assert.deepEqual(
    await lookupScriptsInChild(extensionId, ["matches", "jsPaths"]),
    expectedScriptsInContent,
    "Expected WebExtensionContentScript instances after registration"
  );

  // Simulate browser restart. While promiseShutdownManager unloads the add-on,
  // it is independent of the internal database used by ExtensionUserScripts.
  // To verify that the data is really stored on disk, wipe the in-memory state
  // through _uninitForTesting() so that when the extension starts up again,
  // that the userScripts state is read from disk again.
  await AddonTestUtils.promiseShutdownManager();
  ExtensionUserScripts._getStoreForTesting()._uninitForTesting();
  await AddonTestUtils.promiseStartupManager();

  Assert.deepEqual(
    await lookupScriptsInChild(extensionId, ["matches", "jsPaths"]),
    expectedScriptsInContent,
    "Expected WebExtensionContentScript instances after app+extension startup"
  );

  info("Waking up event page so that test.onMessage can receive a message");
  await extension.wakeupBackground();
  info("Verifying that the scripts are still registered after browser restart");
  extension.sendMessage("testStillRegistered");
  await extension.awaitMessage("testStillRegistered:done");
  await extension.unload();

  Assert.deepEqual(
    await lookupScriptsInChild(extensionId, ["matches", "jsPaths"]),
    null,
    "No user scripts after extension unload"
  );
});

// Test that the userScripts.update() API can be called and update some or all
// properties, while leaving null values untouched. Also confirms that a fully
// populated registration is correctly propagated to the content process.
// Moreover, verifies that code can be persisted and is persisted the internal
// blob:-URL is refreshed after a browser restart.
add_task(async function register_and_update_all_values() {
  let extension = loadTestExtension({
    background() {
      const id = "userScript_id";
      const scriptDefaults = {
        id,
        allFrames: false,
        js: [],
        // matches or includeGlobs must be non-empty, we cannot use [] here.
        matches: ["https://example.com/*"],
        excludeMatches: [],
        includeGlobs: [],
        excludeGlobs: [],
        runAt: "document_idle",
        world: "USER_SCRIPT",
        worldId: "",
      };
      const scriptDefaultsOut = {
        ...scriptDefaults,
        // An input of [] is equivalent to omitted, which is returned as null.
        // Chrome does the same.
        excludeMatches: null,
        includeGlobs: null,
        excludeGlobs: null,
      };
      // Keep this object in sync with expectedScriptsInContent below.
      const scriptNonDefaults = {
        id,
        allFrames: true,
        js: [
          { code: "// Hello world!" },
          { file: "file1.js" },
          { code: "//".repeat(1000) },
        ],
        matches: ["https://example.org/path/*"],
        excludeMatches: ["*://*/excludeme"],
        includeGlobs: ["*"],
        excludeGlobs: ["*excludeme*"],
        runAt: "document_end",
        world: "MAIN",
        // We cannot test a non-default worldId, because worldId can only take
        // a non-empty string with world USER_SCRIPT. Test coverage for worldId
        // is in test_ext_userScripts_mv3_worlds.js, test_worldId_validation.
        worldId: "",
      };

      const nullUpdate = Object.fromEntries(
        Object.keys(scriptDefaults).map(k => [k, null])
      );

      async function testRegisterDefaults() {
        await browser.userScripts.register([scriptDefaults]);
        browser.test.assertDeepEq(
          [scriptDefaultsOut],
          await browser.userScripts.getScripts(),
          "getScripts() after register with near-default properties"
        );

        await browser.userScripts.update([{ id }]);
        browser.test.assertDeepEq(
          [scriptDefaultsOut],
          await browser.userScripts.getScripts(),
          "userScripts.update call without update should not change anything"
        );

        await browser.userScripts.update([{ ...nullUpdate, id }]);
        browser.test.assertDeepEq(
          [scriptDefaultsOut],
          await browser.userScripts.getScripts(),
          "userScripts.update with all fields null should not change anything"
        );

        await browser.userScripts.update([scriptNonDefaults]);
        browser.test.assertDeepEq(
          [scriptNonDefaults],
          await browser.userScripts.getScripts(),
          "userScripts.update can change every property to a non-default value"
        );

        await browser.userScripts.update([scriptDefaults]);
        browser.test.assertDeepEq(
          [scriptDefaultsOut],
          await browser.userScripts.getScripts(),
          "userScripts.update restores non-default values when not null"
        );
      }

      async function testRegisterNonDefaults() {
        await browser.userScripts.register([scriptNonDefaults]);
        browser.test.assertDeepEq(
          [scriptNonDefaults],
          await browser.userScripts.getScripts(),
          "getScripts() after register with non-default properties"
        );

        await browser.userScripts.update([{ id }]);
        browser.test.assertDeepEq(
          [scriptNonDefaults],
          await browser.userScripts.getScripts(),
          "userScripts.update call without update should not change anything"
        );

        await browser.userScripts.update([{ ...nullUpdate, id }]);
        browser.test.assertDeepEq(
          [scriptNonDefaults],
          await browser.userScripts.getScripts(),
          "userScripts.update with all fields null should not change anything"
        );

        await browser.userScripts.update([scriptDefaults]);
        browser.test.assertDeepEq(
          [scriptDefaultsOut],
          await browser.userScripts.getScripts(),
          "userScripts.update can change every property to a near-default value"
        );

        // Sanity check that we can register the original js/code again:
        await browser.userScripts.update([scriptNonDefaults]);
        browser.test.assertDeepEq(
          [scriptNonDefaults],
          await browser.userScripts.getScripts(),
          "userScripts.update can restore original non-default values"
        );
      }

      browser.runtime.onInstalled.addListener(async () => {
        await testRegisterDefaults();
        await browser.userScripts.unregister({ ids: [id] });
        await testRegisterNonDefaults();
        browser.test.sendMessage("start_done");
      });
    },
  });
  await extension.startup();
  await extension.awaitMessage("start_done");

  async function checkScriptsInContentProcess(previousJsPaths = null) {
    const expectedScriptsInContent = {
      isUserScript: true,
      allFrames: true,
      matches: ["https://example.org/path/*"],
      excludeMatches: ["*://*/excludeme"],
      // Cannot inspect includeGlobs & excludeGlobs because they are not
      // exposed in WebExtensionContentScript.webidl
      // includeGlobs: ["*"],
      // excludeGlobs: ["*excludeme*"],
      runAt: "document_end",
      world: "MAIN",
      worldId: "",
    };
    const expectedScriptKeys = Object.keys(expectedScriptsInContent);
    Assert.deepEqual(
      await lookupScriptsInChild(extension.id, expectedScriptKeys),
      [expectedScriptsInContent],
      "Got initial script registration"
    );
    let [{ jsPaths }] = await lookupScriptsInChild(extension.id, ["jsPaths"]);
    equal(jsPaths.length, 3, "test case had 3 script registrations");
    ok(
      jsPaths[0].startsWith("blob:null"),
      `js with 'code' key (short string) becomes a blob:-URL: ${jsPaths[0]}`
    );
    equal(
      jsPaths[1],
      `moz-extension://${extension.uuid}/file1.js`,
      "js with 'file' key becomes a moz-extension:-URL"
    );
    ok(
      jsPaths[2].startsWith("blob:null"),
      `js with 'code' key (long string) becomes a blob:-URL: ${jsPaths[0]}`
    );
    if (previousJsPaths) {
      // When registered again, the blob:-URLs change.
      Assert.notEqual(jsPaths[0], previousJsPaths[0], "blob: changed (short)");
      Assert.notEqual(jsPaths[2], previousJsPaths[2], "blob: changed (long)");
    }
    return jsPaths;
  }

  const previousJsPaths = await checkScriptsInContentProcess(null);

  await AddonTestUtils.promiseShutdownManager();
  ExtensionUserScripts._getStoreForTesting()._uninitForTesting();
  await AddonTestUtils.promiseStartupManager();

  await extension.awaitStartup();

  // The background script should be stopped since it is an event page and we
  // are not triggering any persistent listeners that would wake it.
  ExtensionTestCommon.testAssertions.assertBackgroundStatusStopped(extension);

  const currentJsPaths = await checkScriptsInContentProcess(previousJsPaths);

  const blobUrls = currentJsPaths.filter(url => url.startsWith("blob:"));
  for (const blobUrl of blobUrls) {
    info(`Confirming that the URLs are resolved`);
    equal(URL.isBoundToBlob(blobUrl), true, `Valid URL: ${blobUrl}`);
    let res = await fetch(blobUrl);
    let txt = await res.text();
    // We are just doing a prefix check here as a sanity check. The main thing
    // we care about is that the URL resolves.
    equal(txt.slice(0, 2), "//", `blob:-url ${blobUrl} maps to expected code`);
  }

  await extension.unload();

  for (const blobUrl of blobUrls) {
    equal(URL.isBoundToBlob(blobUrl), false, `Revoked URL: ${blobUrl}`);
    if (AppConstants.platform == "android") {
      // On Android, unlike desktop, revoked blob:-URLs can still be loaded by
      // the system principal: https://searchfox.org/mozilla-central/rev/fcf53e1685bfb990b5abc7312ac1daf617f0991f/dom/file/uri/BlobURLChannel.cpp#62-69
      // The rationale for that logic is at https://bugzilla.mozilla.org/show_bug.cgi?id=1432949
      // The URL will eventually be revoked (after 5 seconds), but we are not
      // going to pause the test for 5 seconds, and instead rely on the
      // isBoundToBlob check above.
      continue;
    }
    await Assert.rejects(
      fetch(blobUrl),
      /NetworkError/,
      `blob:-URL should be revoked after extension uninstall: ${blobUrl}`
    );
  }
});

add_task(async function no_partial_registrations_on_error() {
  async function background() {
    try {
      const scriptBase = {
        matches: ["https://example.com/*"],
        js: [{ code: "// original" }, { file: "file1.js" }],
      };
      // For extra coverage, verify that we can call these APIs without
      // awaiting the promise. They are not expected to interfere with each
      // other.
      let promiseDuplicateSameCall = browser.userScripts.register([
        { ...scriptBase, id: "valid:ignore_me_1" },
        { ...scriptBase, id: "duplic" },
        { ...scriptBase, id: "duplic" },
        { ...scriptBase, id: "valid:ignore_me_2" },
      ]);
      let promiseReservedId = browser.userScripts.register([
        { ...scriptBase, id: "valid:ignore_me_3" },
        { ...scriptBase, id: "_reserved" },
        { ...scriptBase, id: "valid:ignore_me_4" },
      ]);
      let promiseValidBefore = browser.userScripts.register([
        { ...scriptBase, id: "validBefore1" },
        { ...scriptBase, id: "validBefore2" },
      ]);
      let promiseDuplicateBefore = browser.userScripts.register([
        { ...scriptBase, id: "validBefore1" }, // duplicate
      ]);
      let promiseUpdateNonExistent = browser.userScripts.update([
        { id: "validBefore1", js: [{ code: "// ignore me 1" }] },
        { ...scriptBase, id: "non_existent" },
      ]);
      let promiseUpdateDuplicate = browser.userScripts.update([
        { id: "validBefore1", js: [{ code: "// ignore me 2" }] },
        { id: "validBefore2", js: [{ code: "// ignore me 3" }] },
        { id: "validBefore2", js: [{ code: "// ignore me 4" }] },
      ]);
      let promiseUpdateNoMatchesNorGlobs = browser.userScripts.update([
        { id: "validBefore1", js: [{ code: "// ignore me 5" }] },
        { id: "validBefore2", matches: [] }, // = matches + includeGlobs empty
      ]);

      await browser.test.assertRejects(
        promiseDuplicateSameCall,
        "Duplicate script id: duplic",
        "userScripts.register() should reject two scripts with same id"
      );
      await browser.test.assertRejects(
        promiseReservedId,
        "Invalid id for RegisteredUserScript.",
        "userScripts.register() should reject script with reserved ID"
      );

      browser.test.assertEq(
        await promiseValidBefore,
        undefined,
        "userScripts.register() succeeded (validBefore1 + validBefore2)"
      );
      await browser.test.assertRejects(
        promiseDuplicateBefore,
        `User script with id "validBefore1" is already registered.`,
        "userScripts.register() should fail for existing ID"
      );
      await browser.test.assertRejects(
        promiseUpdateNonExistent,
        `User script with id "non_existent" does not exist.`,
        "userScripts.update() should fail for non-existing ID"
      );
      await browser.test.assertRejects(
        promiseUpdateDuplicate,
        "Duplicate script id: validBefore2",
        "userScripts.update() should reject two scripts with same id"
      );
      await browser.test.assertRejects(
        promiseUpdateNoMatchesNorGlobs,
        "matches or includeGlobs must be specified.",
        "userScripts.update() should reject update resulting in no matches"
      );

      const summarize = ({ id, matches, js }) => ({ id, matches, js });
      browser.test.assertDeepEq(
        [
          { ...scriptBase, id: "validBefore1" },
          { ...scriptBase, id: "validBefore2" },
        ],
        (await browser.userScripts.getScripts()).map(summarize),
        "When failures happen, unrelated scripts should not register."
      );

      // To complement promiseUpdateNoMatchesNorGlobs, verify that we can
      // indeed set to an empty matches, when includeGlobs is set.
      await browser.userScripts.update([
        { id: "validBefore2", matches: [], includeGlobs: ["ht*"] },
      ]);
      browser.test.assertDeepEq(
        [
          {
            matches: null,
            js: scriptBase.js,
            id: "validBefore2",
            includeGlobs: ["ht*"],
          },
        ],
        Array.from(
          await browser.userScripts.getScripts({ ids: ["validBefore2"] }),
          s => ({ ...summarize(s), includeGlobs: s.includeGlobs })
        ),
        "userScripts.update() with empty matches is OK if includeGlobs is set."
      );

      await browser.userScripts.unregister({
        ids: ["not_existing_start", "validBefore2", "not_existing_end"],
      });

      browser.test.assertDeepEq(
        [{ ...scriptBase, id: "validBefore1" }],
        (await browser.userScripts.getScripts()).map(summarize),
        "unregister() with non-existing IDs is not an error."
      );
    } catch (e) {
      browser.test.fail(`Unexpected failure: ${e}`);
    }
    browser.test.sendMessage("done");
  }
  let extension = loadTestExtension({ background });
  await extension.startup();
  await extension.awaitMessage("done");

  // Now also look at the internals to verify proper cleanup.
  let { userScriptsManager, registeredContentScripts } =
    WebExtensionPolicy.getByID(extension.id).extension;
  Assert.deepEqual(
    Array.from(userScriptsManager.scriptIdsMap.keys()),
    ["validBefore1"],
    "userScriptsManager.scriptIdsMap should contain only one entry"
  );

  equal(userScriptsManager.blobUrls.size, 1, "Should only have one blob URL");

  equal(registeredContentScripts.size, 1, "Should only have 1 internal script");

  Assert.deepEqual(
    await ExtensionUserScripts._getStoreForTesting().getAllEntries(),
    [
      [
        `${extension.id}/_script_/validBefore1`,
        {
          // In some cases below, the database contains null, because we store
          // the entry from userScripts.register(), which does not fill in the
          // default value through the schema because the same type is shared
          // with userScripts.update(), and we don't want to automatically set
          // values to the default upon update.
          // The publicly observable behavior (userScripts.getScripts() and
          // script execution behavior uses the correct defaults).
          id: "validBefore1",
          allFrames: null, // false,
          js: [{ code: "// original" }, { file: "file1.js" }],
          matches: ["https://example.com/*"],
          excludeMatches: null,
          includeGlobs: null,
          excludeGlobs: null,
          runAt: null, // "document_idle",
          world: null, // "USER_SCRIPT",
          worldId: null, // ""
        },
      ],
    ],
    "The database should only contain one entry, no junk"
  );

  await extension.unload();
});
