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

"use strict";

const { FileTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/FileTestUtils.sys.mjs"
);
const { SharedDataMap } = ChromeUtils.importESModule(
  "resource://nimbus/lib/SharedDataMap.sys.mjs"
);

const KEY = "browser_shareddatamap.js";
const PATH = FileTestUtils.getTempFile("shared-data-map").path;

add_setup(() => {
  SpecialPowers.addTaskImport(
    "SharedDataMap",
    "resource://nimbus/lib/SharedDataMap.sys.mjs"
  );
  SpecialPowers.addTaskImport(
    "TestUtils",
    "resource://testing-common/TestUtils.sys.mjs"
  );
  SpecialPowers.addTaskImport(
    "sinon",
    "resource://testing-common/Sinon.sys.mjs"
  );

  Services.ppmm.releaseCachedProcesses();
});

function setupTest({ multiprocess = false } = {}) {
  const sdm = new SharedDataMap(KEY, { path: PATH });

  return {
    sdm,
    async cleanup() {
      sdm._removeEntriesByKeys(Object.keys(sdm._jsonFile.data));

      // Wait for the store to finish writing to disk, then delete the file on disk.
      await sdm._jsonFile.finalize();
      await IOUtils.remove(PATH);

      if (multiprocess) {
        // Ensure we shut down any cached processes for the next test.
        Services.ppmm.releaseCachedProcesses();
      }
    },
  };
}

add_task(async function testSetSaves() {
  const { sdm, cleanup } = setupTest();
  await sdm.init();

  sinon.spy(sdm._jsonFile, "saveSoon");

  sdm.set("foo", "bar");

  Assert.ok(
    sdm._jsonFile.saveSoon.calledOnce,
    "Should call saveSoon when setting a value"
  );

  await cleanup();
});

add_task(async function testUpdate() {
  const { sdm, cleanup } = setupTest();
  await sdm.init();

  sdm.set("foo", "foo");
  sdm.set("bar", "bar");

  Assert.equal(sdm.get("foo"), "foo");
  Assert.equal(sdm.get("bar"), "bar");

  sdm.set("foo", "baz");
  sdm.set("bar", "qux");

  Assert.equal(sdm.get("foo"), "baz");
  Assert.equal(sdm.get("bar"), "qux");

  sdm._removeEntriesByKeys(["bar", "qux"]);

  Assert.equal(sdm.get("foo"), "baz");
  Assert.equal(typeof sdm.get("bar"), "undefined");

  await cleanup();
});

add_task(async function testInitSafe() {
  const { sdm, cleanup } = setupTest();
  await sdm.init();

  sinon.stub(sdm._jsonFile, "load");
  sinon.replaceGetter(sdm._jsonFile, "data", () => {
    throw new Error("uh oh");
  });

  await sdm.init();
  Assert.ok(sdm._jsonFile.load.calledOnce, "should have called load");

  await cleanup();
}).skip();

add_task(async function testInitMultiple() {
  const { sdm, cleanup } = setupTest();

  sinon.spy(sdm._jsonFile, "load");

  await sdm.init();
  await sdm.ready();

  Assert.ok(sdm._jsonFile.load.calledOnce, "load called");

  await sdm.init();

  Assert.ok(sdm._jsonFile.load.calledOnce, "load called only once");

  await cleanup();
});

add_task(async function testChildInit() {
  const browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
  const tab = await BrowserTestUtils.openNewForegroundTab({
    gBrowser: browserWindow.gBrowser,
    url: "https://example.com",
    forceNewProcess: true,
  });

  await SpecialPowers.spawn(tab.linkedBrowser, [KEY], async key => {
    const childSdm = new SharedDataMap(key);

    Assert.ok(
      !Object.hasOwn(childSdm, "_jsonFile"),
      "child SharedDataMap does not have a store"
    );
  });

  Services.ppmm.releaseCachedProcesses();

  BrowserTestUtils.removeTab(tab);
});

add_task(async function testSetNotifiesParent() {
  const { sdm, cleanup } = setupTest();
  await sdm.init();

  const onUpdate = sinon.stub();
  sdm.on("parent-store-update:foo", onUpdate);
  sdm.set("foo", "bar");

  Assert.ok(
    onUpdate.calledOnceWithExactly("parent-store-update:foo", "bar"),
    "update event sent"
  );

  await cleanup();
});

