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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
  generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
  Log: "chrome://remote/content/shared/Log.sys.mjs",
  waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());

ChromeUtils.defineLazyGetter(lazy, "service", () => {
  try {
    return Cc["@mozilla.org/accessibilityService;1"].getService(
      Ci.nsIAccessibilityService
    );
  } catch (e) {
    lazy.logger.warn("Accessibility module is not present");
    return undefined;
  }
});

/** @namespace */
export const accessibility = {
  get service() {
    return lazy.service;
  },
};

/**
 * Accessible states used to check element"s state from the accessibility API
 * perspective.
 *
 * Note: if gecko is built with --disable-accessibility, the interfaces
 * are not defined. This is why we use getters instead to be able to use
 * these statically.
 */
accessibility.State = {
  get Unavailable() {
    return Ci.nsIAccessibleStates.STATE_UNAVAILABLE;
  },
  get Focusable() {
    return Ci.nsIAccessibleStates.STATE_FOCUSABLE;
  },
  get Selectable() {
    return Ci.nsIAccessibleStates.STATE_SELECTABLE;
  },
  get Selected() {
    return Ci.nsIAccessibleStates.STATE_SELECTED;
  },
};

/**
 * Accessible object roles that support some action.
 */
accessibility.ActionableRoles = new Set([
  "checkbutton",
  "check menu item",
  "check rich option",
  "combobox",
  "combobox option",
  "entry",
  "key",
  "link",
  "listbox option",
  "listbox rich option",
  "menuitem",
  "option",
  "outlineitem",
  "pagetab",
  "pushbutton",
  "radiobutton",
  "radio menu item",
  "rowheader",
  "slider",
  "spinbutton",
  "switch",
]);

/**
 * Factory function that constructs a new `accessibility.Checks`
 * object with enforced strictness or not.
 */
accessibility.get = function (strict = false) {
  return new accessibility.Checks(!!strict);
};

/**
 * Wait for the document accessibility state to be different from STATE_BUSY.
 *
 * @param {Document} doc
 *     The document to wait for.
 * @returns {Promise}
 *     A promise which resolves when the document's accessibility state is no
 *     longer busy.
 */
function waitForDocumentAccessibility(doc) {
  const documentAccessible = accessibility.service.getAccessibleFor(doc);
  const state = {};
  documentAccessible.getState(state, {});
  if ((state.value & Ci.nsIAccessibleStates.STATE_BUSY) == 0) {
    return Promise.resolve();
  }

  // Accessibility for the doc is busy, so wait for the state to change.
  return lazy.waitForObserverTopic("accessible-event", {
    checkFn: subject => {
      // If event type does not match expected type, skip the event.
      // If event's accessible does not match expected accessible,
      // skip the event.
      const event = subject.QueryInterface(Ci.nsIAccessibleEvent);
      return (
        event.eventType === Ci.nsIAccessibleEvent.EVENT_STATE_CHANGE &&
        event.accessible === documentAccessible
      );
    },
  });
}

/**
 * Retrieve the Accessible for the provided element.
 *
 * @param {Element} element
 *     The element for which we need to retrieve the accessible.
 *
 * @returns {nsIAccessible|null}
 *     The Accessible object corresponding to the provided element or null if
 *     the accessibility service is not available.
 */
accessibility.getAccessible = async function (element) {
  if (!accessibility.service) {
    return null;
  }

  // First, wait for accessibility to be ready for the element's document.
  await waitForDocumentAccessibility(element.ownerDocument);

  const acc = accessibility.service.getAccessibleFor(element);
  if (acc) {
    return acc;
  }

  // The Accessible doesn't exist yet. This can happen because a11y tree
  // mutations happen during refresh driver ticks. Stop the refresh driver from
  // doing its regular ticks and force two refresh driver ticks: the first to
  // let layout update and notify a11y, and the second to let a11y process
  // updates.
  const windowUtils = element.ownerGlobal.windowUtils;
  windowUtils.advanceTimeAndRefresh(0);
  windowUtils.advanceTimeAndRefresh(0);
  // Go back to normal refresh driver ticks.
  windowUtils.restoreNormalRefresh();
  return accessibility.service.getAccessibleFor(element);
};

/**
 * Retrieve the accessible name for the provided element.
 *
 * @param {Element} element
 *     The element for which we need to retrieve the accessible name.
 *
 * @returns {string}
 *     The accessible name.
 */
