/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
const {
  accessibleWalkerSpec,
} = require("resource://devtools/shared/specs/accessibility.js");

const {
  simulation: { COLOR_TRANSFORMATION_MATRICES },
} = require("resource://devtools/server/actors/accessibility/constants.js");

loader.lazyRequireGetter(
  this,
  "AccessibleActor",
  "resource://devtools/server/actors/accessibility/accessible.js",
  true
);
loader.lazyRequireGetter(
  this,
  ["CustomHighlighterActor"],
  "resource://devtools/server/actors/highlighters.js",
  true
);
loader.lazyRequireGetter(
  this,
  "DevToolsUtils",
  "resource://devtools/shared/DevToolsUtils.js"
);
loader.lazyRequireGetter(
  this,
  "events",
  "resource://devtools/shared/event-emitter.js"
);
loader.lazyRequireGetter(
  this,
  ["getCurrentZoom", "isWindowIncluded", "isFrameWithChildTarget"],
  "resource://devtools/shared/layout/utils.js",
  true
);
loader.lazyRequireGetter(
  this,
  "isXUL",
  "resource://devtools/server/actors/highlighters/utils/markup.js",
  true
);
loader.lazyRequireGetter(
  this,
  [
    "isDefunct",
    "loadSheetForBackgroundCalculation",
    "removeSheetForBackgroundCalculation",
  ],
  "resource://devtools/server/actors/utils/accessibility.js",
  true
);
loader.lazyRequireGetter(
  this,
  "accessibility",
  "resource://devtools/shared/constants.js",
  true
);

const kStateHover = 0x00000004; // ElementState::HOVER

const {
  EVENT_TEXT_CHANGED,
  EVENT_TEXT_INSERTED,
  EVENT_TEXT_REMOVED,
  EVENT_ACCELERATOR_CHANGE,
  EVENT_ACTION_CHANGE,
  EVENT_DEFACTION_CHANGE,
  EVENT_DESCRIPTION_CHANGE,
  EVENT_DOCUMENT_ATTRIBUTES_CHANGED,
  EVENT_HIDE,
  EVENT_NAME_CHANGE,
  EVENT_OBJECT_ATTRIBUTE_CHANGED,
  EVENT_REORDER,
  EVENT_STATE_CHANGE,
  EVENT_TEXT_ATTRIBUTE_CHANGED,
  EVENT_VALUE_CHANGE,
} = Ci.nsIAccessibleEvent;

// TODO: We do not need this once bug 1422913 is fixed. We also would not need
// to fire a name change event for an accessible that has an updated subtree and
// that has its name calculated from the said subtree.
const NAME_FROM_SUBTREE_RULE_ROLES = new Set([
  Ci.nsIAccessibleRole.ROLE_BUTTONDROPDOWN,
  Ci.nsIAccessibleRole.ROLE_BUTTONMENU,
  Ci.nsIAccessibleRole.ROLE_CELL,
  Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
  Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM,
  Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION,
  Ci.nsIAccessibleRole.ROLE_COLUMNHEADER,
  Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION,
  Ci.nsIAccessibleRole.ROLE_DEFINITION,
  Ci.nsIAccessibleRole.ROLE_GRID_CELL,
  Ci.nsIAccessibleRole.ROLE_HEADING,
  Ci.nsIAccessibleRole.ROLE_KEY,
  Ci.nsIAccessibleRole.ROLE_LABEL,
  Ci.nsIAccessibleRole.ROLE_LINK,
  Ci.nsIAccessibleRole.ROLE_LISTITEM,
  Ci.nsIAccessibleRole.ROLE_MATHML_IDENTIFIER,
  Ci.nsIAccessibleRole.ROLE_MATHML_NUMBER,
  Ci.nsIAccessibleRole.ROLE_MATHML_OPERATOR,
  Ci.nsIAccessibleRole.ROLE_MATHML_TEXT,
  Ci.nsIAccessibleRole.ROLE_MATHML_STRING_LITERAL,
  Ci.nsIAccessibleRole.ROLE_MATHML_GLYPH,
  Ci.nsIAccessibleRole.ROLE_MENUITEM,
  Ci.nsIAccessibleRole.ROLE_OPTION,
  Ci.nsIAccessibleRole.ROLE_OUTLINEITEM,
  Ci.nsIAccessibleRole.ROLE_PAGETAB,
  Ci.nsIAccessibleRole.ROLE_PARENT_MENUITEM,
  Ci.nsIAccessibleRole.ROLE_PUSHBUTTON,
  Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
  Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM,
  Ci.nsIAccessibleRole.ROLE_RICH_OPTION,
  Ci.nsIAccessibleRole.ROLE_ROW,
  Ci.nsIAccessibleRole.ROLE_ROWHEADER,
  Ci.nsIAccessibleRole.ROLE_SUMMARY,
  Ci.nsIAccessibleRole.ROLE_SWITCH,
  Ci.nsIAccessibleRole.ROLE_TERM,
  Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON,
  Ci.nsIAccessibleRole.ROLE_TOOLTIP,
]);

