"use strict";

// This file expects these globals to be defined by the test case.
/* global gTestTab:true, gContentAPI:true, tests:false */

ChromeUtils.defineESModuleGetters(this, {
  UITour: "resource:///modules/UITour.sys.mjs",
});

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

const SINGLE_TRY_TIMEOUT = 100;
const NUMBER_OF_TRIES = 30;

let gProxyCallbackMap = new Map();

function waitForConditionPromise(
  condition,
  timeoutMsg,
  tryCount = NUMBER_OF_TRIES
) {
  return new Promise((resolve, reject) => {
    let tries = 0;
    function checkCondition() {
      if (tries >= tryCount) {
        reject(timeoutMsg);
      }
      var conditionPassed;
      try {
        conditionPassed = condition();
      } catch (e) {
        return reject(e);
      }
      if (conditionPassed) {
        return resolve();
      }
      tries++;
      setTimeout(checkCondition, SINGLE_TRY_TIMEOUT);
      return undefined;
    }
    setTimeout(checkCondition, SINGLE_TRY_TIMEOUT);
  });
}

function waitForCondition(condition, nextTestFn, errorMsg) {
  waitForConditionPromise(condition, errorMsg).then(nextTestFn, reason => {
    ok(false, reason + (reason.stack ? "\n" + reason.stack : ""));
  });
}

/**
 * Wrapper to partially transition tests to Task. Use `add_UITour_task` instead for new tests.
 */
function taskify(fun) {
  return doneFn => {
    // Output the inner function name otherwise no name will be output.
    info("\t" + fun.name);
    return fun().then(doneFn, reason => {
      console.error(reason);
      ok(false, reason);
      doneFn();
    });
  };
}

function is_hidden(element) {
  let win = element.ownerGlobal;
  let style = win.getComputedStyle(element);
  if (style.display == "none") {
    return true;
  }
  if (style.visibility != "visible") {
    return true;
  }
  if (win.XULPopupElement.isInstance(element)) {
    return ["hiding", "closed"].includes(element.state);
  }

  // Hiding a parent element will hide all its children
  if (element.parentNode != element.ownerDocument) {
    return is_hidden(element.parentNode);
  }

  return false;
}

function is_visible(element) {
  let win = element.ownerGlobal;
  let style = win.getComputedStyle(element);
  if (style.display == "none") {
    return false;
  }
  if (style.visibility != "visible") {
    return false;
  }
  if (win.XULPopupElement.isInstance(element) && element.state != "open") {
    return false;
  }

  // Hiding a parent element will hide all its children
  if (element.parentNode != element.ownerDocument) {
    return is_visible(element.parentNode);
  }

  return true;
}

function is_element_visible(element, msg) {
  isnot(element, null, "Element should not be null, when checking visibility");
  ok(is_visible(element), msg);
}

function waitForElementToBeVisible(element, nextTestFn, msg) {
  waitForCondition(
    () => is_visible(element),
    () => {
      ok(true, msg);
      nextTestFn();
    },
    "Timeout waiting for visibility: " + msg
  );
}

function waitForElementToBeHidden(element, nextTestFn, msg) {
  waitForCondition(
    () => is_hidden(element),
    () => {
      ok(true, msg);
      nextTestFn();
    },
    "Timeout waiting for invisibility: " + msg
  );
}

function elementVisiblePromise(element, msg) {
  return waitForConditionPromise(
    () => is_visible(element),
    "Timeout waiting for visibility: " + msg
  );
}

function elementHiddenPromise(element, msg) {
  return waitForConditionPromise(
    () => is_hidden(element),
    "Timeout waiting for invisibility: " + msg
  );
}

function waitForPopupAtAnchor(popup, anchorNode, nextTestFn, msg) {
  waitForCondition(
    () => is_visible(popup) && popup.anchorNode == anchorNode,
    () => {
      ok(true, msg);
      is_element_visible(popup, "Popup should be visible");
      nextTestFn();
    },
    "Timeout waiting for popup at anchor: " + msg
  );
}

function getConfigurationPromise(configName) {
  return SpecialPowers.spawn(
    gTestTab.linkedBrowser,
    [configName],
    contentConfigName => {
      return new Promise(resolve => {
        let contentWin = Cu.waiveXrays(content);
        contentWin.Mozilla.UITour.getConfiguration(contentConfigName, resolve);
      });
    }
  );
}

function getShowHighlightTargetName() {
  let highlight = document.getElementById("UITourHighlight");
  return highlight.parentElement.getAttribute("targetName");
}

function getShowInfoTargetName() {
  let tooltip = document.getElementById("UITourTooltip");
  return tooltip.getAttribute("targetName");
}

function hideInfoPromise(...args) {
  let popup = document.getElementById("UITourTooltip");
  gContentAPI.hideInfo.apply(gContentAPI, args);
  return promisePanelElementHidden(window, popup);
}

/**
 * `buttons` and `options` require functions from the content scope so we take a
 * function name to call to generate the buttons/options instead of the
 * buttons/options themselves. This makes the signature differ from the content one.
 */