accessibility.getAccessibleName = async function (element) {
  const accessible = await accessibility.getAccessible(element);
  if (!accessible) {
    return "";
  }

  // If name is null (absent), expose the empty string.
  if (accessible.name === null) {
    return "";
  }

  return accessible.name;
};

/**
 * Compute the role for the provided element.
 *
 * @param {Element} element
 *     The element for which we need to compute the role.
 *
 * @returns {string}
 *     The computed role.
 */
accessibility.getComputedRole = async function (element) {
  const accessible = await accessibility.getAccessible(element);
  if (!accessible) {
    // If it's not in the a11y tree, it's probably presentational.
    return "none";
  }

  return accessible.computedARIARole;
};

// Data structures to map between Accessibles and globally unique ids. These ids
// are only used by WebDriver.
const accessibleToId = new WeakMap();
const idToAccessible = new Map();
// Clean up the maps when Accessibles die.
const accFinalizationRegistry = new FinalizationRegistry(id => {
  idToAccessible.delete(id);
});

/**
 * Get the WebDriver id for an Accessible, generating the id if necessary.
 */
function getAccessibleId(accessible) {
  let id = accessibleToId.get(accessible);
  if (id) {
    // We generated an id previously.
    return id;
  }
  // We haven't seen this Accessible before. Generate a new id.
  id = lazy.generateUUID();
  accessibleToId.set(accessible, id);
  idToAccessible.set(id, new WeakRef(accessible));
  accFinalizationRegistry.register(accessible, id);
  return id;
}

/**
 * Convert an Accessible state to "true", "false" or "mixed".
 */
function toMixedString(accState, testState) {
  if (accState & Ci.nsIAccessibleStates.STATE_MIXED) {
    return "mixed";
  }
  return String(!!(accState & testState));
}

/**
 * Serialize core properties of an Accessible to a JSON compatible object as
 * specified by WebDriver.
 */
function serializeAccessible(accessible) {
  const role = accessible.computedARIARole;
  // Properties that are very easy to retrieve.
  let result = {
    description: accessible.description,
    accessibilityId: getAccessibleId(accessible),
    label: accessible.name,
    role,
  };

  // Accessibility tree structure.
  const parent = accessible.parent;
  result.parent = parent ? getAccessibleId(parent) : null;
  result.children = [];
  const children = accessible.children;
  for (let c = 0; c < children.length; ++c) {
    result.children.push(
      getAccessibleId(children.queryElementAt(c, Ci.nsIAccessible))
    );
  }

  // States.
  let state = {};
  accessible.getState(state, {});
  state = state.value;
  if (state & Ci.nsIAccessibleStates.STATE_CHECKABLE) {
    result.checked = toMixedString(state, Ci.nsIAccessibleStates.STATE_CHECKED);
  }
  if (role == "button") {
    if (accessible.role == Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON) {
      result.pressed = toMixedString(
        state,
        Ci.nsIAccessibleStates.STATE_PRESSED
      );
    } else {
      result.pressed = "undefined";
    }
  }
  result.required = String(!!(state & Ci.nsIAccessibleStates.STATE_REQUIRED));
  if (state & Ci.nsIAccessibleStates.STATE_SELECTABLE) {
    result.selected = String(!!(state & Ci.nsIAccessibleStates.STATE_SELECTED));
  }

  return result;
}

/**
 * Retrieve serialized accessibility properties for the provided element.
 *
 * @param {Element} element
 *     The element for which to retrieve accessibility properties.
 *
 * @returns {object | null}
 *     The accessibility properties or null if the element isn't in the
 *     accessibility tree.
 */
accessibility.getAccessibilityPropertiesForElement = async function (element) {
  const accessible = await accessibility.getAccessible(element);
  if (!accessible) {
    // If it's not in the a11y tree, it's probably presentational.
    return null;
  }
  return serializeAccessible(accessible);
};

/**
 * Retrieve serialized properties for the provided accessibility node.
 *
 * @param {string} id
 *     The id of the accessibility node for which to retrieve properties.
 *
 * @returns {object | null}
 *     The accessibility properties or null if the id is invalid.
 */
