// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC licenses this file
// to you 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.

/**
 * @fileoverview Chrome specific atoms.
 *
 */

goog.provide('webdriver.chrome');

goog.require('bot.dom');
goog.require('bot.locators');
goog.require('goog.dom');
goog.require('goog.math.Coordinate');
goog.require('goog.math.Rect');
goog.require('goog.math.Size');
goog.require('goog.style');

/**
 * True if shadow dom is enabled.
 * @const
 * @type {boolean}
 */
var SHADOW_DOM_ENABLED = typeof ShadowRoot === 'function';

/**
 * Returns the minimum required offsets to scroll a given region into view.
 * If the region is larger than the scrollable view, the region will be
 * centered or aligned with the top-left of the scrollable view, depending
 * on the value of "center".
 *
 * @param {!goog.math.Size} size The size of the scrollable view.
 * @param {!goog.math.Rect} region The region of the scrollable to bring into
 *     view.
 * @param {boolean} center If true, when the region is too big to view,
 *     center it instead of aligning with the top-left.
 * @return {!goog.math.Coordinate} Offset by which to scroll.
 * @private
 */
webdriver.chrome.computeScrollOffsets_ = function(size, region,
                                                  center) {
  var scroll = [0, 0];
  var scrollableSize = [size.width, size.height];
  var regionLoc = [region.left, region.top];
  var regionSize = [region.width, region.height];

  for (var i = 0; i < 2; i++) {
    if (regionSize[i] > scrollableSize[i]) {
      if (center)
        scroll[i] = regionLoc[i] + regionSize[i] / 2 - scrollableSize[i] / 2;
      else
        scroll[i] = regionLoc[i];
    } else {
      var alignRight = regionLoc[i] - scrollableSize[i] + regionSize[i];
      if (alignRight > 0)
        scroll[i] = alignRight;
      else if (regionLoc[i] < 0)
        scroll[i] = regionLoc[i];
    }
  }

  return new goog.math.Coordinate(scroll[0], scroll[1]);
};


/**
 * Return the offset of the given element from its container.
 *
 * @param {!Element} container The container.
 * @param {!Element} elem The element.
 * @return {!goog.math.Coordinate} The offset.
 * @private
 */
webdriver.chrome.computeOffsetInContainer_ = function(container, elem) {
  var offset = goog.math.Coordinate.difference(
      goog.style.getPageOffset(elem), goog.style.getPageOffset(container));
  var containerBorder = goog.style.getBorderBox(container);
  offset.x -= containerBorder.left;
  offset.y -= containerBorder.top;
  return offset;
};


/**
 * Scrolls the region of an element into view. If the region will not fit,
 * it will be aligned at the top-left or centered, depending on
 * "center".
 *
 * @param {!Element} elem The element with the region to scroll into view.
 * @param {!goog.math.Rect} region The region, relative to the element's
 *     border box, to scroll into view.
 * @param {boolean} center If true, when the region is too big to view,
 *     center it instead of aligning with the top-left.
 * @private
 */
webdriver.chrome.scrollIntoView_ = function(elem, region, center) {
  function scrollHelper(scrollable, size, offset, region, center) {
    region = new goog.math.Rect(
        offset.x + region.left, offset.y + region.top,
        region.width, region.height);

    var scroll = webdriver.chrome.computeScrollOffsets_(size, region, center);
    scrollable.scrollLeft += scroll.x;
    scrollable.scrollTop += scroll.y;
  }

  function getContainer(elem) {
    var container = elem.parentNode;
    if (SHADOW_DOM_ENABLED && (container instanceof ShadowRoot)) {
      container = elem.host;
    }
    return container;
  }

  var doc = goog.dom.getOwnerDocument(elem);
  var container = getContainer(elem);
  var offset;
  while (container &&
         container != doc.documentElement &&
         container != doc.body) {
    offset = webdriver.chrome.computeOffsetInContainer_(
        /** @type {!Element} */ (container), elem);
    var containerSize = new goog.math.Size(container.clientWidth,
                                           container.clientHeight);
    scrollHelper(container, containerSize, offset, region, center);
    container = getContainer(container);
  }

  offset = goog.style.getClientPosition(elem);
  var windowSize = goog.dom.getDomHelper(elem).getViewportSize();
  // Chrome uses either doc.documentElement or doc.body, depending on
  // compatibility settings. For reliability, call scrollHelper on both.
  // Calling scrollHelper on the wrong object is harmless.
  scrollHelper(doc.documentElement, windowSize, offset, region, center);
  if (doc.body)
    scrollHelper(doc.body, windowSize, offset, region, center);
};


