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

const TEST_URL =
  "http://www.example.com/browser/toolkit/components/remotepagemanager/tests/browser/testremotepagemanager.html";

var { RemotePages, RemotePageManager } = ChromeUtils.import(
  "resource://gre/modules/remotepagemanager/RemotePageManagerParent.jsm"
);

function failOnMessage(message) {
  ok(false, "Should not have seen message " + message.name);
}

function waitForMessage(port, message, expectedPort = port) {
  return new Promise(resolve => {
    function listener(message) {
      is(
        message.target,
        expectedPort,
        "Message should be from the right port."
      );

      port.removeMessageListener(listener);
      resolve(message);
    }

    port.addMessageListener(message, listener);
  });
}

function waitForPort(url, createTab = true) {
  return new Promise(resolve => {
    RemotePageManager.addRemotePageListener(url, port => {
      RemotePageManager.removeRemotePageListener(url);

      waitForMessage(port, "RemotePage:Load").then(() => resolve(port));
    });

    if (createTab) {
      gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, url);
    }
  });
}

function waitForPage(pages, url = TEST_URL) {
  return new Promise(resolve => {
    function listener({ target }) {
      pages.removeMessageListener("RemotePage:Init", listener);

      waitForMessage(target, "RemotePage:Load").then(() => resolve(target));
    }

    pages.addMessageListener("RemotePage:Init", listener);
    gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, url);
  });
}

function swapDocShells(browser1, browser2) {
  // Swap frameLoaders.
  browser1.swapDocShells(browser2);

  // Swap permanentKeys.
  let tmp = browser1.permanentKey;
  browser1.permanentKey = browser2.permanentKey;
  browser2.permanentKey = tmp;
}

add_task(async function sharedData_aka_initialProcessData() {
  const includesTest = () =>
    Services.cpmm.sharedData.get("RemotePageManager:urls").has(TEST_URL);
  is(
    includesTest(),
    false,
    "Shouldn't have test url in initial process data yet"
  );

  const loadedPort = waitForPort(TEST_URL);
  is(includesTest(), true, "Should have test url when waiting for it to load");

  await loadedPort;
  is(includesTest(), false, "Should have test url removed when done listening");

  gBrowser.removeCurrentTab();
});

// Test that opening a page creates a port, sends the load event and then
// navigating to a new page sends the unload event. Going back should create a
// new port
add_task(async function init_navigate() {
  let port = await waitForPort(TEST_URL);
  is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser");

  let loaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
  BrowserTestUtils.loadURI(gBrowser, "about:blank");

  await waitForMessage(port, "RemotePage:Unload");

  // Port should be destroyed now
  try {
    port.addMessageListener("Foo", failOnMessage);
    ok(false, "Should have seen exception");
  } catch (e) {
    ok(true, "Should have seen exception");
  }

  try {
    port.sendAsyncMessage("Foo");
    ok(false, "Should have seen exception");
  } catch (e) {
    ok(true, "Should have seen exception");
  }

  await loaded;

  gBrowser.goBack();
  port = await waitForPort(TEST_URL, false);

  port.sendAsyncMessage("Ping2");
  await waitForMessage(port, "Pong2");
  port.destroy();

  gBrowser.removeCurrentTab();
});

// Test that opening a page creates a port, sends the load event and then
// closing the tab sends the unload event
add_task(async function init_close() {
  let port = await waitForPort(TEST_URL);
  is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser");

  let unloadPromise = waitForMessage(port, "RemotePage:Unload");
  gBrowser.removeCurrentTab();
  await unloadPromise;

  // Port should be destroyed now
  try {
    port.addMessageListener("Foo", failOnMessage);
    ok(false, "Should have seen exception");
  } catch (e) {
    ok(true, "Should have seen exception");
  }

  try {
    port.sendAsyncMessage("Foo");
    ok(false, "Should have seen exception");
  } catch (e) {
    ok(true, "Should have seen exception");
  }
});

