/* 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";

loader.lazyRequireGetter(
  this,
  "CssLogic",
  "resource://devtools/server/actors/inspector/css-logic.js",
  true
);
loader.lazyRequireGetter(
  this,
  "getMatchingCSSRules",
  "resource://devtools/shared/inspector/css-logic.js",
  true
);
loader.lazyRequireGetter(
  this,
  "nodeConstants",
  "resource://devtools/shared/dom-node-constants.js"
);
loader.lazyRequireGetter(
  this,
  ["isDefunct", "getAriaRoles"],
  "resource://devtools/server/actors/utils/accessibility.js",
  true
);

const {
  accessibility: {
    AUDIT_TYPE: { KEYBOARD },
    ISSUE_TYPE: {
      [KEYBOARD]: {
        FOCUSABLE_NO_SEMANTICS,
        FOCUSABLE_POSITIVE_TABINDEX,
        INTERACTIVE_NO_ACTION,
        INTERACTIVE_NOT_FOCUSABLE,
        MOUSE_INTERACTIVE_ONLY,
        NO_FOCUS_VISIBLE,
      },
    },
    SCORES: { FAIL, WARNING },
  },
} = require("resource://devtools/shared/constants.js");

// Accessible action for showing long description.
const CLICK_ACTION = "click";

/**
 * Focus specific pseudo classes that the keyboard audit simulates to determine
 * focus styling.
 */
const FOCUS_PSEUDO_CLASS = ":focus";
const MOZ_FOCUSRING_PSEUDO_CLASS = ":-moz-focusring";

const KEYBOARD_FOCUSABLE_ROLES = new Set([
  Ci.nsIAccessibleRole.ROLE_BUTTONMENU,
  Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
  Ci.nsIAccessibleRole.ROLE_COMBOBOX,
  Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX,
  Ci.nsIAccessibleRole.ROLE_ENTRY,
  Ci.nsIAccessibleRole.ROLE_LINK,
  Ci.nsIAccessibleRole.ROLE_LISTBOX,
  Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT,
  Ci.nsIAccessibleRole.ROLE_PUSHBUTTON,
  Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
  Ci.nsIAccessibleRole.ROLE_SLIDER,
  Ci.nsIAccessibleRole.ROLE_SPINBUTTON,
  Ci.nsIAccessibleRole.ROLE_SUMMARY,
  Ci.nsIAccessibleRole.ROLE_SWITCH,
  Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON,
]);

const INTERACTIVE_ROLES = new Set([
  ...KEYBOARD_FOCUSABLE_ROLES,
  Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM,
  Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION,
  Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION,
  Ci.nsIAccessibleRole.ROLE_MENUITEM,
  Ci.nsIAccessibleRole.ROLE_OPTION,
  Ci.nsIAccessibleRole.ROLE_OUTLINE,
  Ci.nsIAccessibleRole.ROLE_OUTLINEITEM,
  Ci.nsIAccessibleRole.ROLE_PAGETAB,
  Ci.nsIAccessibleRole.ROLE_PARENT_MENUITEM,
  Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM,
  Ci.nsIAccessibleRole.ROLE_RICH_OPTION,
]);

const INTERACTIVE_IF_FOCUSABLE_ROLES = new Set([
  // If article is focusable, we can assume it is inside a feed.
  Ci.nsIAccessibleRole.ROLE_ARTICLE,
  // Column header can be focusable.
  Ci.nsIAccessibleRole.ROLE_COLUMNHEADER,
  Ci.nsIAccessibleRole.ROLE_GRID_CELL,
  Ci.nsIAccessibleRole.ROLE_MENUBAR,
  Ci.nsIAccessibleRole.ROLE_MENUPOPUP,
  Ci.nsIAccessibleRole.ROLE_PAGETABLIST,
  // Row header can be focusable.
  Ci.nsIAccessibleRole.ROLE_ROWHEADER,
  Ci.nsIAccessibleRole.ROLE_SCROLLBAR,
  Ci.nsIAccessibleRole.ROLE_SEPARATOR,
  Ci.nsIAccessibleRole.ROLE_TOOLBAR,
]);

