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

/**
 * The Screenshots overlay is inserted into the document's
 * canvasFrame anonymous content container (see dom/webidl/Document.webidl).
 *
 * This container gets cleared automatically when the document navigates.
 *
 * Since the overlay markup is inserted in the canvasFrame using
 * insertAnonymousContent, this means that it can be modified using the API
 * described in AnonymousContent.webidl.
 *
 * Any mutation of this content must be via the AnonymousContent API.
 * This is similar in design to [devtools' highlighters](https://firefox-source-docs.mozilla.org/devtools/tools/highlighters.html#inserting-content-in-the-page),
 * though as Screenshots doesnt need to work on XUL documents, or allow multiple kinds of
 * highlight/overlay our case is a little simpler.
 *
 * To retrieve the AnonymousContent instance, use the `content` getter.
 */

/* States:

  "crosshairs":
    Nothing has happened, and the crosshairs will follow the movement of the mouse
  "draggingReady":
    The user has pressed the mouse button, but hasn't moved enough to create a selection
  "dragging":
    The user has pressed down a mouse button, and is dragging out an area far enough to show a selection
  "selected":
    The user has selected an area
  "resizing":
    The user is resizing the selection

  A pointerdown goes from crosshairs to dragging.
  A pointerup goes from dragging to selected
  A click outside of the selection goes from selected to crosshairs
  A pointerdown on one of the draggers goes from selected to resizing

  */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

XPCOMUtils.defineLazyGetter(lazy, "overlayLocalization", () => {
  return new Localization(["browser/screenshotsOverlay.ftl"], true);
});

const STYLESHEET_URL =
  "chrome://browser/content/screenshots/overlay/overlay.css";

// An autoselection smaller than these will be ignored entirely:
const MIN_DETECT_ABSOLUTE_HEIGHT = 10;
const MIN_DETECT_ABSOLUTE_WIDTH = 30;
// An autoselection smaller than these will not be preferred:
const MIN_DETECT_HEIGHT = 30;
const MIN_DETECT_WIDTH = 100;
// An autoselection bigger than either of these will be ignored:
let MAX_DETECT_HEIGHT = 700;
let MAX_DETECT_WIDTH = 1000;

const REGION_CHANGE_THRESHOLD = 5;
const SCROLL_BY_EDGE = 20;

const doNotAutoselectTags = {
  H1: true,
  H2: true,
  H3: true,
  H4: true,
  H5: true,
  H6: true,
};

class AnonymousContentOverlay {
  constructor(contentDocument, screenshotsChild) {
    this.listeners = new Map();
    this.elements = new Map();

    this.screenshotsChild = screenshotsChild;

    this.contentDocument = contentDocument;
    // aliased for easier diffs/maintenance of the event management code borrowed from devtools highlighters
    this.pageListenerTarget = contentDocument.ownerGlobal;

    this.overlayFragment = null;

    this.overlayId = "screenshots-overlay-container";
    this.previewId = "preview-container";
    this.selectionId = "selection-container";
    this.hoverBoxId = "hover-highlight";

    this._initialized = false;

    this.moverIds = [
      "mover-left",
      "mover-top",
      "mover-right",
      "mover-bottom",
      "mover-topLeft",
      "mover-topRight",
      "mover-bottomLeft",
      "mover-bottomRight",
    ];
  }
  get content() {
    if (!this._content || Cu.isDeadWrapper(this._content)) {
      return null;
    }
    return this._content;
  }
  async initialize() {
    if (this._initialized) {
      return;
    }

    let document = this.contentDocument;
    let window = document.ownerGlobal;

    // Inject stylesheet
    if (!this.overlayFragment) {
      try {
        window.windowUtils.loadSheetUsingURIString(
          STYLESHEET_URL,
          window.windowUtils.AGENT_SHEET
        );
      } catch {
        // The method fails if the url is already loaded.
      }
      // Inject markup for the overlay UI
      this.overlayFragment = this.buildOverlay();
    }

    this._content = document.insertAnonymousContent(
      this.overlayFragment.children[0]
    );

    this.addEventListeners();

    const hoverElementBox = new HoverElementBox(
      this.hoverBoxId,
      this.content,
      document
    );

    const previewLayer = new PreviewLayer(this.previewId, this.content);
    const selectionLayer = new SelectionLayer(
      this.selectionId,
      this.content,
      hoverElementBox
    );

    this.screenshotsContainer = new ScreenshotsContainerLayer(
      this.overlayId,
      this.content,
      previewLayer,
      selectionLayer
    );

    this.stateHandler = new StateHandler(
      this.screenshotsContainer,
      this.screenshotsChild
    );

    this.screenshotsContainer.updateSize(window);

    this.stateHandler.setState("crosshairs");

    this._initialized = true;
  }

  /**
   * The Anonymous Content doesn't shrink when the window is resized so we need
   * to find the largest element that isn't the Anonymous Content and we will
   * use that width and height.
   * Otherwise we will fallback to the documentElement scroll width and height
   * @param eventType If "resize", we called this from a resize event so we will
   *  try shifting the SelectionBox.
   *  If "scroll", we called this from a scroll event so we will redraw the buttons
   */
  updateScreenshotsSize(eventType) {
    this.stateHandler.updateScreenshotsContainerSize(
      this.contentDocument.ownerGlobal,
      eventType
    );
  }

  /**
   * Add required event listeners to the overlay
   */
  addEventListeners() {
    let cancelScreenshotsFunciton = () => {
      this.screenshotsChild.requestCancelScreenshot("overlay_cancel");
    };
    this.addEventListenerForElement(
      "screenshots-cancel-button",
      "click",
      cancelScreenshotsFunciton
    );
    this.addEventListenerForElement(
      "cancel",
      "click",
      cancelScreenshotsFunciton
    );
    this.addEventListenerForElement("copy", "click", (event, targetId) => {
      this.screenshotsChild.requestCopyScreenshot(
        this.screenshotsContainer.getSelectionLayerBoxDimensions()
      );
    });
    this.addEventListenerForElement("download", "click", (event, targetId) => {
      this.screenshotsChild.requestDownloadScreenshot(
        this.screenshotsContainer.getSelectionLayerBoxDimensions()
      );
    });

    // The pointerdown event is added to the selection buttons to prevent the
    // pointerdown event from occurring on the "screenshots-overlay-container"
    this.addEventListenerForElement(
      "cancel",
      "pointerdown",
      (event, targetId) => {
        event.stopPropagation();
      }
    );
    this.addEventListenerForElement(
      "copy",
      "pointerdown",
      (event, targetId) => {
        event.stopPropagation();
      }
    );
    this.addEventListenerForElement(
      "download",
      "pointerdown",
      (event, targetId) => {
        event.stopPropagation();
      }
    );

    this.addEventListenerForElement(
      this.overlayId,
      "pointerdown",
      (event, targetId) => {
        this.dragStart(event, targetId);
      }
    );
    this.addEventListenerForElement(
      this.overlayId,
      "pointerup",
      (event, targetId) => {
        this.dragEnd(event, targetId);
      }
    );
    this.addEventListenerForElement(
      this.overlayId,
      "pointermove",
      (event, targetId) => {
        this.drag(event, targetId);
      }
    );

    for (let id of this.moverIds.concat(["highlight"])) {
      this.addEventListenerForElement(id, "pointerdown", (event, targetId) => {
        this.dragStart(event, targetId);
      });
      this.addEventListenerForElement(id, "pointerup", (event, targetId) => {
        this.dragEnd(event, targetId);
      });
      this.addEventListenerForElement(id, "pointermove", (event, targetId) => {
        this.drag(event, targetId);
      });
    }
  }