// Tests that we can send messages to individual pages even when more than one
// is open
add_task(async function multiple_ports() {
  let port1 = await waitForPort(TEST_URL);
  is(
    port1.browser,
    gBrowser.selectedBrowser,
    "Port is for the correct browser"
  );

  let port2 = await waitForPort(TEST_URL);
  is(
    port2.browser,
    gBrowser.selectedBrowser,
    "Port is for the correct browser"
  );

  port2.addMessageListener("Pong", failOnMessage);
  port1.sendAsyncMessage("Ping", { str: "foobar", counter: 0 });
  let message = await waitForMessage(port1, "Pong");
  port2.removeMessageListener("Pong", failOnMessage);
  is(message.data.str, "foobar", "String should pass through");
  is(message.data.counter, 1, "Counter should be incremented");

  port1.addMessageListener("Pong", failOnMessage);
  port2.sendAsyncMessage("Ping", { str: "foobaz", counter: 5 });
  message = await waitForMessage(port2, "Pong");
  port1.removeMessageListener("Pong", failOnMessage);
  is(message.data.str, "foobaz", "String should pass through");
  is(message.data.counter, 6, "Counter should be incremented");

  let unloadPromise = waitForMessage(port2, "RemotePage:Unload");
  gBrowser.removeTab(gBrowser.getTabForBrowser(port2.browser));
  await unloadPromise;

  try {
    port2.addMessageListener("Pong", failOnMessage);
    ok(
      false,
      "Should not have been able to add a new message listener to a destroyed port."
    );
  } catch (e) {
    ok(
      true,
      "Should not have been able to add a new message listener to a destroyed port."
    );
  }

  port1.sendAsyncMessage("Ping", { str: "foobar", counter: 0 });
  message = await waitForMessage(port1, "Pong");
  is(message.data.str, "foobar", "String should pass through");
  is(message.data.counter, 1, "Counter should be incremented");

  unloadPromise = waitForMessage(port1, "RemotePage:Unload");
  gBrowser.removeTab(gBrowser.getTabForBrowser(port1.browser));
  await unloadPromise;
});

// Tests that swapping browser docshells doesn't break the ports
add_task(async function browser_switch() {
  let port1 = await waitForPort(TEST_URL);
  is(
    port1.browser,
    gBrowser.selectedBrowser,
    "Port is for the correct browser"
  );
  let browser1 = gBrowser.selectedBrowser;
  port1.sendAsyncMessage("SetCookie", { value: "om nom" });

  let port2 = await waitForPort(TEST_URL);
  is(
    port2.browser,
    gBrowser.selectedBrowser,
    "Port is for the correct browser"
  );
  let browser2 = gBrowser.selectedBrowser;
  port2.sendAsyncMessage("SetCookie", { value: "om nom nom" });

  port2.addMessageListener("Cookie", failOnMessage);
  port1.sendAsyncMessage("GetCookie");
  let message = await waitForMessage(port1, "Cookie");
  port2.removeMessageListener("Cookie", failOnMessage);
  is(message.data.value, "om nom", "Should have the right cookie");

  port1.addMessageListener("Cookie", failOnMessage);
  port2.sendAsyncMessage("GetCookie", { str: "foobaz", counter: 5 });
  message = await waitForMessage(port2, "Cookie");
  port1.removeMessageListener("Cookie", failOnMessage);
  is(message.data.value, "om nom nom", "Should have the right cookie");

  swapDocShells(browser1, browser2);
  is(port1.browser, browser2, "Should have noticed the swap");
  is(port2.browser, browser1, "Should have noticed the swap");

  // Cookies should have stayed the same
  port2.addMessageListener("Cookie", failOnMessage);
  port1.sendAsyncMessage("GetCookie");
  message = await waitForMessage(port1, "Cookie");
  port2.removeMessageListener("Cookie", failOnMessage);
  is(message.data.value, "om nom", "Should have the right cookie");

  port1.addMessageListener("Cookie", failOnMessage);
  port2.sendAsyncMessage("GetCookie", { str: "foobaz", counter: 5 });
  message = await waitForMessage(port2, "Cookie");
  port1.removeMessageListener("Cookie", failOnMessage);
  is(message.data.value, "om nom nom", "Should have the right cookie");

  swapDocShells(browser1, browser2);
  is(port1.browser, browser1, "Should have noticed the swap");
  is(port2.browser, browser2, "Should have noticed the swap");

  // Cookies should have stayed the same
  port2.addMessageListener("Cookie", failOnMessage);
  port1.sendAsyncMessage("GetCookie");
  message = await waitForMessage(port1, "Cookie");
  port2.removeMessageListener("Cookie", failOnMessage);
  is(message.data.value, "om nom", "Should have the right cookie");

  port1.addMessageListener("Cookie", failOnMessage);
  port2.sendAsyncMessage("GetCookie", { str: "foobaz", counter: 5 });
  message = await waitForMessage(port2, "Cookie");
  port1.removeMessageListener("Cookie", failOnMessage);
  is(message.data.value, "om nom nom", "Should have the right cookie");

  let unloadPromise = waitForMessage(port2, "RemotePage:Unload");
  gBrowser.removeTab(gBrowser.getTabForBrowser(browser2));
  await unloadPromise;

  unloadPromise = waitForMessage(port1, "RemotePage:Unload");
  gBrowser.removeTab(gBrowser.getTabForBrowser(browser1));
  await unloadPromise;
});

