"use strict";

const BASE_URL = "http://mochi.test:8888/browser/docshell/test/browser/";

const TEST_PAGE = BASE_URL + "file_onbeforeunload_0.html";

const CONTENT_PROMPT_SUBDIALOG = Services.prefs.getBoolPref(
  "prompts.contentPromptSubDialog",
  false
);

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

async function withTabModalPromptCount(expected, task) {
  const DIALOG_TOPIC = CONTENT_PROMPT_SUBDIALOG
    ? "common-dialog-loaded"
    : "tabmodal-dialog-loaded";

  let count = 0;
  function observer() {
    count++;
  }

  Services.obs.addObserver(observer, DIALOG_TOPIC);
  try {
    return await task();
  } finally {
    Services.obs.removeObserver(observer, DIALOG_TOPIC);
    is(count, expected, "Should see expected number of tab modal prompts");
  }
}

function promiseAllowUnloadPrompt(browser, allowNavigation) {
  return PromptTestUtils.handleNextPrompt(
    browser,
    { modalType: Services.prompt.MODAL_TYPE_CONTENT, promptType: "confirmEx" },
    { buttonNumClick: allowNavigation ? 0 : 1 }
  );
}

// Maintain a pool of background tabs with our test document loaded so
// we don't have to wait for a load prior to each test step (potentially
// tearing down and recreating content processes in the process).
const TabPool = {
  poolSize: 5,

  pendingCount: 0,

  readyTabs: [],

  readyPromise: null,
  resolveReadyPromise: null,

  spawnTabs() {
    while (this.pendingCount + this.readyTabs.length < this.poolSize) {
      this.pendingCount++;
      let tab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
      BrowserTestUtils.browserLoaded(tab.linkedBrowser).then(() => {
        this.readyTabs.push(tab);
        this.pendingCount--;

        if (this.resolveReadyPromise) {
          this.readyPromise = null;
          this.resolveReadyPromise();
          this.resolveReadyPromise = null;
        }

        this.spawnTabs();
      });
    }
  },

  getReadyPromise() {
    if (!this.readyPromise) {
      this.readyPromise = new Promise(resolve => {
        this.resolveReadyPromise = resolve;
      });
    }
    return this.readyPromise;
  },

  async getTab() {
    while (!this.readyTabs.length) {
      this.spawnTabs();
      await this.getReadyPromise();
    }

    let tab = this.readyTabs.shift();
    this.spawnTabs();

    gBrowser.selectedTab = tab;
    return tab;
  },

  async cleanup() {
    this.poolSize = 0;

    while (this.pendingCount) {
      await this.getReadyPromise();
    }

    while (this.readyTabs.length) {
      await BrowserTestUtils.removeTab(this.readyTabs.shift());
    }
  },
};

const ACTIONS = {
  NONE: 0,
  LISTEN_AND_ALLOW: 1,
  LISTEN_AND_BLOCK: 2,
};

const ACTION_NAMES = new Map(Object.entries(ACTIONS).map(([k, v]) => [v, k]));

function* generatePermutations(depth) {
  if (depth == 0) {
    yield [];
    return;
  }
  for (let subActions of generatePermutations(depth - 1)) {
    for (let action of Object.values(ACTIONS)) {
      yield [action, ...subActions];
    }
  }
}

const PERMUTATIONS = Array.from(generatePermutations(4));

const FRAMES = [
  { process: 0 },
  { process: SpecialPowers.useRemoteSubframes ? 1 : 0 },
  { process: 0 },
  { process: SpecialPowers.useRemoteSubframes ? 1 : 0 },
];

function addListener(bc, block) {
  return SpecialPowers.spawn(bc, [block], block => {
    return new Promise(resolve => {
      function onbeforeunload(event) {
        if (block) {
          event.preventDefault();
        }
        resolve({ event: "beforeunload" });
      }
      content.addEventListener("beforeunload", onbeforeunload, { once: true });
      content.unlisten = () => {
        content.removeEventListener("beforeunload", onbeforeunload);
      };

      content.addEventListener(
        "unload",
        () => {
          resolve({ event: "unload" });
        },
        { once: true }
      );
    });
  });
}

function descendants(bc) {
  if (bc) {
    return [bc, ...descendants(bc.children[0])];
  }
  return [];
}

async function addListeners(frames, actions, startIdx) {
  let process = startIdx >= 0 ? FRAMES[startIdx].process : -1;

  let roundTripPromises = [];

  let expectNestedEventLoop = false;
  let numBlockers = 0;
  let unloadPromises = [];
  let beforeUnloadPromises = [];

  for (let [i, frame] of frames.entries()) {
    let action = actions[i];
    if (action === ACTIONS.NONE) {
      continue;
    }

    let block = action === ACTIONS.LISTEN_AND_BLOCK;
    let promise = addListener(frame, block);
    if (startIdx <= i) {
      if (block || FRAMES[i].process !== process) {
        expectNestedEventLoop = true;
      }
      beforeUnloadPromises.push(promise);
      numBlockers += block;
    } else {
      unloadPromises.push(promise);
    }

    roundTripPromises.push(SpecialPowers.spawn(frame, [], () => {}));
  }

  // Wait for round trip messages to any processes with event listeners to
  // return so we're sure that all listeners are registered and their state
  // flags are propagated before we continue.
  await Promise.all(roundTripPromises);

  return {
    expectNestedEventLoop,
    expectPrompt: !!numBlockers,
    unloadPromises,
    beforeUnloadPromises,
  };
}

async function doTest(actions, startIdx, navigate) {
  let tab = await TabPool.getTab();
  let browser = tab.linkedBrowser;

  let frames = descendants(browser.browsingContext);
  let expected = await addListeners(frames, actions, startIdx);

  let awaitingPrompt = false;
  let promptPromise;
  if (expected.expectPrompt) {
    awaitingPrompt = true;
    promptPromise = promiseAllowUnloadPrompt(browser, false).then(() => {
      awaitingPrompt = false;
    });
  }

  let promptCount = expected.expectPrompt ? 1 : 0;
  await withTabModalPromptCount(promptCount, async () => {
    await navigate(tab, frames).then(result => {
      ok(
        !awaitingPrompt,
        "Navigation should not complete while we're still expecting a prompt"
      );

      is(
        result.eventLoopSpun,
        expected.expectNestedEventLoop,
        "Should have nested event loop?"
      );
    });

    for (let result of await Promise.all(expected.beforeUnloadPromises)) {
      is(
        result.event,
        "beforeunload",
        "Should have seen beforeunload event before unload"
      );
    }
    await promptPromise;

    await Promise.all(
      frames.map(frame =>
        SpecialPowers.spawn(frame, [], () => {
          if (content.unlisten) {
            content.unlisten();
          }
        }).catch(() => {})
      )
    );

    await BrowserTestUtils.removeTab(tab);
  });

  for (let result of await Promise.all(expected.unloadPromises)) {
    is(result.event, "unload", "Should have seen unload event");
  }
}