/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview Definition of the Bubble class.
 *
 *
 * @see ../demos/bubble.html
 *
 * TODO: support decoration and addChild
 */

goog.provide('goog.ui.Bubble');

goog.require('goog.Timer');
goog.require('goog.dom.safe');
goog.require('goog.events');
goog.require('goog.events.EventType');
goog.require('goog.html.SafeHtml');
goog.require('goog.math.Box');
goog.require('goog.positioning');
goog.require('goog.positioning.AbsolutePosition');
goog.require('goog.positioning.AnchoredPosition');
goog.require('goog.positioning.Corner');
goog.require('goog.positioning.CornerBit');
goog.require('goog.string.Const');
goog.require('goog.style');
goog.require('goog.ui.Component');
goog.require('goog.ui.Popup');
goog.requireType('goog.dom.DomHelper');
goog.requireType('goog.positioning.AbstractPosition');


/**
 * The Bubble provides a general purpose bubble implementation that can be
 * anchored to a particular element and displayed for a period of time.
 *
 * @param {string|!goog.html.SafeHtml|?Element} message Message or an element
 *     to display inside the bubble. Strings are treated as plain-text and will
 *     be HTML escaped.
 * @param {Object=} opt_config The configuration
 *     for the bubble. If not specified, the default configuration will be
 *     used. {@see goog.ui.Bubble.defaultConfig}.
 * @param {goog.dom.DomHelper=} opt_domHelper Optional DOM helper.
 * @constructor
 * @extends {goog.ui.Component}
 */
goog.ui.Bubble = function(message, opt_config, opt_domHelper) {
  'use strict';
  goog.ui.Component.call(this, opt_domHelper);

  if (typeof message === 'string') {
    message = goog.html.SafeHtml.htmlEscape(message);
  }

  /**
   * The HTML string or element to display inside the bubble.
   *
   * @type {!goog.html.SafeHtml|Element}
   * @private
   */
  this.message_ = message;

  /**
   * The Popup element used to position and display the bubble.
   *
   * @type {goog.ui.Popup}
   * @private
   */
  this.popup_ = new goog.ui.Popup();

  /**
   * Configuration map that contains bubble's UI elements.
   *
   * @type {Object}
   * @private
   */
  this.config_ = opt_config || goog.ui.Bubble.defaultConfig;

  /**
   * Id of the close button for this bubble.
   *
   * @type {string}
   * @private
   */
  this.closeButtonId_ = this.makeId('cb');

  /**
   * Id of the div for the embedded element.
   *
   * @type {string}
   * @private
   */
  this.messageId_ = this.makeId('mi');
};
goog.inherits(goog.ui.Bubble, goog.ui.Component);


/**
 * In milliseconds, timeout after which the button auto-hides. Null means
 * infinite.
 * @type {?number}
 * @private
 */
goog.ui.Bubble.prototype.timeout_ = null;


/**
 * Key returned by the bubble timer.
 * @type {?number}
 * @private
 */
goog.ui.Bubble.prototype.timerId_ = 0;


/**
 * Key returned by the listen function for the close button.
 * @type {?goog.events.Key}
 * @private
 */
goog.ui.Bubble.prototype.listener_ = null;



/** @override */
goog.ui.Bubble.prototype.createDom = function() {
  'use strict';
  goog.ui.Bubble.superClass_.createDom.call(this);

  var element = this.getElement();
  element.style.position = 'absolute';
  element.style.visibility = 'hidden';

  this.popup_.setElement(element);
};


/**
 * Attaches the bubble to an anchor element. Computes the positioning and
 * orientation of the bubble.
 *
 * @param {Element} anchorElement The element to which we are attaching.
 */
goog.ui.Bubble.prototype.attach = function(anchorElement) {
  'use strict';
  this.setAnchoredPosition_(
      anchorElement, this.computePinnedCorner_(anchorElement));
};


/**
 * Sets the corner of the bubble to used in the positioning algorithm.
 *
 * @param {goog.positioning.Corner} corner The bubble corner used for
 *     positioning constants.
 */