// Tests that removeMessageListener in chrome works
add_task(async function remove_chrome_listener() {
  let port = await waitForPort(TEST_URL);
  is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser");

  // This relies on messages sent arriving in the same order. Pong will be
  // sent back before Pong2 so if removeMessageListener fails the test will fail
  port.addMessageListener("Pong", failOnMessage);
  port.removeMessageListener("Pong", failOnMessage);
  port.sendAsyncMessage("Ping", { str: "remove_listener", counter: 27 });
  port.sendAsyncMessage("Ping2");
  await waitForMessage(port, "Pong2");

  let unloadPromise = waitForMessage(port, "RemotePage:Unload");
  gBrowser.removeCurrentTab();
  await unloadPromise;
});

// Tests that removeMessageListener in content works
add_task(async function remove_content_listener() {
  let port = await waitForPort(TEST_URL);
  is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser");

  // This relies on messages sent arriving in the same order. Pong3 would be
  // sent back before Pong2 so if removeMessageListener fails the test will fail
  port.addMessageListener("Pong3", failOnMessage);
  port.sendAsyncMessage("Ping3");
  port.sendAsyncMessage("Ping2");
  await waitForMessage(port, "Pong2");

  let unloadPromise = waitForMessage(port, "RemotePage:Unload");
  gBrowser.removeCurrentTab();
  await unloadPromise;
});

// Test RemotePages works
add_task(async function remote_pages_basic() {
  let pages = new RemotePages(TEST_URL);
  let port = await waitForPage(pages);
  is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser");

  // Listening to global messages should work
  let unloadPromise = waitForMessage(pages, "RemotePage:Unload", port);
  gBrowser.removeCurrentTab();
  await unloadPromise;

  pages.destroy();

  // RemotePages should be destroyed now
  try {
    pages.addMessageListener("Foo", failOnMessage);
    ok(false, "Should have seen exception");
  } catch (e) {
    ok(true, "Should have seen exception");
  }

  try {
    pages.sendAsyncMessage("Foo");
    ok(false, "Should have seen exception");
  } catch (e) {
    ok(true, "Should have seen exception");
  }
});

// Test that properties exist on the target port provided to listeners
add_task(async function check_port_properties() {
  let pages = new RemotePages(TEST_URL);

  const expectedProperties = [
    "addMessageListener",
    "browser",
    "destroy",
    "loaded",
    "portID",
    "removeMessageListener",
    "sendAsyncMessage",
    "url",
  ];
  function checkProperties(port, description) {
    const expected = [];
    const unexpected = [];
    for (const key in port) {
      (expectedProperties.includes(key) ? expected : unexpected).push(key);
    }
    is(
      `${expected.sort()}`,
      `${expectedProperties}`,
      `${description} has expected keys`
    );
    is(
      `${unexpected.sort()}`,
      "",
      `${description} should not have unexpected keys`
    );
  }

  function portFrom(message, extraFn = () => {}) {
    return new Promise(resolve => {
      function onMessage({ target }) {
        pages.removeMessageListener(message, onMessage);
        resolve(target);
      }
      pages.addMessageListener(message, onMessage);
      extraFn();
    });
  }

  let portFromInit = await portFrom(
    "RemotePage:Init",
    () => (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_URL))
  );
  checkProperties(portFromInit, "inited port");
  ok(
    ["about:blank", TEST_URL].includes(portFromInit.browser.currentURI.spec),
    `inited port browser is either still blank or already at the target url - got ${portFromInit.browser.currentURI.spec}`
  );
  is(portFromInit.loaded, false, "inited port has not been loaded yet");
  is(portFromInit.url, TEST_URL, "got expected url");

  let portFromLoad = await portFrom("RemotePage:Load");
  is(portFromLoad, portFromInit, "got the same port from init and load");
  checkProperties(portFromLoad, "loaded port");
  is(
    portFromInit.browser.currentURI.spec,
    TEST_URL,
    "loaded port has browser with actual url"
  );
  is(portFromInit.loaded, true, "loaded port is now loaded");
  is(portFromInit.url, TEST_URL, "still got expected url");

  let portFromUnload = await portFrom("RemotePage:Unload", () =>
    BrowserTestUtils.removeTab(gBrowser.selectedTab)
  );
  is(portFromUnload, portFromInit, "got the same port from init and unload");
  checkProperties(portFromUnload, "unloaded port");
  is(portFromInit.browser, null, "unloaded port has no browser");
  is(portFromInit.loaded, false, "unloaded port is now not loaded");
  is(portFromInit.url, TEST_URL, "still got expected url");

  pages.destroy();
});

