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

var {utils: Cu} = Components;

Cu.import("chrome://marionette/content/error.js");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");

XPCOMUtils.defineLazyModuleGetter(this, 'setInterval',
  'resource://gre/modules/Timer.jsm');
XPCOMUtils.defineLazyModuleGetter(this, 'clearInterval',
  'resource://gre/modules/Timer.jsm');

/**
 * The ElementManager manages DOM references and interactions with elements.
 * According to the WebDriver spec (http://code.google.com/p/selenium/wiki/JsonWireProtocol), the
 * server sends the client an element reference, and maintains the map of reference to element.
 * The client uses this reference when querying/interacting with the element, and the
 * server uses maps this reference to the actual element when it executes the command.
 */

this.EXPORTED_SYMBOLS = [
  "Accessibility",
  "ElementManager",
  "CLASS_NAME",
  "SELECTOR",
  "ID",
  "NAME",
  "LINK_TEXT",
  "PARTIAL_LINK_TEXT",
  "TAG",
  "XPATH",
  "ANON",
  "ANON_ATTRIBUTE"
];

const DOCUMENT_POSITION_DISCONNECTED = 1;

const uuidGen = Components.classes["@mozilla.org/uuid-generator;1"]
    .getService(Components.interfaces.nsIUUIDGenerator);

this.CLASS_NAME = "class name";
this.SELECTOR = "css selector";
this.ID = "id";
this.NAME = "name";
this.LINK_TEXT = "link text";
this.PARTIAL_LINK_TEXT = "partial link text";
this.TAG = "tag name";
this.XPATH = "xpath";
this.ANON= "anon";
this.ANON_ATTRIBUTE = "anon attribute";

this.Accessibility = function Accessibility() {
  // A flag indicating whether the accessibility issue should be logged or cause
  // an exception. Default: log to stdout.
  this.strict = false;
  // An interface for in-process accessibility clients
  // Note: we access it lazily to not enable accessibility when it is not needed
  Object.defineProperty(this, 'accessibleRetrieval', {
    configurable: true,
    get: function() {
      delete this.accessibleRetrieval;
      this.accessibleRetrieval = Components.classes[
        '@mozilla.org/accessibleRetrieval;1'].getService(
          Components.interfaces.nsIAccessibleRetrieval);
      return this.accessibleRetrieval;
    }
  });
};