  /**
   * Removes all event listeners and removes the overlay from the Anonymous Content
   */
  tearDown() {
    if (this._content) {
      this._removeAllListeners();
      try {
        this.contentDocument.removeAnonymousContent(this._content);
      } catch (e) {
        // If the current window isn't the one the content was inserted into, this
        // will fail, but that's fine.
      }
    }
    this._initialized = false;
  }

  /**
   * Creates the document fragment that will be added to the Anonymous Content
   * @returns document fragment that can be injected into the Anonymous Content
   */
  buildOverlay() {
    let [cancel, instructions, download, copy] =
      lazy.overlayLocalization.formatMessagesSync([
        { id: "screenshots-overlay-cancel-button" },
        { id: "screenshots-overlay-instructions" },
        { id: "screenshots-overlay-download-button" },
        { id: "screenshots-overlay-copy-button" },
      ]);

    const htmlString = `
    <div id="screenshots-component">
      <div id="${this.overlayId}">
        <div id="${this.previewId}">
          <div class="fixed-container">
            <div class="face-container">
              <div class="eye left"><div id="left-eye" class="eyeball"></div></div>
              <div class="eye right"><div id="right-eye" class="eyeball"></div></div>
              <div class="face"></div>
            </div>
            <div class="preview-instructions">${instructions.value}</div>
            <button class="screenshots-button" id="screenshots-cancel-button">${cancel.value}</button>
          </div>
        </div>
        <div id="${this.hoverBoxId}"></div>
        <div id="${this.selectionId}" style="display:none;">
          <div id="bgTop" class="bghighlight" style="display:none;"></div>
          <div id="bgBottom" class="bghighlight" style="display:none;"></div>
          <div id="bgLeft" class="bghighlight" style="display:none;"></div>
          <div id="bgRight" class="bghighlight" style="display:none;"></div>
          <div id="highlight" class="highlight" style="display:none;">
            <div id="mover-topLeft" class="mover-target direction-topLeft">
              <div class="mover"></div>
            </div>
            <div id="mover-top" class="mover-target direction-top">
              <div class="mover"></div>
            </div>
            <div id="mover-topRight" class="mover-target direction-topRight">
              <div class="mover"></div>
            </div>
            <div id="mover-left" class="mover-target direction-left">
              <div class="mover"></div>
            </div>
            <div id="mover-right" class="mover-target direction-right">
              <div class="mover"></div>
            </div>
            <div id="mover-bottomLeft" class="mover-target direction-bottomLeft">
              <div class="mover"></div>
            </div>
            <div id="mover-bottom" class="mover-target direction-bottom">
              <div class="mover"></div>
            </div>
            <div id="mover-bottomRight" class="mover-target direction-bottomRight">
              <div class="mover"></div>
            </div>
          </div>
          <div id="buttons" style="display:none;">
            <button id="cancel" class="screenshots-button" title="${cancel.value}" aria-label="${cancel.value}"><img/></button>
            <button id="copy" class="screenshots-button" title="${copy.value}" aria-label="${copy.value}"><img/>${copy.value}</button>
            <button id="download" class="screenshots-button primary" title="${download.value}" aria-label="${download.value}"><img/>${download.value}</button>
          </div>
        </div>
      </div>
    </div>`;

    const parser = new this.contentDocument.ownerGlobal.DOMParser();
    const tmpDoc = parser.parseFromSafeString(htmlString, "text/html");
    const fragment = this.contentDocument.createDocumentFragment();

    fragment.appendChild(tmpDoc.body.children[0]);
    return fragment;
  }

  // The event tooling is borrowed directly from devtools' highlighters (CanvasFrameAnonymousContentHelper)
  /**
   * Add an event listener to one of the elements inserted in the canvasFrame
   * native anonymous container.
   * Like other methods in this helper, this requires the ID of the element to
   * be passed in.
   *
   * Note that if the content page navigates, the event listeners won't be
   * added again.
   *
   * Also note that unlike traditional DOM events, the events handled by
   * listeners added here will propagate through the document only through
   * bubbling phase, so the useCapture parameter isn't supported.
   * It is possible however to call e.stopPropagation() to stop the bubbling.
   *
   * IMPORTANT: the chrome-only canvasFrame insertion API takes great care of
   * not leaking references to inserted elements to chrome JS code. That's
   * because otherwise, chrome JS code could freely modify native anon elements
   * inside the canvasFrame and probably change things that are assumed not to
   * change by the C++ code managing this frame.
   * See https://wiki.mozilla.org/DevTools/Highlighter#The_AnonymousContent_API
   * Unfortunately, the inserted nodes are still available via
   * event.originalTarget, and that's what the event handler here uses to check
   * that the event actually occured on the right element, but that also means
   * consumers of this code would be able to access the inserted elements.
   * Therefore, the originalTarget property will be nullified before the event
   * is passed to your handler.
   *
   * IMPL DETAIL: A single event listener is added per event types only, at
   * browser level and if the event originalTarget is found to have the provided
   * ID, the callback is executed (and then IDs of parent nodes of the
   * originalTarget are checked too).
   *
   * @param {String} id
   * @param {String} type
   * @param {Function} handler
   */
  addEventListenerForElement(id, type, handler) {
    if (typeof id !== "string") {
      throw new Error(
        "Expected a string ID in addEventListenerForElement but got: " + id
      );
    }

    // If no one is listening for this type of event yet, add one listener.
    if (!this.listeners.has(type)) {
      const target = this.pageListenerTarget;
      target.addEventListener(type, this, true);
      // Each type entry in the map is a map of ids:handlers.
      this.listeners.set(type, new Map());
    }

    const listeners = this.listeners.get(type);
    listeners.set(id, handler);
  }

  /**
   * Remove an event listener from one of the elements inserted in the
   * canvasFrame native anonymous container.
   * @param {String} id
   * @param {String} type
   */
  removeEventListenerForElement(id, type) {
    const listeners = this.listeners.get(type);
    if (!listeners) {
      return;
    }
    listeners.delete(id);

    // If no one is listening for event type anymore, remove the listener.
    if (!listeners.size) {
      const target = this.pageListenerTarget;
      target.removeEventListener(type, this, true);
    }
  }

  handleEvent(event) {
    const listeners = this.listeners.get(event.type);
    if (!listeners) {
      return;
    }

    // Hide the originalTarget property to avoid exposing references to native
    // anonymous elements. See addEventListenerForElement's comment.
    let isPropagationStopped = false;
    const eventProxy = new Proxy(event, {
      get: (obj, name) => {
        if (name === "originalTarget") {
          return null;
        } else if (name === "stopPropagation") {
          return () => {
            isPropagationStopped = true;
          };
        }
        return obj[name];
      },
    });

    // Start at originalTarget, bubble through ancestors and call handlers when
    // needed.
    let node = event.originalTarget;
    while (node) {
      let nodeId = node.id;
      if (nodeId) {
        const handler = listeners.get(node.id);
        if (handler) {
          handler(eventProxy, nodeId);
          if (isPropagationStopped) {
            break;
          }
        }
        if (nodeId == this.overlayId) {
          break;
        }
      }
      node = node.parentNode;
    }
  }