const IS_OSX = Services.appinfo.OS === "Darwin";

const {
  SCORES: { BEST_PRACTICES, FAIL, WARNING },
} = accessibility;

/**
 * Helper function that determines if nsIAccessible object is in stale state. When an
 * object is stale it means its subtree is not up to date.
 *
 * @param  {nsIAccessible}  accessible
 *         object to be tested.
 * @return {Boolean}
 *         True if accessible object is stale, false otherwise.
 */
function isStale(accessible) {
  const extraState = {};
  accessible.getState({}, extraState);
  // extraState.value is a bitmask. We are applying bitwise AND to mask out
  // irrelevant states.
  return !!(extraState.value & Ci.nsIAccessibleStates.EXT_STATE_STALE);
}

/**
 * Get accessibility audit starting with the passed accessible object as a root.
 *
 * @param {Object} acc
 *        AccessibileActor to be used as the root for the audit.
 * @param {Object} options
 *        Options for running audit, may include:
 *        - types: Array of audit types to be performed during audit.
 * @param {Map} report
 *        An accumulator map to be used to store audit information.
 * @param {Object} progress
 *        An audit project object that is used to track the progress of the
 *        audit and send progress "audit-event" events to the client.
 */
function getAudit(acc, options, report, progress) {
  if (acc.isDefunct) {
    return;
  }

  // Audit returns a promise, save the actual value in the report.
  report.set(
    acc,
    acc.audit(options).then(result => {
      report.set(acc, result);
      progress.increment();
    })
  );

  for (const child of acc.children()) {
    getAudit(child, options, report, progress);
  }
}

/**
 * A helper class that is used to track audit progress and send progress events
 * to the client.
 */
class AuditProgress {
  constructor(walker) {
    this.completed = 0;
    this.percentage = 0;
    this.walker = walker;
  }

  setTotal(size) {
    this.size = size;
  }

  notify() {
    this.walker.emit("audit-event", {
      type: "progress",
      progress: {
        total: this.size,
        percentage: this.percentage,
        completed: this.completed,
      },
    });
  }

  increment() {
    this.completed++;
    const { completed, size } = this;
    if (!size) {
      return;
    }

    const percentage = Math.round((completed / size) * 100);
    if (percentage > this.percentage) {
      this.percentage = percentage;
      this.notify();
    }
  }

  destroy() {
    this.walker = null;
  }
}

/**
 * The AccessibleWalkerActor stores a cache of AccessibleActors that represent
 * accessible objects in a given document.
 *
 * It is also responsible for implicitely initializing and shutting down
 * accessibility engine by storing a reference to the XPCOM accessibility
 * service.
 */
class AccessibleWalkerActor extends Actor {
  constructor(conn, targetActor) {
    super(conn, accessibleWalkerSpec);
    this.targetActor = targetActor;
    this.refMap = new Map();
    this._loadedSheets = new WeakMap();
    this.setA11yServiceGetter();
    this.onPick = this.onPick.bind(this);
    this.onHovered = this.onHovered.bind(this);
    this._preventContentEvent = this._preventContentEvent.bind(this);
    this.onKey = this.onKey.bind(this);
    this.onFocusIn = this.onFocusIn.bind(this);
    this.onFocusOut = this.onFocusOut.bind(this);
    this.onHighlighterEvent = this.onHighlighterEvent.bind(this);
  }

  get highlighter() {
    if (!this._highlighter) {
      this._highlighter = new CustomHighlighterActor(
        this,
        "AccessibleHighlighter"
      );

      this.manage(this._highlighter);
      this._highlighter.on("highlighter-event", this.onHighlighterEvent);
    }

    return this._highlighter;
  }

  get tabbingOrderHighlighter() {
    if (!this._tabbingOrderHighlighter) {
      this._tabbingOrderHighlighter = new CustomHighlighterActor(
        this,
        "TabbingOrderHighlighter"
      );

      this.manage(this._tabbingOrderHighlighter);
    }

    return this._tabbingOrderHighlighter;
  }

  setA11yServiceGetter() {
    DevToolsUtils.defineLazyGetter(this, "a11yService", () => {
      Services.obs.addObserver(this, "accessible-event");
      return Cc["@mozilla.org/accessibilityService;1"].getService(
        Ci.nsIAccessibilityService
      );
    });
  }

  get rootWin() {
    return this.targetActor && this.targetActor.window;
  }

  get rootDoc() {
    return this.targetActor && this.targetActor.window.document;
  }

  get isXUL() {
    return isXUL(this.rootWin);
  }

