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

"use strict";

// The purpose of this test is to test the urlbar popup's add-on iframe.  It has
// a few parts:
//
// (1) This file, a normal browser mochitest.
// (2) html/js files that are loaded in the urlbar popup's add-on iframe:
//     urlbarAddonIframe.{html,js}
// (3) A content script that mediates between the first two parts:
//     urlbarAddonIframeContentScript.js
//
// The main test file (this file) sends messages to the content script, which
// forwards them as events to the iframe.  These messages tell the iframe js to
// do various things like call functions on the urlbar API and expect events.
// In response, the iframe js dispatches ack events to the content script, which
// forwards them as messages to the main test file.
//
// The content script may not be necessary right now since the iframe is not
// remote.  But this structure ensures that if the iframe is made remote in the
// future, then the test won't have to change very much, and ideally not at all.
//
// Actually there's one other part:
//
// (4) The Panel.jsm that's bundled with add-ons that use the iframe.
//
// Panel.jsm defines the API that's made available to add-on scripts running in
// the iframe.  This API is orthogonal to the add-on iframe itself.  You could
// load any html/js in the iframe, technically.  But the purpose of the iframe
// is to support this Panel.jsm API, so that's what this test tests.

const PANEL_JSM_BASENAME = "Panel.jsm";
const IFRAME_BASENAME = "urlbarAddonIframe.html";
const CONTENT_SCRIPT_BASENAME = "urlbarAddonIframeContentScript.js";

// The iframe's message manager.
let gMsgMan;

add_task(async function() {
  let rootDirURL = getRootDirectory(gTestPath);
  let jsmURL = rootDirURL + PANEL_JSM_BASENAME;
  let iframeURL = rootDirURL + IFRAME_BASENAME;
  let contentScriptURL = rootDirURL + CONTENT_SCRIPT_BASENAME;

  let { Panel } = ChromeUtils.import(jsmURL, {});
  let panel = new Panel(gURLBar.popup, iframeURL);
  registerCleanupFunction(() => {
    panel.destroy();
    Assert.ok(gURLBar.popup._addonIframe === null, "iframe should be gone");
  });

  let iframe = gURLBar.popup._addonIframe;
  Assert.ok(!!iframe, "iframe should not be null");

  gMsgMan =
    iframe.frameLoader.messageManager;
  gMsgMan.loadFrameScript(contentScriptURL, false);

  await promiseIframeLoad();

  // urlbar.getValue
  let value = "this value set by the test";
  gURLBar.value = value;
  let readValue = await promiseUrlbarFunctionCall("getValue");
  Assert.equal(readValue, value, "value");

  // urlbar.setValue
  value = "this value set by the iframe";
  await promiseUrlbarFunctionCall("setValue", value);
  Assert.equal(gURLBar.value, value, "setValue");

  // urlbar.getMaxResults
  let maxResults = gURLBar.popup.maxResults;
  Assert.equal(typeof(maxResults), "number", "Sanity check");
  let readMaxResults = await promiseUrlbarFunctionCall("getMaxResults");
  Assert.equal(readMaxResults, maxResults, "getMaxResults");

  // urlbar.setMaxResults
  let newMaxResults = maxResults + 10;
  await promiseUrlbarFunctionCall("setMaxResults", newMaxResults);
  Assert.equal(gURLBar.popup.maxResults, newMaxResults, "setMaxResults");
  gURLBar.popup.maxResults = maxResults;

  // urlbar.enter
  value = "http://mochi.test:8888/";
  await promiseUrlbarFunctionCall("setValue", value);
  Assert.equal(gURLBar.value, value, "setValue");
  await promiseUrlbarFunctionCall("enter");
  let browser = gBrowser.selectedBrowser;
  await BrowserTestUtils.browserLoaded(browser);
  Assert.equal(browser.currentURI.spec, value,
               "enter should have loaded the URL");

  // input, reset, and result events.  There should always be at least one
  // result, the heuristic result.
  value = "test";
  let promiseValues = await Promise.all([
    promiseEvent("input")[1],
    promiseEvent("reset")[1],
    promiseEvent("result")[1],
    promiseAutocompleteResultPopup(value, window, true),
  ]);

  // Check the heuristic result.
  let result = promiseValues[2];
  let engineName = (await Services.search.getDefault()).name;
  Assert.deepEqual(
    PlacesUtils.parseActionUrl(result.url),
    {
      type: "searchengine",
      params: {
        engineName,
        input: "test",
        searchQuery: "test",
      },
    },
    "result.url"
  );
  Assert.ok("action" in result, "result.action");
  Assert.equal(result.action.type, "searchengine", "result.action.type");
  Assert.ok("params" in result.action, "result.action.params");
  Assert.equal(result.action.params.engineName, engineName,
               "result.action.params.engineName");
  Assert.equal(typeof(result.image), "string", "result.image");
  Assert.equal(result.title, engineName, "result.title");
  Assert.equal(result.type, "action searchengine heuristic", "result.type");
  Assert.equal(result.text, value, "result.text");

  // keydown event.  promiseEvent sends an async message to the iframe, but
  // synthesizeKey is sync, so we need to wait until the content JS receives
  // the message and adds its event listener before synthesizing the key.
  let keydownPromises = promiseEvent("keydown");
  await keydownPromises[0];
  EventUtils.synthesizeKey("KEY_ArrowDown", {type: "keydown"});
  await keydownPromises[1];

  // urlbar.getPanelHeight
  let height = iframe.getBoundingClientRect().height;
  let readHeight = await promiseUrlbarFunctionCall("getPanelHeight");
  Assert.equal(readHeight, height, "getPanelHeight");

  // urlbar.setPanelHeight
  let newHeight = height + 100;
  await promiseUrlbarFunctionCall("setPanelHeight", newHeight);
  // The height change is animated, so give it time to complete.
  await TestUtils.waitForCondition(
    () => Math.round(iframe.getBoundingClientRect().height) == newHeight,
    "Wait for panel height change after setPanelHeight"
  ).catch(ex => {
    info("Last detected height: " + Math.round(iframe.getBoundingClientRect().height));
    throw ex;
  });
});