  _removeAllListeners() {
    if (this.pageListenerTarget) {
      const target = this.pageListenerTarget;
      for (const [type] of this.listeners) {
        target.removeEventListener(type, this, true);
      }
    }
    this.listeners.clear();
  }

  /**
   * Pass the pointer down event to the state handler
   * @param event The pointer down event
   * @param targetId The target element id
   */
  dragStart(event, targetId) {
    this.stateHandler.dragStart(event, targetId);
  }

  /**
   * Pass the pointer move event to the state handler
   * @param event The pointer move event
   * @param targetId The target element id
   */
  drag(event, targetId) {
    this.stateHandler.drag(event, targetId);
  }

  /**
   * Pass the pointer up event to the state handler
   * @param event The pointer up event
   * @param targetId The target element id
   */
  dragEnd(event, targetId) {
    this.stateHandler.dragEnd(event);
  }
}

export var ScreenshotsOverlayChild = {
  AnonymousContentOverlay,
};

/**
 * The StateHandler class handles the state of the overlay
 */
class StateHandler {
  #state;
  #lastBox;
  #moverId;
  #lastX;
  #lastY;
  #screenshotsContainer;
  #screenshotsChild;
  #previousDimensions;

  constructor(screenshotsContainer, screenshotsChild) {
    this.#state = "crosshairs";
    this.#lastBox = {};

    this.#screenshotsContainer = screenshotsContainer;
    this.#screenshotsChild = screenshotsChild;
  }