goog.ui.Bubble.prototype.setPinnedCorner = function(corner) {
  'use strict';
  this.popup_.setPinnedCorner(corner);
};


/**
 * Sets the position of the bubble. Pass null for corner in AnchoredPosition
 * for corner to be computed automatically.
 *
 * @param {goog.positioning.AbstractPosition} position The position of the
 *     bubble.
 */
goog.ui.Bubble.prototype.setPosition = function(position) {
  'use strict';
  if (position instanceof goog.positioning.AbsolutePosition) {
    this.popup_.setPosition(position);
  } else if (position instanceof goog.positioning.AnchoredPosition) {
    this.setAnchoredPosition_(position.element, position.corner);
  } else {
    throw new Error('Bubble only supports absolute and anchored positions!');
  }
};


/**
 * Sets the timeout after which bubble hides itself.
 *
 * @param {number} timeout Timeout of the bubble.
 */
goog.ui.Bubble.prototype.setTimeout = function(timeout) {
  'use strict';
  this.timeout_ = timeout;
};


/**
 * Sets whether the bubble should be automatically hidden whenever user clicks
 * outside the bubble element.
 *
 * @param {boolean} autoHide Whether to hide if user clicks outside the bubble.
 */
goog.ui.Bubble.prototype.setAutoHide = function(autoHide) {
  'use strict';
  this.popup_.setAutoHide(autoHide);
};


/**
 * Sets whether the bubble should be visible.
 *
 * @param {boolean} visible Desired visibility state.
 */
goog.ui.Bubble.prototype.setVisible = function(visible) {
  'use strict';
  if (visible && !this.popup_.isVisible()) {
    this.configureElement_();
  }
  this.popup_.setVisible(visible);
  if (!this.popup_.isVisible()) {
    this.unconfigureElement_();
  }
};


/**
 * @return {boolean} Whether the bubble is visible.
 */
goog.ui.Bubble.prototype.isVisible = function() {
  'use strict';
  return this.popup_.isVisible();
};


/** @override */
goog.ui.Bubble.prototype.disposeInternal = function() {
  'use strict';
  this.unconfigureElement_();
  this.popup_.dispose();
  this.popup_ = null;
  goog.ui.Bubble.superClass_.disposeInternal.call(this);
};


/**
 * Creates element's contents and configures all timers. This is called on
 * setVisible(true).
 * @private
 */
goog.ui.Bubble.prototype.configureElement_ = function() {
  'use strict';
  if (!this.isInDocument()) {
    throw new Error('You must render the bubble before showing it!');
  }

  var element = this.getElement();
  var corner = this.popup_.getPinnedCorner();
  goog.dom.safe.setInnerHtml(
      /** @type {!Element} */ (element), this.computeHtmlForCorner_(corner));

  if (!(this.message_ instanceof goog.html.SafeHtml)) {
    var messageDiv = this.getDomHelper().getElement(this.messageId_);
    this.getDomHelper().appendChild(messageDiv, this.message_);
  }
  var closeButton = this.getDomHelper().getElement(this.closeButtonId_);
  this.listener_ = goog.events.listen(
      closeButton, goog.events.EventType.CLICK, this.hideBubble_, false, this);

  if (this.timeout_) {
    this.timerId_ = goog.Timer.callOnce(this.hideBubble_, this.timeout_, this);
  }
};


/**
 * Gets rid of the element's contents and all associated timers and listeners.
 * This is called on dispose as well as on setVisible(false).
 * @private
 */
goog.ui.Bubble.prototype.unconfigureElement_ = function() {
  'use strict';
  if (this.listener_) {
    goog.events.unlistenByKey(this.listener_);
    this.listener_ = null;
  }
  if (this.timerId_) {
    goog.Timer.clear(this.timerId_);
    this.timerId_ = null;
  }

  var element = this.getElement();
  if (element) {
    this.getDomHelper().removeChildren(element);
    goog.dom.safe.setInnerHtml(element, goog.html.SafeHtml.EMPTY);
  }
};