  get colorMatrix() {
    if (!this.targetActor.docShell) {
      return null;
    }

    const colorMatrix = this.targetActor.docShell.getColorMatrix();
    if (
      colorMatrix.length === 0 ||
      colorMatrix === COLOR_TRANSFORMATION_MATRICES.NONE
    ) {
      return null;
    }

    return colorMatrix;
  }

  reset() {
    try {
      Services.obs.removeObserver(this, "accessible-event");
    } catch (e) {
      // Accessible event observer might not have been initialized if a11y
      // service was never used.
    }

    this.cancelPick();

    // Clean up accessible actors cache.
    this.clearRefs();

    this._childrenPromise = null;
    delete this.a11yService;
    this.setA11yServiceGetter();
  }

  /**
   * Remove existing cache (of accessible actors) from tree.
   */
  clearRefs() {
    for (const actor of this.refMap.values()) {
      actor.destroy();
    }
  }

  destroy() {
    super.destroy();

    this.reset();

    if (this._highlighter) {
      this._highlighter.off("highlighter-event", this.onHighlighterEvent);
      this._highlighter = null;
    }

    if (this._tabbingOrderHighlighter) {
      this._tabbingOrderHighlighter = null;
    }

    this.targetActor = null;
    this.refMap = null;
  }

  getRef(rawAccessible) {
    return this.refMap.get(rawAccessible);
  }

  addRef(rawAccessible) {
    let actor = this.refMap.get(rawAccessible);
    if (actor) {
      return actor;
    }

    actor = new AccessibleActor(this, rawAccessible);
    // Add the accessible actor as a child of this accessible walker actor,
    // assigning it an actorID.
    this.manage(actor);
    this.refMap.set(rawAccessible, actor);

    return actor;
  }

  /**
   * Clean up accessible actors cache for a given accessible's subtree.
   *
   * @param  {null|nsIAccessible} rawAccessible
   */
  purgeSubtree(rawAccessible) {
    if (!rawAccessible) {
      return;
    }

    try {
      for (
        let child = rawAccessible.firstChild;
        child;
        child = child.nextSibling
      ) {
        this.purgeSubtree(child);
      }
    } catch (e) {
      // rawAccessible or its descendants are defunct.
    }

    const actor = this.getRef(rawAccessible);
    if (actor) {
      actor.destroy();
    }
  }

  unmanage(actor) {
    if (actor instanceof AccessibleActor) {
      this.refMap.delete(actor.rawAccessible);
    }
    Actor.prototype.unmanage.call(this, actor);
  }

  /**
   * A helper method. Accessibility walker is assumed to have only 1 child which
   * is the top level document.
   */
  async children() {
    if (this._childrenPromise) {
      return this._childrenPromise;
    }

    this._childrenPromise = Promise.all([this.getDocument()]);
    const children = await this._childrenPromise;
    this._childrenPromise = null;
    return children;
  }

  /**
   * A promise for a root document accessible actor that only resolves when its
   * corresponding document accessible object is fully loaded.
   *
   * @return {Promise}
   */
  getDocument() {
    if (!this.rootDoc || !this.rootDoc.documentElement) {
      return this.once("document-ready").then(docAcc => this.addRef(docAcc));
    }

    if (this.isXUL) {
      const doc = this.addRef(this.getRawAccessibleFor(this.rootDoc));
      return Promise.resolve(doc);
    }

    const doc = this.getRawAccessibleFor(this.rootDoc);

    // For non-visible same-process iframes we don't get a document and
    // won't get a "document-ready" event.
    if (!doc && !this.rootWin.windowGlobalChild.isProcessRoot) {
      // We can ignore such document as there won't be anything to audit in them.
      return null;
    }

    if (!doc || isStale(doc)) {
      return this.once("document-ready").then(docAcc => this.addRef(docAcc));
    }

    return Promise.resolve(this.addRef(doc));
  }

  /**
   * Get an accessible actor for a domnode actor.
   * @param  {Object} domNode
   *         domnode actor for which accessible actor is being created.
   * @return {Promse}
   *         A promise that resolves when accessible actor is created for a
   *         domnode actor.
   */
  getAccessibleFor(domNode) {
    // We need to make sure that the document is loaded processed by a11y first.
    return this.getDocument().then(() => {
      const rawAccessible = this.getRawAccessibleFor(domNode.rawNode);
      // Not all DOM nodes have corresponding accessible objects. It's usually
      // the case where there is no semantics or relevance to the accessibility
      // client.
      if (!rawAccessible) {
        return null;
      }

      return this.addRef(rawAccessible);
    });
  }

