const DIRPATH = getRootDirectory(gTestPath).replace(
  "chrome://mochitests/content/",
  ""
);

/**
 * We choose blob contents that will roundtrip cleanly through the `textContent`
 * of our returned HTML page.
 */
const TEST_BLOB_CONTENTS = `I'm a disk-backed test blob! Hooray!`;

add_setup(async function () {
  await SpecialPowers.pushPrefEnv({
    set: [
      // Set preferences so that opening a page with the origin "example.org"
      // will result in a remoteType of "privilegedmozilla" for both the
      // page and the ServiceWorker.
      ["browser.tabs.remote.separatePrivilegedMozillaWebContentProcess", true],
      ["browser.tabs.remote.separatedMozillaDomains", "example.org"],
      ["dom.ipc.processCount.privilegedmozilla", 1],
      ["dom.ipc.processPrelaunch.enabled", false],
      ["dom.serviceWorkers.enabled", true],
      ["dom.serviceWorkers.testing.enabled", true],
      // ServiceWorker worker instances should stay alive until explicitly
      // caused to terminate by dropping these timeouts to 0 in
      // `waitForWorkerAndProcessShutdown`.
      ["dom.serviceWorkers.idle_timeout", 299999],
      ["dom.serviceWorkers.idle_extended_timeout", 299999],
    ],
  });
});

function countRemoteType(remoteType) {
  return ChromeUtils.getAllDOMProcesses().filter(
    p => p.remoteType == remoteType
  ).length;
}

/**
 * Helper function to get a list of all current processes and their remote
 * types.  Note that when in used in a templated literal that it is
 * synchronously invoked when the string is evaluated and captures system state
 * at that instant.
 */
function debugRemotes() {
  return ChromeUtils.getAllDOMProcesses()
    .map(p => p.remoteType || "parent")
    .join(",");
}

/**
 * Wait for there to be zero processes of the given remoteType.  This check is
 * considered successful if there are already no processes of the given type
 * at this very moment.
 */
async function waitForNoProcessesOfType(remoteType) {
  info(`waiting for there to be no ${remoteType} procs`);
  await TestUtils.waitForCondition(
    () => countRemoteType(remoteType) == 0,
    "wait for the worker's process to shutdown"
  );
}

/**
 * Given a ServiceWorkerRegistrationInfo with an active ServiceWorker that
 * has no active ExtendableEvents but would otherwise continue running thanks
 * to the idle keepalive:
 * - Assert that there is a ServiceWorker instance in the given registration's
 *   active slot.  (General invariant check.)
 * - Assert that a single process with the given remoteType currently exists.
 *   (This doesn't mean the SW is alive in that process, though this test
 *   verifies that via other checks when appropriate.)
 * - Induce the worker to shutdown by temporarily dropping the idle timeout to 0
 *   and causing the idle timer to be reset due to rapid debugger attach/detach.
 * - Wait for the the single process with the given remoteType to go away.
 * - Reset the idle timeouts back to their previous high values.
 */
async function waitForWorkerAndProcessShutdown(swRegInfo, remoteType) {
  info(`terminating worker and waiting for ${remoteType} procs to shut down`);
  ok(swRegInfo.activeWorker, "worker should be in the active slot");
  is(
    countRemoteType(remoteType),
    1,
    `should have a single ${remoteType} process but have: ${debugRemotes()}`
  );

  // Let's not wait too long for the process to shutdown.
  await SpecialPowers.pushPrefEnv({
    set: [
      ["dom.serviceWorkers.idle_timeout", 0],
      ["dom.serviceWorkers.idle_extended_timeout", 0],
    ],
  });

  // We need to cause the worker to re-evaluate its idle timeout. The easiest
  // way to do this I could think of is to attach and then detach the debugger
  // from the active worker.
  swRegInfo.activeWorker.attachDebugger();
  await new Promise(resolve => Cu.dispatch(resolve));
  swRegInfo.activeWorker.detachDebugger();

  // Eventually the length will reach 0, meaning we're done!
  await waitForNoProcessesOfType(remoteType);

  is(
    countRemoteType(remoteType),
    0,
    `processes with remoteType=${remoteType} type should have shut down`
  );

  // Make sure we never kill workers on idle except when this is called.
  await SpecialPowers.popPrefEnv();
}

