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

"use strict";

const { Cu, Cr } = require("chrome");
const {
  getCurrentZoom,
  getWindowDimensions,
  getViewportDimensions,
  loadSheet,
  removeSheet,
} = require("devtools/shared/layout/utils");
const EventEmitter = require("devtools/shared/event-emitter");
const InspectorUtils = require("InspectorUtils");

const lazyContainer = {};

loader.lazyRequireGetter(
  lazyContainer,
  "CssLogic",
  "devtools/server/actors/inspector/css-logic",
  true
);
loader.lazyRequireGetter(
  this,
  "isDocumentReady",
  "devtools/server/actors/inspector/utils",
  true
);

exports.getComputedStyle = node =>
  lazyContainer.CssLogic.getComputedStyle(node);

exports.getBindingElementAndPseudo = node =>
  lazyContainer.CssLogic.getBindingElementAndPseudo(node);

exports.hasPseudoClassLock = (...args) =>
  InspectorUtils.hasPseudoClassLock(...args);

exports.addPseudoClassLock = (...args) =>
  InspectorUtils.addPseudoClassLock(...args);

exports.removePseudoClassLock = (...args) =>
  InspectorUtils.removePseudoClassLock(...args);

const SVG_NS = "http://www.w3.org/2000/svg";
const XHTML_NS = "http://www.w3.org/1999/xhtml";
const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
// Highlighter in parent process will create an iframe relative to its target
// window. We need to make sure that the iframe is styled correctly. Note:
// this styles are taken from browser/base/content/browser.css
// iframe.devtools-highlighter-renderer rules.
const XUL_HIGHLIGHTER_STYLES_SHEET = `data:text/css;charset=utf-8,
:root > iframe.devtools-highlighter-renderer {
  border: none;
  pointer-events: none;
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 2;
}`;

const STYLESHEET_URI =
  "resource://devtools/server/actors/" + "highlighters.css";

const _tokens = Symbol("classList/tokens");

/**
 * Shims the element's `classList` for anonymous content elements; used
 * internally by `CanvasFrameAnonymousContentHelper.getElement()` method.
 */
function ClassList(className) {
  const trimmed = (className || "").trim();
  this[_tokens] = trimmed ? trimmed.split(/\s+/) : [];
}

ClassList.prototype = {
  item(index) {
    return this[_tokens][index];
  },
  contains(token) {
    return this[_tokens].includes(token);
  },
  add(token) {
    if (!this.contains(token)) {
      this[_tokens].push(token);
    }
    EventEmitter.emit(this, "update");
  },
  remove(token) {
    const index = this[_tokens].indexOf(token);

    if (index > -1) {
      this[_tokens].splice(index, 1);
    }
    EventEmitter.emit(this, "update");
  },
  toggle(token, force) {
    // If force parameter undefined retain the toggle behavior
    if (force === undefined) {
      if (this.contains(token)) {
        this.remove(token);
      } else {
        this.add(token);
      }
    } else if (force) {
      // If force is true, enforce token addition
      this.add(token);
    } else {
      // If force is falsy value, enforce token removal
      this.remove(token);
    }
  },
  get length() {
    return this[_tokens].length;
  },
  [Symbol.iterator]: function*() {
    for (let i = 0; i < this.tokens.length; i++) {
      yield this[_tokens][i];
    }
  },
  toString() {
    return this[_tokens].join(" ");
  },
};

/**
 * Is this content window a XUL window?
 * @param {Window} window
 * @return {Boolean}
 */
function isXUL(window) {
  // XXX: We temporarily return true for HTML documents if the document disables
  // scroll frames since the regular highlighter is broken in this case. This
  // should be removed when bug 1594587 is fixed.
  return (
    window.document.documentElement.namespaceURI === XUL_NS ||
    (window.isChromeWindow &&
      window.document.documentElement.getAttribute("scrolling") === "false")
  );
}
exports.isXUL = isXUL;