  setState(newState) {
    if (this.#state === "selected" && newState === "crosshairs") {
      this.#screenshotsChild.recordTelemetryEvent(
        "started",
        "overlay_retry",
        {}
      );
    }
    this.#state = newState;
    this.start();
  }

  getState() {
    return this.#state;
  }

  getHoverElementBoxRect() {
    return this.#screenshotsContainer.hoverElementBoxRect;
  }

  /**
   * At the start of the some states we need to perform some actions
   */
  start() {
    switch (this.#state) {
      case "crosshairs": {
        this.crosshairsStart();
        break;
      }
      case "draggingReady": {
        this.draggingReadyStart();
        break;
      }
      case "dragging": {
        this.draggingStart();
        break;
      }
      case "selected": {
        this.selectedStart();
        break;
      }
      case "resizing": {
        this.resizingStart();
        break;
      }
    }
  }

  /**
   * Returns the x and y coordinates of the event
   * @param event The mouse or touch event
   * @returns object containing the x and y coordinates of the mouse
   */
  getCoordinates(event) {
    const { clientX, clientY, pageX, pageY } = event;

    MAX_DETECT_HEIGHT = Math.max(event.target.clientHeight + 100, 700);
    MAX_DETECT_WIDTH = Math.max(event.target.clientWidth + 100, 1000);

    return { clientX, clientY, pageX, pageY };
  }

  /**
   * Handles the mousedown/touchstart event depending on the state
   * @param event The mousedown or touchstart event
   * @param targetId The id of the event target
   */
  dragStart(event, targetId) {
    const { pageX, pageY } = this.getCoordinates(event);

    switch (this.#state) {
      case "crosshairs": {
        this.crosshairsDragStart(pageX, pageY);
        break;
      }
      case "selected": {
        this.selectedDragStart(pageX, pageY, targetId);
        break;
      }
    }
  }

  /**
   * Handles the move event depending on the state
   * @param event The mousemove or touchmove event
   * @param targetId The id of the event target
   */
  drag(event, targetId) {
    const { pageX, pageY, clientX, clientY } = this.getCoordinates(event);

    switch (this.#state) {
      case "crosshairs": {
        this.crosshairsMove(clientX, clientY, targetId);
        break;
      }
      case "draggingReady": {
        this.draggingReadyDrag(pageX, pageY);
        break;
      }
      case "dragging": {
        this.draggingDrag(pageX, pageY);
        break;
      }
      case "resizing": {
        this.resizingDrag(pageX, pageY);
        break;
      }
    }
  }

  /**
   * Handles the move event depending on the state
   * @param event The mouseup event
   * @param targetId The id of the event target
   */
  dragEnd(event, targetId) {
    const { pageX, pageY, clientX, clientY } = this.getCoordinates(event);

    switch (this.#state) {
      case "draggingReady": {
        this.draggingReadyDragEnd(pageX - clientX, pageY - clientY);
        break;
      }
      case "dragging": {
        this.draggingDragEnd(pageX, pageY, targetId);
        break;
      }
      case "resizing": {
        this.resizingDragEnd(pageX, pageY, targetId);
        break;
      }
    }
  }

  /**
   * Hide the box and highlighter and show the overlay at the start of crosshairs state
   */
  crosshairsStart() {
    this.#screenshotsContainer.hideSelectionLayer();
    this.#screenshotsContainer.showPreviewLayer();
    this.#screenshotsChild.showPanel();
    this.#previousDimensions = null;
  }

  /**
   *
   */
  draggingReadyStart() {
    this.#screenshotsChild.hidePanel();
  }

  /**
   * Hide the overlay and draw the box at the start of dragging state
   */
  draggingStart() {
    this.#screenshotsContainer.hidePreviewLayer();
    this.#screenshotsContainer.hideButtonsLayer();
    this.#screenshotsContainer.drawSelectionBox();
  }

  /**
   * Show the buttons at the start of the selected state
   */
  selectedStart() {
    this.#screenshotsContainer.drawButtonsLayer();
  }

  /**
   * Hide the buttons and store width and height of box at the start of the resizing state
   */
  resizingStart() {
    this.#screenshotsContainer.hideButtonsLayer();
    let { width, height } =
      this.#screenshotsContainer.getSelectionLayerBoxDimensions();
    this.#lastBox = {
      width,
      height,
    };
  }

  /**
   * Set the initial box coordinates and set the state to "draggingReady"
   * @param pageX x coordinate
   * @param pageY y coordinate
   */
  crosshairsDragStart(pageX, pageY) {
    this.#screenshotsContainer.setSelectionBoxDimensions({
      left: pageX,
      top: pageY,
      right: pageX,
      bottom: pageY,
    });

    this.setState("draggingReady");
  }

  /**
   * If the background is clicked we set the state to crosshairs
   * otherwise set the state to resizing
   * @param pageX x coordinate
   * @param pageY y coordinate
   * @param targetId The id of the event target
   */
  selectedDragStart(pageX, pageY, targetId) {
    if (targetId === this.#screenshotsContainer.id) {
      this.setState("crosshairs");
      return;
    }
    this.#moverId = targetId;
    this.#lastX = pageX;
    this.#lastY = pageY;

    this.setState("resizing");
  }

  /**
   * Handles the pointer move for the crosshairs state
   * @param clientX x pointer position in the visible window
   * @param clientY y pointer position in the visible window
   * @param targetId The id of the target element
   */
  crosshairsMove(clientX, clientY, targetId) {
    this.#screenshotsContainer.drawPreviewEyes(clientX, clientY);

    this.#screenshotsContainer.handleElementHover(clientX, clientY, targetId);
  }

  /**
   * Set the bottom and right coordinates of the box and draw the box
   * @param pageX x coordinate
   * @param pageY y coordinate
   */
  draggingDrag(pageX, pageY) {
    this.scrollIfByEdge(pageX, pageY);
    this.#screenshotsContainer.setSelectionBoxDimensions({
      right: pageX,
      bottom: pageY,
    });

    this.#screenshotsContainer.drawSelectionBox();
  }

  /**
   * If the mouse has moved at least 40 pixels then set the state to "dragging"
   * @param pageX x coordinate
   * @param pageY y coordinate
   */
  draggingReadyDrag(pageX, pageY) {
    this.#screenshotsContainer.setSelectionBoxDimensions({
      right: pageX,
      bottom: pageY,
    });

    if (this.#screenshotsContainer.selectionBoxDistance() > 40) {
      this.setState("dragging");
    }
  }

  /**
   * Depending on what mover was selected we will resize the box accordingly
   * @param pageX x coordinate
   * @param pageY y coordinate
   */
  resizingDrag(pageX, pageY) {
    this.scrollIfByEdge(pageX, pageY);
    switch (this.#moverId) {
      case "mover-topLeft": {
        this.#screenshotsContainer.setSelectionBoxDimensions({
          left: pageX,
          top: pageY,
        });
        break;
      }
      case "mover-top": {
        this.#screenshotsContainer.setSelectionBoxDimensions({ top: pageY });
        break;
      }
      case "mover-topRight": {
        this.#screenshotsContainer.setSelectionBoxDimensions({
          top: pageY,
          right: pageX,
        });
        break;
      }
      case "mover-right": {
        this.#screenshotsContainer.setSelectionBoxDimensions({
          right: pageX,
        });
        break;
      }
      case "mover-bottomRight": {
        this.#screenshotsContainer.setSelectionBoxDimensions({
          right: pageX,
          bottom: pageY,
        });
        break;
      }
      case "mover-bottom": {
        this.#screenshotsContainer.setSelectionBoxDimensions({
          bottom: pageY,
        });
        break;
      }
      case "mover-bottomLeft": {
        this.#screenshotsContainer.setSelectionBoxDimensions({
          left: pageX,
          bottom: pageY,
        });
        break;
      }
      case "mover-left": {
        this.#screenshotsContainer.setSelectionBoxDimensions({ left: pageX });
        break;
      }
      case "highlight": {
        let lastBox = this.#lastBox;
        let diffX = this.#lastX - pageX;
        let diffY = this.#lastY - pageY;

        let newLeft;
        let newRight;
        let newTop;
        let newBottom;

        // Unpack SelectionBox dimensions to use here
        let {
          boxLeft,
          boxTop,
          boxRight,
          boxBottom,
          boxWidth,
          boxHeight,
          scrollWidth,
          scrollHeight,
        } = this.#screenshotsContainer.getSelectionLayerDimensions();

        // wait until all 4 if elses have completed before setting box dimensions
        if (boxWidth <= lastBox.width && boxLeft === 0) {
          newLeft = boxRight - lastBox.width;
        } else {
          newLeft = boxLeft;
        }

        if (boxWidth <= lastBox.width && boxRight === scrollWidth) {
          newRight = boxLeft + lastBox.width;
        } else {
          newRight = boxRight;
        }

        if (boxHeight <= lastBox.height && boxTop === 0) {
          newTop = boxBottom - lastBox.height;
        } else {
          newTop = boxTop;
        }

        if (boxHeight <= lastBox.height && boxBottom === scrollHeight) {
          newBottom = boxTop + lastBox.height;
        } else {
          newBottom = boxBottom;
        }

        this.#screenshotsContainer.setSelectionBoxDimensions({
          left: newLeft - diffX,
          top: newTop - diffY,
          right: newRight - diffX,
          bottom: newBottom - diffY,
        });

        this.#lastX = pageX;
        this.#lastY = pageY;
        break;
      }
    }
    this.#screenshotsContainer.drawSelectionBox();
  }

  /**
   * Draw the selection box from the hover element box if it exists
   * Else set the state to "crosshairs"
   */
  draggingReadyDragEnd(scrollX, scrollY) {
    if (this.#screenshotsContainer.hoverElementBoxRect) {
      this.#screenshotsContainer.hidePreviewLayer();
      this.#screenshotsContainer.updateSelectionBoxFromRect(scrollX, scrollY);
      this.#screenshotsContainer.drawSelectionBox();
      this.setState("selected");
      this.#screenshotsChild.recordTelemetryEvent("selected", "element", {});
    } else {
      this.setState("crosshairs");
    }
  }

  /**
   * Draw the box one last time and set the state to "selected"
   * @param pageX x coordinate
   * @param pageY y coordinate
   */
  draggingDragEnd(pageX, pageY) {
    this.#screenshotsContainer.setSelectionBoxDimensions({
      right: pageX,
      bottom: pageY,
    });
    this.#screenshotsContainer.sortSelectionLayerBoxCoords();
    this.setState("selected");

    let { width, height } =
      this.#screenshotsContainer.getSelectionLayerBoxDimensions();

    if (
      !this.#previousDimensions ||
      (Math.abs(this.#previousDimensions.width - width) >
        REGION_CHANGE_THRESHOLD &&
        Math.abs(this.#previousDimensions.height - height) >
          REGION_CHANGE_THRESHOLD)
    ) {
      this.#screenshotsChild.recordTelemetryEvent(
        "selected",
        "region_selection",
        {}
      );
    }
    this.#previousDimensions = { width, height };
  }

  /**
   * Draw the box one last time and set the state to "selected"
   * @param pageX x coordinate
   * @param pageY y coordinate
   */
  resizingDragEnd(pageX, pageY, targetId) {
    this.resizingDrag(pageX, pageY, targetId);
    this.#screenshotsContainer.sortSelectionLayerBoxCoords();
    this.setState("selected");
  }

  /**
   * The page was resized or scrolled. We need to update the
   * ScreenshotsContainer size so we don't draw outside the window bounds
   * If the current state is "selected" and this was called from a resize event
   * then we need to maybe shift the SelectionBox
   * @param win The window object of the page
   * @param eventType If this was called from a resize event
   */
  updateScreenshotsContainerSize(win, eventType) {
    if (this.#state === "crosshairs" && eventType === "resize") {
      this.#screenshotsContainer.hideHoverElementBox();
    }

    this.#screenshotsContainer.updateSize(win);

    if (this.#state === "selected" && eventType === "resize") {
      this.#screenshotsContainer.shiftSelectionLayerBox();
    } else if (
      this.#state !== "resizing" &&
      this.#state !== "dragging" &&
      eventType === "scroll"
    ) {
      this.#screenshotsContainer.drawButtonsLayer();
      if (this.#state === "crosshairs") {
        this.#screenshotsContainer.handleElementScroll();
      }
    }
  }

  scrollIfByEdge(pageX, pageY) {
    let dimensions = this.#screenshotsContainer.getSelectionLayerDimensions();

    if (pageY - dimensions.scrollY <= SCROLL_BY_EDGE) {
      // Scroll up
      this.#screenshotsChild.scrollWindow(0, -SCROLL_BY_EDGE);
    } else if (
      dimensions.scrollY + dimensions.clientHeight - pageY <=
      SCROLL_BY_EDGE
    ) {
      // Scroll down
      this.#screenshotsChild.scrollWindow(0, SCROLL_BY_EDGE);
    }

    if (pageX - dimensions.scrollX <= SCROLL_BY_EDGE) {
      // Scroll left
      this.#screenshotsChild.scrollWindow(-SCROLL_BY_EDGE, 0);
    } else if (
      dimensions.scrollX + dimensions.clientWidth - pageX <=
      SCROLL_BY_EDGE
    ) {
      // Scroll right
      this.#screenshotsChild.scrollWindow(SCROLL_BY_EDGE, 0);
    }
  }
}