/**
 * Determine if a node is dead or is not an element node.
 *
 * @param   {DOMNode} node
 *          Node to be tested for validity.
 *
 * @returns {Boolean}
 *          True if the node is either dead or is not an element node.
 */
function isInvalidNode(node) {
  return (
    !node ||
    Cu.isDeadWrapper(node) ||
    node.nodeType !== nodeConstants.ELEMENT_NODE ||
    !node.ownerGlobal
  );
}

/**
 * Determine if accessible is focusable with the keyboard.
 *
 * @param   {nsIAccessible} accessible
 *          Accessible for which to determine if it is keyboard focusable.
 *
 * @returns {Boolean}
 *          True if focusable with the keyboard.
 */
function isKeyboardFocusable(accessible) {
  const state = {};
  accessible.getState(state, {});
  // State will be focusable even if the tabindex is negative.
  return (
    state.value & Ci.nsIAccessibleStates.STATE_FOCUSABLE &&
    // Platform accessibility will still report STATE_FOCUSABLE even with the
    // tabindex="-1" so we need to check that it is >= 0 to be considered
    // keyboard focusable.
    accessible.DOMNode.tabIndex > -1
  );
}

/**
 * Determine if a current node has focus specific styling by applying a
 * focus-related pseudo class (such as :focus or :-moz-focusring) to a focusable
 * node.
 *
 * @param   {DOMNode} focusableNode
 *          Node to apply focus-related pseudo class to.
 * @param   {DOMNode} currentNode
 *          Node to be checked for having focus specific styling.
 * @param   {String} pseudoClass
 *          A focus related pseudo-class to be simulated for style comparison.
 *
 * @returns {Boolean}
 *          True if the currentNode has focus specific styling.
 */
function hasStylesForFocusRelatedPseudoClass(
  focusableNode,
  currentNode,
  pseudoClass
) {
  const defaultRules = getMatchingCSSRules(currentNode);

  InspectorUtils.addPseudoClassLock(focusableNode, pseudoClass);

  // Determine a set of properties that are specific to CSS rules that are only
  // present when a focus related pseudo-class is locked in.
  const tempRules = getMatchingCSSRules(currentNode);
  const properties = new Set();
  for (const rule of tempRules) {
    if (!defaultRules.includes(rule)) {
      for (let index = 0; index < rule.style.length; index++) {
        properties.add(rule.style.item(index));
      }
    }
  }

  // If there are no focus specific CSS rules or properties, currentNode does
  // node have any focus specific styling, we are done.
  if (properties.size === 0) {
    InspectorUtils.removePseudoClassLock(focusableNode, pseudoClass);
    return false;
  }

  // Determine values for properties that are focus specific.
  const tempStyle = CssLogic.getComputedStyle(currentNode);
  const focusStyle = {};
  for (const name of properties.values()) {
    focusStyle[name] = tempStyle.getPropertyValue(name);
  }

  InspectorUtils.removePseudoClassLock(focusableNode, pseudoClass);

  // If values for focus specific properties are different from default style
  // values, assume we have focus spefic styles for the currentNode.
  const defaultStyle = CssLogic.getComputedStyle(currentNode);
  for (const name of properties.values()) {
    if (defaultStyle.getPropertyValue(name) !== focusStyle[name]) {
      return true;
    }
  }

  return false;
}

/**
 * Check if an element node (currentNode) has distinct focus styling. This
 * function also takes into account a case when focus styling is applied to a
 * descendant too.
 *
 * @param   {DOMNode} focusableNode
 *          Node to apply focus-related pseudo class to.
 * @param   {DOMNode} currentNode
 *          Node to be checked for having focus specific styling.
 *
 * @returns {Boolean}
 *          True if the node or its descendant has distinct focus styling.
 */