/**
 * Returns true if a DOM node is "valid", where "valid" means that the node isn't a dead
 * object wrapper, is still attached to a document, and is of a given type.
 * @param {DOMNode} node
 * @param {Number} nodeType Optional, defaults to ELEMENT_NODE
 * @return {Boolean}
 */
function isNodeValid(node, nodeType = Node.ELEMENT_NODE) {
  // Is it still alive?
  if (!node || Cu.isDeadWrapper(node)) {
    return false;
  }

  // Is it of the right type?
  if (node.nodeType !== nodeType) {
    return false;
  }

  // Is its document accessible?
  const doc = node.nodeType === Node.DOCUMENT_NODE ? node : node.ownerDocument;
  if (!doc || !doc.defaultView) {
    return false;
  }

  // Is the node connected to the document?
  if (!node.isConnected) {
    return false;
  }

  return true;
}
exports.isNodeValid = isNodeValid;

/**
 * Every highlighters should insert their markup content into the document's
 * canvasFrame anonymous content container (see dom/webidl/Document.webidl).
 *
 * Since this container gets cleared when the document navigates, highlighters
 * should use this helper to have their markup content automatically re-inserted
 * in the new document.
 *
 * Since the markup content is inserted in the canvasFrame using
 * insertAnonymousContent, this means that it can be modified using the API
 * described in AnonymousContent.webidl.
 * To retrieve the AnonymousContent instance, use the content getter.
 *
 * @param {HighlighterEnv} highlighterEnv
 *        The environemnt which windows will be used to insert the node.
 * @param {Function} nodeBuilder
 *        A function that, when executed, returns a DOM node to be inserted into
 *        the canvasFrame.
 */
function CanvasFrameAnonymousContentHelper(highlighterEnv, nodeBuilder) {
  this.highlighterEnv = highlighterEnv;
  this.nodeBuilder = nodeBuilder;

  this._onWindowReady = this._onWindowReady.bind(this);
  this.highlighterEnv.on("window-ready", this._onWindowReady);

  this.listeners = new Map();
  this.elements = new Map();
}

