/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { BrowserTestUtils } from "resource://testing-common/BrowserTestUtils.sys.mjs";
import { NetUtil } from "resource://gre/modules/NetUtil.sys.mjs";
import * as EventUtils from "resource://testing-common/mail/EventUtils.sys.mjs";
import { TestUtils } from "resource://testing-common/TestUtils.sys.mjs";

/**
 * Timeout for focusing a window.  Only really an issue on linux.
 */
var WINDOW_FOCUS_TIMEOUT_MS = 10000;

function getWindowTypeOrID(win) {
  const docElement = win.document.documentElement;
  return docElement.getAttribute("windowtype") || docElement.id;
}

/**
 * Call this if the window you want to get may already be open.  What we
 *  provide above just directly grabbing the window yourself is:
 * - We wait for it to finish loading.
 *
 * @param {string} aWindowType - The window type that will be created. This is
 *   the value of the "windowtype" attribute on the window. The values tend to
 *   look like "app:windowname", for example "mailnews:search".
 * @returns {Window}
 */
export function wait_for_existing_window(aWindowType) {
  return Services.wm.getMostRecentWindow(aWindowType);
}

/**
 * Call this just before you trigger the event that will cause a window to be
 *  displayed.
 *
 * @param {string} aWindowType - The window type that will be created. This is
 *   the value of the "windowtype" attribute on the window. The values tend to
 *   look like "app:windowname", for example "mailnews:search".
 * @returns {Promise} A promise resolved when a window of the right type opens.
 */
export async function promise_new_window(aWindowType) {
  const domWindow = await BrowserTestUtils.domWindowOpenedAndLoaded(
    null,
    win => getWindowTypeOrID(win) == aWindowType
  );
  await new Promise(resolve => domWindow.setTimeout(resolve));
  await new Promise(resolve => domWindow.setTimeout(resolve));

  return domWindow;
}

/**
 * Plan for the imminent display of a modal dialog.  Modal dialogs spin their
 *  own event loop which means that either that control flow will not return
 *  to the caller until the modal dialog finishes running.  This means that
 *  you need to provide a sub-test function to be run inside the modal dialog
 *  (and it should not start with "test" or mozmill will also try and run it.)
 *
 * @param {string} aWindowType - The window type that you expect the modal
 *   dialog to have or the ID of the window if there is no window type available.
 * @param {function} aSubTestFunction - The sub-test function that will be run
 *   once the modal dialog appears and is loaded. This function should take one
 *   argument, the modal dialog.
 */
export async function promise_modal_dialog(aWindowType, aSubTestFunction) {
  const domWindow = await BrowserTestUtils.domWindowOpenedAndLoaded(
    null,
    win => getWindowTypeOrID(win) == aWindowType
  );
  await aSubTestFunction(domWindow);
  await BrowserTestUtils.windowClosed(domWindow);
}

/**
 * Wait for the window to be focused.
 *
 * @param aWindow the window to be focused.
 */
export async function wait_for_window_focused(aWindow) {
  let targetWindow = {};

  Services.focus.getFocusedElementForWindow(aWindow, true, targetWindow);
  targetWindow = targetWindow.value;

  let focusedWindow = {};
  if (Services.focus.activeWindow) {
    Services.focus.getFocusedElementForWindow(
      Services.focus.activeWindow,
      true,
      focusedWindow
    );
    focusedWindow = focusedWindow.value;
  }

  let focused = false;
  if (focusedWindow == targetWindow) {
    focused = true;
  } else {
    targetWindow.addEventListener("focus", () => (focused = true), {
      capture: true,
      once: true,
    });
    targetWindow.focus();
  }

  await TestUtils.waitForCondition(
    () => focused,
    "Timeout waiting for window to be focused.",
    WINDOW_FOCUS_TIMEOUT_MS,
    100,
    this
  );
}

/**
 * Given a <browser>, waits for it to completely load.
 *
 * @param {XULBrowserElement} aBrowser - The <browser> element to wait for.
 * @param {string|function} aURLOrPredicate - The URL that should be loaded
 *   (string) or a predicate for the URL (function).
 * @returns {Window} The browser's content window.
 */
export async function wait_for_browser_load(aBrowser, aURLOrPredicate) {
  // aBrowser has all the fields we need already.
  return _wait_for_generic_load(aBrowser, aURLOrPredicate);
}

/**
 * Given an HTML <frame> or <iframe>, waits for it to completely load.
 *
 * @param aFrame The element to wait for.
 * @param aURLOrPredicate The URL that should be loaded (string) or a predicate
 *                        for the URL (function).
 * @returns The frame.
 */
export async function wait_for_frame_load(aFrame, aURLOrPredicate) {
  return _wait_for_generic_load(aFrame, aURLOrPredicate);
}