function showInfoPromise() {
  let popup = document.getElementById("UITourTooltip");
  let shownPromise = promisePanelElementShown(window, popup);
  return SpecialPowers.spawn(gTestTab.linkedBrowser, [[...arguments]], args => {
    let contentWin = Cu.waiveXrays(content);
    let [
      contentTarget,
      contentTitle,
      contentText,
      contentIcon,
      contentButtonsFunctionName,
      contentOptionsFunctionName,
    ] = args;
    let buttons = contentButtonsFunctionName
      ? contentWin[contentButtonsFunctionName]()
      : null;
    let options = contentOptionsFunctionName
      ? contentWin[contentOptionsFunctionName]()
      : null;
    contentWin.Mozilla.UITour.showInfo(
      contentTarget,
      contentTitle,
      contentText,
      contentIcon,
      buttons,
      options
    );
  }).then(() => shownPromise);
}

function showHighlightPromise(...args) {
  let popup = document.getElementById("UITourHighlightContainer");
  gContentAPI.showHighlight.apply(gContentAPI, args);
  return promisePanelElementShown(window, popup);
}

function showMenuPromise(name) {
  return SpecialPowers.spawn(gTestTab.linkedBrowser, [name], contentName => {
    return new Promise(resolve => {
      let contentWin = Cu.waiveXrays(content);
      contentWin.Mozilla.UITour.showMenu(contentName, resolve);
    });
  });
}

function waitForCallbackResultPromise() {
  return SpecialPowers.spawn(gTestTab.linkedBrowser, [], async function () {
    let contentWin = Cu.waiveXrays(content);
    await ContentTaskUtils.waitForCondition(() => {
      return contentWin.callbackResult;
    }, "callback should be called");
    return {
      data: contentWin.callbackData,
      result: contentWin.callbackResult,
    };
  });
}

function promisePanelShown(win) {
  let panelEl = win.PanelUI.panel;
  return promisePanelElementShown(win, panelEl);
}

function promisePanelElementEvent(win, aPanel, aEvent) {
  return new Promise((resolve, reject) => {
    let timeoutId = win.setTimeout(() => {
      aPanel.removeEventListener(aEvent, onPanelEvent);
      reject(aEvent + " event did not happen within 5 seconds.");
    }, 5000);

    function onPanelEvent() {
      aPanel.removeEventListener(aEvent, onPanelEvent);
      win.clearTimeout(timeoutId);
      // Wait one tick to let UITour.sys.mjs process the event as well.
      executeSoon(resolve);
    }

    aPanel.addEventListener(aEvent, onPanelEvent);
  });
}

function promisePanelElementShown(win, aPanel) {
  return promisePanelElementEvent(win, aPanel, "popupshown");
}

function promisePanelElementHidden(win, aPanel) {
  return promisePanelElementEvent(win, aPanel, "popuphidden");
}

function is_element_hidden(element, msg) {
  isnot(element, null, "Element should not be null, when checking visibility");
  ok(is_hidden(element), msg);
}

function isTourBrowser(aBrowser) {
  let chromeWindow = aBrowser.ownerGlobal;
  return (
    UITour.tourBrowsersByWindow.has(chromeWindow) &&
    UITour.tourBrowsersByWindow.get(chromeWindow).has(aBrowser)
  );
}

async function loadUITourTestPage(callback, host = "https://example.org/") {
  if (gTestTab) {
    gProxyCallbackMap.clear();
    gBrowser.removeTab(gTestTab);
  }

  if (!window.gProxyCallbackMap) {
    window.gProxyCallbackMap = gProxyCallbackMap;
  }

  let url = getRootDirectory(gTestPath) + "uitour.html";
  url = url.replace("chrome://mochitests/content/", host);

  gTestTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
  // When e10s is enabled, make gContentAPI a proxy which has every property
  // return a function which calls the method of the same name on
  // contentWin.Mozilla.UITour in a ContentTask.
  let UITourHandler = {
    get(target, prop) {
      return (...args) => {
        let browser = gTestTab.linkedBrowser;
        // We need to proxy any callback functions using messages:
        let fnIndices = [];
        args = args.map((arg, index) => {
          // Replace function arguments with "", and add them to the list of
          // forwarded functions. We'll construct a function on the content-side
          // that forwards all its arguments to a message, and we'll listen for
          // those messages on our side and call the corresponding function with
          // the arguments we got from the content side.
          if (typeof arg == "function") {
            gProxyCallbackMap.set(index, arg);
            fnIndices.push(index);
            return "";
          }
          return arg;
        });
        let taskArgs = {
          methodName: prop,
          args,
          fnIndices,
        };
        return SpecialPowers.spawn(
          browser,
          [taskArgs],
          async function (contentArgs) {
            let contentWin = Cu.waiveXrays(content);
            let callbacksCalled = 0;
            let resolveCallbackPromise;
            let allCallbacksCalledPromise = new Promise(
              resolve => (resolveCallbackPromise = resolve)
            );
            let argumentsWithFunctions = Cu.cloneInto(
              contentArgs.args.map((arg, index) => {
                if (arg === "" && contentArgs.fnIndices.includes(index)) {
                  return function () {
                    callbacksCalled++;
                    SpecialPowers.spawnChrome(
                      [index, Array.from(arguments)],
                      (indexParent, argumentsParent) => {
                        // Please note that this handler only allows the callback to be used once.
                        // That means that a single gContentAPI.observer() call can't be used
                        // to observe multiple events.
                        let window = this.browsingContext.topChromeWindow;
                        let cb = window.gProxyCallbackMap.get(indexParent);
                        window.gProxyCallbackMap.delete(indexParent);
                        cb.apply(null, argumentsParent);
                      }
                    );
                    if (callbacksCalled >= contentArgs.fnIndices.length) {
                      resolveCallbackPromise();
                    }
                  };
                }
                return arg;
              }),
              content,
              { cloneFunctions: true }
            );
            let rv = contentWin.Mozilla.UITour[contentArgs.methodName].apply(
              contentWin.Mozilla.UITour,
              argumentsWithFunctions
            );
            if (contentArgs.fnIndices.length) {
              await allCallbacksCalledPromise;
            }
            return rv;
          }
        );
      };
    },
  };
  gContentAPI = new Proxy({}, UITourHandler);

  await SimpleTest.promiseFocus(gTestTab.linkedBrowser);
  callback();
}

