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

"use strict";

// Test simulated touch events can correctly target embedded iframes.

// These tests put a target iframe in a small embedding area, nested
// different ways. Then a simulated mouse click is made on top of the
// target iframe. If everything works, the translation done in
// touch-simulator.js should exactly match the translation done in the
// Platform code, such that the target is hit by the synthesized tap
// is at the expected location.

info("--- Starting viewport test output ---");

info(`*** WARNING *** This test will move the mouse pointer to simulate
native mouse clicks. Do not move the mouse during this test or you may
cause intermittent failures.`);

// This test could run awhile, so request a 4x timeout duration.
requestLongerTimeout(4);

// The viewport will be square, set to VIEWPORT_DIMENSION on each axis.
const VIEWPORT_DIMENSION = 200;

const META_VIEWPORT_CONTENTS = ["width=device-width", "width=400"];

const DPRS = [1, 2, 3];

const URL_ROOT_2 = CHROME_URL_ROOT.replace(
  "chrome://mochitests/content/",
  "http://mochi.test:8888/"
);
const IFRAME_PATHS = [`${URL_ROOT}`, `${URL_ROOT_2}`];

const TESTS = [
  {
    description: "untranslated iframe",
    style: {},
  },
  {
    description: "translated 50% iframe",
    style: {
      position: "absolute",
      left: "50%",
      top: "50%",
      transform: "translate(-50%, -50%)",
    },
  },
  {
    description: "translated 100% iframe",
    style: {
      position: "absolute",
      left: "100%",
      top: "100%",
      transform: "translate(-100%, -100%)",
    },
  },
];

let testID = 0;