/**
 * Generic function to wait for some sort of document to load. We expect
 * aDetails to have three fields:
 * - webProgress: an nsIWebProgress associated with the contentWindow.
 * - currentURI: the currently loaded page (nsIURI).
 */
async function _wait_for_generic_load(aDetails, aURLOrPredicate) {
  let predicate;
  if (typeof aURLOrPredicate == "string") {
    const expectedURL = NetUtil.newURI(aURLOrPredicate);
    predicate = url => expectedURL.equals(url);
  } else {
    predicate = aURLOrPredicate;
  }

  await TestUtils.waitForCondition(function () {
    if (aDetails.webProgress?.isLoadingDocument) {
      return false;
    }
    if (
      aDetails.contentDocument &&
      aDetails.contentDocument.readyState != "complete"
    ) {
      return false;
    }

    return predicate(
      aDetails.currentURI ||
        NetUtil.newURI(aDetails.contentWindow.location.href)
    );
  }, `waiting for content page to load. Current URL is: ${aDetails.currentURI?.spec}`);

  // Lie to mozmill to convince it to not explode because these frames never
  // get a mozmillDocumentLoaded attribute (bug 666438).
  const contentWindow = aDetails.contentWindow;
  if (contentWindow) {
    return contentWindow;
  }
  return null;
}

/**
 * Dynamically-built/XBL-defined menus can be hard to work with, this makes it
 *  easier.
 *
 * @param aRootPopup  The base popup. The caller is expected to activate it
 *     (by clicking/rightclicking the right widget). We will only wait for it
 *     to open if it is in the process.
 * @param aActions  An array of objects where each object has attributes
 *     with a value defined. We pick the menu item whose DOM node matches
 *     all the attributes with the specified names and value. We click whatever
 *     we find. We throw if the element being asked for is not found.
 * @param aKeepOpen  If set to true the popups are not closed after last click.
 *
 * @returns An array of popup elements that were left open. It will be
 *          an empty array if aKeepOpen was set to false.
 */
export async function click_menus_in_sequence(aRootPopup, aActions, aKeepOpen) {
  if (aRootPopup.state != "open") {
    await BrowserTestUtils.waitForEvent(aRootPopup, "popupshown");
  }

  /**
   * Check if a node's attributes match all those given in actionObj.
   * Nodes that are obvious containers are skipped, and their children
   * will be used to recursively find a match instead.
   *
   * @param {Element} node - The node to check.
   * @param {object} actionObj - Contains attribute-value pairs to match.
   * @returns {Element|null} The matched node or null if no match.
   */
  const findMatch = function (node, actionObj) {
    // Ignore some elements and just use their children instead.
    if (node.localName == "hbox" || node.localName == "vbox") {
      for (let i = 0; i < node.children.length; i++) {
        const childMatch = findMatch(node.children[i]);
        if (childMatch) {
          return childMatch;
        }
      }
      return null;
    }

    let matchedAll = true;
    for (const name in actionObj) {
      const value = actionObj[name];
      if (!node.hasAttribute(name) || node.getAttribute(name) != value) {
        matchedAll = false;
        break;
      }
    }
    return matchedAll ? node : null;
  };

  // These popups sadly do not close themselves, so we need to keep track
  // of them so we can make sure they end up closed.
  const closeStack = [aRootPopup];

  let curPopup = aRootPopup;
  for (const [iAction, actionObj] of aActions.entries()) {
    let matchingNode = null;
    const kids = curPopup.children;
    for (let iKid = 0; iKid < kids.length; iKid++) {
      const node = kids[iKid];
      matchingNode = findMatch(node, actionObj);
      if (matchingNode) {
        break;
      }
    }

    if (!matchingNode) {
      throw new Error(
        "Did not find matching menu item for action index " +
          iAction +
          ": " +
          JSON.stringify(actionObj)
      );
    }

    if (matchingNode.localName == "menu") {
      matchingNode.openMenu(true);
    } else {
      curPopup.activateItem(matchingNode);
    }
    await new Promise(r => matchingNode.ownerGlobal.setTimeout(r, 500));

    let newPopup = null;
    if ("menupopup" in matchingNode) {
      newPopup = matchingNode.menupopup;
    }
    if (newPopup) {
      curPopup = newPopup;
      closeStack.push(curPopup);
      if (curPopup.state != "open") {
        await BrowserTestUtils.waitForEvent(curPopup, "popupshown");
      }
    }
  }

  if (!aKeepOpen) {
    close_popup_sequence(closeStack);
    return [];
  }
  return closeStack;
}

/**
 * Close given menupopups.
 *
 * @param aCloseStack  An array of menupopup elements that are to be closed.
 *                     The elements are processed from the end of the array
 *                     to the front (a stack).
 */
