/**
 * Copyright 2018 Google Inc. All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the 'License');
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an 'AS IS' BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * @typedef {Object} SerializedAXNode
 * @property {string} role
 *
 * @property {string=} name
 * @property {string|number=} value
 * @property {string=} description
 *
 * @property {string=} keyshortcuts
 * @property {string=} roledescription
 * @property {string=} valuetext
 *
 * @property {boolean=} disabled
 * @property {boolean=} expanded
 * @property {boolean=} focused
 * @property {boolean=} modal
 * @property {boolean=} multiline
 * @property {boolean=} multiselectable
 * @property {boolean=} readonly
 * @property {boolean=} required
 * @property {boolean=} selected
 *
 * @property {boolean|"mixed"=} checked
 * @property {boolean|"mixed"=} pressed
 *
 * @property {number=} level
 * @property {number=} valuemin
 * @property {number=} valuemax
 *
 * @property {string=} autocomplete
 * @property {string=} haspopup
 * @property {string=} invalid
 * @property {string=} orientation
 *
 * @property {Array<SerializedAXNode>=} children
 */

class Accessibility {
  /**
   * @param {!Puppeteer.CDPSession} client
   */
  constructor(client) {
    this._client = client;
  }

  /**
   * @param {{interestingOnly?: boolean, root?: ?Puppeteer.ElementHandle}=} options
   * @return {!Promise<!SerializedAXNode>}
   */
  async snapshot(options = {}) {
    const {
      interestingOnly = true,
      root = null,
    } = options;
    const {nodes} = await this._client.send('Accessibility.getFullAXTree');
    let backendNodeId = null;
    if (root) {
      const {node} = await this._client.send('DOM.describeNode', {objectId: root._remoteObject.objectId});
      backendNodeId = node.backendNodeId;
    }
    const defaultRoot = AXNode.createTree(nodes);
    let needle = defaultRoot;
    if (backendNodeId) {
      needle = defaultRoot.find(node => node._payload.backendDOMNodeId === backendNodeId);
      if (!needle)
        return null;
    }
    if (!interestingOnly)
      return serializeTree(needle)[0];

    /** @type {!Set<!AXNode>} */
    const interestingNodes = new Set();
    collectInterestingNodes(interestingNodes, defaultRoot, false);
    if (!interestingNodes.has(needle))
      return null;
    return serializeTree(needle, interestingNodes)[0];
  }
}

/**
 * @param {!Set<!AXNode>} collection
 * @param {!AXNode} node
 * @param {boolean} insideControl
 */
function collectInterestingNodes(collection, node, insideControl) {
  if (node.isInteresting(insideControl))
    collection.add(node);
  if (node.isLeafNode())
    return;
  insideControl = insideControl || node.isControl();
  for (const child of node._children)
    collectInterestingNodes(collection, child, insideControl);
}

/**
 * @param {!AXNode} node
 * @param {!Set<!AXNode>=} whitelistedNodes
 * @return {!Array<!SerializedAXNode>}
 */
function serializeTree(node, whitelistedNodes) {
  /** @type {!Array<!SerializedAXNode>} */
  const children = [];
  for (const child of node._children)
    children.push(...serializeTree(child, whitelistedNodes));

  if (whitelistedNodes && !whitelistedNodes.has(node))
    return children;

  const serializedNode = node.serialize();
  if (children.length)
    serializedNode.children = children;
  return [serializedNode];
}


class AXNode {
  /**
   * @param {!Protocol.Accessibility.AXNode} payload
   */
  constructor(payload) {
    this._payload = payload;

    /** @type {!Array<!AXNode>} */
    this._children = [];

    this._richlyEditable = false;
    this._editable = false;
    this._focusable = false;
    this._expanded = false;
    this._name = this._payload.name ? this._payload.name.value : '';
    this._role = this._payload.role ? this._payload.role.value : 'Unknown';
    this._cachedHasFocusableChild;

    for (const property of this._payload.properties || []) {
      if (property.name === 'editable') {
        this._richlyEditable = property.value.value === 'richtext';
        this._editable = true;
      }
      if (property.name === 'focusable')
        this._focusable = property.value.value;
      if (property.name === 'expanded')
        this._expanded = property.value.value;
    }
  }

  /**
   * @return {boolean}
   */
  _isPlainTextField() {
    if (this._richlyEditable)
      return false;
    if (this._editable)
      return true;
    return this._role === 'textbox' || this._role === 'ComboBox' || this._role === 'searchbox';
  }

  /**
   * @return {boolean}
   */
  _isTextOnlyObject() {
    const role = this._role;
    return (role === 'LineBreak' || role === 'text' ||
            role === 'InlineTextBox');
  }

  /**
   * @return {boolean}
   */
  _hasFocusableChild() {
    if (this._cachedHasFocusableChild === undefined) {
      this._cachedHasFocusableChild = false;
      for (const child of this._children) {
        if (child._focusable || child._hasFocusableChild()) {
          this._cachedHasFocusableChild = true;
          break;
        }
      }
    }
    return this._cachedHasFocusableChild;
  }

  /**
   * @param {function(AXNode):boolean} predicate
   * @return {?AXNode}
   */
  find(predicate) {
    if (predicate(this))
      return this;
    for (const child of this._children) {
      const result = child.find(predicate);
      if (result)
        return result;
    }
    return null;
  }