for (const mvcontent of META_VIEWPORT_CONTENTS) {
  info(`Starting test series with meta viewport content "${mvcontent}".`);

  const TEST_URL =
    `data:text/html;charset=utf-8,` +
    `<html><meta name="viewport" content="${mvcontent}">` +
    `<body style="margin:0; width:100%; height:200%;">` +
    `<iframe id="host" ` +
    `style="margin:0; border:0; width:100%; height:100%"></iframe>` +
    `</body></html>`;

  addRDMTask(TEST_URL, async function({ ui, manager, browser }) {
    await setViewportSize(ui, manager, VIEWPORT_DIMENSION, VIEWPORT_DIMENSION);
    await setTouchAndMetaViewportSupport(ui, true);

    // Figure out our window origin in screen space, which we'll need as we calculate
    // coordinates for our simulated click events. These values are in CSS units, which
    // is weird, but we compensate for that later.
    const screenToWindowX = window.mozInnerScreenX;
    const screenToWindowY = window.mozInnerScreenY;

    for (const dpr of DPRS) {
      await selectDevicePixelRatio(ui, dpr);

      for (const path of IFRAME_PATHS) {
        for (const test of TESTS) {
          const { description, style } = test;

          const title = `ID ${testID} - ${description} with DPR ${dpr} and path ${path}`;

          info(`Starting test ${title}.`);

          await spawnViewportTask(
            ui,
            {
              title,
              style,
              path,
              VIEWPORT_DIMENSION,
              screenToWindowX,
              screenToWindowY,
            },
            async args => {
              // Define a function that returns a promise for one message that
              // contains, at least, the supplied prop, and resolves with the
              // data from that message. If a timeout value is supplied, the
              // promise will reject if the timeout elapses first.
              const oneMatchingMessageWithTimeout = (win, prop, timeout) => {
                return new Promise((resolve, reject) => {
                  let ourTimeoutID = 0;

                  const ourListener = win.addEventListener("message", e => {
                    if (typeof e.data[prop] !== "undefined") {
                      if (ourTimeoutID) {
                        win.clearTimeout(ourTimeoutID);
                      }
                      win.removeEventListener("message", ourListener);
                      resolve(e.data);
                    }
                  });

                  if (timeout) {
                    // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
                    ourTimeoutID = win.setTimeout(() => {
                      win.removeEventListener("message", ourListener);
                      reject(
                        `Timeout waiting for message with prop ${prop} after ${timeout}ms.`
                      );
                    }, timeout);
                  }
                });
              };

              // Our checks are not always precise, due to rounding errors in the
              // scaling from css to screen and back. For now we use an epsilon and
              // a locally-defined isfuzzy to compensate. We can't use
              // SimpleTest.isfuzzy, because it's not bridged to the ContentTask.
              // If that is ever bridged, we can remove the isfuzzy definition here and
              // everything should "just work".
              function isfuzzy(actual, expected, epsilon, msg) {
                if (
                  actual >= expected - epsilon &&
                  actual <= expected + epsilon
                ) {
                  ok(true, msg);
                } else {
                  // This will trigger the usual failure message for is.
                  is(actual, expected, msg);
                }
              }

              // Lift a set of functions out of apz_test_native_event_utils.js
              // to use for sending native mouse events. Bug 1126772 would instead
              // allow us to use a method in EventUtils.
              function getPlatform() {
                if (content.navigator.platform.indexOf("Win") == 0) {
                  return "windows";
                }
                if (content.navigator.platform.indexOf("Mac") == 0) {
                  return "mac";
                }
                // Check for Android before Linux
                if (content.navigator.appVersion.includes("Android")) {
                  return "android";
                }
                if (content.navigator.platform.indexOf("Linux") == 0) {
                  return "linux";
                }
                return "unknown";
              }

              function nativeMouseDownEventMsg() {
                switch (getPlatform()) {
                  case "windows":
                    return 2; // MOUSEEVENTF_LEFTDOWN
                  case "mac":
                    return 1; // NSLeftMouseDown
                  case "linux":
                    return 4; // GDK_BUTTON_PRESS
                  case "android":
                    return 5; // ACTION_POINTER_DOWN
                }
                throw new Error(
                  "Native mouse-down events not supported on platform " +
                    getPlatform()
                );
              }

              function nativeMouseUpEventMsg() {
                switch (getPlatform()) {
                  case "windows":
                    return 4; // MOUSEEVENTF_LEFTUP
                  case "mac":
                    return 2; // NSLeftMouseUp
                  case "linux":
                    return 7; // GDK_BUTTON_RELEASE
                  case "android":
                    return 6; // ACTION_POINTER_UP
                }
                throw new Error(
                  "Native mouse-up events not supported on platform " +
                    getPlatform()
                );
              }

              // This function takes screen coordinates in css pixels.
              function synthesizeNativeMouseClick(win, screenX, screenY) {
                const utils = win.windowUtils;
                const scale = utils.screenPixelsPerCSSPixelNoOverride;

                return new Promise(resolve => {
                  utils.sendNativeMouseEvent(
                    screenX * scale,
                    screenY * scale,
                    nativeMouseDownEventMsg(),
                    0,
                    win.document.documentElement,
                    () => {
                      utils.sendNativeMouseEvent(
                        screenX * scale,
                        screenY * scale,
                        nativeMouseUpEventMsg(),
                        0,
                        win.document.documentElement,
                        resolve
                      );
                    }
                  );
                });
              }

              // We're done defining functions; start the actual loading of the iframe
              // and triggering the onclick handler in its content.
              const host = content.document.getElementById("host");

              // Modify the iframe style by adding the properties in the
              // provided style object.
              for (const prop in args.style) {
                info(`Setting style.${prop} to ${args.style[prop]}.`);
                host.style[prop] = args.style[prop];
              }

              // Set the iframe source, and await the ready message.
              const IFRAME_URL = args.path + "touch_event_target.html";
              const READY_TIMEOUT_MS = 5000;
              const iframeReady = oneMatchingMessageWithTimeout(
                content,
                "ready",
                READY_TIMEOUT_MS
              );
              host.src = IFRAME_URL;
              try {
                await iframeReady;
              } catch (error) {
                ok(false, `${args.title} ${error}`);
                return;
              }

              info(`iframe has finished loading.`);

              // Await reflow of the parent window.
              await new Promise(resolve => {
                content.requestAnimationFrame(() => {
                  content.requestAnimationFrame(resolve);
                });
              });

              // Now we're going to calculate screen coordinates for the upper-left
              // quadrant of the target area. We're going to do that by using the
              // following sources:
              // 1) args.screenToWindow: the window position in screen space, in CSS
              //    pixels.
              // 2) host.getBoxQuadsFromWindowOrigin(): the iframe position, relative
              //    to the window origin, in CSS pixels.
              // 3) args.VIEWPORT_DIMENSION: the viewport size, in CSS pixels.
              // We calculate the screen position of the center of the upper-left
              // quadrant of the iframe, then use sendNativeMouseEvent to dispatch
              // a click at that position. It should trigger the RDM TouchSimulator
              // and turn the mouse click into a touch event that hits the onclick
              // handler in the iframe content. If it's done correctly, the message
              // we get back should have x,y coordinates that match the center of the
              // upper left quadrant of the iframe, in CSS units.

              const hostBounds = host
                .getBoxQuadsFromWindowOrigin()[0]
                .getBounds();
              const windowToHostX = hostBounds.left;
              const windowToHostY = hostBounds.top;

              const screenToHostX = args.screenToWindowX + windowToHostX;
              const screenToHostY = args.screenToWindowY + windowToHostY;

              const quadrantOffsetDoc = hostBounds.width * 0.25;
              const hostUpperLeftQuadrantDocX = quadrantOffsetDoc;
              const hostUpperLeftQuadrantDocY = quadrantOffsetDoc;

              const quadrantOffsetViewport = args.VIEWPORT_DIMENSION * 0.25;
              const hostUpperLeftQuadrantViewportX = quadrantOffsetViewport;
              const hostUpperLeftQuadrantViewportY = quadrantOffsetViewport;

              const targetX = screenToHostX + hostUpperLeftQuadrantViewportX;
              const targetY = screenToHostY + hostUpperLeftQuadrantViewportY;

              // We're going to try a few times to click on the target area. Our method
              // for triggering a native mouse click is vulnerable to interactive mouse
              // moves while the test is running. Letting the click timeout gives us a
              // chance to try again.
              const CLICK_TIMEOUT_MS = 1000;
              const CLICK_ATTEMPTS = 3;
              let eventWasReceived = false;

              for (let attempt = 0; attempt < CLICK_ATTEMPTS; attempt++) {
                const gotXAndY = oneMatchingMessageWithTimeout(
                  content,
                  "x",
                  CLICK_TIMEOUT_MS
                );
                info(
                  `Sending native mousedown and mouseup to screen position ${targetX}, ${targetY} (attempt ${attempt}).`
                );
                await synthesizeNativeMouseClick(content, targetX, targetY);
                try {
                  const { x, y, screenX, screenY } = await gotXAndY;
                  eventWasReceived = true;
                  isfuzzy(
                    x,
                    hostUpperLeftQuadrantDocX,
                    1,
                    `${args.title} got click at close enough X ${x}, screen is ${screenX}.`
                  );
                  isfuzzy(
                    y,
                    hostUpperLeftQuadrantDocY,
                    1,
                    `${args.title} got click at close enough Y ${y}, screen is ${screenY}.`
                  );
                  break;
                } catch (error) {
                  // That click didn't work. The for loop will trigger another attempt,
                  // or give up.
                }
              }

              if (!eventWasReceived) {
                ok(
                  false,
                  `${args.title} failed to get a click after ${CLICK_ATTEMPTS} tries.`
                );
              }
            }
          );

          testID++;
        }
      }
    }
  });
}