class AnonLayer {
  id;
  content;

  constructor(id, content) {
    this.id = id;
    this.content = content;
  }

  /**
   * Show element with id this.id
   */
  show() {
    this.content.removeAttributeForElement(this.id, "style");
  }

  /**
   * Hide element with id this.id
   */
  hide() {
    this.content.setAttributeForElement(this.id, "style", "display:none;");
  }
}

class HoverElementBox extends AnonLayer {
  #document;
  #rect;
  #lastX;
  #lastY;

  constructor(id, content, document) {
    super(id, content);

    this.#document = document;
  }

  get rect() {
    return this.#rect;
  }

  /**
   * Draws the hover box over an element from the given rect
   * @param rect The rect to draw the hover element box
   */
  drawHoverBox(rect) {
    if (!rect) {
      this.hide();
    } else {
      let maxHeight = this.selectionLayer.scrollHeight;
      let maxWidth = this.selectionLayer.scrollWidth;
      let top = this.#document.documentElement.scrollTop + rect.top;
      top = top > 0 ? top : 0;
      let left = this.#document.documentElement.scrollLeft + rect.left;
      left = left > 0 ? left : 0;
      let height =
        rect.top + rect.height > maxHeight ? maxHeight - rect.top : rect.height;
      let width =
        rect.left + rect.width > maxWidth ? maxWidth - rect.left : rect.width;

      this.content.setAttributeForElement(
        this.id,
        "style",
        `top:${top}px;left:${left}px;height:${height}px;width:${width}px;`
      );
    }
  }

  /**
   * Handles when the user moves the mouse over an element
   * @param clientX The x coordinate in the visible window
   * @param clientY The y coordinate in the visible window
   * @param targetId The target element id
   */
  handleElementHover(clientX, clientY, targetId) {
    if (targetId === "screenshots-overlay-container") {
      let ele = this.getElementFromPoint(clientX, clientY);

      if (this.cachedEle && this.cachedEle === ele) {
        // Still hovering over the same element
        return;
      }
      this.cachedEle = ele;

      this.getBestRectForElement(ele);

      this.#lastX = clientX;
      this.#lastY = clientY;
    }
  }

  /**
   * Handles moving the rect when the user has scrolled but not moved the mouse
   * It uses the last x and y coordinates to find the new element at the mouse position
   */
  handleElementScroll() {
    if (this.#lastX && this.#lastY) {
      this.cachedEle = null;
      this.handleElementHover(
        this.#lastX,
        this.#lastY,
        "screenshots-overlay-container"
      );
    }
  }

  /**
   * Finds an element for the given coordinates within the viewport
   * @param x The x coordinate in the visible window
   * @param y The y coordinate in the visible window
   * @returns An element location at the given coordinates
   */
  getElementFromPoint(x, y) {
    this.setPointerEventsNone();
    let ele;
    try {
      ele = this.#document.elementFromPoint(x, y);
    } finally {
      this.resetPointerEvents();
    }

    return ele;
  }

  /**
   * Gets the rect for an element if getBoundingClientRect exists
   * @param ele The element to get the rect from
   * @returns The bounding client rect of the element or null
   */
  getBoundingClientRect(ele) {
    if (!ele.getBoundingClientRect) {
      return null;
    }

    return ele.getBoundingClientRect();
  }

  /**
   * This function takes an element and finds a suitable rect to draw the hover box on
   * @param ele The element to find a suitale rect of
   */
  getBestRectForElement(ele) {
    let lastRect;
    let lastNode;
    let rect;
    let attemptExtend = false;
    let node = ele;
    while (node) {
      rect = this.getBoundingClientRect(node);
      if (!rect) {
        rect = lastRect;
        break;
      }
      if (rect.width < MIN_DETECT_WIDTH || rect.height < MIN_DETECT_HEIGHT) {
        // Avoid infinite loop for elements with zero or nearly zero height,
        // like non-clearfixed float parents with or without borders.
        break;
      }
      if (rect.width > MAX_DETECT_WIDTH || rect.height > MAX_DETECT_HEIGHT) {
        // Then the last rectangle is better
        rect = lastRect;
        attemptExtend = true;
        break;
      }
      if (rect.width >= MIN_DETECT_WIDTH && rect.height >= MIN_DETECT_HEIGHT) {
        if (!doNotAutoselectTags[node.tagName]) {
          break;
        }
      }
      lastRect = rect;
      lastNode = node;
      node = node.parentNode;
    }
    if (rect && node) {
      const evenBetter = this.evenBetterElement(node);
      if (evenBetter) {
        node = lastNode = evenBetter;
        rect = this.getBoundingClientRect(evenBetter);
        attemptExtend = false;
      }
    }
    if (rect && attemptExtend) {
      let extendNode = lastNode.nextSibling;
      while (extendNode) {
        if (extendNode.nodeType === this.#document.ELEMENT_NODE) {
          break;
        }
        extendNode = extendNode.nextSibling;
        if (!extendNode) {
          const parent = lastNode.parentNode;
          for (let i = 0; i < parent.childNodes.length; i++) {
            if (parent.childNodes[i] === lastNode) {
              extendNode = parent.childNodes[i + 1];
            }
          }
        }
      }
      if (extendNode) {
        const extendRect = this.getBoundingClientRect(extendNode);
        let x = Math.min(rect.x, extendRect.x);
        let y = Math.min(rect.y, extendRect.y);
        let width = Math.max(rect.right, extendRect.right) - x;
        let height = Math.max(rect.bottom, extendRect.bottom) - y;
        const combinedRect = new DOMRect(x, y, width, height);
        if (
          combinedRect.width <= MAX_DETECT_WIDTH &&
          combinedRect.height <= MAX_DETECT_HEIGHT
        ) {
          rect = combinedRect;
        }
      }
    }

    if (
      rect &&
      (rect.width < MIN_DETECT_ABSOLUTE_WIDTH ||
        rect.height < MIN_DETECT_ABSOLUTE_HEIGHT)
    ) {
      rect = null;
    }

    if (!rect) {
      this.hide();
    } else {
      this.drawHoverBox(rect);
    }

    this.#rect = rect;
  }

  /**
   * This finds a better element by looking for elements with role article
   * @param node The currently hovered node
   * @returns A better node or null
   */
  evenBetterElement(node) {
    let el = node.parentNode;
    const ELEMENT_NODE = this.#document.ELEMENT_NODE;
    while (el && el.nodeType === ELEMENT_NODE) {
      if (!el.getAttribute) {
        return null;
      }
      if (el.getAttribute("role") === "article") {
        const rect = this.getBoundingClientRect(el);
        if (!rect) {
          return null;
        }
        if (
          rect.width <= MAX_DETECT_WIDTH &&
          rect.height <= MAX_DETECT_HEIGHT
        ) {
          return el;
        }
        return null;
      }
      el = el.parentNode;
    }
    return null;
  }