CanvasFrameAnonymousContentHelper.prototype = {
  initialize() {
    // _insert will resolve this promise once the markup is displayed
    const onInitialized = new Promise(resolve => {
      this._initialized = resolve;
    });
    // Only try to create the highlighter when the document is loaded,
    // otherwise, wait for the window-ready event to fire.
    const doc = this.highlighterEnv.document;
    if (
      doc.documentElement &&
      (isDocumentReady(doc) || doc.readyState !== "uninitialized")
    ) {
      this._insert();
    }

    return onInitialized;
  },

  destroy() {
    this._remove();
    if (this._iframe) {
      // If iframe is used, remove one ref count from its numberOfHighlighters
      // data attribute.
      const numberOfHighlighters =
        parseInt(this._iframe.dataset.numberOfHighlighters, 10) - 1;
      this._iframe.dataset.numberOfHighlighters = numberOfHighlighters;
      // If we reached 0, we can now remove the iframe and its styling from
      // target window.
      if (numberOfHighlighters === 0) {
        this._iframe.remove();
        removeSheet(this.highlighterEnv.window, XUL_HIGHLIGHTER_STYLES_SHEET);
      }
      this._iframe = null;
    }

    this.highlighterEnv.off("window-ready", this._onWindowReady);
    this.highlighterEnv = this.nodeBuilder = this._content = null;
    this.anonymousContentDocument = null;
    this.anonymousContentWindow = null;
    this.pageListenerTarget = null;

    this._removeAllListeners();
    this.elements.clear();
  },

  async _insert() {
    await waitForContentLoaded(this.highlighterEnv.window);
    if (!this.highlighterEnv) {
      // CanvasFrameAnonymousContentHelper was already destroyed.
      return;
    }
    if (isXUL(this.highlighterEnv.window)) {
      // In order to use anonymous content, we need to create and use an IFRAME
      // inside a XUL document first and use its window/document the same way we
      // would normally use highlighter environment's window/document. See
      // TODO: bug 1594587 for more details.
      //
      // Note: xul:window is not necessarily the top chrome window (as it's the
      // case with about:devtools-toolbox). We need to ensure that we use the
      // top chrome window to look up or create the iframe.
      if (!this._iframe) {
        const { documentElement } = this.highlighterEnv.window.document;
        this._iframe = documentElement.querySelector(
          ":scope > .devtools-highlighter-renderer"
        );
        if (this._iframe) {
          // If iframe is used and already exists, add one ref count to its
          // numberOfHighlighters data attribute.
          const numberOfHighlighters =
            parseInt(this._iframe.dataset.numberOfHighlighters, 10) + 1;
          this._iframe.dataset.numberOfHighlighters = numberOfHighlighters;
        } else {
          this._iframe = this.highlighterEnv.window.document.createElement(
            "iframe"
          );
          this._iframe.classList.add("devtools-highlighter-renderer");
          // If iframe is used for the first time, add ref count of one to its
          // numberOfHighlighters data attribute.
          this._iframe.dataset.numberOfHighlighters = 1;
          documentElement.append(this._iframe);
          loadSheet(this.highlighterEnv.window, XUL_HIGHLIGHTER_STYLES_SHEET);
        }
      }

      await waitForContentLoaded(this._iframe);
      if (!this.highlighterEnv) {
        // CanvasFrameAnonymousContentHelper was already destroyed.
        return;
      }

      // If it's a XUL window anonymous content will be inserted inside a newly
      // created IFRAME in the chrome window.
      this.anonymousContentDocument = this._iframe.contentDocument;
      this.anonymousContentWindow = this._iframe.contentWindow;
      this.pageListenerTarget = this._iframe.contentWindow;
    } else {
      // Regular highlighters are drawn inside the anonymous content of the
      // highlighter environment document.
      this.anonymousContentDocument = this.highlighterEnv.document;
      this.anonymousContentWindow = this.highlighterEnv.window;
      this.pageListenerTarget = this.highlighterEnv.pageListenerTarget;
    }

    // For now highlighters.css is injected in content as a ua sheet because
    // we no longer support scoped style sheets (see bug 1345702).
    // If it did, highlighters.css would be injected as an anonymous content
    // node using CanvasFrameAnonymousContentHelper instead.
    loadSheet(this.anonymousContentWindow, STYLESHEET_URI);

    const node = this.nodeBuilder();

    // It was stated that hidden documents don't accept
    // `insertAnonymousContent` calls yet. That doesn't seems the case anymore,
    // at least on desktop. Therefore, removing the code that was dealing with
    // that scenario, fixes when we're adding anonymous content in a tab that
    // is not the active one (see bug 1260043 and bug 1260044)
    try {
      this._content = this.anonymousContentDocument.insertAnonymousContent(
        node
      );
    } catch (e) {
      // If the `insertAnonymousContent` fails throwing a `NS_ERROR_UNEXPECTED`, it means
      // we don't have access to a `CustomContentContainer` yet (see bug 1365075).
      // At this point, it could only happen on document's interactive state, and we
      // need to wait until the `complete` state before inserting the anonymous content
      // again.
      if (
        e.result === Cr.NS_ERROR_UNEXPECTED &&
        this.anonymousContentDocument.readyState === "interactive"
      ) {
        // The next state change will be "complete" since the current is "interactive"
        await new Promise(resolve => {
          this.anonymousContentDocument.addEventListener(
            "readystatechange",
            resolve,
            { once: true }
          );
        });
        this._content = this.anonymousContentDocument.insertAnonymousContent(
          node
        );
      } else {
        throw e;
      }
    }

    this._initialized();
  },

  _remove() {
    try {
      this.anonymousContentDocument.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.
    }
  },

  /**
   * The "window-ready" event can be triggered when:
   *   - a new window is created
   *   - a window is unfrozen from bfcache
   *   - when first attaching to a page
   *   - when swapping frame loaders (moving tabs, toggling RDM)
   */
  _onWindowReady({ isTopLevel }) {
    if (isTopLevel) {
      this._removeAllListeners();
      this.elements.clear();
      if (this._iframe) {
        // When we are switching top level targets, we can remove the iframe and
        // its styling as well, since it will be re-created for the new top
        // level target document.
        this._iframe.remove();
        removeSheet(this.highlighterEnv.window, XUL_HIGHLIGHTER_STYLES_SHEET);
        this._iframe = null;
      }

      this._insert();
    }
  },

  getComputedStylePropertyValue(id, property) {
    return (
      this.content && this.content.getComputedStylePropertyValue(id, property)
    );
  },

  getTextContentForElement(id) {
    return this.content && this.content.getTextContentForElement(id);
  },

  setTextContentForElement(id, text) {
    if (this.content) {
      this.content.setTextContentForElement(id, text);
    }
  },

  setAttributeForElement(id, name, value) {
    if (this.content) {
      this.content.setAttributeForElement(id, name, value);
    }
  },

  getAttributeForElement(id, name) {
    return this.content && this.content.getAttributeForElement(id, name);
  },

  removeAttributeForElement(id, name) {
    if (this.content) {
      this.content.removeAttributeForElement(id, name);
    }
  },

  hasAttributeForElement(id, name) {
    return typeof this.getAttributeForElement(id, name) === "string";
  },

  getCanvasContext(id, type = "2d") {
    return this.content && this.content.getCanvasContext(id, type);
  },

  /**
   * 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 (!this.listeners.has(type)) {
      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) {
      const handler = listeners.get(node.id);
      if (handler) {
        handler(eventProxy, node.id);
        if (isPropagationStopped) {
          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();
  },

  getElement(id) {
    if (this.elements.has(id)) {
      return this.elements.get(id);
    }

    const classList = new ClassList(this.getAttributeForElement(id, "class"));

    EventEmitter.on(classList, "update", () => {
      this.setAttributeForElement(id, "class", classList.toString());
    });

    const element = {
      getTextContent: () => this.getTextContentForElement(id),
      setTextContent: text => this.setTextContentForElement(id, text),
      setAttribute: (name, val) => this.setAttributeForElement(id, name, val),
      getAttribute: name => this.getAttributeForElement(id, name),
      removeAttribute: name => this.removeAttributeForElement(id, name),
      hasAttribute: name => this.hasAttributeForElement(id, name),
      getCanvasContext: type => this.getCanvasContext(id, type),
      addEventListener: (type, handler) => {
        return this.addEventListenerForElement(id, type, handler);
      },
      removeEventListener: (type, handler) => {
        return this.removeEventListenerForElement(id, type, handler);
      },
      computedStyle: {
        getPropertyValue: property =>
          this.getComputedStylePropertyValue(id, property),
      },
      classList,
    };

    this.elements.set(id, element);

    return element;
  },

  get content() {
    if (!this._content || Cu.isDeadWrapper(this._content)) {
      return null;
    }
    return this._content;
  },

  /**
   * The canvasFrame anonymous content container gets zoomed in/out with the
   * page. If this is unwanted, i.e. if you want the inserted element to remain
   * unzoomed, then this method can be used.
   *
   * Consumers of the CanvasFrameAnonymousContentHelper should call this method,
   * it isn't executed automatically. Typically, AutoRefreshHighlighter can call
   * it when _update is executed.
   *
   * The matching element will be scaled down or up by 1/zoomLevel (using css
   * transform) to cancel the current zoom. The element's width and height
   * styles will also be set according to the scale. Finally, the element's
   * position will be set as absolute.
   *
   * Note that if the matching element already has an inline style attribute, it
   * *won't* be preserved.
   *
   * @param {DOMNode} node This node is used to determine which container window
   * should be used to read the current zoom value.
   * @param {String} id The ID of the root element inserted with this API.
   */
  scaleRootElement(node, id) {
    const boundaryWindow = this.highlighterEnv.window;
    const zoom = getCurrentZoom(node);
    // Hide the root element and force the reflow in order to get the proper window's
    // dimensions without increasing them.
    this.setAttributeForElement(id, "style", "display: none");
    node.offsetWidth;

    let { width, height } = getWindowDimensions(boundaryWindow);
    let value = "";

    if (zoom !== 1) {
      value = `transform-origin:top left; transform:scale(${1 / zoom}); `;
      width *= zoom;
      height *= zoom;
    }

    value += `position:absolute; width:${width}px;height:${height}px; overflow:hidden`;

    this.setAttributeForElement(id, "style", value);
  },

  /**
   * Helper function that creates SVG DOM nodes.
   * @param {Object} Options for the node include:
   * - nodeType: the type of node, defaults to "box".
   * - attributes: a {name:value} object to be used as attributes for the node.
   * - prefix: a string that will be used to prefix the values of the id and class
   *   attributes.
   * - parent: if provided, the newly created element will be appended to this
   *   node.
   */
  createSVGNode(options) {
    if (!options.nodeType) {
      options.nodeType = "box";
    }

    options.namespace = SVG_NS;

    return this.createNode(options);
  },

  /**
   * Helper function that creates DOM nodes.
   * @param {Object} Options for the node include:
   * - nodeType: the type of node, defaults to "div".
   * - namespace: the namespace to use to create the node, defaults to XHTML namespace.
   * - attributes: a {name:value} object to be used as attributes for the node.
   * - prefix: a string that will be used to prefix the values of the id and class
   *   attributes.
   * - parent: if provided, the newly created element will be appended to this
   *   node.
   * - text: if provided, set the text content of the element.
   */
  createNode(options) {
    const type = options.nodeType || "div";
    const namespace = options.namespace || XHTML_NS;
    const doc = this.anonymousContentDocument;

    const node = doc.createElementNS(namespace, type);

    for (const name in options.attributes || {}) {
      let value = options.attributes[name];
      if (options.prefix && (name === "class" || name === "id")) {
        value = options.prefix + value;
      }
      node.setAttribute(name, value);
    }

    if (options.parent) {
      options.parent.appendChild(node);
    }

    if (options.text) {
      node.appendChild(doc.createTextNode(options.text));
    }

    return node;
  },
};
exports.CanvasFrameAnonymousContentHelper = CanvasFrameAnonymousContentHelper;