Accessibility.prototype = {

  /**
   * Number of attempts to get an accessible object for an element. We attempt
   * more than once because accessible tree can be out of sync with the DOM tree
   * for a short period of time.
   * @type {Number}
   */
  GET_ACCESSIBLE_ATTEMPTS: 100,

  /**
   * An interval between attempts to retrieve an accessible object for an
   * element.
   * @type {Number} ms
   */
  GET_ACCESSIBLE_ATTEMPT_INTERVAL: 10,

  /**
   * Accessible object roles that support some action
   * @type Object
   */
  actionableRoles: new Set([
    'pushbutton',
    'checkbutton',
    'combobox',
    'key',
    'link',
    'menuitem',
    'check menu item',
    'radio menu item',
    'option',
    'listbox option',
    'listbox rich option',
    'check rich option',
    'combobox option',
    'radiobutton',
    'rowheader',
    'switch',
    'slider',
    'spinbutton',
    'pagetab',
    'entry',
    'outlineitem'
  ]),

  /**
   * Get an accessible object for a DOM element
   * @param nsIDOMElement element
   * @param Boolean mustHaveAccessible a flag indicating that the element must
   * have an accessible object
   * @return nsIAccessible object for the element
   */
  getAccessibleObject(element, mustHaveAccessible = false) {
    return new Promise((resolve, reject) => {
      let acc = this.accessibleRetrieval.getAccessibleFor(element);

      if (acc || !mustHaveAccessible) {
        // If accessible object is found, return it. If it is not required,
        // also resolve.
        resolve(acc);
      } else {
        // If we require an accessible object, we need to poll for it because
        // accessible tree might be out of sync with DOM tree for a short time.
        let attempts = this.GET_ACCESSIBLE_ATTEMPTS;
        let intervalId = setInterval(() => {
          let acc = this.accessibleRetrieval.getAccessibleFor(element);
          if (acc || --attempts <= 0) {
            clearInterval(intervalId);
            if (acc) { resolve(acc); }
            else { reject(); }
          }
        }, this.GET_ACCESSIBLE_ATTEMPT_INTERVAL);
      }
    }).catch(() => this.handleErrorMessage(
      'Element does not have an accessible object', element));
  },

  /**
   * Check if the accessible has a role that supports some action
   * @param nsIAccessible object
   * @return Boolean an indicator of role being actionable
   */
  isActionableRole(accessible) {
    return this.actionableRoles.has(
      this.accessibleRetrieval.getStringRole(accessible.role));
  },

  /**
   * Determine if an accessible has at least one action that it supports
   * @param nsIAccessible object
   * @return Boolean an indicator of supporting at least one accessible action
   */
  hasActionCount(accessible) {
    return accessible.actionCount > 0;
  },

  /**
   * Determine if an accessible has a valid name
   * @param nsIAccessible object
   * @return Boolean an indicator that the element has a non empty valid name
   */
  hasValidName(accessible) {
    return accessible.name && accessible.name.trim();
  },

  /**
   * Check if an accessible has a set hidden attribute
   * @param nsIAccessible object
   * @return Boolean an indicator that the element has a hidden accessible
   * attribute set to true
   */
  hasHiddenAttribute(accessible) {
    let hidden;
    try {
      hidden = accessible.attributes.getStringProperty('hidden');
    } finally {
      // If the property is missing, exception will be thrown.
      return hidden && hidden === 'true';
    }
  },

  /**
   * Verify if an accessible has a given state
   * @param nsIAccessible object
   * @param String stateName name of the state to match
   * @return Boolean accessible has a state
   */
  matchState(accessible, stateName) {
    let stateToMatch = Components.interfaces.nsIAccessibleStates[stateName];
    let state = {};
    accessible.getState(state, {});
    return !!(state.value & stateToMatch);
  },

  /**
   * Check if an accessible is hidden from the user of the accessibility API
   * @param nsIAccessible object
   * @return Boolean an indicator that the element is hidden from the user
   */
  isHidden(accessible) {
    while (accessible) {
      if (this.hasHiddenAttribute(accessible)) {
        return true;
      }
      accessible = accessible.parent;
    }
    return false;
  },

  /**
   * Send an error message or log the error message in the log
   * @param String message
   * @param DOMElement element that caused an error
   */
  handleErrorMessage(message, element) {
    if (!message) {
      return;
    }
    if (element) {
      message += ` -> id: ${element.id}, tagName: ${element.tagName}, className: ${element.className}\n`;
    }
    if (this.strict) {
      throw new ElementNotAccessibleError(message);
    }
    dump(Date.now() + " Marionette: " + message);
  }
};

this.ElementManager = function ElementManager(notSupported) {
  this.seenItems = {};
  this.timer = Components.classes["@mozilla.org/timer;1"].createInstance(Components.interfaces.nsITimer);
  this.elementKey = 'ELEMENT';
  this.w3cElementKey = 'element-6066-11e4-a52e-4f735466cecf';
  this.elementStrategies = [CLASS_NAME, SELECTOR, ID, NAME, LINK_TEXT, PARTIAL_LINK_TEXT, TAG, XPATH, ANON, ANON_ATTRIBUTE];
  for (let i = 0; i < notSupported.length; i++) {
    this.elementStrategies.splice(this.elementStrategies.indexOf(notSupported[i]), 1);
  }
}