  /**
   * Get a raw accessible object for a raw node.
   * @param  {DOMNode} rawNode
   *         Raw node for which accessible object is being retrieved.
   * @return {nsIAccessible}
   *         Accessible object for a given DOMNode.
   */
  getRawAccessibleFor(rawNode) {
    // Accessible can only be retrieved iff accessibility service is enabled.
    if (!Services.appinfo.accessibilityEnabled) {
      return null;
    }

    return this.a11yService.getAccessibleFor(rawNode);
  }

  async getAncestry(accessible) {
    if (!accessible || accessible.indexInParent === -1) {
      return [];
    }
    const doc = await this.getDocument();
    if (!doc) {
      return [];
    }

    const ancestry = [];
    if (accessible === doc) {
      return ancestry;
    }

    try {
      let parent = accessible;
      while (parent && (parent = parent.parentAcc) && parent != doc) {
        ancestry.push(parent);
      }
      ancestry.push(doc);
    } catch (error) {
      throw new Error(`Failed to get ancestor for ${accessible}: ${error}`);
    }

    return ancestry.map(parent => ({
      accessible: parent,
      children: parent.children(),
    }));
  }

  /**
   * Run accessibility audit and return relevant ancestries for AccessibleActors
   * that have non-empty audit checks.
   *
   * @param  {Object} options
   *         Options for running audit, may include:
   *         - types: Array of audit types to be performed during audit.
   *
   * @return {Promise}
   *         A promise that resolves when the audit is complete and all relevant
   *         ancestries are calculated.
   */
  async audit(options) {
    const doc = await this.getDocument();
    if (!doc) {
      return [];
    }

    const report = new Map();
    this._auditProgress = new AuditProgress(this);
    getAudit(doc, options, report, this._auditProgress);
    this._auditProgress.setTotal(report.size);
    await Promise.all(report.values());

    const ancestries = [];
    for (const [acc, audit] of report.entries()) {
      // Filter out audits that have no failing checks.
      if (
        audit &&
        Object.values(audit).some(
          check =>
            check != null &&
            !check.error &&
            [BEST_PRACTICES, FAIL, WARNING].includes(check.score)
        )
      ) {
        ancestries.push(this.getAncestry(acc));
      }
    }

    return Promise.all(ancestries);
  }

  /**
   * Start accessibility audit. The result of this function will not be an audit
   * report. Instead, an "audit-event" event will be fired when the audit is
   * completed or fails.
   *
   * @param {Object} options
   *        Options for running audit, may include:
   *        - types: Array of audit types to be performed during audit.
   */
  startAudit(options) {
    // Audit is already running, wait for the "audit-event" event.
    if (this._auditing) {
      return;
    }

    this._auditing = this.audit(options)
      // We do not want to block on audit request, instead fire "audit-event"
      // event when internal audit is finished or failed.
      .then(ancestries =>
        this.emit("audit-event", {
          type: "completed",
          ancestries,
        })
      )
      .catch(() => this.emit("audit-event", { type: "error" }))
      .finally(() => {
        this._auditing = null;
        if (this._auditProgress) {
          this._auditProgress.destroy();
          this._auditProgress = null;
        }
      });
  }

  onHighlighterEvent(data) {
    this.emit("highlighter-event", data);
  }