async function do_test_sw(host, remoteType, swMode, fileBlob) {
  info(
    `### entering test: host=${host}, remoteType=${remoteType}, mode=${swMode}`
  );

  const prin = Services.scriptSecurityManager.createContentPrincipal(
    Services.io.newURI(`https://${host}`),
    {}
  );
  const sw = `https://${host}/${DIRPATH}file_service_worker_fetch_synthetic.js`;
  const scope = `https://${host}/${DIRPATH}server_fetch_synthetic.sjs`;

  const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService(
    Ci.nsIServiceWorkerManager
  );
  const swRegInfo = await swm.registerForTest(prin, scope, sw);
  swRegInfo.QueryInterface(Ci.nsIServiceWorkerRegistrationInfo);

  info(
    `service worker registered: ${JSON.stringify({
      principal: swRegInfo.principal.spec,
      scope: swRegInfo.scope,
    })}`
  );

  // Wait for the worker to install & shut down.
  await TestUtils.waitForCondition(
    () => swRegInfo.activeWorker,
    "wait for the worker to become active"
  );
  await waitForWorkerAndProcessShutdown(swRegInfo, remoteType);

  info(
    `test navigation interception with mode=${swMode} starting from about:blank`
  );
  await BrowserTestUtils.withNewTab(
    {
      gBrowser,
      url: "about:blank",
    },
    async browser => {
      // NOTE: We intentionally trigger the navigation from content in order to
      // make sure frontend doesn't eagerly process-switch for us.
      SpecialPowers.spawn(
        browser,
        [scope, swMode, fileBlob],
        // eslint-disable-next-line no-shadow
        async (scope, swMode, fileBlob) => {
          const pageUrl = `${scope}?mode=${swMode}`;
          if (!fileBlob) {
            content.location.href = pageUrl;
          } else {
            const doc = content.document;
            const formElem = doc.createElement("form");
            doc.body.appendChild(formElem);

            formElem.action = pageUrl;
            formElem.method = "POST";
            formElem.enctype = "multipart/form-data";

            const fileElem = doc.createElement("input");
            formElem.appendChild(fileElem);

            fileElem.type = "file";
            fileElem.name = "foo";

            fileElem.mozSetFileArray([fileBlob]);

            formElem.submit();
          }
        }
      );

      await BrowserTestUtils.browserLoaded(browser);

      is(
        countRemoteType(remoteType),
        1,
        `should have spawned a content process with remoteType=${remoteType}`
      );

      const { source, blobContents } = await SpecialPowers.spawn(
        browser,
        [],
        () => {
          return {
            source: content.document.getElementById("source").textContent,
            blobContents: content.document.getElementById("blob").textContent,
          };
        }
      );

      is(
        source,
        swMode === "synthetic" ? "ServiceWorker" : "ServerJS",
        "The page contents should come from the right place."
      );

      is(
        blobContents,
        fileBlob ? TEST_BLOB_CONTENTS : "",
        "The request blob contents should be the blob/empty as appropriate."
      );

      // Ensure the worker was loaded in this process.
      const workerDebuggerURLs = await SpecialPowers.spawn(
        browser,
        [sw],
        async url => {
          if (!content.navigator.serviceWorker.controller) {
            throw new Error("document not controlled!");
          }
          const wdm = Cc[
            "@mozilla.org/dom/workers/workerdebuggermanager;1"
          ].getService(Ci.nsIWorkerDebuggerManager);

          return Array.from(wdm.getWorkerDebuggerEnumerator())
            .map(wd => {
              return wd.url;
            })
            .filter(swURL => swURL == url);
        }
      );
      if (remoteType.startsWith("webServiceWorker=")) {
        Assert.notDeepEqual(
          workerDebuggerURLs,
          [sw],
          "Isolated workers should not be running in the content child process"
        );
      } else {
        Assert.deepEqual(
          workerDebuggerURLs,
          [sw],
          "The worker should be running in the correct child process"
        );
      }

      // Unregister the ServiceWorker.  The registration will continue to control
      // `browser` and therefore continue to exist and its worker to continue
      // running until the tab is closed.
      await SpecialPowers.spawn(browser, [], async () => {
        let registration = await content.navigator.serviceWorker.ready;
        await registration.unregister();
      });
    }
  );

  // Now that the controlled tab is closed and the registration has been
  // removed, the ServiceWorker will be made redundant which will forcibly
  // terminate it, which will result in the shutdown of the given content
  // process.  Wait for that to happen both as a verification and so the next
  // test has a sufficiently clean slate.
  await waitForNoProcessesOfType(remoteType);
}

/**
 * Create a File-backed blob.  This will happen synchronously from the main
 * thread, which isn't optimal, but the test blocks on this progress anyways.
 * Bug 1669578 has been filed on improving this idiom and avoiding the sync
 * writes.
 */