accessibility.getAccessibilityPropertiesForAccessibilityNode = async function (
  id
) {
  const accessible = idToAccessible.get(id);
  if (!accessible) {
    return null;
  }
  return serializeAccessible(accessible.deref());
};

/**
 * Component responsible for interacting with platform accessibility
 * API.
 *
 * Its methods serve as wrappers for testing content and chrome
 * accessibility as well as accessibility of user interactions.
 */
accessibility.Checks = class {
  /**
   * @param {boolean} strict
   *     Flag indicating whether the accessibility issue should be logged
   *     or cause an error to be thrown.  Default is to log to stdout.
   */
  constructor(strict) {
    this.strict = strict;
  }

  /**
   * Assert that the element has a corresponding accessible object, and retrieve
   * this accessible. Note that if the accessibility.Checks component was
   * created in non-strict mode, this helper will not attempt to resolve the
   * accessible at all and will simply return null.
   *
   * @param {DOMElement|XULElement} element
   *     Element to get the accessible object for.
   * @param {boolean=} mustHaveAccessible
   *     Flag indicating that the element must have an accessible object.
   *     Defaults to not require this.
   *
   * @returns {Promise.<nsIAccessible>}
   *     Promise with an accessibility object for the given element.
   */
  async assertAccessible(element, mustHaveAccessible = false) {
    if (!this.strict) {
      return null;
    }

    const accessible = await accessibility.getAccessible(element);
    if (!accessible && mustHaveAccessible) {
      this.error("Element does not have an accessible object", element);
    }

    return accessible;
  }

  /**
   * Test if the accessible has a role that supports some arbitrary
   * action.
   *
   * @param {nsIAccessible} accessible
   *     Accessible object.
   *
   * @returns {boolean}
   *     True if an actionable role is found on the accessible, false
   *     otherwise.
   */
  isActionableRole(accessible) {
    return accessibility.ActionableRoles.has(
      accessibility.service.getStringRole(accessible.role)
    );
  }

  /**
   * Test if an accessible has at least one action that it supports.
   *
   * @param {nsIAccessible} accessible
   *     Accessible object.
   *
   * @returns {boolean}
   *     True if the accessible has at least one supported action,
   *     false otherwise.
   */
  hasActionCount(accessible) {
    return accessible.actionCount > 0;
  }

  /**
   * Test if an accessible has a valid name.
   *
   * @param {nsIAccessible} accessible
   *     Accessible object.
   *
   * @returns {boolean}
   *     True if the accessible has a non-empty valid name, or false if
   *     this is not the case.
   */
  hasValidName(accessible) {
    return accessible.name && accessible.name.trim();
  }

  /**
   * Test if an accessible has a `hidden` attribute.
   *
   * @param {nsIAccessible} accessible
   *     Accessible object.
   *
   * @returns {boolean}
   *     True if the accessible object has a `hidden` attribute,
   *     false otherwise.
   */
  hasHiddenAttribute(accessible) {
    let hidden = false;
    try {
      hidden = accessible.attributes.getStringProperty("hidden");
    } catch (e) {}
    // if the property is missing, error will be thrown
    return hidden && hidden === "true";
  }

  /**
   * Verify if an accessible has a given state.
   * Test if an accessible has a given state.
   *
   * @param {nsIAccessible} accessible
   *     Accessible object to test.
   * @param {number} stateToMatch
   *     State to match.
   *
   * @returns {boolean}
   *     True if |accessible| has |stateToMatch|, false otherwise.
   */
  matchState(accessible, stateToMatch) {
    let state = {};
    accessible.getState(state, {});
    return !!(state.value & stateToMatch);
  }

  /**
   * Test if an accessible is hidden from the user.
   *
   * @param {nsIAccessible} accessible
   *     Accessible object.
   *
   * @returns {boolean}
   *     True if element is hidden from user, false otherwise.
   */
  isHidden(accessible) {
    if (!accessible) {
      return true;
    }

    while (accessible) {
      if (this.hasHiddenAttribute(accessible)) {
        return true;
      }
      accessible = accessible.parent;
    }
    return false;
  }

  /**
   * Test if the element's visible state corresponds to its accessibility
   * API visibility.
   *
   * @param {nsIAccessible} accessible
   *     Accessible object.
   * @param {DOMElement|XULElement} element
   *     Element associated with |accessible|.
   * @param {boolean} visible
   *     Visibility state of |element|.
   *
   * @throws ElementNotAccessibleError
   *     If |element|'s visibility state does not correspond to
   *     |accessible|'s.
   */
  assertVisible(accessible, element, visible) {
    let hiddenAccessibility = this.isHidden(accessible);

    let message;
    if (visible && hiddenAccessibility) {
      message =
        "Element is not currently visible via the accessibility API " +
        "and may not be manipulated by it";
    } else if (!visible && !hiddenAccessibility) {
      message =
        "Element is currently only visible via the accessibility API " +
        "and can be manipulated by it";
    }
    this.error(message, element);
  }

  /**
   * Test if the element's unavailable accessibility state matches the
   * enabled state.
   *
   * @param {nsIAccessible} accessible
   *     Accessible object.
   * @param {DOMElement|XULElement} element
   *     Element associated with |accessible|.
   * @param {boolean} enabled
   *     Enabled state of |element|.
   *
   * @throws ElementNotAccessibleError
   *     If |element|'s enabled state does not match |accessible|'s.
   */
  assertEnabled(accessible, element, enabled) {
    if (!accessible) {
      return;
    }

    let win = element.ownerGlobal;
    let disabledAccessibility = this.matchState(
      accessible,
      accessibility.State.Unavailable
    );
    let explorable =
      win.getComputedStyle(element).getPropertyValue("pointer-events") !==
      "none";

    let message;
    if (!explorable && !disabledAccessibility) {
      message =
        "Element is enabled but is not explorable via the " +
        "accessibility API";
    } else if (enabled && disabledAccessibility) {
      message = "Element is enabled but disabled via the accessibility API";
    } else if (!enabled && !disabledAccessibility) {
      message = "Element is disabled but enabled via the accessibility API";
    }
    this.error(message, element);
  }

  /**
   * Test if it is possible to activate an element with the accessibility
   * API.
   *
   * @param {nsIAccessible} accessible
   *     Accessible object.
   * @param {DOMElement|XULElement} element
   *     Element associated with |accessible|.
   *
   * @throws ElementNotAccessibleError
   *     If it is impossible to activate |element| with |accessible|.
   */
  assertActionable(accessible, element) {
    if (!accessible) {
      return;
    }

    let message;
    if (!this.hasActionCount(accessible)) {
      message = "Element does not support any accessible actions";
    } else if (!this.isActionableRole(accessible)) {
      message =
        "Element does not have a correct accessibility role " +
        "and may not be manipulated via the accessibility API";
    } else if (!this.hasValidName(accessible)) {
      message = "Element is missing an accessible name";
    } else if (!this.matchState(accessible, accessibility.State.Focusable)) {
      message = "Element is not focusable via the accessibility API";
    }

    this.error(message, element);
  }

  /**
   * Test that an element's selected state corresponds to its
   * accessibility API selected state.
   *
   * @param {nsIAccessible} accessible
   *     Accessible object.
   * @param {DOMElement|XULElement} element
   *     Element associated with |accessible|.
   * @param {boolean} selected
   *     The |element|s selected state.
   *
   * @throws ElementNotAccessibleError
   *     If |element|'s selected state does not correspond to
   *     |accessible|'s.
   */
  assertSelected(accessible, element, selected) {
    if (!accessible) {
      return;
    }

    // element is not selectable via the accessibility API
    if (!this.matchState(accessible, accessibility.State.Selectable)) {
      return;
    }

    let selectedAccessibility = this.matchState(
      accessible,
      accessibility.State.Selected
    );

    let message;
    if (selected && !selectedAccessibility) {
      message =
        "Element is selected but not selected via the accessibility API";
    } else if (!selected && selectedAccessibility) {
      message =
        "Element is not selected but selected via the accessibility API";
    }
    this.error(message, element);
  }

  /**
   * Throw an error if strict accessibility checks are enforced and log
   * the error to the log.
   *
   * @param {string} message
   * @param {DOMElement|XULElement} element
   *     Element that caused an error.
   *
   * @throws ElementNotAccessibleError
   *     If |strict| is true.
   */
  error(message, element) {
    if (!message || !this.strict) {
      return;
    }
    if (element) {
      let { id, tagName, className } = element;
      message += `: id: ${id}, tagName: ${tagName}, className: ${className}`;
    }

    throw new lazy.error.ElementNotAccessibleError(message);
  }
};