// Test sending messages to all remote pages works
add_task(async function remote_pages_multiple_pages() {
  let pages = new RemotePages(TEST_URL);
  let port1 = await waitForPage(pages);
  let port2 = await waitForPage(pages);

  let pongPorts = [];
  await new Promise(resolve => {
    function listener({ name, target, data }) {
      is(name, "Pong", "Should have seen the right response.");
      is(data.str, "remote_pages", "String should pass through");
      is(data.counter, 43, "Counter should be incremented");
      pongPorts.push(target);
      if (pongPorts.length == 2) {
        resolve();
      }
    }

    pages.addMessageListener("Pong", listener);
    pages.sendAsyncMessage("Ping", { str: "remote_pages", counter: 42 });
  });

  // We don't make any guarantees about which order messages are sent to known
  // pages so the pongs could have come back in any order.
  isnot(
    pongPorts[0],
    pongPorts[1],
    "Should have received pongs from different ports"
  );
  ok(pongPorts.includes(port1), "Should have seen a pong from port1");
  ok(pongPorts.includes(port2), "Should have seen a pong from port2");

  // After destroy we should see no messages
  pages.addMessageListener("RemotePage:Unload", failOnMessage);
  pages.destroy();

  gBrowser.removeTab(gBrowser.getTabForBrowser(port1.browser));
  gBrowser.removeTab(gBrowser.getTabForBrowser(port2.browser));
});

// Test that RemotePages with multiple urls works
add_task(async function remote_pages_multiple_urls() {
  const TEST_URLS = [TEST_URL, TEST_URL.replace(".html", "2.html")];
  const pages = new RemotePages(TEST_URLS);

  const ports = [];
  // Load two pages for each url
  for (const [i, url] of TEST_URLS.entries()) {
    const port = await waitForPage(pages, url);
    is(
      port.browser,
      gBrowser.selectedBrowser,
      `port${i} is for the correct browser`
    );
    ports.push(port);
    ports.push(await waitForPage(pages, url));
  }

  let unloadPromise = waitForMessage(pages, "RemotePage:Unload", ports.pop());
  gBrowser.removeCurrentTab();
  await unloadPromise;

  const pongPorts = new Set();
  await new Promise(resolve => {
    function listener({ name, target, data }) {
      is(name, "Pong", "Should have seen the right response.");
      is(data.str, "FAKE_DATA", "String should pass through");
      is(data.counter, 1235, "Counter should be incremented");
      pongPorts.add(target);
      if (pongPorts.size === ports.length) {
        resolve();
      }
    }

    pages.addMessageListener("Pong", listener);
    pages.sendAsyncMessage("Ping", { str: "FAKE_DATA", counter: 1234 });
  });

  ports.forEach(port => ok(pongPorts.has(port)));

  pages.destroy();
  ports.forEach(port =>
    gBrowser.removeTab(gBrowser.getTabForBrowser(port.browser))
  );
});

// Test sending various types of data across the boundary
add_task(async function send_data() {
  let port = await waitForPort(TEST_URL);
  is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser");

  let data = {
    integer: 45,
    real: 45.78,
    str: "foobar",
    array: [1, 2, 3, 5, 27],
  };

  port.sendAsyncMessage("SendData", data);
  let message = await waitForMessage(port, "ReceivedData");

  ok(message.data.result, message.data.status);

  gBrowser.removeCurrentTab();
});

// Test sending an object of data across the boundary
add_task(async function send_data2() {
  let port = await waitForPort(TEST_URL);
  is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser");

  let data = {
    integer: 45,
    real: 45.78,
    str: "foobar",
    array: [1, 2, 3, 5, 27],
  };

  port.sendAsyncMessage("SendData2", { data });
  let message = await waitForMessage(port, "ReceivedData2");

  ok(message.data.result, message.data.status);

  gBrowser.removeCurrentTab();
});

add_task(async function get_ports_for_browser() {
  let pages = new RemotePages(TEST_URL);
  let port = await waitForPage(pages);
  // waitForPage creates a new tab and selects it by default, so
  // the selected tab should be the one hosting this port.
  let browser = gBrowser.selectedBrowser;
  let foundPorts = pages.portsForBrowser(browser);
  is(
    foundPorts.length,
    1,
    "There should only be one port for this simple page"
  );
  is(foundPorts[0], port, "Should find the port");

  pages.destroy();
  gBrowser.removeCurrentTab();
});