/**
 * Scrolls a region of the given element into the client's view and returns
 * its position relative to the client viewport. If the element or region is too
 * large to fit in the view, it will be centered or aligned to the top-left,
 * depending on the value of "center".
 *
 * scrollIntoView is not used because it does not work correctly in Chrome:
 *     http://crbug.com/73953.
 *
 * The element should be attached to the current document.
 *
 * @param {!Element} elem The element to use.
 * @param {boolean} center If true, center the region when it is too big
 *     to fit in the view.
 * @param {!goog.math.Rect} opt_region The region relative to the element's
 *     border box to be scrolled into view. If null, the border box will be
 *     used.
 * @return {!goog.math.Coordinate} The top-left coordinate of the element's
 *     region in client space.
 */
webdriver.chrome.getLocationInView = function(elem, center, opt_region) {
  var region = opt_region;
  if (!region)
    region = new goog.math.Rect(0, 0, elem.offsetWidth, elem.offsetHeight);

  if (elem != elem.ownerDocument.documentElement)
    webdriver.chrome.scrollIntoView_(elem, region, center);

  var elemClientPos = goog.style.getClientPosition(elem);
  return new goog.math.Coordinate(
      elemClientPos.x + region.left, elemClientPos.y + region.top);
};


/**
 * Returns the first client rect of the given element, relative to the
 * element's border box. If the element does not have any client rects,
 * throws an error.
 *
 * @param {!Element} elem The element to use.
 * @return {!goog.math.Rect} The first client rect of the given element,
 *     relative to the element's border box.
 */
webdriver.chrome.getFirstClientRect = function(elem) {
  var clientRects = elem.getClientRects();
  if (clientRects.length == 0)
    throw new Error('Element does not have any client rects');
  var clientRect = clientRects[0];
  var clientPos = goog.style.getClientPosition(elem);
  return new goog.math.Rect(
      clientRect.left - clientPos.x, clientRect.top - clientPos.y,
      clientRect.right - clientRect.left, clientRect.bottom - clientRect.top);
};


/**
 * Returns whether the element or any of its descendants would receive a click
 * at the given location. Useful for debugging test clicking issues.
 *
 * @param {!Element} elem The element to use.
 * @param {!goog.math.Coordinate} coord The coordinate to use.
 * @return {{clickable:boolean, message: (string|undefined)}} Object containing
 *     a boolean "clickable" property, as to whether it can be clicked, and an
 *     optional "message" string property, which contains any warning/error
 *     message.
 */
webdriver.chrome.isElementClickable = function(elem, coord) {
  /**
   * @param {boolean} clickable .
   * @param {string=} opt_msg .
   * @return {{clickable: boolean, message: (string|undefined)}} .
   */
  function makeResult(clickable, opt_msg) {
    var dict = {'clickable': clickable};
    if (opt_msg)
      dict['message'] = opt_msg;
    return dict;
  }

  // get the outermost ancestor of the element. This will be either the document
  // or a shadow root.
  var owner = elem;
  while (owner.parentNode) {
    owner = owner.parentNode;
  }

  var elemAtPoint = owner.elementFromPoint(coord.x, coord.y);
  if (elemAtPoint == elem)
    return makeResult(true);

  var coordStr = '(' + coord.x + ', ' + coord.y + ')';
  if (elemAtPoint == null) {
    return makeResult(
        false, 'Element is not clickable at point ' + coordStr);
  }
  var elemAtPointHTML = elemAtPoint.outerHTML.replace(elemAtPoint.innerHTML, 
                                                      elemAtPoint.hasChildNodes() 
                                                      ? '...' : '');
  var parentElemIter = elemAtPoint.parentNode;
  while (parentElemIter) {
    if (parentElemIter == elem) {
      return makeResult(
          true,
          'Element\'s descendant would receive the click. Consider ' +
              'clicking the descendant instead. Descendant: ' +
                  elemAtPointHTML);
    }
    parentElemIter = parentElemIter.parentNode;
  }
  var elemHTML = elem.outerHTML.replace(elem.innerHTML, 
                                        elem.hasChildNodes() ? '...' : '');
  return makeResult(
      false,
      'Element ' + elemHTML + ' is not clickable at point '
      + coordStr + '. Other element ' +
      'would receive the click: ' + elemAtPointHTML);
};