  /**
   * Accessible event observer function.
   *
   * @param {Ci.nsIAccessibleEvent} subject
   *                                      accessible event object.
   */
  // eslint-disable-next-line complexity
  observe(subject) {
    const event = subject.QueryInterface(Ci.nsIAccessibleEvent);
    const rawAccessible = event.accessible;
    const accessible = this.getRef(rawAccessible);

    if (rawAccessible instanceof Ci.nsIAccessibleDocument && !accessible) {
      const rootDocAcc = this.getRawAccessibleFor(this.rootDoc);
      if (rawAccessible === rootDocAcc && !isStale(rawAccessible)) {
        this.clearRefs();
        // If it's a top level document notify listeners about the document
        // being ready.
        events.emit(this, "document-ready", rawAccessible);
      }
    }

    switch (event.eventType) {
      case EVENT_STATE_CHANGE:
        const { state, isEnabled } = event.QueryInterface(
          Ci.nsIAccessibleStateChangeEvent
        );
        const isBusy = state & Ci.nsIAccessibleStates.STATE_BUSY;
        if (accessible) {
          // Only propagate state change events for active accessibles.
          if (isBusy && isEnabled) {
            if (rawAccessible instanceof Ci.nsIAccessibleDocument) {
              // Remove existing cache from tree.
              this.clearRefs();
            }
            return;
          }
          events.emit(accessible, "states-change", accessible.states);
        }

        break;
      case EVENT_NAME_CHANGE:
        if (accessible) {
          events.emit(
            accessible,
            "name-change",
            rawAccessible.name,
            event.DOMNode == this.rootDoc
              ? undefined
              : this.getRef(rawAccessible.parent)
          );
        }
        break;
      case EVENT_VALUE_CHANGE:
        if (accessible) {
          events.emit(accessible, "value-change", rawAccessible.value);
        }
        break;
      case EVENT_DESCRIPTION_CHANGE:
        if (accessible) {
          events.emit(
            accessible,
            "description-change",
            rawAccessible.description
          );
        }
        break;
      case EVENT_REORDER:
        if (accessible) {
          accessible
            .children()
            .forEach(child =>
              events.emit(child, "index-in-parent-change", child.indexInParent)
            );
          events.emit(accessible, "reorder", rawAccessible.childCount);
        }
        break;
      case EVENT_HIDE:
        if (event.DOMNode == this.rootDoc) {
          this.clearRefs();
        } else {
          this.purgeSubtree(rawAccessible);
        }
        break;
      case EVENT_DEFACTION_CHANGE:
      case EVENT_ACTION_CHANGE:
        if (accessible) {
          events.emit(accessible, "actions-change", accessible.actions);
        }
        break;
      case EVENT_TEXT_CHANGED:
      case EVENT_TEXT_INSERTED:
      case EVENT_TEXT_REMOVED:
        if (accessible) {
          events.emit(accessible, "text-change");
          if (NAME_FROM_SUBTREE_RULE_ROLES.has(rawAccessible.role)) {
            events.emit(
              accessible,
              "name-change",
              rawAccessible.name,
              event.DOMNode == this.rootDoc
                ? undefined
                : this.getRef(rawAccessible.parent)
            );
          }
        }
        break;
      case EVENT_DOCUMENT_ATTRIBUTES_CHANGED:
      case EVENT_OBJECT_ATTRIBUTE_CHANGED:
      case EVENT_TEXT_ATTRIBUTE_CHANGED:
        if (accessible) {
          events.emit(accessible, "attributes-change", accessible.attributes);
        }
        break;
      // EVENT_ACCELERATOR_CHANGE is currently not fired by gecko accessibility.
      case EVENT_ACCELERATOR_CHANGE:
        if (accessible) {
          events.emit(
            accessible,
            "shortcut-change",
            accessible.keyboardShortcut
          );
        }
        break;
      default:
        break;
    }
  }

  /**
   * Ensure that nothing interferes with the audit for an accessible object
   * (CSS, overlays) by load accessibility highlighter style sheet used for
   * preventing transitions and applying transparency when calculating colour
   * contrast as well as temporarily hiding accessible highlighter overlay.
   * @param  {Object} win
   *         Window where highlighting happens.
   */
  async clearStyles(win) {
    const requests = this._loadedSheets.get(win);
    if (requests != null) {
      this._loadedSheets.set(win, requests + 1);
      return;
    }

    // Disable potential mouse driven transitions (This is important because accessibility
    // highlighter temporarily modifies text color related CSS properties. In case where
    // there are transitions that affect them, there might be unexpected side effects when
    // taking a snapshot for contrast measurement).
    loadSheetForBackgroundCalculation(win);
    this._loadedSheets.set(win, 1);
    await this.hideHighlighter();
  }

  /**
   * Restore CSS and overlays that could've interfered with the audit for an
   * accessible object by unloading accessibility highlighter style sheet used
   * for preventing transitions and applying transparency when calculating
   * colour contrast and potentially restoring accessible highlighter overlay.
   * @param  {Object} win
   *         Window where highlighting was happenning.
   */
  async restoreStyles(win) {
    const requests = this._loadedSheets.get(win);
    if (!requests) {
      return;
    }

    if (requests > 1) {
      this._loadedSheets.set(win, requests - 1);
      return;
    }

    await this.showHighlighter();
    removeSheetForBackgroundCalculation(win);
    this._loadedSheets.delete(win);
  }

  async hideHighlighter() {
    // TODO: Fix this workaround that temporarily removes higlighter bounds
    // overlay that can interfere with the contrast ratio calculation.
    if (this._highlighter) {
      const highlighter = this._highlighter.instance;
      await highlighter.isReady;
      highlighter.hideAccessibleBounds();
    }
  }

  async showHighlighter() {
    // TODO: Fix this workaround that temporarily removes higlighter bounds
    // overlay that can interfere with the contrast ratio calculation.
    if (this._highlighter) {
      const highlighter = this._highlighter.instance;
      await highlighter.isReady;
      highlighter.showAccessibleBounds();
    }
  }