ElementManager.prototype = {
  /**
   * Reset values
   */
  reset: function EM_clear() {
    this.seenItems = {};
  },

  /**
  * Add element to list of seen elements
  *
  * @param nsIDOMElement element
  *        The element to add
  *
  * @return string
  *        Returns the server-assigned reference ID
  */
  addToKnownElements: function EM_addToKnownElements(element) {
    for (let i in this.seenItems) {
      let foundEl = null;
      try {
        foundEl = this.seenItems[i].get();
      } catch (e) {}
      if (foundEl) {
        if (XPCNativeWrapper(foundEl) == XPCNativeWrapper(element)) {
          return i;
        }
      } else {
        // cleanup reference to GC'd element
        delete this.seenItems[i];
      }
    }
    let uuid = uuidGen.generateUUID().toString();
    let id = uuid.substring(1, uuid.length - 1);
    this.seenItems[id] = Components.utils.getWeakReference(element);
    return id;
  },

  /**
   * Retrieve element from its unique ID
   *
   * @param String id
   *        The DOM reference ID
   * @param nsIDOMWindow, ShadowRoot container
   *        The window and an optional shadow root that contains the element
   *
   * @returns nsIDOMElement
   *        Returns the element or throws Exception if not found
   */
  getKnownElement: function EM_getKnownElement(id, container) {
    let el = this.seenItems[id];
    if (!el) {
      throw new JavaScriptError("Element has not been seen before. Id given was " + id);
    }
    try {
      el = el.get();
    }
    catch(e) {
      el = null;
      delete this.seenItems[id];
    }
    // use XPCNativeWrapper to compare elements; see bug 834266
    let wrappedFrame = XPCNativeWrapper(container.frame);
    let wrappedShadowRoot;
    if (container.shadowRoot) {
      wrappedShadowRoot = XPCNativeWrapper(container.shadowRoot);
    }

    if (!el ||
        !(XPCNativeWrapper(el).ownerDocument == wrappedFrame.document) ||
        this.isDisconnected(XPCNativeWrapper(el), wrappedShadowRoot,
          wrappedFrame)) {
      throw new StaleElementReferenceError(
          "The element reference is stale. Either the element " +
          "is no longer attached to the DOM or the page has been refreshed.");
    }
    return el;
  },

  /**
   * Check if the element is detached from the current frame as well as the
   * optional shadow root (when inside a Shadow DOM context).
   * @param nsIDOMElement el
   *        element to be checked
   * @param ShadowRoot shadowRoot
   *        an optional shadow root containing an element
   * @param nsIDOMWindow frame
   *        window that contains the element or the current host of the shadow
   *        root.
   * @return {Boolean} a flag indicating that the element is disconnected
   */
  isDisconnected: function EM_isDisconnected(el, shadowRoot, frame) {
    if (shadowRoot && frame.ShadowRoot) {
      if (el.compareDocumentPosition(shadowRoot) &
        DOCUMENT_POSITION_DISCONNECTED) {
        return true;
      }
      // Looking for next possible ShadowRoot ancestor
      let parent = shadowRoot.host;
      while (parent && !(parent instanceof frame.ShadowRoot)) {
        parent = parent.parentNode;
      }
      return this.isDisconnected(shadowRoot.host, parent, frame);
    } else {
      return el.compareDocumentPosition(frame.document.documentElement) &
        DOCUMENT_POSITION_DISCONNECTED;
    }
  },

  /**
   * Convert values to primitives that can be transported over the
   * Marionette protocol.
   *
   * This function implements the marshaling algorithm defined in the
   * WebDriver specification:
   *
   *     https://dvcs.w3.org/hg/webdriver/raw-file/tip/webdriver-spec.html#synchronous-javascript-execution
   *
   * @param object val
   *        object to be marshaled
   *
   * @return object
   *         Returns a JSON primitive or Object
   */
  wrapValue: function EM_wrapValue(val) {
    let result = null;

    switch (typeof(val)) {
      case "undefined":
        result = null;
        break;

      case "string":
      case "number":
      case "boolean":
        result = val;
        break;

      case "object":
        let type = Object.prototype.toString.call(val);
        if (type == "[object Array]" ||
            type == "[object NodeList]") {
          result = [];
          for (let i = 0; i < val.length; ++i) {
            result.push(this.wrapValue(val[i]));

          }
        }
        else if (val == null) {
          result = null;
        }
        else if (val.nodeType == 1) {
          let elementId = this.addToKnownElements(val);
          result = {[this.elementKey]: elementId, [this.w3cElementKey]: elementId};
        }
        else {
          result = {};
          for (let prop in val) {
            result[prop] = this.wrapValue(val[prop]);
          }
        }
        break;
    }

    return result;
  },

  /**
   * Convert any ELEMENT references in 'args' to the actual elements
   *
   * @param object args
   *        Arguments passed in by client
   * @param nsIDOMWindow, ShadowRoot container
   *        The window and an optional shadow root that contains the element
   *
   * @returns object
   *        Returns the objects passed in by the client, with the
   *        reference IDs replaced by the actual elements.
   */
  convertWrappedArguments: function EM_convertWrappedArguments(args, container) {
    let converted;
    switch (typeof(args)) {
      case 'number':
      case 'string':
      case 'boolean':
        converted = args;
        break;
      case 'object':
        if (args == null) {
          converted = null;
        }
        else if (Object.prototype.toString.call(args) == '[object Array]') {
          converted = [];
          for (let i in args) {
            converted.push(this.convertWrappedArguments(args[i], container));
          }
        }
        else if (((typeof(args[this.elementKey]) === 'string') && args.hasOwnProperty(this.elementKey)) ||
                 ((typeof(args[this.w3cElementKey]) === 'string') &&
                     args.hasOwnProperty(this.w3cElementKey))) {
          let elementUniqueIdentifier = args[this.w3cElementKey] ? args[this.w3cElementKey] : args[this.elementKey];
          converted = this.getKnownElement(elementUniqueIdentifier, container);
          if (converted == null) {
            throw new WebDriverError(`Unknown element: ${elementUniqueIdentifier}`);
          }
        }
        else {
          converted = {};
          for (let prop in args) {
            converted[prop] = this.convertWrappedArguments(args[prop], container);
          }
        }
        break;
    }
    return converted;
  },

  /*
   * Execute* helpers
   */

  /**
   * Return an object with any namedArgs applied to it. Used
   * to let clients use given names when refering to arguments
   * in execute calls, instead of using the arguments list.
   *
   * @param object args
   *        list of arguments being passed in
   *
   * @return object
   *        If '__marionetteArgs' is in args, then
   *        it will return an object with these arguments
   *        as its members.
   */
  applyNamedArgs: function EM_applyNamedArgs(args) {
    namedArgs = {};
    args.forEach(function(arg) {
      if (arg && typeof(arg['__marionetteArgs']) === 'object') {
        for (let prop in arg['__marionetteArgs']) {
          namedArgs[prop] = arg['__marionetteArgs'][prop];
        }
      }
    });
    return namedArgs;
  },

  /**
   * Find an element or elements starting at the document root or
   * given node, using the given search strategy. Search
   * will continue until the search timelimit has been reached.
   *
   * @param nsIDOMWindow, ShadowRoot container
   *        The window and an optional shadow root that contains the element
   * @param object values
   *        The 'using' member of values will tell us which search
   *        method to use. The 'value' member tells us the value we
   *        are looking for.
   *        If this object has an 'element' member, this will be used
   *        as the start node instead of the document root
   *        If this object has a 'time' member, this number will be
   *        used to see if we have hit the search timelimit.
   * @param boolean all
   *        If true, all found elements will be returned.
   *        If false, only the first element will be returned.
   * @param function on_success
   *        Callback used when operating is successful.
   * @param function on_error
   *        Callback to invoke when an error occurs.
   *
   * @return nsIDOMElement or list of nsIDOMElements
   *        Returns the element(s) by calling the on_success function.
   */
  find: function EM_find(container, values, searchTimeout, all, on_success, on_error, command_id) {
    let startTime = values.time ? values.time : new Date().getTime();
    let rootNode = container.shadowRoot || container.frame.document;
    let startNode = (values.element != undefined) ?
                    this.getKnownElement(values.element, container) : rootNode;
    if (this.elementStrategies.indexOf(values.using) < 0) {
      throw new InvalidSelectorError("No such strategy: " + values.using);
    }
    let found = all ? this.findElements(values.using, values.value, rootNode, startNode) :
                      this.findElement(values.using, values.value, rootNode, startNode);
    let type = Object.prototype.toString.call(found);
    let isArrayLike = ((type == '[object Array]') || (type == '[object HTMLCollection]') || (type == '[object NodeList]'));
    if (found == null || (isArrayLike && found.length <= 0)) {
      if (!searchTimeout || new Date().getTime() - startTime > searchTimeout) {
        if (all) {
          on_success([], command_id); // findElements should return empty list
        } else {
          // Format message depending on strategy if necessary
          let message = "Unable to locate element: " + values.value;
          if (values.using == ANON) {
            message = "Unable to locate anonymous children";
          } else if (values.using == ANON_ATTRIBUTE) {
            message = "Unable to locate anonymous element: " + JSON.stringify(values.value);
          }
          on_error(new NoSuchElementError(message), command_id);
        }
      } else {
        values.time = startTime;
        this.timer.initWithCallback(this.find.bind(this, container, values,
                                                   searchTimeout, all,
                                                   on_success, on_error,
                                                   command_id),
                                    100,
                                    Components.interfaces.nsITimer.TYPE_ONE_SHOT);
      }
    } else {
      if (isArrayLike) {
        let ids = []
        for (let i = 0 ; i < found.length ; i++) {
          let foundElement = this.addToKnownElements(found[i]);
          let returnElement = {
            [this.elementKey] : foundElement,
            [this.w3cElementKey] : foundElement,
          };
          ids.push(returnElement);
        }
        on_success(ids, command_id);
      } else {
        let id = this.addToKnownElements(found);
        on_success({[this.elementKey]: id, [this.w3cElementKey]:id}, command_id);
      }
    }
  },

  /**
   * Find a value by XPATH
   *
   * @param nsIDOMElement root
   *        Document root
   * @param string value
   *        XPATH search string
   * @param nsIDOMElement node
   *        start node
   *
   * @return nsIDOMElement
   *        returns the found element
   */
  findByXPath: function EM_findByXPath(root, value, node) {
    return root.evaluate(value, node, null,
            Components.interfaces.nsIDOMXPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
  },

  /**
   * Find values by XPATH
   *
   * @param nsIDOMElement root
   *        Document root
   * @param string value
   *        XPATH search string
   * @param nsIDOMElement node
   *        start node
   *
   * @return object
   *        returns a list of found nsIDOMElements
   */
  findByXPathAll: function EM_findByXPathAll(root, value, node) {
    let values = root.evaluate(value, node, null,
                      Components.interfaces.nsIDOMXPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
    let elements = [];
    let element = values.iterateNext();
    while (element) {
      elements.push(element);
      element = values.iterateNext();
    }
    return elements;
  },

  /**
   * Helper method to find. Finds one element using find's criteria
   *
   * @param string using
   *        String identifying which search method to use
   * @param string value
   *        Value to look for
   * @param nsIDOMElement rootNode
   *        Document root
   * @param nsIDOMElement startNode
   *        Node from which we start searching
   *
   * @return nsIDOMElement
   *        Returns found element or throws Exception if not found
   */
  findElement: function EM_findElement(using, value, rootNode, startNode) {
    let element;
    switch (using) {
      case ID:
        element = startNode.getElementById ?
                  startNode.getElementById(value) :
                  this.findByXPath(rootNode, './/*[@id="' + value + '"]', startNode);
        break;
      case NAME:
        element = startNode.getElementsByName ?
                  startNode.getElementsByName(value)[0] :
                  this.findByXPath(rootNode, './/*[@name="' + value + '"]', startNode);
        break;
      case CLASS_NAME:
        element = startNode.getElementsByClassName(value)[0]; //works for >=FF3
        break;
      case TAG:
        element = startNode.getElementsByTagName(value)[0]; //works for all elements
        break;
      case XPATH:
        element = this.findByXPath(rootNode, value, startNode);
        break;
      case LINK_TEXT:
      case PARTIAL_LINK_TEXT:
        let allLinks = startNode.getElementsByTagName('A');
        for (let i = 0; i < allLinks.length && !element; i++) {
          let text = allLinks[i].text;
          if (PARTIAL_LINK_TEXT == using) {
            if (text.indexOf(value) != -1) {
              element = allLinks[i];
            }
          } else if (text == value) {
            element = allLinks[i];
          }
        }
        break;
      case SELECTOR:
        element = startNode.querySelector(value);
        break;
      case ANON:
        element = rootNode.getAnonymousNodes(startNode);
        if (element != null) {
          element = element[0];
        }
        break;
      case ANON_ATTRIBUTE:
        let attr = Object.keys(value)[0];
        element = rootNode.getAnonymousElementByAttribute(startNode, attr, value[attr]);
        break;
      default:
        throw new InvalidSelectorError("No such strategy: " + using);
    }
    return element;
  },

  /**
   * Helper method to find. Finds all element using find's criteria
   *
   * @param string using
   *        String identifying which search method to use
   * @param string value
   *        Value to look for
   * @param nsIDOMElement rootNode
   *        Document root
   * @param nsIDOMElement startNode
   *        Node from which we start searching
   *
   * @return nsIDOMElement
   *        Returns found elements or throws Exception if not found
   */
  findElements: function EM_findElements(using, value, rootNode, startNode) {
    let elements = [];
    switch (using) {
      case ID:
        value = './/*[@id="' + value + '"]';
      case XPATH:
        elements = this.findByXPathAll(rootNode, value, startNode);
        break;
      case NAME:
        elements = startNode.getElementsByName ?
                   startNode.getElementsByName(value) :
                   this.findByXPathAll(rootNode, './/*[@name="' + value + '"]', startNode);
        break;
      case CLASS_NAME:
        elements = startNode.getElementsByClassName(value);
        break;
      case TAG:
        elements = startNode.getElementsByTagName(value);
        break;
      case LINK_TEXT:
      case PARTIAL_LINK_TEXT:
        let allLinks = startNode.getElementsByTagName('A');
        for (let i = 0; i < allLinks.length; i++) {
          let text = allLinks[i].text;
          if (PARTIAL_LINK_TEXT == using) {
            if (text.indexOf(value) != -1) {
              elements.push(allLinks[i]);
            }
          } else if (text == value) {
            elements.push(allLinks[i]);
          }
        }
        break;
      case SELECTOR:
        elements = Array.slice(startNode.querySelectorAll(value));
        break;
      case ANON:
        elements = rootNode.getAnonymousNodes(startNode) || [];
        break;
      case ANON_ATTRIBUTE:
        let attr = Object.keys(value)[0];
        let el = rootNode.getAnonymousElementByAttribute(startNode, attr, value[attr]);
        if (el != null) {
          elements = [el];
        }
        break;
      default:
        throw new InvalidSelectorError("No such strategy: " + using);
    }
    return elements;
  },
}