/**
 * Returns the current page zoom ratio for the page with the given element.
 *
 * @param {!Element} elem The element to use.
 * @return {number} Page zoom ratio.
 */
webdriver.chrome.getPageZoom = function(elem) {
  // From http://stackoverflow.com/questions/1713771/
  //     how-to-detect-page-zoom-level-in-all-modern-browsers
  var doc = goog.dom.getOwnerDocument(elem);
  var docElem = doc.documentElement;
  var width = Math.max(
      docElem.clientWidth, docElem.offsetWidth, docElem.scrollWidth);
  return doc.width / width;
};

/**
 * Determines whether an element is what a user would call "shown". Mainly based
 * on bot.dom.isShown, but with extra intelligence regarding shadow DOM.
 *
 * @param {!Element} elem The element to consider.
 * @param {boolean=} opt_inComposedDom Whether to check if the element is shown
 *     within the composed DOM; defaults to false.
 * @param {boolean=} opt_ignoreOpacity Whether to ignore the element's opacity
 *     when determining whether it is shown; defaults to false.
 * @return {boolean} Whether or not the element is visible.
 */
webdriver.chrome.isElementDisplayed = function(elem,
                                               opt_inComposedDom,
                                               opt_ignoreOpacity) {
  if (!bot.dom.isShown(elem, opt_ignoreOpacity)) {
    return false;
  }
  // if it's not invisible then check if the element is within the shadow DOM
  // of an invisible element, using recursive calls to this function
  if (SHADOW_DOM_ENABLED) {
    var topLevelNode = elem;
    while (topLevelNode.parentNode) {
      topLevelNode = topLevelNode.parentNode;
    }
    if (topLevelNode instanceof ShadowRoot) {
      return webdriver.chrome.isElementDisplayed(topLevelNode.host,
                                                 opt_inComposedDom);
    }
  }
  // if it's not invisible, or in a shadow DOM, then it's definitely visible
  return true;
};

/**
 * Same as bot.locators.findElement (description copied below), but
 * with workarounds for shadow DOM.
 *
 * Find the first element in the DOM matching the target. The target
 * object should have a single key, the name of which determines the
 * locator strategy and the value of which gives the value to be
 * searched for. For example {id: 'foo'} indicates that the first
 * element on the DOM with the ID 'foo' should be returned.
 *
 * @param {!Object} target The selector to search for.
 * @param {(Document|Element)=} opt_root The node from which to start the
 *     search. If not specified, will use {@code document} as the root.
 * @return {Element} The first matching element found in the DOM, or null if no
 *     such element could be found.
 */
webdriver.chrome.findElement = function(target, opt_root) {
  // This works fine if opt_root is outside of a shadow DOM, but for various
  // (presumably performance-based) reasons, it works by getting opt_root's
  // owning document, searching that, and then checking if the result is owned
  // by opt_root. Searching the owning document for a child of a shadow root
  // obviously doesn't work. However we try the performance-optimised version
  // first...
  var elem = bot.locators.findElement(target, opt_root);
  if (elem) {
    return elem;
  }
  // If we didn't find anything using that method, check to see if opt_root
  // is within a shadow DOM...
  if (SHADOW_DOM_ENABLED && opt_root) {
    var topLevelNode = opt_root;
    while (topLevelNode.parentNode) {
      topLevelNode = topLevelNode.parentNode;
    }
    if (topLevelNode instanceof ShadowRoot) {
      // findElement_s_ works fine if passed an root that's in a shadow root.
      elem = bot.locators.findElements(target, opt_root)[0];
      if (elem) {
        return elem;
      }
    }
  }
  return null;
};
