// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview Bubble implementation.
 */

// TODO(xiyuan): Move this into shared.
cr.define('cr.ui', function() {
  /**
   * Creates a bubble div.
   * @constructor
   * @extends {HTMLDivElement}
   */
  var Bubble = cr.ui.define('div');

  /**
   * Bubble key codes.
   * @enum {number}
   */
  var Keys = {
    TAB: 'Tab',
    ENTER: 'Enter',
    ESC: 'Escape',
    SPACE: ' '
  };

  /**
   * Bubble attachment side.
   * @enum {number}
   */
  Bubble.Attachment = {
    RIGHT: 0,
    LEFT: 1,
    TOP: 2,
    BOTTOM: 3
  };

  Bubble.prototype = {
    __proto__: HTMLDivElement.prototype,

    // Anchor element for this bubble.
    anchor_: undefined,

    // If defined, sets focus to this element once bubble is closed. Focus is
    // set to this element only if there's no any other focused element.
    elementToFocusOnHide_: undefined,

    // With help of these elements we create closed artificial tab-cycle through
    // bubble elements.
    firstBubbleElement_: undefined,
    lastBubbleElement_: undefined,

    // Whether to hide bubble when key is pressed.
    hideOnKeyPress_: true,

    /** @override */
    decorate: function() {
      this.docKeyDownHandler_ = this.handleDocKeyDown_.bind(this);
      this.selfClickHandler_ = this.handleSelfClick_.bind(this);
      this.ownerDocument.addEventListener('click',
                                          this.handleDocClick_.bind(this));
      // Set useCapture to true because scroll event does not bubble.
      this.ownerDocument.addEventListener('scroll',
                                          this.handleScroll_.bind(this),
                                          true);
      this.ownerDocument.addEventListener('keydown',
                                          this.docKeyDownHandler_);
      window.addEventListener('blur', this.handleWindowBlur_.bind(this));
      this.addEventListener('transitionend',
                            this.handleTransitionEnd_.bind(this));
      // Guard timer for 200ms + epsilon.
      ensureTransitionEndEvent(this, 250);
    },

    /**
     * Element that should be focused on hide.
     * @type {HTMLElement}
     */
    set elementToFocusOnHide(value) {
      this.elementToFocusOnHide_ = value;
    },

    /**
     * Element that should be focused on shift-tab of first bubble element
     * to create artificial closed tab-cycle through bubble.
     * Usually close-button.
     * @type {HTMLElement}
     */
    set lastBubbleElement(value) {
      this.lastBubbleElement_ = value;
    },

    /**
     * Element that should be focused on tab of last bubble element
     * to create artificial closed tab-cycle through bubble.
     * Same element as first focused on bubble opening.
     * @type {HTMLElement}
     */
    set firstBubbleElement(value) {
      this.firstBubbleElement_ = value;
    },

    /**
     * Whether to hide bubble when key is pressed.
     * @type {boolean}
     */
    set hideOnKeyPress(value) {
      this.hideOnKeyPress_ = value;
    },

    /**
     * Whether to hide bubble when clicked inside bubble element.
     * Default is true.
     * @type {boolean}
     */
    set hideOnSelfClick(value) {
      if (value)
        this.removeEventListener('click', this.selfClickHandler_);
      else
        this.addEventListener('click', this.selfClickHandler_);
    },

    /**
     * Handler for click event which prevents bubble auto hide.
     * @private
     */
    handleSelfClick_: function(e) {
      // Allow clicking on [x] button.
      if (e.target && e.target.classList.contains('close-button'))
        return;
      e.stopPropagation();
    },

    /**
     * Sets the attachment of the bubble.
     * @param {!Attachment} attachment Bubble attachment.
     */
    setAttachment_: function(attachment) {
      var styleClassList = ['bubble-right', 'bubble-left',
                            'bubble-top', 'bubble-bottom'];
      for (var i = 0; i < styleClassList.length; ++i)
        this.classList.toggle(styleClassList[i], i == attachment);
    },

    /**
     * Shows the bubble for given anchor element.
     * @param {!Object} pos Bubble position (left, top, right, bottom in px).
     * @param {HTMLElement} opt_content Content to show in bubble.
     *     If not specified, bubble element content is shown.
     * @param {Attachment=} opt_attachment Bubble attachment (on which side of
     *     the element it should be displayed).
     * @param {boolean=} opt_oldstyle Optional flag to force old style bubble,
     *     i.e. pre-MD-style.
     * @private
     */
    showContentAt_: function(pos, opt_content, opt_attachment, opt_oldstyle) {
      this.style.top = this.style.left = this.style.right = this.style.bottom =
          'auto';
      for (var k in pos) {
        if (typeof pos[k] == 'number')
          this.style[k] = pos[k] + 'px';
      }
      if (opt_content !== undefined)
        this.replaceContent(opt_content);

      if (opt_oldstyle) {
        this.setAttribute('oldstyle', '');
        this.setAttachment_(opt_attachment);
      }

      this.hidden = false;
      this.classList.remove('faded');
    },

    /**
     * Replaces error message content with the given DOM element.
     * @param {HTMLElement} content Content to show in bubble.
     */
    replaceContent: function(content) {
      this.innerHTML = '';
      this.appendChild(content);
    },

    /**
     * Shows the bubble for given anchor element. Bubble content is not cleared.
     * @param {!HTMLElement} el Anchor element of the bubble.
     * @param {!Attachment} attachment Bubble attachment (on which side of the
     *     element it should be displayed).
     * @param {number=} opt_offset Offset of the bubble.
     * @param {number=} opt_padding Optional padding of the bubble.
     */
    showForElement: function(el, attachment, opt_offset, opt_padding) {
      /* showForElement() is used only to display Accessibility popup in
       * oobe_screen_network*. It requires old-style bubble, so it is safe
       * to always set this flag here.
       */
      this.showContentForElement(
          el, attachment, undefined, opt_offset, opt_padding, undefined, true);
    },

    /**
     * Shows the bubble for given anchor element.
     * @param {!HTMLElement} el Anchor element of the bubble.
     * @param {!Attachment} attachment Bubble attachment (on which side of the
     *     element it should be displayed).
     * @param {HTMLElement} opt_content Content to show in bubble.
     *     If not specified, bubble element content is shown.
     * @param {number=} opt_offset Offset of the bubble attachment point from
     *     left (for vertical attachment) or top (for horizontal attachment)
     *     side of the element. If not specified, the bubble is positioned to
     *     be aligned with the left/top side of the element but not farther than
     *     half of its width/height.
     * @param {number=} opt_padding Optional padding of the bubble.
     * @param {boolean=} opt_match_width Optional flag to force the bubble have
     *     the same width as the element it it attached to.
     * @param {boolean=} opt_oldstyle Optional flag to force old style bubble,
     *     i.e. pre-MD-style.
     */
    showContentForElement: function(el, attachment, opt_content,
                                    opt_offset, opt_padding, opt_match_width,
                                    opt_oldstyle) {
      /** @const */ var ARROW_OFFSET = 25;
      /** @const */ var DEFAULT_PADDING = 18;

      if (opt_padding == undefined)
        opt_padding = DEFAULT_PADDING;

      if (!opt_oldstyle)
        opt_padding += 10;

      var origin = cr.ui.login.DisplayManager.getPosition(el);
      var offset = opt_offset == undefined ?
          [Math.min(ARROW_OFFSET, el.offsetWidth / 2),
           Math.min(ARROW_OFFSET, el.offsetHeight / 2)] :
          [opt_offset, opt_offset];

      var pos = {};
      if (isRTL()) {
        switch (attachment) {
          case Bubble.Attachment.TOP:
            pos.right = origin.right + offset[0] - ARROW_OFFSET;
            pos.bottom = origin.bottom + el.offsetHeight + opt_padding;
            break;
          case Bubble.Attachment.RIGHT:
            pos.top = origin.top + offset[1] - ARROW_OFFSET;
            pos.right = origin.right + el.offsetWidth + opt_padding;
            break;
          case Bubble.Attachment.BOTTOM:
            pos.right = origin.right + offset[0] - ARROW_OFFSET;
            pos.top = origin.top + el.offsetHeight + opt_padding;
            break;
          case Bubble.Attachment.LEFT:
            pos.top = origin.top + offset[1] - ARROW_OFFSET;
            pos.left = origin.left + el.offsetWidth + opt_padding;
            break;
        }
      } else {
        switch (attachment) {
          case Bubble.Attachment.TOP:
            pos.left = origin.left + offset[0] - ARROW_OFFSET;
            pos.bottom = origin.bottom + el.offsetHeight + opt_padding;
            break;
          case Bubble.Attachment.RIGHT:
            pos.top = origin.top + offset[1] - ARROW_OFFSET;
            pos.left = origin.left + el.offsetWidth + opt_padding;
            break;
          case Bubble.Attachment.BOTTOM:
            pos.left = origin.left + offset[0] - ARROW_OFFSET;
            pos.top = origin.top + el.offsetHeight + opt_padding;
            break;
          case Bubble.Attachment.LEFT:
            pos.top = origin.top + offset[1] - ARROW_OFFSET;
            pos.right = origin.right + el.offsetWidth + opt_padding;
            break;
        }
      }
      this.style.width = '';
      this.removeAttribute('match-width');
      if (opt_match_width) {
        this.setAttribute('match-width', '');
        var elWidth =
            window.getComputedStyle(el, null).getPropertyValue('width');
        var paddingLeft = parseInt(window.getComputedStyle(this, null)
            .getPropertyValue('padding-left'));
        var paddingRight = parseInt(window.getComputedStyle(this, null)
            .getPropertyValue('padding-right'));
        if (elWidth)
          this.style.width =
              (parseInt(elWidth) - paddingLeft - paddingRight) + 'px';
      }

      this.anchor_ = el;
      this.showContentAt_(pos, opt_content, attachment, opt_oldstyle);
    },

    /**
     * Shows the bubble for given anchor element.
     * @param {!HTMLElement} el Anchor element of the bubble.
     * @param {string} text Text content to show in bubble.
     * @param {!Attachment} attachment Bubble attachment (on which side of the
     *     element it should be displayed).
     * @param {number=} opt_offset Offset of the bubble attachment point from
     *     left (for vertical attachment) or top (for horizontal attachment)
     *     side of the element. If not specified, the bubble is positioned to
     *     be aligned with the left/top side of the element but not farther than
     *     half of its weight/height.
     * @param {number=} opt_padding Optional padding of the bubble.
     */
    showTextForElement: function(el, text, attachment,
                                 opt_offset, opt_padding) {
      var span = this.ownerDocument.createElement('span');
      span.textContent = text;
      this.showContentForElement(el, attachment, span, opt_offset, opt_padding);
    },

    /**
     * Hides the bubble.
     */
    hide: function() {
      if (!this.classList.contains('faded'))
        this.classList.add('faded');
    },

    /**
     * Hides the bubble anchored to the given element (if any).
     * @param {!Object} el Anchor element.
     */
    hideForElement: function(el) {
      if (!this.hidden && this.anchor_ == el)
        this.hide();
    },

    /**
     * Handler for faded transition end.
     * @private
     */
    handleTransitionEnd_: function(e) {
      if (this.classList.contains('faded')) {
        this.hidden = true;
        if (this.elementToFocusOnHide_)
          this.elementToFocusOnHide_.focus();
      }
    },

    /**
     * Handler of scroll event.
     * @private
     */
    handleScroll_: function(e) {
      if (!this.hidden)
        this.hide();
    },

    /**
     * Handler of document click event.
     * @private
     */
    handleDocClick_: function(e) {
      // Ignore clicks on anchor element.
      if (e.target == this.anchor_)
        return;

      if (!this.hidden)
        this.hide();
    },

    /**
     * Handle of document keydown event.
     * @private
     */
    handleDocKeyDown_: function(e) {
      if (this.hidden)
        return;

      if (this.hideOnKeyPress_) {
        this.hide();
        return;
      }
      // Artificial tab-cycle.

      if (e.key == Keys.TAB && e.shiftKey == true &&
          e.target == this.firstBubbleElement_) {
        this.lastBubbleElement_.focus();
        e.preventDefault();
      }
      if (e.key == Keys.TAB && e.shiftKey == false &&
          e.target == this.lastBubbleElement_) {
        this.firstBubbleElement_.focus();
        e.preventDefault();
      }
      // Close bubble on ESC or on hitting spacebar or Enter at close-button.
      if (e.key == Keys.ESC ||
          ((e.key == Keys.ENTER ||
            e.key == Keys.SPACE) &&
             e.target && e.target.classList.contains('close-button')))
        this.hide();
    },

    /**
     * Handler of window blur event.
     * @private
     */
    handleWindowBlur_: function(e) {
      if (!this.hidden)
        this.hide();
    }
  };

  return {
    Bubble: Bubble
  };
});