  /**
   * Public method used to show an accessible object highlighter on the client
   * side.
   *
   * @param  {Object} accessible
   *         AccessibleActor to be highlighted.
   * @param  {Object} options
   *         Object used for passing options. Available options:
   *         - duration {Number}
   *                    Duration of time that the highlighter should be shown.
   * @return {Boolean}
   *         True if highlighter shows the accessible object.
   */
  async highlightAccessible(accessible, options = {}) {
    this.unhighlight();
    // Do not highlight if accessible is dead.
    if (!accessible || accessible.isDefunct || accessible.indexInParent < 0) {
      return false;
    }

    this._highlightingAccessible = accessible;
    const { bounds } = accessible;
    if (!bounds) {
      return false;
    }

    const { DOMNode: rawNode } = accessible.rawAccessible;
    const audit = await accessible.audit();
    if (this._highlightingAccessible !== accessible) {
      return false;
    }

    const { name, role } = accessible;
    const { highlighter } = this;
    await highlighter.instance.isReady;
    if (this._highlightingAccessible !== accessible) {
      return false;
    }

    const shown = highlighter.show(
      { rawNode },
      { ...options, ...bounds, name, role, audit, isXUL: this.isXUL }
    );
    this._highlightingAccessible = null;

    return shown;
  }

  /**
   * Public method used to hide an accessible object highlighter on the client
   * side.
   */
  unhighlight() {
    if (!this._highlighter) {
      return;
    }

    this.highlighter.hide();
    this._highlightingAccessible = null;
  }

  /**
   * Picking state that indicates if picking is currently enabled and, if so,
   * what the current and hovered accessible objects are.
   */
  _isPicking = false;
  _currentAccessible = null;

  /**
   * Check is event handling is allowed.
   */
  _isEventAllowed({ view }) {
    return this.rootWin.isChromeWindow || isWindowIncluded(this.rootWin, view);
  }

  /**
   * Check if the DOM event received when picking shold be ignored.
   * @param {Event} event
   */
  _ignoreEventWhenPicking(event) {
    return (
      !this._isPicking ||
      // If the DOM event is about a remote frame, only the WalkerActor for that
      // remote frame target should emit RDP events (hovered/picked/...). And
      // all other WalkerActor for intermediate iframe and top level document
      // targets should stay silent.
      isFrameWithChildTarget(
        this.targetActor,
        event.originalTarget || event.target
      )
    );
  }

  _preventContentEvent(event) {
    if (this._ignoreEventWhenPicking(event)) {
      return;
    }

    event.stopPropagation();
    event.preventDefault();

    const target = event.originalTarget || event.target;
    if (target !== this._currentTarget) {
      this._resetStateAndReleaseTarget();
      this._currentTarget = target;
      // We use InspectorUtils to save the original hover content state of the target
      // element (that includes its hover state). In order to not trigger any visual
      // changes to the element that depend on its hover state we remove the state while
      // the element is the most current target of the highlighter.
      //
      // TODO: This logic can be removed if/when we can use elementsAtPoint API for
      // determining topmost DOMNode that corresponds to specific coordinates. We would
      // then be able to use a highlighter overlay that would prevent all pointer events
      // to content but still render highlighter for the node/element correctly.
      this._currentTargetHoverState =
        InspectorUtils.getContentState(target) & kStateHover;
      InspectorUtils.removeContentState(target, kStateHover);
    }
  }

  /**
   * Click event handler for when picking is enabled.
   *
   * @param  {Object} event
   *         Current click event.
   */
  onPick(event) {
    if (this._ignoreEventWhenPicking(event)) {
      return;
    }

    this._preventContentEvent(event);
    if (!this._isEventAllowed(event)) {
      return;
    }

    // If shift is pressed, this is only a preview click, send the event to
    // the client, but don't stop picking.
    if (event.shiftKey) {
      if (!this._currentAccessible) {
        this._currentAccessible = this._findAndAttachAccessible(event);
      }
      events.emit(this, "picker-accessible-previewed", this._currentAccessible);
      return;
    }

    this._unsetPickerEnvironment();
    this._isPicking = false;
    if (!this._currentAccessible) {
      this._currentAccessible = this._findAndAttachAccessible(event);
    }
    events.emit(this, "picker-accessible-picked", this._currentAccessible);
  }

  /**
   * Hover event handler for when picking is enabled.
   *
   * @param  {Object} event
   *         Current hover event.
   */
  async onHovered(event) {
    if (this._ignoreEventWhenPicking(event)) {
      return;
    }

    this._preventContentEvent(event);
    if (!this._isEventAllowed(event)) {
      return;
    }

    const accessible = this._findAndAttachAccessible(event);
    if (!accessible || this._currentAccessible === accessible) {
      return;
    }

    this._currentAccessible = accessible;
    // Highlight current accessible and by the time we are done, if accessible that was
    // highlighted is not current any more (user moved the mouse to a new node) highlight
    // the most current accessible again.
    const shown = await this.highlightAccessible(accessible);
    if (this._isPicking && shown && accessible === this._currentAccessible) {
      events.emit(this, "picker-accessible-hovered", accessible);
    }
  }

