/* 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 lazy = {};
loader.lazyGetter(
  lazy,
  "ContentDOMReference",
  () =>
    ChromeUtils.importESModule(
      "resource://gre/modules/ContentDOMReference.sys.mjs",
      // ContentDOMReference needs to be retrieved from the shared global
      // since it is a shared singleton.
      { global: "shared" }
    ).ContentDOMReference
);
loader.lazyRequireGetter(
  this,
  ["isFrameWithChildTarget", "isWindowIncluded"],
  "resource://devtools/shared/layout/utils.js",
  true
);
loader.lazyRequireGetter(
  this,
  "NodeTabbingOrderHighlighter",
  "resource://devtools/server/actors/highlighters/node-tabbing-order.js",
  true
);

const DEFAULT_FOCUS_FLAGS = Services.focus.FLAG_NOSCROLL;

/**
 * The TabbingOrderHighlighter uses focus manager to traverse all focusable
 * nodes on the page and then uses the NodeTabbingOrderHighlighter to highlight
 * these nodes.
 */
class TabbingOrderHighlighter {
  constructor(highlighterEnv) {
    this.highlighterEnv = highlighterEnv;
    this._highlighters = new Map();

    this.onMutation = this.onMutation.bind(this);
    this.onPageHide = this.onPageHide.bind(this);
    this.onWillNavigate = this.onWillNavigate.bind(this);

    this.highlighterEnv.on("will-navigate", this.onWillNavigate);

    const { pageListenerTarget } = highlighterEnv;
    pageListenerTarget.addEventListener("pagehide", this.onPageHide);
  }

  /**
   * Static getter that indicates that TabbingOrderHighlighter supports
   * highlighting in XUL windows.
   */
  static get XULSupported() {
    return true;
  }

  get win() {
    return this.highlighterEnv.window;
  }

  get focusedElement() {
    return Services.focus.getFocusedElementForWindow(this.win, true, {});
  }

  set focusedElement(element) {
    Services.focus.setFocus(element, DEFAULT_FOCUS_FLAGS);
  }

  moveFocus(startElement) {
    return Services.focus.moveFocus(
      this.win,
      startElement.nodeType === Node.DOCUMENT_NODE
        ? startElement.documentElement
        : startElement,
      Services.focus.MOVEFOCUS_FORWARD,
      DEFAULT_FOCUS_FLAGS
    );
  }

  /**
   * Show NodeTabbingOrderHighlighter on each node that belongs to the keyboard
   * tabbing order.
   *
   * @param  {DOMNode} startElm
   *         Starting element to calculate tabbing order from.
   *
   * @param  {JSON} options
   *         - options.index
   *           Start index for the tabbing order. Starting index will be 0 at
   *           the start of the tabbing order highlighting; in remote frames
   *           starting index will, typically, be greater than 0 (unless there
   *           was nothing to focus in the top level content document prior to
   *           the remote frame).
   */
  async show(startElm, { index }) {
    const focusableElements = [];
    const originalFocusedElement = this.focusedElement;
    let currentFocusedElement = this.moveFocus(startElm);
    while (
      currentFocusedElement &&
      isWindowIncluded(this.win, currentFocusedElement.ownerGlobal)
    ) {
      focusableElements.push(currentFocusedElement);
      currentFocusedElement = this.moveFocus(currentFocusedElement);
    }

    // Allow to flush pending notifications to ensure the PresShell and frames
    // are updated.
    await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
    let endElm = this.focusedElement;
    if (
      currentFocusedElement &&
      !isWindowIncluded(this.win, currentFocusedElement.ownerGlobal)
    ) {
      endElm = null;
    }

    if (
      !endElm &&
      !!focusableElements.length &&
      isFrameWithChildTarget(
        this.highlighterEnv.targetActor,
        focusableElements[focusableElements.length - 1]
      )
    ) {
      endElm = focusableElements[focusableElements.length - 1];
    }

    if (originalFocusedElement && originalFocusedElement !== endElm) {
      this.focusedElement = originalFocusedElement;
    }

    const highlighters = [];
    for (let i = 0; i < focusableElements.length; i++) {
      highlighters.push(
        this._accumulateHighlighter(focusableElements[i], index++)
      );
    }
    await Promise.all(highlighters);

    this._trackMutations();

    return {
      contentDOMReference: endElm && lazy.ContentDOMReference.get(endElm),
      index,
    };
  }

  async _accumulateHighlighter(node, index) {
    const highlighter = new NodeTabbingOrderHighlighter(this.highlighterEnv);
    await highlighter.isReady;

    highlighter.show(node, { index: index + 1 });
    this._highlighters.set(node, highlighter);
  }

  hide() {
    this._untrackMutations();
    for (const highlighter of this._highlighters.values()) {
      highlighter.destroy();
    }

    this._highlighters.clear();
  }

  /**
   * Track mutations in the top level document subtree so that the appropriate
   * NodeTabbingOrderHighlighter infobar's could be updated to reflect the
   * attribute mutations on relevant nodes.
   */
  _trackMutations() {
    const { win } = this;
    this.currentMutationObserver = new win.MutationObserver(this.onMutation);
    this.currentMutationObserver.observe(win.document.documentElement, {
      subtree: true,
      attributes: true,
    });
  }

  _untrackMutations() {
    if (!this.currentMutationObserver) {
      return;
    }

    this.currentMutationObserver.disconnect();
    this.currentMutationObserver = null;
  }

  onMutation(mutationList) {
    for (const { target } of mutationList) {
      const highlighter = this._highlighters.get(target);
      if (highlighter) {
        highlighter.update();
      }
    }
  }

  /**
   * Update NodeTabbingOrderHighlighter focus styling for a node that,
   * potentially, belongs to the tabbing order.
   * @param {Object} options
   *        Options specifying the node and its focused state.
   */
  updateFocus({ node, focused }) {
    const highlighter = this._highlighters.get(node);
    if (!highlighter) {
      return;
    }

    highlighter.updateFocus(focused);
  }

  destroy() {
    this.highlighterEnv.off("will-navigate", this.onWillNavigate);

    const { pageListenerTarget } = this.highlighterEnv;
    if (pageListenerTarget) {
      pageListenerTarget.removeEventListener("pagehide", this.onPageHide);
    }

    this.hide();
    this.highlighterEnv = null;
  }

  onPageHide({ target }) {
    // If a pagehide event is triggered for current window's highlighter, hide
    // the highlighter.
    if (target.defaultView === this.win) {
      this.hide();
    }
  }

  onWillNavigate({ isTopLevel }) {
    if (isTopLevel) {
      this.hide();
    }
  }
}

exports.TabbingOrderHighlighter = TabbingOrderHighlighter;