function hasFocusStyling(focusableNode, currentNode) {
  if (isInvalidNode(currentNode)) {
    return false;
  }

  // Check if an element node has distinct :-moz-focusring styling.
  const hasStylesForMozFocusring = hasStylesForFocusRelatedPseudoClass(
    focusableNode,
    currentNode,
    MOZ_FOCUSRING_PSEUDO_CLASS
  );
  if (hasStylesForMozFocusring) {
    return true;
  }

  // Check if an element node has distinct :focus styling.
  const hasStylesForFocus = hasStylesForFocusRelatedPseudoClass(
    focusableNode,
    currentNode,
    FOCUS_PSEUDO_CLASS
  );
  if (hasStylesForFocus) {
    return true;
  }

  // If no element specific focus styles where found, check if its element
  // children have them.
  for (
    let child = currentNode.firstElementChild;
    child;
    child = currentNode.nextnextElementSibling
  ) {
    if (hasFocusStyling(focusableNode, child)) {
      return true;
    }
  }

  return false;
}

/**
 * A rule that determines if a focusable accessible object has appropriate focus
 * styling.
 *
 * @param  {nsIAccessible} accessible
 *         Accessible to be checked for being focusable and having focus
 *         styling.
 *
 * @return {null|Object}
 *         Null if accessible has keyboard focus styling, audit report object
 *         otherwise.
 */
function focusStyleRule(accessible) {
  const { DOMNode } = accessible;
  if (isInvalidNode(DOMNode)) {
    return null;
  }

  // Ignore non-focusable elements.
  if (!isKeyboardFocusable(accessible)) {
    return null;
  }

  if (hasFocusStyling(DOMNode, DOMNode)) {
    return null;
  }

  // If no browser or author focus styling was found, check if the node is a
  // widget that is themed by platform native theme.
  if (InspectorUtils.isElementThemed(DOMNode)) {
    return null;
  }

  return { score: WARNING, issue: NO_FOCUS_VISIBLE };
}

/**
 * A rule that determines if an interactive accessible has any associated
 * accessible actions with it. If the element is interactive but and has no
 * actions, assistive technology users will not be able to interact with it.
 *
 * @param  {nsIAccessible} accessible
 *         Accessible to be checked for being interactive and having accessible
 *         actions.
 *
 * @return {null|Object}
 *         Null if accessible is not interactive or if it is and it has
 *         accessible action associated with it, audit report object otherwise.
 */
function interactiveRule(accessible) {
  if (!INTERACTIVE_ROLES.has(accessible.role)) {
    return null;
  }

  if (accessible.actionCount > 0) {
    return null;
  }

  return { score: FAIL, issue: INTERACTIVE_NO_ACTION };
}

/**
 * A rule that determines if an interactive accessible is also focusable when
 * not disabled.
 *
 * @param  {nsIAccessible} accessible
 *         Accessible to be checked for being interactive and being focusable
 *         when enabled.
 *
 * @return {null|Object}
 *         Null if accessible is not interactive or if it is and it is focusable
 *         when enabled, audit report object otherwise.
 */
function focusableRule(accessible) {
  if (!KEYBOARD_FOCUSABLE_ROLES.has(accessible.role)) {
    return null;
  }

  const state = {};
  accessible.getState(state, {});
  // We only expect in interactive accessible object to be focusable if it is
  // not disabled.
  if (state.value & Ci.nsIAccessibleStates.STATE_UNAVAILABLE) {
    return null;
  }

  if (isKeyboardFocusable(accessible)) {
    return null;
  }

  const ariaRoles = getAriaRoles(accessible);
  if (
    ariaRoles &&
    (ariaRoles.includes("combobox") || ariaRoles.includes("listbox"))
  ) {
    // Do not force ARIA combobox or listbox to be focusable.
    return null;
  }

  return { score: FAIL, issue: INTERACTIVE_NOT_FOCUSABLE };
}

/**
 * A rule that determines if a focusable accessible has an associated
 * interactive role.
 *
 * @param  {nsIAccessible} accessible
 *         Accessible to be checked for having an interactive role if it is
 *         focusable.
 *
 * @return {null|Object}
 *         Null if accessible is not interactive or if it is and it has an
 *         interactive role, audit report object otherwise.
 */