/**
 * Computes bubble position based on anchored element.
 *
 * @param {Element} anchorElement The element to which we are attaching.
 * @param {goog.positioning.Corner} corner The bubble corner used for
 *     positioning.
 * @private
 */
goog.ui.Bubble.prototype.setAnchoredPosition_ = function(
    anchorElement, corner) {
  'use strict';
  this.popup_.setPinnedCorner(corner);
  var margin = this.createMarginForCorner_(corner);
  this.popup_.setMargin(margin);
  var anchorCorner = goog.positioning.flipCorner(corner);
  this.popup_.setPosition(
      new goog.positioning.AnchoredPosition(anchorElement, anchorCorner));
};


/**
 * Hides the bubble. This is called asynchronously by timer of event processor
 * for the mouse click on the close button.
 * @private
 */
goog.ui.Bubble.prototype.hideBubble_ = function() {
  'use strict';
  this.setVisible(false);
};


/**
 * Returns an AnchoredPosition that will position the bubble optimally
 * given the position of the anchor element and the size of the viewport.
 *
 * @param {Element} anchorElement The element to which the bubble is attached.
 * @return {!goog.positioning.AnchoredPosition} The AnchoredPosition
 *     to give to {@link #setPosition}.
 */
goog.ui.Bubble.prototype.getComputedAnchoredPosition = function(anchorElement) {
  'use strict';
  return new goog.positioning.AnchoredPosition(
      anchorElement, this.computePinnedCorner_(anchorElement));
};


/**
 * Computes the pinned corner for the bubble.
 * @param {Element} anchorElement The element to which the button is attached.
 * @return {goog.positioning.Corner} The pinned corner.
 * @private
 * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration
 */
goog.ui.Bubble.prototype.computePinnedCorner_ = function(anchorElement) {
  'use strict';
  var doc = this.getDomHelper().getOwnerDocument(anchorElement);
  var viewportElement = goog.style.getClientViewportElement(doc);
  var viewportWidth = viewportElement.offsetWidth;
  var viewportHeight = viewportElement.offsetHeight;
  var anchorElementOffset = goog.style.getPageOffset(anchorElement);
  var anchorElementSize = goog.style.getSize(anchorElement);
  var anchorType = 0;
  // right margin or left?
  if (viewportWidth - anchorElementOffset.x - anchorElementSize.width >
      anchorElementOffset.x) {
    anchorType += 1;
  }
  // attaches to the top or to the bottom?
  if (viewportHeight - anchorElementOffset.y - anchorElementSize.height >
      anchorElementOffset.y) {
    anchorType += 2;
  }
  return goog.ui.Bubble.corners_[anchorType];
};


/**
 * Computes the right offset for a given bubble corner
 * and creates a margin element for it. This is done to have the
 * button anchor element on its frame rather than on the corner.
 * @param {goog.positioning.Corner} corner The corner.
 * @return {!goog.math.Box} the computed margin. Only left or right fields are
 *     non-zero, but they may be negative.
 * @private
 * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration
 */
goog.ui.Bubble.prototype.createMarginForCorner_ = function(corner) {
  'use strict';
  var margin = new goog.math.Box(0, 0, 0, 0);
  if (corner & goog.positioning.CornerBit.RIGHT) {
    margin.right -= this.config_.marginShift;
  } else {
    margin.left -= this.config_.marginShift;
  }
  return margin;
};


/**
 * Computes the HTML string for a given bubble orientation.
 * @param {goog.positioning.Corner} corner The corner.
 * @return {!goog.html.SafeHtml} The HTML string to place inside the
 *     bubble's popup.
 * @private
 * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration
 */