  /**
   * The pointer events need to be removed temporarily so we can find the
   * correct element from document.elementFromPoint()
   * If the pointer events are on for the screenshots elements, then we will always
   * get the screenshots elements as the elements from a given point
   */
  setPointerEventsNone() {
    this.content.setAttributeForElement(
      "screenshots-component",
      "style",
      "pointer-events:none;"
    );

    let temp = this.content.getAttributeForElement(
      "screenshots-overlay-container",
      "style"
    );
    this.content.setAttributeForElement(
      "screenshots-overlay-container",
      "style",
      temp + "pointer-events:none;"
    );
  }

  /**
   * Return the pointer events to the original state because we found the element
   */
  resetPointerEvents() {
    this.content.setAttributeForElement("screenshots-component", "style", "");

    let temp = this.content.getAttributeForElement(
      "screenshots-overlay-container",
      "style"
    );
    this.content.setAttributeForElement(
      "screenshots-overlay-container",
      "style",
      temp.replace("pointer-events:none;", "")
    );
  }
}

class SelectionLayer extends AnonLayer {
  #selectionBox;
  #hoverElementBox;
  #buttons;
  #hidden;
  /**
   * the documentDimensions follows the below structure
   * {
   *    scrollWidth: the total document width
   *    scrollHeight: the total document height
   *    scrollX: the x scrolled offset
   *    scrollY: the y scrolled offset
   *    clientWidth: the viewport width
   *    clientHeight: the viewport height
   * }
   */
  #documentDimensions;

  constructor(id, content, hoverElementBox) {
    super(id, content);
    this.#selectionBox = new SelectionBox(content, this);
    this.#buttons = new ButtonsLayer("buttons", content, this);
    this.#hoverElementBox = hoverElementBox;
    this.#hoverElementBox.selectionLayer = this;

    this.#hidden = true;
    this.#documentDimensions = {};
  }

  /**
   * Hide the buttons layer
   */
  hideButtons() {
    this.#buttons.hide();
  }

  /**
   * Call
   */
  drawButtonsLayer() {
    this.#buttons.show();
  }

  /**
   * Hide the selection-container element
   */
  hide() {
    super.hide();
    this.#hidden = true;
  }

  /**
   * Draw the SelectionBox
   */
  drawSelectionBox() {
    if (this.#hidden) {
      this.show();
      this.#hidden = false;
    }
    this.#selectionBox.show();
  }

  /**
   * Sort the SelectionBox coordinates
   */
  sortSelectionBoxCoords() {
    this.#selectionBox.sortCoords();
  }

  /**
   * Sets the SelectionBox dimensions
   * @param {Object} dims The new box dimensions
   *  {
   *    left: new left dimension value or undefined
   *    top: new top dimension value or undefined
   *    right: new right dimension value or undefined
   *    bottom: new bottom dimension value or undefined
   *   }
   */
  setSelectionBoxDimensions(dims) {
    if (dims.left) {
      this.#selectionBox.left = dims.left;
    }
    if (dims.top) {
      this.#selectionBox.top = dims.top;
    }
    if (dims.right) {
      this.#selectionBox.right = dims.right;
    }
    if (dims.bottom) {
      this.#selectionBox.bottom = dims.bottom;
    }
  }

  /**
   * Gets the selections box dimensions
   * @returns {Object}
   *  {
   *    x1: the left dimension value
   *    y1: the top dimension value
   *    width: the width of the selected region
   *    height: the height of the selected region
   *  }
   */
  getSelectionBoxDimensions() {
    return this.#selectionBox.getDimensions();
  }

  /**
   * Returns the box dimensions and the page dimensions
   * @returns {Object}
   *  {
   *    boxLeft: the left position of the box
   *    boxTop: the top position of the box
   *    boxRight: the right position of the box
   *    boxBottom: the bottom position of the box
   *    scrollWidth: the total document width
   *    scrollHeight: the total document height
   *    scrollX: the x scrolled offset
   *    scrollY: the y scrolled offset
   *    clientWidth: the viewport width
   *    clientHeight: the viewport height
   *  }
   */
  getDimensions() {
    return {
      boxLeft: this.#selectionBox.left,
      boxTop: this.#selectionBox.top,
      boxRight: this.#selectionBox.right,
      boxBottom: this.#selectionBox.bottom,
      boxWidth: this.#selectionBox.width,
      boxHeight: this.#selectionBox.height,
      ...this.#documentDimensions,
    };
  }

  /**
   * Gets the diagonal distance of the SelectionBox
   * @returns The diagonal distance of the SelectionBox
   */
  getSelectionBoxDistance() {
    return this.#selectionBox.distance;
  }

  /**
   * Shift the SelectionBox so that it is always within the document
   */
  shiftSelectionBox() {
    this.#selectionBox.shiftBox();
  }

  /**
   * Update the box coordinates from the hover element rect
   */
  updateSelectionBoxFromRect(scrollX, scrollY) {
    this.#selectionBox.updateBoxFromRect(
      this.#hoverElementBox.rect,
      scrollX,
      scrollY
    );
  }

  /**
   * Handles when the user moves the mouse over an element
   * @param clientX The x coordinate in the visible window
   * @param clientY The y coordinate in the visible window
   * @param targetId The target element id
   */
  handleElementHover(clientX, clientY, targetId) {
    this.#hoverElementBox.handleElementHover(clientX, clientY, targetId);
  }

  /**
   * Handles moving the rect when the user has scrolled but not moved the mouse
   * It uses the last x and y coordinates to find the new element at the mouse position
   */
  handleElementScroll() {
    this.#hoverElementBox.handleElementScroll();
  }

  hideHoverElementSelection() {
    this.#hoverElementBox.hide();
  }

  get hoverElementBoxRect() {
    return this.#hoverElementBox.rect;
  }

  get scrollWidth() {
    return this.#documentDimensions.scrollWidth;
  }
  set scrollWidth(val) {
    this.#documentDimensions.scrollWidth = val;
  }

  get scrollHeight() {
    return this.#documentDimensions.scrollHeight;
  }
  set scrollHeight(val) {
    this.#documentDimensions.scrollHeight = val;
  }

  get scrollX() {
    return this.#documentDimensions.scrollX;
  }
  set scrollX(val) {
    this.#documentDimensions.scrollX = val;
  }

  get scrollY() {
    return this.#documentDimensions.scrollY;
  }
  set scrollY(val) {
    this.#documentDimensions.scrollY = val;
  }

  get clientWidth() {
    return this.#documentDimensions.clientWidth;
  }
  set clientWidth(val) {
    this.#documentDimensions.clientWidth = val;
  }

  get clientHeight() {
    return this.#documentDimensions.clientHeight;
  }
  set clientHeight(val) {
    this.#documentDimensions.clientHeight = val;
  }
}

/**
 * The SelectionBox class handles drawing the highlight and background
 */
class SelectionBox extends AnonLayer {
  #x1;
  #x2;
  #y1;
  #y2;
  #xOffset;
  #yOffset;
  #selectionLayer;

  constructor(content, selectionLayer) {
    super("", content);

    this.#selectionLayer = selectionLayer;

    this.#x1 = 0;
    this.#x2 = 0;
    this.#y1 = 0;
    this.#y2 = 0;
    this.#xOffset = 0;
    this.#yOffset = 0;
  }