function semanticsRule(accessible) {
  if (
    INTERACTIVE_ROLES.has(accessible.role) ||
    // Visible listboxes will have focusable state when inside comboboxes.
    accessible.role === Ci.nsIAccessibleRole.ROLE_COMBOBOX_LIST
  ) {
    return null;
  }

  if (isKeyboardFocusable(accessible)) {
    if (INTERACTIVE_IF_FOCUSABLE_ROLES.has(accessible.role)) {
      return null;
    }

    // ROLE_TABLE is used for grids too which are considered interactive.
    if (accessible.role === Ci.nsIAccessibleRole.ROLE_TABLE) {
      const ariaRoles = getAriaRoles(accessible);
      if (ariaRoles && ariaRoles.includes("grid")) {
        return null;
      }
    }

    return { score: WARNING, issue: FOCUSABLE_NO_SEMANTICS };
  }

  const state = {};
  accessible.getState(state, {});
  if (
    // Ignore text leafs.
    accessible.role === Ci.nsIAccessibleRole.ROLE_TEXT_LEAF ||
    // Ignore accessibles with no accessible actions.
    accessible.actionCount === 0 ||
    // Ignore labels that have a label for relation with their target because
    // they are clickable.
    (accessible.role === Ci.nsIAccessibleRole.ROLE_LABEL &&
      accessible.getRelationByType(Ci.nsIAccessibleRelation.RELATION_LABEL_FOR)
        .targetsCount > 0) ||
    // Ignore images that are inside an anchor (have linked state).
    (accessible.role === Ci.nsIAccessibleRole.ROLE_GRAPHIC &&
      state.value & Ci.nsIAccessibleStates.STATE_LINKED)
  ) {
    return null;
  }

  // Ignore anything but a click action in the list of actions.
  for (let i = 0; i < accessible.actionCount; i++) {
    if (accessible.getActionName(i) === CLICK_ACTION) {
      return { score: FAIL, issue: MOUSE_INTERACTIVE_ONLY };
    }
  }

  return null;
}

/**
 * A rule that determines if an element associated with a focusable accessible
 * has a positive tabindex.
 *
 * @param  {nsIAccessible} accessible
 *         Accessible to be checked for having an element with positive tabindex
 *         attribute.
 *
 * @return {null|Object}
 *         Null if accessible is not focusable or if it is and its element's
 *         tabindex attribute is less than 1, audit report object otherwise.
 */
function tabIndexRule(accessible) {
  const { DOMNode } = accessible;
  if (isInvalidNode(DOMNode)) {
    return null;
  }

  if (!isKeyboardFocusable(accessible)) {
    return null;
  }

  if (DOMNode.tabIndex > 0) {
    return { score: WARNING, issue: FOCUSABLE_POSITIVE_TABINDEX };
  }

  return null;
}

function auditKeyboard(accessible) {
  if (isDefunct(accessible)) {
    return null;
  }
  // Do not test anything on accessible objects for documents or frames.
  if (
    accessible.role === Ci.nsIAccessibleRole.ROLE_DOCUMENT ||
    accessible.role === Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME
  ) {
    return null;
  }

  // Check if interactive accessible can be used by the assistive
  // technology.
  let issue = interactiveRule(accessible);
  if (issue) {
    return issue;
  }

  // Check if interactive accessible is also focusable when enabled.
  issue = focusableRule(accessible);
  if (issue) {
    return issue;
  }

  // Check if accessible object has an element with a positive tabindex.
  issue = tabIndexRule(accessible);
  if (issue) {
    return issue;
  }

  // Check if a focusable accessible has interactive semantics.
  issue = semanticsRule(accessible);
  if (issue) {
    return issue;
  }

  // Check if focusable accessible has associated focus styling.
  issue = focusStyleRule(accessible);
  if (issue) {
    return issue;
  }

  return issue;
}

module.exports.auditKeyboard = auditKeyboard;