// Wrapper for UITourTest to be used by add_task tests.
function setup_UITourTest() {
  return UITourTest(true);
}

// Use `add_task(setup_UITourTest);` instead as we will fold this into `setup_UITourTest` once all tests are using `add_UITour_task`.
function UITourTest(usingAddTask = false) {
  Services.prefs.setBoolPref("browser.uitour.enabled", true);
  let testHttpsOrigin = "https://example.org";
  let testHttpOrigin = "http://example.org";
  PermissionTestUtils.add(
    testHttpsOrigin,
    "uitour",
    Services.perms.ALLOW_ACTION
  );
  PermissionTestUtils.add(
    testHttpOrigin,
    "uitour",
    Services.perms.ALLOW_ACTION
  );

  UITour.getHighlightContainerAndMaybeCreate(window.document);
  UITour.getTooltipAndMaybeCreate(window.document);

  // If a test file is using add_task, we don't need to have a test function or
  // call `waitForExplicitFinish`.
  if (!usingAddTask) {
    waitForExplicitFinish();
  }

  registerCleanupFunction(function () {
    delete window.gContentAPI;
    if (gTestTab) {
      gBrowser.removeTab(gTestTab);
    }
    delete window.gTestTab;
    delete window.gProxyCallbackMap;
    Services.prefs.clearUserPref("browser.uitour.enabled");
    PermissionTestUtils.remove(testHttpsOrigin, "uitour");
    PermissionTestUtils.remove(testHttpOrigin, "uitour");
  });

  // When using tasks, the harness will call the next added task for us.
  if (!usingAddTask) {
    nextTest();
  }
}

function done(usingAddTask = false) {
  info("== Done test, doing shared checks before teardown ==");
  return new Promise(resolve => {
    executeSoon(() => {
      if (gTestTab) {
        gBrowser.removeTab(gTestTab);
      }
      gTestTab = null;
      gProxyCallbackMap.clear();

      let highlight = document.getElementById("UITourHighlightContainer");
      is_element_hidden(
        highlight,
        "Highlight should be closed/hidden after UITour tab is closed"
      );

      let tooltip = document.getElementById("UITourTooltip");
      is_element_hidden(
        tooltip,
        "Tooltip should be closed/hidden after UITour tab is closed"
      );

      ok(
        !PanelUI.panel.hasAttribute("noautohide"),
        "@noautohide on the menu panel should have been cleaned up"
      );
      ok(
        !PanelUI.panel.hasAttribute("panelopen"),
        "The panel shouldn't have @panelopen"
      );
      isnot(PanelUI.panel.state, "open", "The panel shouldn't be open");
      is(
        document.getElementById("PanelUI-menu-button").hasAttribute("open"),
        false,
        "Menu button should know that the menu is closed"
      );

      info("Done shared checks");
      if (usingAddTask) {
        executeSoon(resolve);
      } else {
        executeSoon(nextTest);
      }
    });
  });
}

function nextTest() {
  if (!tests.length) {
    info("finished tests in this file");
    finish();
    return;
  }
  let test = tests.shift();
  info("Starting " + test.name);
  waitForFocus(function () {
    loadUITourTestPage(function () {
      test(done);
    });
  });
}

/**
 * All new tests that need the help of `loadUITourTestPage` should use this
 * wrapper around their test's generator function to reduce boilerplate.
 */
function add_UITour_task(func) {
  let genFun = async function () {
    await new Promise(resolve => {
      waitForFocus(function () {
        loadUITourTestPage(function () {
          let funcPromise = (func() || Promise.resolve()).then(
            () => done(true),
            reason => {
              ok(false, reason);
              return done(true);
            }
          );
          resolve(funcPromise);
        });
      });
    });
  };
  Object.defineProperty(genFun, "name", {
    configurable: true,
    value: func.name,
  });
  add_task(genFun);
}