  /**
   * Draw the selected region for screenshotting
   */
  show() {
    this.content.setAttributeForElement(
      "highlight",
      "style",
      `top:${this.top}px;left:${this.left}px;height:${this.height}px;width:${this.width}px;`
    );

    this.content.setAttributeForElement(
      "bgTop",
      "style",
      `top:0px;height:${this.top}px;left:0px;width:100%;`
    );

    this.content.setAttributeForElement(
      "bgBottom",
      "style",
      `top:${this.bottom}px;height:calc(100% - ${this.bottom}px);left:0px;width:100%;`
    );

    this.content.setAttributeForElement(
      "bgLeft",
      "style",
      `top:${this.top}px;height:${this.height}px;left:0px;width:${this.left}px;`
    );

    this.content.setAttributeForElement(
      "bgRight",
      "style",
      `top:${this.top}px;height:${this.height}px;left:${this.right}px;width:calc(100% - ${this.right}px);`
    );
  }

  /**
   * Update the box coordinates from the rect
   * @param rect The hover element box
   * @param scrollX The x offset the page is scrolled
   * @param scrollY The y offset the page is scrolled
   */
  updateBoxFromRect(rect, scrollX, scrollY) {
    this.top = rect.top + scrollY;
    this.left = rect.left + scrollX;
    this.right = rect.right + scrollX;
    this.bottom = rect.bottom + scrollY;
  }

  /**
   * Hide the selected region
   */
  hide() {
    this.content.setAttributeForElement("highlight", "style", "display:none;");
    this.content.setAttributeForElement("bgTop", "style", "display:none;");
    this.content.setAttributeForElement("bgBottom", "style", "display:none;");
    this.content.setAttributeForElement("bgLeft", "style", "display:none;");
    this.content.setAttributeForElement("bgRight", "style", "display:none;");
  }

  /**
   * The box should never appear outside the document so the SelectionBox will
   * be shifted if the bounds of the box are outside the documents width or height
   */
  shiftBox() {
    let didShift = false;
    let xDiff = this.right - this.#selectionLayer.scrollWidth;
    if (xDiff > 0) {
      this.right -= xDiff;
      this.left -= xDiff;

      didShift = true;
    }

    let yDiff = this.bottom - this.#selectionLayer.scrollHeight;
    if (yDiff > 0) {
      let curHeight = this.height;

      this.bottom -= yDiff;
      this.top = this.bottom - curHeight;

      didShift = true;
    }

    if (didShift) {
      this.show();
      this.#selectionLayer.drawButtonsLayer();
    }
  }