function promiseIframeLoad() {
  let msgName = "TestIframeLoadAck";
  return new Promise(resolve => {
    info("Waiting for iframe load ack");
    gMsgMan.addMessageListener(msgName, function onMsg(msg) {
      info("Received iframe load ack");
      gMsgMan.removeMessageListener(msgName, onMsg);
      resolve();
    });
  });
}

/**
 * Returns a single promise that's resolved when the content JS has called the
 * function.
 * @returns {Promise}
 */
function promiseUrlbarFunctionCall(...args) {
  return promiseMessage("function", args)[0];
}

/**
 * Returns two promises in an array.  The first is resolved when the content JS
 * has added its event listener.  The second is resolved when the content JS
 * has received the event.
 *
 * @param {string} type
 * @returns {Promise}
 */
function promiseEvent(type) {
  return promiseMessage("event", type, 2);
}

let gNextMessageID = 1;

/**
 * Returns an array of promises, one per ack.  Each is resolved when the content
 * JS acks the message.  numExpectedAcks is the number of acks you expect.
 *
 * @param {string} type
 * @param {string} data
 * @param {number} [numExpectedAcks]
 * @returns {Promise}
 */
function promiseMessage(type, data, numExpectedAcks = 1) {
  let testMsgName = "TestMessage";
  let ackMsgName = "TestMessageAck";
  let msgID = gNextMessageID++;
  gMsgMan.sendAsyncMessage(testMsgName, {
    type,
    messageID: msgID,
    data,
  });
  let ackPromises = [];
  for (let i = 0; i < numExpectedAcks; i++) {
    let ackIndex = i;
    ackPromises.push(new Promise(resolve => {
      info("Waiting for message ack: " + JSON.stringify({
        type,
        msgID,
        ackIndex,
      }));
      gMsgMan.addMessageListener(ackMsgName, function onMsg(msg) {
        // Messages have IDs so that an ack can be correctly paired with the
        // initial message it's replying to.  It's not an error if the ack's ID
        // isn't equal to msgID here.  That will happen when multiple messages
        // have been sent in a single turn of the event loop so that they're all
        // waiting on acks.  Same goes for ackIndex.
        if (msg.data.messageID != msgID || msg.data.ackIndex != ackIndex) {
          return;
        }
        info("Received message ack: " + JSON.stringify({
          type,
          msgID: msg.data.messageID,
          ackIndex,
        }));
        gMsgMan.removeMessageListener(ackMsgName, onMsg);
        resolve(msg.data.data);
      });
    }));
  }
  return ackPromises;
}