  /**
   * Keyboard event handler for when picking is enabled.
   *
   * @param  {Object} event
   *         Current keyboard event.
   */
  onKey(event) {
    if (!this._currentAccessible || this._ignoreEventWhenPicking(event)) {
      return;
    }

    this._preventContentEvent(event);
    if (!this._isEventAllowed(event)) {
      return;
    }

    /**
     * KEY: Action/scope
     * ENTER/CARRIAGE_RETURN: Picks current accessible
     * ESC/CTRL+SHIFT+C: Cancels picker
     */
    switch (event.keyCode) {
      // Select the element.
      case event.DOM_VK_RETURN:
        this.onPick(event);
        break;
      // Cancel pick mode.
      case event.DOM_VK_ESCAPE:
        this.cancelPick();
        events.emit(this, "picker-accessible-canceled");
        break;
      case event.DOM_VK_C:
        if (
          (IS_OSX && event.metaKey && event.altKey) ||
          (!IS_OSX && event.ctrlKey && event.shiftKey)
        ) {
          this.cancelPick();
          events.emit(this, "picker-accessible-canceled");
        }
        break;
      default:
        break;
    }
  }

  /**
   * Picker method that starts picker content listeners.
   */
  pick() {
    if (!this._isPicking) {
      this._isPicking = true;
      this._setPickerEnvironment();
    }
  }

  /**
   * This pick method also focuses the highlighter's target window.
   */
  pickAndFocus() {
    this.pick();
    this.rootWin.focus();
  }

  attachAccessible(rawAccessible, accessibleDocument) {
    // If raw accessible object is defunct or detached, no need to cache it and
    // its ancestry.
    if (
      !rawAccessible ||
      isDefunct(rawAccessible) ||
      rawAccessible.indexInParent < 0
    ) {
      return null;
    }

    const accessible = this.addRef(rawAccessible);
    // There is a chance that ancestry lookup can fail if the accessible is in
    // the detached subtree. At that point the root accessible object would be
    // defunct and accessing it via parent property will throw.
    try {
      let parent = accessible;
      while (parent && parent.rawAccessible != accessibleDocument) {
        parent = parent.parentAcc;
      }
    } catch (error) {
      throw new Error(`Failed to get ancestor for ${accessible}: ${error}`);
    }

    return accessible;
  }

  get pixelRatio() {
    return this.rootWin.devicePixelRatio;
  }

  /**
   * Find deepest accessible object that corresponds to the screen coordinates of the
   * mouse pointer and attach it to the AccessibilityWalker tree.
   *
   * @param  {Object} event
   *         Correspoinding content event.
   * @return {null|Object}
   *         Accessible object, if available, that corresponds to a DOM node.
   */
  _findAndAttachAccessible(event) {
    const target = event.originalTarget || event.target;
    const docAcc = this.getRawAccessibleFor(this.rootDoc);
    const win = target.ownerGlobal;
    const zoom = this.isXUL ? 1 : getCurrentZoom(win);
    const scale = this.pixelRatio / zoom;
    const rawAccessible = docAcc.getDeepestChildAtPointInProcess(
      event.screenX * scale,
      event.screenY * scale
    );
    return this.attachAccessible(rawAccessible, docAcc);
  }

  /**
   * Start picker content listeners.
   */
  _setPickerEnvironment() {
    const target = this.targetActor.chromeEventHandler;
    target.addEventListener("mousemove", this.onHovered, true);
    target.addEventListener("click", this.onPick, true);
    target.addEventListener("mousedown", this._preventContentEvent, true);
    target.addEventListener("mouseup", this._preventContentEvent, true);
    target.addEventListener("mouseover", this._preventContentEvent, true);
    target.addEventListener("mouseout", this._preventContentEvent, true);
    target.addEventListener("mouseleave", this._preventContentEvent, true);
    target.addEventListener("mouseenter", this._preventContentEvent, true);
    target.addEventListener("dblclick", this._preventContentEvent, true);
    target.addEventListener("keydown", this.onKey, true);
    target.addEventListener("keyup", this._preventContentEvent, true);
  }

  /**
   * If content is still alive, stop picker content listeners, reset the hover state for
   * last target element.
   */
  _unsetPickerEnvironment() {
    const target = this.targetActor.chromeEventHandler;

    if (!target) {
      return;
    }

    target.removeEventListener("mousemove", this.onHovered, true);
    target.removeEventListener("click", this.onPick, true);
    target.removeEventListener("mousedown", this._preventContentEvent, true);
    target.removeEventListener("mouseup", this._preventContentEvent, true);
    target.removeEventListener("mouseover", this._preventContentEvent, true);
    target.removeEventListener("mouseout", this._preventContentEvent, true);
    target.removeEventListener("mouseleave", this._preventContentEvent, true);
    target.removeEventListener("mouseenter", this._preventContentEvent, true);
    target.removeEventListener("dblclick", this._preventContentEvent, true);
    target.removeEventListener("keydown", this.onKey, true);
    target.removeEventListener("keyup", this._preventContentEvent, true);

    this._resetStateAndReleaseTarget();
  }