add_task(async function testSetNotifiesChild() {
  const { sdm, cleanup } = setupTest({ multiprocess: true });
  await sdm.init();

  const browserWindow = Services.wm.getMostRecentWindow("navigator:browser");

  // Open a tab so we have a content process.
  const tab = await BrowserTestUtils.openNewForegroundTab({
    gBrowser: browserWindow.gBrowser,
    url: "https://example.com",
    forceNewProcess: true,
  });

  const messagePrefix = "testSetNotifiesChild";

  const complete = SpecialPowers.spawn(
    tab.linkedBrowser,
    [KEY, messagePrefix],
    async (key, prefix) => {
      function waitForSharedDataChanged() {
        return new Promise(resolve => {
          const listener = () => {
            resolve();
            Services.cpmm.sharedData.removeEventListener("change", listener);
          };

          Services.cpmm.sharedData.addEventListener("change", listener);
        });
      }

      const childSdm = new SharedDataMap(key);
      const onUpdate = sinon.stub();
      childSdm.on("child-store-update:foo", onUpdate);
      await childSdm.ready();

      Assert.equal(
        typeof childSdm.get("foo"),
        "undefined",
        "child does not see value"
      );

      // Send the begin message and wait for the SharedData update.
      let sharedDataChanged = waitForSharedDataChanged();
      Services.cpmm.sendAsyncMessage(`${prefix}:begin`);
      await sharedDataChanged;

      // Wait for the update event to fire.
      await TestUtils.waitForCondition(
        () => onUpdate.callCount,
        "Child received an update event"
      );
      Assert.ok(
        onUpdate.calledOnceWith("child-store-update:foo", { bar: 1 }),
        "child received update event"
      );
      Assert.deepEqual(
        childSdm.get("foo"),
        { bar: 1 },
        "child sees updated value"
      );

      onUpdate.resetHistory();

      // Send the delete message and wait for the SharedData update.
      sharedDataChanged = waitForSharedDataChanged();
      Services.cpmm.sendAsyncMessage(`${prefix}:delete`);
      await sharedDataChanged;

      await TestUtils.waitForCondition(() => !childSdm.get("foo"));
      Assert.ok(onUpdate.notCalled, "onUpdate not called for deletions");
      Assert.equal(
        typeof childSdm.get("foo"),
        "undefined",
        "After deletion, foo is undefined"
      );
    }
  );

  // Wait for the child process to register its listeners.
  await waitForChildMessage(`${messagePrefix}:begin`);

  // Set the value in the parent and wait for the child to finish its assertions.
  const continuePromise = waitForChildMessage(`${messagePrefix}:delete`);

  // Flush immediately. Do not wait for idle dispatch.
  sdm.set("foo", { bar: 1 });
  Services.ppmm.sharedData.flush();
  await continuePromise;

  // Delete the entry. _deleteForTests flushes so we don't have to.
  sdm._deleteForTests("foo");

  // Wait for the child task to complete.
  await complete;

  BrowserTestUtils.removeTab(tab);

  await cleanup();
});

add_task(async function testGetFromChildExisting() {
  const { sdm, cleanup } = setupTest({ multiprocess: true });
  await sdm.init();

  sdm.set("foo", { bar: "baz", qux: ["quux"] });

  // Ensure that we re-serialize SharedData to make the content immediately
  // available to new processes.
  Services.ppmm.sharedData.flush();

  const browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
  const tab = await BrowserTestUtils.openNewForegroundTab({
    gBrowser: browserWindow.gBrowser,
    url: "https://example.com",
    forceNewProcess: true,
  });

  await SpecialPowers.spawn(tab.linkedBrowser, [KEY], async key => {
    const childSdm = new SharedDataMap(key);
    await childSdm.ready();

    Assert.deepEqual(
      childSdm.get("foo"),
      { bar: "baz", qux: ["quux"] },
      "child sees correct value"
    );
  });

  BrowserTestUtils.removeTab(tab);

  await cleanup();
});