export function close_popup_sequence(aCloseStack) {
  while (aCloseStack.length) {
    const curPopup = aCloseStack.pop();
    if (curPopup.state == "open") {
      curPopup.focus();
      curPopup.hidePopup();
    }
  }
}

/**
 * Click through the appmenu. Callers are expected to open the initial
 * appmenu panelview (e.g. by clicking the appmenu button). We wait for it
 * to open if it is not open yet. Then we use a recursive style approach
 * with a sequence of event listeners handling "ViewShown" events. The
 * `navTargets` parameter specifies items to click to navigate through the
 * menu. The optional `nonNavTarget` parameter specifies a final item to
 * click to perform a command after navigating through the menu. If this
 * argument is omitted, callers can interact with the last view panel that
 * is returned. Callers will then need to close the appmenu when they are
 * done with it.
 *
 * @param {object[]} navTargets - Array of objects that contain
 *   attribute->value pairs. We pick the menu item whose DOM node matches
 *   all the attribute->value pairs. We click whatever we find. We throw
 *   if the element being asked for is not found.
 * @param {object} [nonNavTarget] - Contains attribute->value pairs used
 *   to identify a final menu item to click.
 * @param {Window} win - The window we're using.
 * @returns {Element} The <vbox class="panel-subview-body"> element inside
 *   the last shown <panelview>.
 */
async function _click_appmenu_in_sequence(navTargets, nonNavTarget, win) {
  const rootPopup = win.document.getElementById("appMenu-popup");

  async function viewShownListener(navTargets, nonNavTarget, allDone, event) {
    // Set up the next listener if there are more navigation targets.
    if (navTargets.length > 0) {
      rootPopup.addEventListener(
        "ViewShown",
        viewShownListener.bind(
          null,
          navTargets.slice(1),
          nonNavTarget,
          allDone
        ),
        { once: true }
      );
    }

    const subview = event.target.querySelector(".panel-subview-body");

    // Click a target if there is a target left to click.
    const clickTarget = navTargets[0] || nonNavTarget;

    if (clickTarget) {
      const kids = Array.from(subview.children);
      const findFunction = node => {
        const selectors = [];
        for (const name in clickTarget) {
          const value = clickTarget[name];
          selectors.push(`[${name}="${value}"]`);
        }
        const s = selectors.join(",");
        return node.matches(s) || node.querySelector(s);
      };

      // Some views are dynamically populated after ViewShown, so we wait.
      await TestUtils.waitForCondition(
        () => kids.find(findFunction),
        () =>
          "Waited but did not find matching menu item for target: " +
          JSON.stringify(clickTarget)
      );

      const foundNode = kids.find(findFunction);

      EventUtils.synthesizeMouseAtCenter(foundNode, {}, foundNode.ownerGlobal);
    }

    // We are all done when there are no more navigation targets.
    if (navTargets.length == 0) {
      allDone(subview);
    }
  }

  let done = false;
  let subviewToReturn;
  const allDone = subview => {
    subviewToReturn = subview;
    done = true;
  };

  await TestUtils.waitForCondition(
    () => rootPopup.getAttribute("panelopen") == "true",
    "Waited for the appmenu to open, but it never opened."
  );

  // Because the appmenu button has already been clicked in the calling
  // code (to match click_menus_in_sequence), we have to call the first
  // viewShownListener manually, using a fake event argument, to start the
  // series of event listener calls.
  const fakeEvent = {
    target: win.document.getElementById("appMenu-mainView"),
  };
  viewShownListener(navTargets, nonNavTarget, allDone, fakeEvent);

  await TestUtils.waitForCondition(
    () => done,
    "Timed out in _click_appmenu_in_sequence."
  );
  return subviewToReturn;
}

/**
 * Utility wrapper function that clicks the main appmenu button to open the
 * appmenu before calling `click_appmenu_in_sequence`. Makes things simple
 * and concise for the most common case while still allowing for tests that
 * open the appmenu via keyboard before calling `_click_appmenu_in_sequence`.
 *
 * @param {object[]} navTargets - Array of objects that contain
 *     attribute->value pairs to be used to identify menu items to click.
 * @param {?object} nonNavTarget - Contains attribute->value pairs used
 *   to identify a final menu item to click.
 * @param {Window} win - The window we're using.
 * @returns {Element} The <vbox class="panel-subview-body"> element inside
 *                    the last shown <panelview>.
 */
export async function click_through_appmenu(navTargets, nonNavTarget, win) {
  const appmenu = win.document.getElementById("button-appmenu");
  EventUtils.synthesizeMouseAtCenter(appmenu, {}, appmenu.ownerGlobal);
  return _click_appmenu_in_sequence(navTargets, nonNavTarget, win);
}