/**
 * Wait for document readyness.
 * @param {Object} iframeOrWindow
 *        IFrame or Window for which the content should be loaded.
 */
function waitForContentLoaded(iframeOrWindow) {
  let loadEvent = "DOMContentLoaded";
  // If we are waiting for an iframe to load and it is for a XUL window
  // highlighter that is not browser toolbox, we must wait for IFRAME's "load".
  if (
    iframeOrWindow.contentWindow &&
    iframeOrWindow.ownerGlobal !==
      iframeOrWindow.contentWindow.browsingContext.topChromeWindow
  ) {
    loadEvent = "load";
  }

  const doc = iframeOrWindow.contentDocument || iframeOrWindow.document;
  if (isDocumentReady(doc)) {
    return Promise.resolve();
  }

  return new Promise(resolve => {
    iframeOrWindow.addEventListener(loadEvent, resolve, { once: true });
  });
}

/**
 * Move the infobar to the right place in the highlighter. This helper method is utilized
 * in both css-grid.js and box-model.js to help position the infobar in an appropriate
 * space over the highlighted node element or grid area. The infobar is used to display
 * relevant information about the highlighted item (ex, node or grid name and dimensions).
 *
 * This method will first try to position the infobar to top or bottom of the container
 * such that it has enough space for the height of the infobar. Afterwards, it will try
 * to horizontally center align with the container element if possible.
 *
 * @param  {DOMNode} container
 *         The container element which will be used to position the infobar.
 * @param  {Object} bounds
 *         The content bounds of the container element.
 * @param  {Window} win
 *         The window object.
 * @param  {Object} [options={}]
 *         Advanced options for the infobar.
 * @param  {String} options.position
 *         Force the infobar to be displayed either on "top" or "bottom". Any other value
 *         will be ingnored.
 * @param  {Boolean} options.hideIfOffscreen
 *         If set to `true`, hides the infobar if it's offscreen, instead of automatically
 *         reposition it.
 */
