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

"use strict";

add_task(async function () {
  await SpecialPowers.pushPrefEnv({
    set: [
      ["general.autoScroll", true],
      ["middlemouse.contentLoadURL", false],
      ["test.events.async.enabled", false],
    ],
  });

  await BrowserTestUtils.withNewTab(
    "https://example.com/browser/toolkit/content/tests/browser/file_empty.html",
    async function (browser) {
      ok(browser.isRemoteBrowser, "This test passes only in e10s mode");
      await SpecialPowers.spawn(browser, [], () => {
        content.document.body.innerHTML =
          '<div style="height: 10000px;"></div>';
        content.document.documentElement.scrollTop = 500;
        content.document.documentElement.scrollTop; // Flush layout.
        // Prevent to open context menu when testing the secondary button click.
        content.window.addEventListener(
          "contextmenu",
          event => event.preventDefault(),
          { capture: true }
        );
      });

      function promiseFlushLayoutInContent() {
        return SpecialPowers.spawn(browser, [], () => {
          content.document.documentElement.scrollTop; // Flush layout in the remote content.
        });
      }

      function promiseContentTick() {
        return SpecialPowers.spawn(browser, [], async () => {
          await new Promise(r => {
            content.requestAnimationFrame(() => {
              content.requestAnimationFrame(r);
            });
          });
        });
      }

      let autoScroller;
      function promiseWaitForAutoScrollerOpen() {
        if (autoScroller?.state == "open") {
          info("The autoscroller has already been open");
          return Promise.resolve();
        }
        return BrowserTestUtils.waitForEvent(
          window,
          "popupshown",
          { capture: true },
          event => {
            if (event.originalTarget.id != "autoscroller") {
              return false;
            }
            autoScroller = event.originalTarget;
            info('"popupshown" event is fired');
            autoScroller.getBoundingClientRect(); // Flush layout of the autoscroller
            return true;
          }
        );
      }

      function promiseWaitForAutoScrollerClosed() {
        if (!autoScroller || autoScroller.state == "closed") {
          info("The autoscroller has already been closed");
          return Promise.resolve();
        }
        return BrowserTestUtils.waitForEvent(
          autoScroller,
          "popuphidden",
          { capture: true },
          () => {
            info('"popuphidden" event is fired');
            return true;
          }
        );
      }

      // Unfortunately, we cannot use synthesized mouse events for starting and
      // stopping autoscrolling because they may run different path from user
      // operation especially when there is a popup.

      /**
       * Instead of using `waitForContentEvent`, we use `addContentEventListener`
       * for checking which events are fired because `waitForContentEvent` cannot
       * detect redundant event since it's removed automatically at first event
       * or timeout if the expected count is 0.
       */
      class ContentEventCounter {
        constructor(aBrowser, aEventTypes) {
          this.eventData = new Map();
          for (let eventType of aEventTypes) {
            const removeEventListener =
              BrowserTestUtils.addContentEventListener(
                aBrowser,
                eventType,
                () => {
                  let eventData = this.eventData.get(eventType);
                  eventData.count++;
                },
                { capture: true }
              );
            this.eventData.set(eventType, {
              count: 0, // how many times the event fired.
              removeEventListener, // function to remove the event listener.
            });
          }
        }

        getCountAndRemoveEventListener(aEventType) {
          let eventData = this.eventData.get(aEventType);
          if (eventData.removeEventListener) {
            eventData.removeEventListener();
            eventData.removeEventListener = null;
          }
          return eventData.count;
        }

        promiseMouseEvents(aEventTypes, aMessage) {
          let needsToWait = [];
          for (const eventType of aEventTypes) {
            let eventData = this.eventData.get(eventType);
            if (eventData.count > 0) {
              info(`${aMessage}: Waiting "${eventType}" event in content...`);
              needsToWait.push(
                // Let's use `waitForCondition` here.  "timeout" is not worthwhile
                // to debug this test.  We want clearer failure log.
                TestUtils.waitForCondition(
                  () => eventData.count > 0,
                  `${aMessage}: "${eventType}" should be fired, but timed-out`
                )
              );
              break;
            }
          }
          return Promise.all(needsToWait);
        }
      }

      await SpecialPowers.pushPrefEnv({ set: [["middlemouse.paste", true]] });
      await (async function testMouseEventsAtStartingAutoScrolling() {
        info(
          "Waiting autoscroller popup for testing mouse events at starting autoscrolling"
        );
        await promiseFlushLayoutInContent();
        let eventsInContent = new ContentEventCounter(browser, [
          "click",
          "auxclick",
          "mousedown",
          "mouseup",
          "paste",
        ]);
        // Ensure that the event listeners added in the content with accessing
        // the remote content.
        await promiseFlushLayoutInContent();
        await EventUtils.promiseNativeMouseEvent({
          type: "mousemove",
          target: browser,
          atCenter: true,
        });
        const waitForOpenAutoScroll = promiseWaitForAutoScrollerOpen();
        await EventUtils.promiseNativeMouseEvent({
          type: "mousedown",
          target: browser,
          atCenter: true,
          button: 1, // middle button
        });
        await waitForOpenAutoScroll;
        // In the wild, native "mouseup" event occurs after the popup is open.
        await EventUtils.promiseNativeMouseEvent({
          type: "mouseup",
          target: browser,
          atCenter: true,
          button: 1, // middle button
        });
        await promiseFlushLayoutInContent();
        await promiseContentTick();
        await eventsInContent.promiseMouseEvents(
          ["mouseup"],
          "At starting autoscrolling"
        );
        for (let eventType of ["click", "auxclick", "paste"]) {
          is(
            eventsInContent.getCountAndRemoveEventListener(eventType),
            0,
            `"${eventType}" event shouldn't be fired in the content when a middle click starts autoscrolling`
          );
        }
        for (let eventType of ["mousedown", "mouseup"]) {
          is(
            eventsInContent.getCountAndRemoveEventListener(eventType),
            1,
            `"${eventType}" event should be fired in the content when a middle click starts autoscrolling`
          );
        }
        info("Waiting autoscroller close for preparing the following tests");
        let waitForAutoScrollEnd = promiseWaitForAutoScrollerClosed();
        EventUtils.synthesizeKey("KEY_Escape");
        await waitForAutoScrollEnd;
      })();

      if (
        // Bug 1693240: We don't support setting modifiers while posting a mouse event on Windows.
        !navigator.platform.includes("Win") &&
        // Bug 1693237: We don't support setting modifiers on Android.
        !navigator.appVersion.includes("Android") &&
        // In Headless mode, modifiers are not supported by this kind of APIs.
        !Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo).isHeadless
      ) {
        await SpecialPowers.pushPrefEnv({
          set: [
            ["general.autoscroll.prevent_to_start.shiftKey", true],
            ["general.autoscroll.prevent_to_start.altKey", true],
            ["general.autoscroll.prevent_to_start.ctrlKey", true],
            ["general.autoscroll.prevent_to_start.metaKey", true],
          ],
        });
        for (const modifier of ["Shift", "Control", "Alt", "Meta"]) {
          if (modifier == "Meta" && !navigator.platform.includes("Mac")) {
            continue; // Delete this after fixing bug 1232918.
          }
          await (async function modifiersPreventToStartAutoScrolling() {
            info(
              `Waiting to check not to open autoscroller popup with middle button click with ${modifier}`
            );
            await promiseFlushLayoutInContent();
            let eventsInContent = new ContentEventCounter(browser, [
              "click",
              "auxclick",
              "mousedown",
              "mouseup",
              "paste",
            ]);
            // Ensure that the event listeners added in the content with accessing
            // the remote content.
            await promiseFlushLayoutInContent();
            await EventUtils.promiseNativeMouseEvent({
              type: "mousemove",
              target: browser,
              atCenter: true,
            });
            info(
              `Waiting to MozAutoScrollNoStart event for the middle button click with ${modifier}`
            );
            await EventUtils.promiseNativeMouseEvent({
              type: "mousedown",
              target: browser,
              atCenter: true,
              button: 1, // middle button
              modifiers: {
                altKey: modifier == "Alt",
                ctrlKey: modifier == "Control",
                metaKey: modifier == "Meta",
                shiftKey: modifier == "Shift",
              },
            });
            try {
              await TestUtils.waitForCondition(
                () => autoScroller?.state == "open",
                `Waiting to check not to open autoscroller popup with ${modifier}`,
                100,
                10
              );
              ok(
                false,
                `The autoscroller popup shouldn't be opened by middle click with ${modifier}`
              );
            } catch (ex) {
              ok(
                true,
                `The autoscroller popup was not open as expected after middle click with ${modifier}`
              );
            }
            // In the wild, native "mouseup" event occurs after the popup is open.
            await EventUtils.promiseNativeMouseEvent({
              type: "mouseup",
              target: browser,
              atCenter: true,
              button: 1, // middle button
            });
            await promiseFlushLayoutInContent();
            await promiseContentTick();
            await eventsInContent.promiseMouseEvents(
              ["paste"],
              `At middle clicking with ${modifier}`
            );
            for (let eventType of [
              "mousedown",
              "mouseup",
              "click",
              "auxclick",
              "paste",
            ]) {
              is(
                eventsInContent.getCountAndRemoveEventListener(eventType),
                1,
                `"${eventType}" event should be fired in the content when a middle click with ${modifier}`
              );
            }
            info(
              "Waiting autoscroller close for preparing the following tests"
            );
          })();
        }
      }

      async function doTestMouseEventsAtStoppingAutoScrolling({
        aButton = 0,
        aClickOutsideAutoScroller = false,
        aDescription = "Unspecified",
      }) {
        info(
          `Starting autoscrolling for testing to stop autoscrolling with ${aDescription}`
        );
        await promiseFlushLayoutInContent();
        await EventUtils.promiseNativeMouseEvent({
          type: "mousemove",
          target: browser,
          atCenter: true,
        });
        const waitForOpenAutoScroll = promiseWaitForAutoScrollerOpen();
        await EventUtils.promiseNativeMouseEvent({
          type: "mousedown",
          target: browser,
          atCenter: true,
          button: 1, // middle button
        });
        // In the wild, native "mouseup" event occurs after the popup is open.
        await waitForOpenAutoScroll;
        await EventUtils.promiseNativeMouseEvent({
          type: "mouseup",
          target: browser,
          atCenter: true,
          button: 1, // middle button
        });
        await promiseFlushLayoutInContent();
        // Just to be sure, wait for a tick for wait APZ stable.
        await TestUtils.waitForTick();

        let eventsInContent = new ContentEventCounter(browser, [
          "click",
          "auxclick",
          "mousedown",
          "mouseup",
          "paste",
          "contextmenu",
        ]);
        // Ensure that the event listeners added in the content with accessing
        // the remote content.
        await promiseFlushLayoutInContent();

        aDescription = `Stop autoscrolling with ${aDescription}`;
        info(
          `${aDescription}: Synthesizing primary mouse button event on the autoscroller`
        );
        const autoScrollerRect = autoScroller.getOuterScreenRect();
        info(
          `${aDescription}: autoScroller: { left: ${autoScrollerRect.left}, top: ${autoScrollerRect.top}, width: ${autoScrollerRect.width}, height: ${autoScrollerRect.height} }`
        );
        const waitForCloseAutoScroller = promiseWaitForAutoScrollerClosed();
        if (aClickOutsideAutoScroller) {
          info(
            `${aDescription}: Synthesizing mousemove move cursor outside the autoscroller...`
          );
          await EventUtils.promiseNativeMouseEvent({
            type: "mousemove",
            target: autoScroller,
            offsetX: -10,
            offsetY: -10,
            elementOnWidget: browser, // use widget for the parent window of the autoscroller
          });
          info(
            `${aDescription}: Synthesizing mousedown to stop autoscrolling...`
          );
          await EventUtils.promiseNativeMouseEvent({
            type: "mousedown",
            target: autoScroller,
            offsetX: -10,
            offsetY: -10,
            button: aButton,
            elementOnWidget: browser, // use widget for the parent window of the autoscroller
          });
        } else {
          info(
            `${aDescription}: Synthesizing mousemove move cursor onto the autoscroller...`
          );
          await EventUtils.promiseNativeMouseEvent({
            type: "mousemove",
            target: autoScroller,
            atCenter: true,
            elementOnWidget: browser, // use widget for the parent window of the autoscroller
          });
          info(
            `${aDescription}: Synthesizing mousedown to stop autoscrolling...`
          );
          await EventUtils.promiseNativeMouseEvent({
            type: "mousedown",
            target: autoScroller,
            atCenter: true,
            button: aButton,
            elementOnWidget: browser, // use widget for the parent window of the autoscroller
          });
        }
        // In the wild, native "mouseup" event occurs after the popup is closed.
        await waitForCloseAutoScroller;
        info(
          `${aDescription}: Synthesizing mouseup event for preceding mousedown which is for stopping autoscrolling`
        );
        await EventUtils.promiseNativeMouseEvent({
          type: "mouseup",
          target: browser,
          atCenter: true,
          button: aButton,
        });
        await promiseFlushLayoutInContent();
        await promiseContentTick();
        await eventsInContent.promiseMouseEvents(
          aButton != 2 ? ["mouseup"] : ["mouseup", "contextmenu"],
          aDescription
        );
        is(
          autoScroller.state,
          "closed",
          `${aDescription}: The autoscroller should've been closed`
        );
        // - On macOS, when clicking outside autoscroller, nsChildView
        // intentionally blocks both "mousedown" and "mouseup" events in the
        // case of the primary button click, and only "mousedown" for the
        // middle button when the "mousedown".  I'm not sure how it should work
        // on macOS for conforming to the platform manner.  Note that autoscroll
        // isn't available on the other browsers on macOS.  So, there is no
        // reference, but for consistency between platforms, it may be better
        // to ignore the platform manner.
        // - On Windows, when clicking outside autoscroller, nsWindow
        // intentionally blocks only "mousedown" events for the primary button
        // and the middle button.  But this behavior is different from Chrome
        // so that we need to fix this in the future.
        // - On Linux, when clicking outside autoscroller, nsWindow
        // intentionally blocks only "mousedown" events for any buttons.  But
        // on Linux, autoscroll isn't available by the default settings.  So,
        // not so urgent, but should be fixed in the future for consistency
        // between platforms and compatibility with Chrome on Windows.
        const rollingUpPopupConsumeMouseDown =
          aClickOutsideAutoScroller &&
          (aButton != 2 || navigator.platform.includes("Linux"));
        const rollingUpPopupConsumeMouseUp =
          aClickOutsideAutoScroller &&
          aButton == 0 &&
          navigator.platform.includes("Mac");
        const checkFuncForClick =
          aClickOutsideAutoScroller &&
          aButton == 2 &&
          !navigator.platform.includes("Linux")
            ? todo_is
            : is;
        for (let eventType of ["click", "auxclick"]) {
          checkFuncForClick(
            eventsInContent.getCountAndRemoveEventListener(eventType),
            0,
            `${aDescription}: "${eventType}" event shouldn't be fired in the remote content`
          );
        }
        is(
          eventsInContent.getCountAndRemoveEventListener("paste"),
          0,
          `${aDescription}: "paste" event shouldn't be fired in the remote content`
        );
        const checkFuncForMouseDown = rollingUpPopupConsumeMouseDown
          ? todo_is
          : is;
        checkFuncForMouseDown(
          eventsInContent.getCountAndRemoveEventListener("mousedown"),
          1,
          `${aDescription}: "mousedown" event should be fired in the remote content`
        );
        const checkFuncForMouseUp = rollingUpPopupConsumeMouseUp ? todo_is : is;
        checkFuncForMouseUp(
          eventsInContent.getCountAndRemoveEventListener("mouseup"),
          1,
          `${aDescription}: "mouseup" event should be fired in the remote content`
        );
        const checkFuncForContextMenu =
          aButton == 2 &&
          aClickOutsideAutoScroller &&
          navigator.platform.includes("Linux")
            ? todo_is
            : is;
        checkFuncForContextMenu(
          eventsInContent.getCountAndRemoveEventListener("contextmenu"),
          aButton == 2 ? 1 : 0,
          `${aDescription}: "contextmenu" event should${
            aButton != 2 ? " not" : ""
          } be fired in the remote content`
        );

        const promiseClickEvent = BrowserTestUtils.waitForContentEvent(
          browser,
          "click",
          {
            capture: true,
          }
        );
        await promiseFlushLayoutInContent();
        info(`${aDescription}: Waiting for click event in the remote content`);
        EventUtils.synthesizeNativeMouseEvent({
          type: "click",
          target: browser,
          atCenter: true,
        });
        await promiseClickEvent;
        ok(
          true,
          `${aDescription}: click event is fired in the remote content after stopping autoscrolling`
        );
      }

      // Clicking the primary button to stop autoscrolling.
      await doTestMouseEventsAtStoppingAutoScrolling({
        aButton: 0,
        aClickOutsideAutoScroller: false,
        aDescription: "a primary button click on autoscroller",
      });
      await doTestMouseEventsAtStoppingAutoScrolling({
        aButton: 0,
        aClickOutsideAutoScroller: true,
        aDescription: "a primary button click outside autoscroller",
      });

      // Clicking the secondary button to stop autoscrolling.
      await doTestMouseEventsAtStoppingAutoScrolling({
        aButton: 2,
        aClickOutsideAutoScroller: false,
        aDescription: "a secondary button click on autoscroller",
      });
      await doTestMouseEventsAtStoppingAutoScrolling({
        aButton: 2,
        aClickOutsideAutoScroller: true,
        aDescription: "a secondary button click outside autoscroller",
      });

      // Clicking the middle button to stop autoscrolling.
      await SpecialPowers.pushPrefEnv({ set: [["middlemouse.paste", true]] });
      await doTestMouseEventsAtStoppingAutoScrolling({
        aButton: 1,
        aClickOutsideAutoScroller: false,
        aDescription:
          "a middle button click on autoscroller (middle click paste enabled)",
      });
      await doTestMouseEventsAtStoppingAutoScrolling({
        aButton: 1,
        aClickOutsideAutoScroller: true,
        aDescription:
          "a middle button click outside autoscroller (middle click paste enabled)",
      });
      await SpecialPowers.pushPrefEnv({ set: [["middlemouse.paste", false]] });
      await doTestMouseEventsAtStoppingAutoScrolling({
        aButton: 1,
        aClickOutsideAutoScroller: false,
        aDescription:
          "a middle button click on autoscroller (middle click paste disabled)",
      });
      await doTestMouseEventsAtStoppingAutoScrolling({
        aButton: 1,
        aClickOutsideAutoScroller: true,
        aDescription:
          "a middle button click outside autoscroller (middle click paste disabled)",
      });
    }
  );
});