async function makeFileBlob(blobContents) {
  const tmpFile = Cc["@mozilla.org/file/directory_service;1"]
    .getService(Ci.nsIDirectoryService)
    .QueryInterface(Ci.nsIProperties)
    .get("TmpD", Ci.nsIFile);
  tmpFile.append("test-file-backed-blob.txt");
  tmpFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);

  var outStream = Cc[
    "@mozilla.org/network/file-output-stream;1"
  ].createInstance(Ci.nsIFileOutputStream);
  outStream.init(
    tmpFile,
    0x02 | 0x08 | 0x20, // write, create, truncate
    0o666,
    0
  );
  outStream.write(blobContents, blobContents.length);
  outStream.close();

  const fileBlob = await File.createFromNsIFile(tmpFile);
  return fileBlob;
}

function getSWTelemetrySums() {
  let telemetry = Cc["@mozilla.org/base/telemetry;1"].getService(
    Ci.nsITelemetry
  );
  let keyedhistograms = telemetry.getSnapshotForKeyedHistograms(
    "main",
    false
  ).parent;
  let keyedscalars = telemetry.getSnapshotForKeyedScalars("main", false).parent;
  // We're not looking at the distribution of the histograms, just that they changed
  return {
    SERVICE_WORKER_RUNNING_All: keyedhistograms.SERVICE_WORKER_RUNNING
      ? keyedhistograms.SERVICE_WORKER_RUNNING.All.sum
      : 0,
    SERVICE_WORKER_RUNNING_Fetch: keyedhistograms.SERVICE_WORKER_RUNNING
      ? keyedhistograms.SERVICE_WORKER_RUNNING.Fetch.sum
      : 0,
  };
}

add_task(async function test() {
  // Can't test telemetry without this since we may not be on the nightly channel
  let oldCanRecord = Services.telemetry.canRecordExtended;
  Services.telemetry.canRecordExtended = true;
  registerCleanupFunction(() => {
    Services.telemetry.canRecordExtended = oldCanRecord;
  });

  let initialSums = getSWTelemetrySums();

  // ## Isolated Privileged Process
  // Trigger a straightforward intercepted navigation with no request body that
  // returns a synthetic response.
  await do_test_sw("example.org", "privilegedmozilla", "synthetic", null);

  // Trigger an intercepted navigation with FormData containing an
  // <input type="file"> which will result in the request body containing a
  // RemoteLazyInputStream which will be consumed in the content process by the
  // ServiceWorker while generating the synthetic response.
  const fileBlob = await makeFileBlob(TEST_BLOB_CONTENTS);
  await do_test_sw("example.org", "privilegedmozilla", "synthetic", fileBlob);

  // Trigger an intercepted navigation with FormData containing an
  // <input type="file"> which will result in the request body containing a
  // RemoteLazyInputStream which will be relayed back to the parent process
  // via direct invocation of fetch() on the event.request but without any
  // cloning.
  await do_test_sw("example.org", "privilegedmozilla", "fetch", fileBlob);

  // Same as the above but cloning the request before fetching it.
  await do_test_sw("example.org", "privilegedmozilla", "clone", fileBlob);

  // ## Fission Isolation
  if (Services.appinfo.fissionAutostart) {
    // ## ServiceWorker isolation
    const isolateUrl = "example.com";
    const isolateRemoteType = `webServiceWorker=https://` + isolateUrl;
    await do_test_sw(isolateUrl, isolateRemoteType, "synthetic", null);
    await do_test_sw(isolateUrl, isolateRemoteType, "synthetic", fileBlob);
  }
  let telemetrySums = getSWTelemetrySums();
  info(JSON.stringify(telemetrySums));
  info(
    "Initial Running All: " +
      initialSums.SERVICE_WORKER_RUNNING_All +
      ", Fetch: " +
      initialSums.SERVICE_WORKER_RUNNING_Fetch
  );
  info(
    "Running All: " +
      telemetrySums.SERVICE_WORKER_RUNNING_All +
      ", Fetch: " +
      telemetrySums.SERVICE_WORKER_RUNNING_Fetch
  );
  Assert.greater(
    telemetrySums.SERVICE_WORKER_RUNNING_All,
    initialSums.SERVICE_WORKER_RUNNING_All,
    "ServiceWorker running count changed"
  );
  Assert.greater(
    telemetrySums.SERVICE_WORKER_RUNNING_Fetch,
    initialSums.SERVICE_WORKER_RUNNING_Fetch,
    "ServiceWorker running count changed"
  );
});
