/* 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/. */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  setTimeout: "resource://gre/modules/Timer.sys.mjs",

  Log: "chrome://remote/content/shared/Log.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "logger", () =>
  lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
);

/**
 * Child JSWindowActor to handle navigation for reftests relying on marionette.
 */
export class MarionetteReftestChild extends JSWindowActorChild {
  constructor() {
    super();

    // This promise will resolve with the URL recorded in the "load" event
    // handler. This URL will not be impacted by any hash modification that
    // might be performed by the test script.
    // The harness should be loaded before loading any test page, so the actors
    // should be registered before the "load" event is received for a test page.
    this._loadedURLPromise = new Promise(
      r => (this._resolveLoadedURLPromise = r)
    );
  }

  handleEvent(event) {
    if (event.type == "load") {
      const url = event.target.location.href;
      lazy.logger.debug(`Handle load event with URL ${url}`);
      this._resolveLoadedURLPromise(url);
    }
  }

  actorCreated() {
    lazy.logger.trace(
      `[${this.browsingContext.id}] Reftest actor created ` +
        `for window id ${this.manager.innerWindowId}`
    );
  }

  async receiveMessage(msg) {
    const { name, data } = msg;

    let result;
    switch (name) {
      case "MarionetteReftestParent:flushRendering":
        result = await this.flushRendering(data);
        break;
      case "MarionetteReftestParent:reftestWait":
        result = await this.reftestWait(data);
        break;
    }
    return result;
  }

  /**
   * Wait for a reftest page to be ready for screenshots:
   * - wait for the loadedURL to be available (see handleEvent)
   * - check if the URL matches the expected URL
   * - if present, wait for the "reftest-wait" classname to be removed from the
   *   document element
   *
   * @param {object} options
   * @param {string} options.url
   *        The expected test page URL
   * @param {boolean} options.useRemote
   *        True when using e10s
   * @param {boolean} options.warnOnOverflow
   *        True if we should check the content fits in the viewport.
   *        This isn't necessary for print reftests where we will render the full
   *        size of the paginated content.
   * @returns {boolean}
   *         Returns true when the correct page is loaded and ready for
   *         screenshots. Returns false if the page loaded bug does not have the
   *         expected URL.
   */
  async reftestWait(options = {}) {
    const { url, useRemote } = options;
    const loadedURL = await this._loadedURLPromise;
    if (loadedURL !== url) {
      lazy.logger.debug(
        `Window URL does not match the expected URL "${loadedURL}" !== "${url}"`
      );
      return false;
    }

    const documentElement = this.document.documentElement;
    const hasReftestWait = documentElement.classList.contains("reftest-wait");

    lazy.logger.debug("Waiting for event loop to spin");
    await new Promise(resolve => lazy.setTimeout(resolve, 0));

    await this.paintComplete({ useRemote, ignoreThrottledAnimations: true });

    if (hasReftestWait) {
      const event = new this.document.defaultView.Event("TestRendered", {
        bubbles: true,
      });
      documentElement.dispatchEvent(event);
      lazy.logger.info("Emitted TestRendered event");
      await this.reftestWaitRemoved();
      await this.paintComplete({ useRemote, ignoreThrottledAnimations: false });
    }
    if (
      options.warnOnOverflow &&
      (this.document.defaultView.innerWidth < documentElement.scrollWidth ||
        this.document.defaultView.innerHeight < documentElement.scrollHeight)
    ) {
      lazy.logger.warn(
        `${url} overflows viewport (width: ${documentElement.scrollWidth}, height: ${documentElement.scrollHeight})`
      );
    }
    return true;
  }

  paintComplete({ useRemote, ignoreThrottledAnimations }) {
    lazy.logger.debug("Waiting for rendering");
    let windowUtils = this.document.defaultView.windowUtils;
    return new Promise(resolve => {
      let maybeResolve = () => {
        this.flushRendering({ ignoreThrottledAnimations });
        if (useRemote) {
          // Flush display (paint)
          lazy.logger.debug("Force update of layer tree");
          windowUtils.updateLayerTree();
        }

        if (windowUtils.isMozAfterPaintPending) {
          lazy.logger.debug("isMozAfterPaintPending: true");
          this.document.defaultView.addEventListener(
            "MozAfterPaint",
            maybeResolve,
            {
              once: true,
            }
          );
        } else {
          // resolve at the start of the next frame in case of leftover paints
          lazy.logger.debug("isMozAfterPaintPending: false");
          this.document.defaultView.requestAnimationFrame(() => {
            this.document.defaultView.requestAnimationFrame(resolve);
          });
        }
      };
      maybeResolve();
    });
  }

  reftestWaitRemoved() {
    lazy.logger.debug("Waiting for reftest-wait removal");
    return new Promise(resolve => {
      const documentElement = this.document.documentElement;
      let observer = new this.document.defaultView.MutationObserver(() => {
        if (!documentElement.classList.contains("reftest-wait")) {
          observer.disconnect();
          lazy.logger.debug("reftest-wait removed");
          lazy.setTimeout(resolve, 0);
        }
      });
      if (documentElement.classList.contains("reftest-wait")) {
        observer.observe(documentElement, { attributes: true });
      } else {
        lazy.setTimeout(resolve, 0);
      }
    });
  }

  /**
   * Ensure layout is flushed in each frame
   *
   * @param {object} options
   * @param {boolean} options.ignoreThrottledAnimations Don't flush
   *        the layout of throttled animations. We can end up in a
   *        situation where flushing a throttled animation causes
   *        mozAfterPaint events even when all rendering we care about
   *        should have ceased. See
   *        https://searchfox.org/mozilla-central/rev/d58860eb739af613774c942c3bb61754123e449b/layout/tools/reftest/reftest-content.js#723-729
   *        for more detail.
   */
  flushRendering(options = {}) {
    let { ignoreThrottledAnimations } = options;
    lazy.logger.debug(
      `flushRendering ignoreThrottledAnimations:${ignoreThrottledAnimations}`
    );
    let anyPendingPaintsGeneratedInDescendants = false;

    let windowUtils = this.document.defaultView.windowUtils;

    function flushWindow(win) {
      let utils = win.windowUtils;
      let afterPaintWasPending = utils.isMozAfterPaintPending;

      let root = win.document.documentElement;
      if (root) {
        try {
          if (ignoreThrottledAnimations) {
            utils.flushLayoutWithoutThrottledAnimations();
          } else {
            root.getBoundingClientRect();
          }
        } catch (e) {
          lazy.logger.error("flushWindow failed", e);
        }
      }

      if (!afterPaintWasPending && utils.isMozAfterPaintPending) {
        anyPendingPaintsGeneratedInDescendants = true;
      }

      for (let i = 0; i < win.frames.length; ++i) {
        // Skip remote frames, flushRendering will be called on their individual
        // MarionetteReftest actor via _recursiveFlushRendering performed from
        // the topmost MarionetteReftest actor.
        if (!Cu.isRemoteProxy(win.frames[i])) {
          flushWindow(win.frames[i]);
        }
      }
    }
    flushWindow(this.document.defaultView);

    if (
      anyPendingPaintsGeneratedInDescendants &&
      !windowUtils.isMozAfterPaintPending
    ) {
      lazy.logger.error(
        "Descendant frame generated a MozAfterPaint event, " +
          "but the root document doesn't have one!"
      );
    }
  }
}