goog.ui.Bubble.prototype.computeHtmlForCorner_ = function(corner) {
  'use strict';
  var bubbleTopClass;
  var bubbleBottomClass;
  switch (corner) {
    case goog.positioning.Corner.TOP_LEFT:
      bubbleTopClass = this.config_.cssBubbleTopLeftAnchor;
      bubbleBottomClass = this.config_.cssBubbleBottomNoAnchor;
      break;
    case goog.positioning.Corner.TOP_RIGHT:
      bubbleTopClass = this.config_.cssBubbleTopRightAnchor;
      bubbleBottomClass = this.config_.cssBubbleBottomNoAnchor;
      break;
    case goog.positioning.Corner.BOTTOM_LEFT:
      bubbleTopClass = this.config_.cssBubbleTopNoAnchor;
      bubbleBottomClass = this.config_.cssBubbleBottomLeftAnchor;
      break;
    case goog.positioning.Corner.BOTTOM_RIGHT:
      bubbleTopClass = this.config_.cssBubbleTopNoAnchor;
      bubbleBottomClass = this.config_.cssBubbleBottomRightAnchor;
      break;
    default:
      throw new Error('This corner type is not supported by bubble!');
  }
  var message = null;
  if (this.message_ instanceof goog.html.SafeHtml) {
    message = this.message_;
  } else {
    message = goog.html.SafeHtml.create('div', {'id': this.messageId_});
  }

  var tableRows = goog.html.SafeHtml.concat(
      goog.html.SafeHtml.create(
          'tr', {},
          goog.html.SafeHtml.create(
              'td', {'colspan': 4, 'class': bubbleTopClass})),
      goog.html.SafeHtml.create(
          'tr', {},
          goog.html.SafeHtml.concat(
              goog.html.SafeHtml.create(
                  'td', {'class': this.config_.cssBubbleLeft}),
              goog.html.SafeHtml.create(
                  'td', {
                    'class': this.config_.cssBubbleFont,
                    'style':
                        goog.string.Const.from('padding:0 4px;background:white')
                  },
                  message),
              goog.html.SafeHtml.create('td', {
                'id': this.closeButtonId_,
                'class': this.config_.cssCloseButton
              }),
              goog.html.SafeHtml.create(
                  'td', {'class': this.config_.cssBubbleRight}))),
      goog.html.SafeHtml.create(
          'tr', {},
          goog.html.SafeHtml.create(
              'td', {'colspan': 4, 'class': bubbleBottomClass})));

  return goog.html.SafeHtml.create(
      'table', {
        'border': 0,
        'cellspacing': 0,
        'cellpadding': 0,
        'width': this.config_.bubbleWidth,
        'style': goog.string.Const.from('z-index:1')
      },
      tableRows);
};


/**
 * A default configuration for the bubble.
 *
 * @type {Object}
 */
goog.ui.Bubble.defaultConfig = {
  bubbleWidth: 147,
  marginShift: 60,
  cssBubbleFont: goog.getCssName('goog-bubble-font'),
  cssCloseButton: goog.getCssName('goog-bubble-close-button'),
  cssBubbleTopRightAnchor: goog.getCssName('goog-bubble-top-right-anchor'),
  cssBubbleTopLeftAnchor: goog.getCssName('goog-bubble-top-left-anchor'),
  cssBubbleTopNoAnchor: goog.getCssName('goog-bubble-top-no-anchor'),
  cssBubbleBottomRightAnchor:
      goog.getCssName('goog-bubble-bottom-right-anchor'),
  cssBubbleBottomLeftAnchor: goog.getCssName('goog-bubble-bottom-left-anchor'),
  cssBubbleBottomNoAnchor: goog.getCssName('goog-bubble-bottom-no-anchor'),
  cssBubbleLeft: goog.getCssName('goog-bubble-left'),
  cssBubbleRight: goog.getCssName('goog-bubble-right')
};


/**
 * An auxiliary array optimizing the corner computation.
 *
 * @type {Array<goog.positioning.Corner>}
 * @private
 */
goog.ui.Bubble.corners_ = [
  goog.positioning.Corner.BOTTOM_RIGHT, goog.positioning.Corner.BOTTOM_LEFT,
  goog.positioning.Corner.TOP_RIGHT, goog.positioning.Corner.TOP_LEFT
];