  /**
   * When using accessibility highlighter, we keep track of the most current event pointer
   * event target. In order to update or release the target, we need to make sure we set
   * the content state (using InspectorUtils) to its original value.
   *
   * TODO: This logic can be removed if/when we can use elementsAtPoint API for
   * determining topmost DOMNode that corresponds to specific coordinates. We would then
   * be able to use a highlighter overlay that would prevent all pointer events to content
   * but still render highlighter for the node/element correctly.
   */
  _resetStateAndReleaseTarget() {
    if (!this._currentTarget) {
      return;
    }

    try {
      if (this._currentTargetHoverState) {
        InspectorUtils.setContentState(this._currentTarget, kStateHover);
      }
    } catch (e) {
      // DOMNode is already dead.
    }

    this._currentTarget = null;
    this._currentTargetState = null;
  }

  /**
   * Cacncel picker pick. Remvoe all content listeners and hide the highlighter.
   */
  cancelPick() {
    this.unhighlight();

    if (this._isPicking) {
      this._unsetPickerEnvironment();
      this._isPicking = false;
      this._currentAccessible = null;
    }
  }

  /**
   * Indicates that the tabbing order current active element (focused) is being
   * tracked.
   */
  _isTrackingTabbingOrderFocus = false;

  /**
   * Current focused element in the tabbing order.
   */
  _currentFocusedTabbingOrder = null;

  /**
   * Focusin event handler for when interacting with tabbing order overlay.
   *
   * @param  {Object} event
   *         Most recent focusin event.
   */
  async onFocusIn(event) {
    if (!this._isTrackingTabbingOrderFocus) {
      return;
    }

    const target = event.originalTarget || event.target;
    if (target === this._currentFocusedTabbingOrder) {
      return;
    }

    this._currentFocusedTabbingOrder = target;
    this.tabbingOrderHighlighter._highlighter.updateFocus({
      node: target,
      focused: true,
    });
  }

  /**
   * Focusout event handler for when interacting with tabbing order overlay.
   *
   * @param  {Object} event
   *         Most recent focusout event.
   */
  async onFocusOut(event) {
    if (
      !this._isTrackingTabbingOrderFocus ||
      !this._currentFocusedTabbingOrder
    ) {
      return;
    }

    const target = event.originalTarget || event.target;
    // Sanity check.
    if (target !== this._currentFocusedTabbingOrder) {
      console.warn(
        `focusout target: ${target} does not match current focused element in tabbing order: ${this._currentFocusedTabbingOrder}`
      );
    }

    this.tabbingOrderHighlighter._highlighter.updateFocus({
      node: this._currentFocusedTabbingOrder,
      focused: false,
    });
    this._currentFocusedTabbingOrder = null;
  }

  /**
   * Show tabbing order overlay for a given target.
   *
   * @param  {Object} elm
   *         domnode actor to be used as the starting point for generating the
   *         tabbing order.
   * @param  {Number} index
   *         Starting index for the tabbing order.
   *
   * @return {JSON}
   *         Tabbing order information for the last element in the tabbing
   *         order. It includes a ContentDOMReference for the node and a tabbing
   *         index. If we are at the end of the tabbing order for the top level
   *         content document, the ContentDOMReference will be null. If focus
   *         manager discovered a remote IFRAME, then the ContentDOMReference
   *         references the IFRAME itself.
   */
  showTabbingOrder(elm, index) {
    // Start track focus related events (only once). `showTabbingOrder` will be
    // called multiple times for a given target if it contains other remote
    // targets.
    if (!this._isTrackingTabbingOrderFocus) {
      this._isTrackingTabbingOrderFocus = true;
      const target = this.targetActor.chromeEventHandler;
      target.addEventListener("focusin", this.onFocusIn, true);
      target.addEventListener("focusout", this.onFocusOut, true);
    }

    return this.tabbingOrderHighlighter.show(elm, { index });
  }

  /**
   * Hide tabbing order overlay for a given target.
   */
  hideTabbingOrder() {
    if (!this._tabbingOrderHighlighter) {
      return;
    }

    this.tabbingOrderHighlighter.hide();
    if (!this._isTrackingTabbingOrderFocus) {
      return;
    }

    this._isTrackingTabbingOrderFocus = false;
    this._currentFocusedTabbingOrder = null;
    const target = this.targetActor.chromeEventHandler;
    if (target) {
      target.removeEventListener("focusin", this.onFocusIn, true);
      target.removeEventListener("focusout", this.onFocusOut, true);
    }
  }
}

exports.AccessibleWalkerActor = AccessibleWalkerActor;