  /**
   * @return {boolean}
   */
  isLeafNode() {
    if (!this._children.length)
      return true;

    // These types of objects may have children that we use as internal
    // implementation details, but we want to expose them as leaves to platform
    // accessibility APIs because screen readers might be confused if they find
    // any children.
    if (this._isPlainTextField() || this._isTextOnlyObject())
      return true;

    // Roles whose children are only presentational according to the ARIA and
    // HTML5 Specs should be hidden from screen readers.
    // (Note that whilst ARIA buttons can have only presentational children, HTML5
    // buttons are allowed to have content.)
    switch (this._role) {
      case 'doc-cover':
      case 'graphics-symbol':
      case 'img':
      case 'Meter':
      case 'scrollbar':
      case 'slider':
      case 'separator':
      case 'progressbar':
        return true;
      default:
        break;
    }

    // Here and below: Android heuristics
    if (this._hasFocusableChild())
      return false;
    if (this._focusable && this._name)
      return true;
    if (this._role === 'heading' && this._name)
      return true;
    return false;
  }

  /**
   * @return {boolean}
   */
  isControl() {
    switch (this._role) {
      case 'button':
      case 'checkbox':
      case 'ColorWell':
      case 'combobox':
      case 'DisclosureTriangle':
      case 'listbox':
      case 'menu':
      case 'menubar':
      case 'menuitem':
      case 'menuitemcheckbox':
      case 'menuitemradio':
      case 'radio':
      case 'scrollbar':
      case 'searchbox':
      case 'slider':
      case 'spinbutton':
      case 'switch':
      case 'tab':
      case 'textbox':
      case 'tree':
        return true;
      default:
        return false;
    }
  }

  /**
   * @param {boolean} insideControl
   * @return {boolean}
   */
  isInteresting(insideControl) {
    const role = this._role;
    if (role === 'Ignored')
      return false;

    if (this._focusable || this._richlyEditable)
      return true;

    // If it's not focusable but has a control role, then it's interesting.
    if (this.isControl())
      return true;

    // A non focusable child of a control is not interesting
    if (insideControl)
      return false;

    return this.isLeafNode() && !!this._name;
  }

  /**
   * @return {!SerializedAXNode}
   */
  serialize() {
    /** @type {!Map<string, number|string|boolean>} */
    const properties = new Map();
    for (const property of this._payload.properties || [])
      properties.set(property.name.toLowerCase(), property.value.value);
    if (this._payload.name)
      properties.set('name', this._payload.name.value);
    if (this._payload.value)
      properties.set('value', this._payload.value.value);
    if (this._payload.description)
      properties.set('description', this._payload.description.value);

    /** @type {SerializedAXNode} */
    const node = {
      role: this._role
    };

    /** @type {!Array<keyof SerializedAXNode>} */
    const userStringProperties = [
      'name',
      'value',
      'description',
      'keyshortcuts',
      'roledescription',
      'valuetext',
    ];
    for (const userStringProperty of userStringProperties) {
      if (!properties.has(userStringProperty))
        continue;
      node[userStringProperty] = properties.get(userStringProperty);
    }

    /** @type {!Array<keyof SerializedAXNode>} */
    const booleanProperties = [
      'disabled',
      'expanded',
      'focused',
      'modal',
      'multiline',
      'multiselectable',
      'readonly',
      'required',
      'selected',
    ];
    for (const booleanProperty of booleanProperties) {
      // WebArea's treat focus differently than other nodes. They report whether their frame  has focus,
      // not whether focus is specifically on the root node.
      if (booleanProperty === 'focused' && this._role === 'WebArea')
        continue;
      const value = properties.get(booleanProperty);
      if (!value)
        continue;
      node[booleanProperty] = value;
    }

    /** @type {!Array<keyof SerializedAXNode>} */
    const tristateProperties = [
      'checked',
      'pressed',
    ];
    for (const tristateProperty of tristateProperties) {
      if (!properties.has(tristateProperty))
        continue;
      const value = properties.get(tristateProperty);
      node[tristateProperty] = value === 'mixed' ? 'mixed' : value === 'true' ? true : false;
    }
    /** @type {!Array<keyof SerializedAXNode>} */
    const numericalProperties = [
      'level',
      'valuemax',
      'valuemin',
    ];
    for (const numericalProperty of numericalProperties) {
      if (!properties.has(numericalProperty))
        continue;
      node[numericalProperty] = properties.get(numericalProperty);
    }
    /** @type {!Array<keyof SerializedAXNode>} */
    const tokenProperties = [
      'autocomplete',
      'haspopup',
      'invalid',
      'orientation',
    ];
    for (const tokenProperty of tokenProperties) {
      const value = properties.get(tokenProperty);
      if (!value || value === 'false')
        continue;
      node[tokenProperty] = value;
    }
    return node;
  }

  /**
   * @param {!Array<!Protocol.Accessibility.AXNode>} payloads
   * @return {!AXNode}
   */
  static createTree(payloads) {
    /** @type {!Map<string, !AXNode>} */
    const nodeById = new Map();
    for (const payload of payloads)
      nodeById.set(payload.nodeId, new AXNode(payload));
    for (const node of nodeById.values()) {
      for (const childId of node._payload.childIds || [])
        node._children.push(nodeById.get(childId));
    }
    return nodeById.values().next().value;
  }
}

module.exports = {Accessibility};