  /**
   * Sort the coordinates so x1 < x2 and y1 < y2
   */
  sortCoords() {
    if (this.#x1 > this.#x2) {
      [this.#x1, this.#x2] = [this.#x2, this.#x1];
    }
    if (this.#y1 > this.#y2) {
      [this.#y1, this.#y2] = [this.#y2, this.#y1];
    }
  }

  /**
   * Gets the dimensions of the currently selected region
   * @returns {Object}
   *  {
   *    x1: the left dimension value
   *    y1: the top dimension value
   *    width: the width of the selected region
   *    height: the height of the selected region
   *  }
   */
  getDimensions() {
    return {
      x1: this.left,
      y1: this.top,
      width: this.width,
      height: this.height,
    };
  }

  get distance() {
    return Math.sqrt(Math.pow(this.width, 2) + Math.pow(this.height, 2));
  }

  get xOffset() {
    return this.#xOffset;
  }
  set xOffset(val) {
    this.#xOffset = val;
  }

  get yOffset() {
    return this.#yOffset;
  }
  set yOffset(val) {
    this.#yOffset = val;
  }

  get top() {
    return Math.min(this.#y1, this.#y2);
  }
  set top(val) {
    this.#y1 = val > 0 ? val : 0;
  }

  get left() {
    return Math.min(this.#x1, this.#x2);
  }
  set left(val) {
    this.#x1 = val > 0 ? val : 0;
  }

  get right() {
    return Math.max(this.#x1, this.#x2);
  }
  set right(val) {
    this.#x2 =
      val > this.#selectionLayer.scrollWidth
        ? this.#selectionLayer.scrollWidth
        : val;
  }

  get bottom() {
    return Math.max(this.#y1, this.#y2);
  }
  set bottom(val) {
    this.#y2 =
      val > this.#selectionLayer.scrollHeight
        ? this.#selectionLayer.scrollHeight
        : val;
  }

  get width() {
    return Math.abs(this.#x2 - this.#x1);
  }
  get height() {
    return Math.abs(this.#y2 - this.#y1);
  }
}

class ButtonsLayer extends AnonLayer {
  #selectionLayer;

  constructor(id, content, selectionLayer) {
    super(id, content);

    this.#selectionLayer = selectionLayer;
  }

  /**
   * Draw the buttons. Check if the box is too near the bottom or left of the
   * viewport and adjust the buttons accordingly
   */
  show() {
    let {
      boxLeft,
      boxTop,
      boxRight,
      boxBottom,
      scrollX,
      scrollY,
      clientWidth,
      clientHeight,
    } = this.#selectionLayer.getDimensions();

    if (
      boxTop > scrollY + clientHeight ||
      boxBottom < scrollY ||
      boxLeft > scrollX + clientWidth ||
      boxRight < scrollX
    ) {
      // The box is offscreen so need to draw the buttons
      return;
    }

    let top = boxBottom;
    let leftOrRight = `right:calc(100% - ${boxRight}px);`;

    if (scrollY + clientHeight - boxBottom < 70) {
      if (boxBottom < scrollY + clientHeight) {
        top = boxBottom - 60;
      } else if (scrollY + clientHeight - boxTop < 70) {
        top = boxTop - 60;
      } else {
        top = scrollY + clientHeight - 60;
      }
    }
    if (boxRight < 300) {
      leftOrRight = `left:${boxLeft}px;`;
    }

    this.content.setAttributeForElement(
      "buttons",
      "style",
      `top:${top}px;${leftOrRight}`
    );
  }
}

class PreviewLayer extends AnonLayer {
  constructor(id, content) {
    super(id, content);
  }

  /**
   * Draw the eyeballs facing the mouse
   * @param clientX x pointer position
   * @param clientY y pointer position
   * @param width width of the viewport
   * @param height height of the viewport
   */
  drawEyes(clientX, clientY, width, height) {
    const xpos = Math.floor((10 * (clientX - width / 2)) / width);
    const ypos = Math.floor((10 * (clientY - height / 2)) / height);
    const move = `transform:translate(${xpos}px, ${ypos}px);`;
    this.content.setAttributeForElement("left-eye", "style", move);
    this.content.setAttributeForElement("right-eye", "style", move);
  }
}

class ScreenshotsContainerLayer extends AnonLayer {
  #width;
  #height;
  #previewLayer;
  #selectionLayer;

  constructor(id, content, previewLayer, selectionLayer) {
    super(id, content);

    this.#previewLayer = previewLayer;
    this.#selectionLayer = selectionLayer;
  }

  /**
   * Hide the SelectionLayer
   */
  hideSelectionLayer() {
    this.#selectionLayer.hide();
  }

  /**
   * Show the PreviewLayer
   */
  showPreviewLayer() {
    this.#previewLayer.show();
  }

  /**
   * Hide the PreviewLayer
   */
  hidePreviewLayer() {
    this.#previewLayer.hide();
    this.#selectionLayer.hideHoverElementSelection();
  }

  /**
   * Show the ButtonsLayer
   */
  drawButtonsLayer() {
    this.#selectionLayer.drawButtonsLayer();
  }

  /**
   * Hide the ButtonsLayer
   */
  hideButtonsLayer() {
    this.#selectionLayer.hideButtons();
  }

  /**
   * Show the SelectionBox
   */
  drawSelectionBox() {
    this.#selectionLayer.drawSelectionBox();
  }

  hideHoverElementBox() {
    this.#selectionLayer.hideHoverElementSelection();
  }

  /**
   * Update the box coordinates from the hover element rect
   */
  updateSelectionBoxFromRect(scrollX, scrollY) {
    this.#selectionLayer.updateSelectionBoxFromRect(scrollX, scrollY);
  }

  /**
   * Handles when the user moves the mouse over an element
   * @param clientX The x coordinate in the visible window
   * @param clientY The y coordinate in the visible window
   * @param targetId The target element id
   */
  handleElementHover(clientX, clientY, targetId) {
    this.#selectionLayer.handleElementHover(clientX, clientY, targetId);
  }

  /**
   * Handles moving the rect when the user has scrolled but not moved the mouse
   * It uses the last x and y coordinates to find the new element at the mouse position
   */
  handleElementScroll() {
    this.#selectionLayer.handleElementScroll();
  }

  /**
   * Draw the eyes in the PreviewLayer
   * @param clientX The x mouse position
   * @param clientY The y mouse position
   */
  drawPreviewEyes(clientX, clientY) {
    this.#previewLayer.drawEyes(
      clientX,
      clientY,
      this.#selectionLayer.clientWidth,
      this.#selectionLayer.clientHeight
    );
  }

  /**
   * Get the diagonal distance of the SelectionBox
   * @returns The diagonal distance of the currently selected region
   */
  selectionBoxDistance() {
    return this.#selectionLayer.getSelectionBoxDistance();
  }

  /**
   * Sort the coordinates of the SelectionBox
   */
  sortSelectionLayerBoxCoords() {
    this.#selectionLayer.sortSelectionBoxCoords();
  }

  /**
   * Get the SelectionLayer dimensions
   * @returns {Object}
   *  {
   *    x1: the left dimension value
   *    y1: the top dimension value
   *    width: the width of the selected region
   *    height: the height of the selected region
   *  }
   */
  getSelectionLayerBoxDimensions() {
    return this.#selectionLayer.getSelectionBoxDimensions();
  }

  /**
   * Gets the SelectionBox and page dimensions
   * @returns {Object}
   *  {
   *    boxLeft: the left position of the box
   *    boxTop: the top position of the box
   *    boxRight: the right position of the box
   *    boxBottom: the bottom position of the box
   *    scrollWidth: the total document width
   *    scrollHeight: the total document height
   *    scrollX: the x scrolled offset
   *    scrollY: the y scrolled offset
   *    clientWidth: the viewport width
   *    clientHeight: the viewport height
   *  }
   */
  getSelectionLayerDimensions() {
    return this.#selectionLayer.getDimensions();
  }

  /**
   * Shift the SelectionBox
   */
  shiftSelectionLayerBox() {
    this.#selectionLayer.shiftSelectionBox();
  }

  /**
   * Set the respective dimensions of the SelectionBox
   * @param {Object} boxDimensionObject The new box dimensions
   *  {
   *    left: new left dimension value or undefined
   *    top: new top dimension value or undefined
   *    right: new right dimension value or undefined
   *    bottom: new bottom dimension value or undefined
   *   }
   */
  setSelectionBoxDimensions(boxDimensionObject) {
    this.#selectionLayer.setSelectionBoxDimensions(boxDimensionObject);
  }

  /**
   * Returns the window's dimensions for the `window` given.
   *
   * @return {Object} An object containing window dimensions
   *   {
   *     clientWidth: The width of the viewport
   *     clientHeight: The height of the viewport
   *     width: The width of the enitre page
   *     height: The height of the entire page
   *     scrollX: The X scroll offset of the viewport
   *     scrollY: The Y scroll offest of the viewport
   *   }
   */
  getDimensionsFromWindow(window) {
    let {
      innerHeight,
      innerWidth,
      scrollMaxY,
      scrollMaxX,
      scrollMinY,
      scrollMinX,
      scrollY,
      scrollX,
    } = window;

    let width = innerWidth + scrollMaxX - scrollMinX;
    let height = innerHeight + scrollMaxY - scrollMinY;
    let clientHeight = innerHeight;
    let clientWidth = innerWidth;

    const scrollbarHeight = {};
    const scrollbarWidth = {};
    window.windowUtils.getScrollbarSize(false, scrollbarWidth, scrollbarHeight);
    width -= scrollbarWidth.value;
    height -= scrollbarHeight.value;
    clientWidth -= scrollbarWidth.value;
    clientHeight -= scrollbarHeight.value;

    return { clientWidth, clientHeight, width, height, scrollX, scrollY };
  }

  /**
   * The screenshots-overlay-container doesn't shrink with the window when the
   * window is resized so we have to manually find the width and height of the
   * window by looping throught the documentElement's children
   * If the children mysteriously have a height or width of 0 then we will
   * fallback to the scrollWidth and scrollHeight which can cause the container
   * to be larger than the window dimensions
   * @param win The window object
   */
  updateSize(win) {
    let { clientWidth, clientHeight, width, height, scrollX, scrollY } =
      this.getDimensionsFromWindow(win);

    let shouldDraw = true;

    if (
      clientHeight < this.#selectionLayer.clientHeight ||
      clientWidth < this.#selectionLayer.clientWidth
    ) {
      let widthDiff = this.#selectionLayer.clientWidth - clientWidth;
      let heightDiff = this.#selectionLayer.clientHeight - clientHeight;

      this.#width -= widthDiff;
      this.#height -= heightDiff;

      this.drawScreenshotsContainer();
      // We just updated the screenshots container so we check if the window
      // dimensions are still accurate
      let { width: updatedWidth, height: updatedHeight } =
        this.getDimensionsFromWindow(win);

      // If the width and height are the same then we don't need to draw the overlay again
      if (updatedWidth === width && updatedHeight === height) {
        shouldDraw = false;
      }

      width = updatedWidth;
      height = updatedHeight;
    }

    this.#selectionLayer.clientWidth = clientWidth;
    this.#selectionLayer.clientHeight = clientHeight;
    this.#selectionLayer.scrollX = scrollX;
    this.#selectionLayer.scrollY = scrollY;

    this.#selectionLayer.scrollWidth = width;
    this.#selectionLayer.scrollHeight = height;

    this.#width = width;
    this.#height = height;

    if (shouldDraw) {
      this.drawScreenshotsContainer();
    }
  }

  /**
   * Return the dimensions of the screenshots container
   * @returns {Object}
   *  width: the container width
   *  height: the container height
   */
  getDimension() {
    return { width: this.#width, height: this.#height };
  }

  /**
   * Draw the screenshots container
   */
  drawScreenshotsContainer() {
    this.content.setAttributeForElement(
      this.id,
      "style",
      `top:0;left:0;width:${this.#width}px;height:${this.#height}px;`
    );
  }

  get hoverElementBoxRect() {
    return this.#selectionLayer.hoverElementBoxRect;
  }
}