function moveInfobar(container, bounds, win, options = {}) {
  const zoom = getCurrentZoom(win);
  const viewport = getViewportDimensions(win);

  const { computedStyle } = container;

  const margin = 2;
  const arrowSize = parseFloat(
    computedStyle.getPropertyValue("--highlighter-bubble-arrow-size")
  );
  const containerHeight = parseFloat(computedStyle.getPropertyValue("height"));
  const containerWidth = parseFloat(computedStyle.getPropertyValue("width"));
  const containerHalfWidth = containerWidth / 2;

  const viewportWidth = viewport.width * zoom;
  const viewportHeight = viewport.height * zoom;
  let { pageXOffset, pageYOffset } = win;

  pageYOffset *= zoom;
  pageXOffset *= zoom;

  // Defines the boundaries for the infobar.
  const topBoundary = margin;
  const bottomBoundary = viewportHeight - containerHeight - margin - 1;
  const leftBoundary = containerHalfWidth + margin;
  const rightBoundary = viewportWidth - containerHalfWidth - margin;

  // Set the default values.
  let top = bounds.y - containerHeight - arrowSize;
  const bottom = bounds.bottom + margin + arrowSize;
  let left = bounds.x + bounds.width / 2;
  let isOverlapTheNode = false;
  let positionAttribute = "top";
  let position = "absolute";

  // Here we start the math.
  // We basically want to position absolutely the infobar, except when is pointing to a
  // node that is offscreen or partially offscreen, in a way that the infobar can't
  // be placed neither on top nor on bottom.
  // In such cases, the infobar will overlap the node, and to limit the latency given
  // by APZ (See Bug 1312103) it will be positioned as "fixed".
  // It's a sort of "position: sticky" (but positioned as absolute instead of relative).
  const canBePlacedOnTop = top >= pageYOffset;
  const canBePlacedOnBottom = bottomBoundary + pageYOffset - bottom > 0;
  const forcedOnTop = options.position === "top";
  const forcedOnBottom = options.position === "bottom";

  if (
    (!canBePlacedOnTop && canBePlacedOnBottom && !forcedOnTop) ||
    forcedOnBottom
  ) {
    top = bottom;
    positionAttribute = "bottom";
  }

  const isOffscreenOnTop = top < topBoundary + pageYOffset;
  const isOffscreenOnBottom = top > bottomBoundary + pageYOffset;
  const isOffscreenOnLeft = left < leftBoundary + pageXOffset;
  const isOffscreenOnRight = left > rightBoundary + pageXOffset;

  if (isOffscreenOnTop) {
    top = topBoundary;
    isOverlapTheNode = true;
  } else if (isOffscreenOnBottom) {
    top = bottomBoundary;
    isOverlapTheNode = true;
  } else if (isOffscreenOnLeft || isOffscreenOnRight) {
    isOverlapTheNode = true;
    top -= pageYOffset;
  }

  if (isOverlapTheNode && options.hideIfOffscreen) {
    container.setAttribute("hidden", "true");
    return;
  } else if (isOverlapTheNode) {
    left = Math.min(Math.max(leftBoundary, left - pageXOffset), rightBoundary);

    position = "fixed";
    container.setAttribute("hide-arrow", "true");
  } else {
    position = "absolute";
    container.removeAttribute("hide-arrow");
  }

  // We need to scale the infobar Independently from the highlighter's container;
  // otherwise the `position: fixed` won't work, since "any value other than `none` for
  // the transform, results in the creation of both a stacking context and a containing
  // block. The object acts as a containing block for fixed positioned descendants."
  // (See https://www.w3.org/TR/css-transforms-1/#transform-rendering)
  // We also need to shift the infobar 50% to the left in order for it to appear centered
  // on the element it points to.
  container.setAttribute(
    "style",
    `
    position:${position};
    transform-origin: 0 0;
    transform: scale(${1 / zoom}) translate(calc(${left}px - 50%), ${top}px)`
  );

  container.setAttribute("position", positionAttribute);
}
exports.moveInfobar = moveInfobar;
