/**
 * @ngdoc module
 * @name material.components.tooltip
 */
angular
    .module('material.components.tooltip', [
      'material.core',
      'material.components.panel'
    ])
    .directive('mdTooltip', MdTooltipDirective)
    .service('$$mdTooltipRegistry', MdTooltipRegistry);


/**
 * @ngdoc directive
 * @name mdTooltip
 * @module material.components.tooltip
 * @description
 * Tooltips are used to describe elements that are interactive and primarily
 * graphical (not textual).
 *
 * Place a `<md-tooltip>` as a child of the element it describes.
 *
 * A tooltip will activate when the user hovers over, focuses, or touches the
 * parent element.
 *
 * @usage
 * <hljs lang="html">
 *   <md-button class="md-fab md-accent" aria-label="Play">
 *     <md-tooltip>Play Music</md-tooltip>
 *     <md-icon md-svg-src="img/icons/ic_play_arrow_24px.svg"></md-icon>
 *   </md-button>
 * </hljs>
 *
 * @param {number=} md-z-index The visual level that the tooltip will appear
 *     in comparison with the rest of the elements of the application.
 * @param {expression=} md-visible Boolean bound to whether the tooltip is
 *     currently visible.
 * @param {number=} md-delay How many milliseconds to wait to show the tooltip
 *     after the user hovers over, focuses, or touches the parent element.
 *     Defaults to 0ms on non-touch devices and 75ms on touch.
 * @param {boolean=} md-autohide If present or provided with a boolean value,
 *     the tooltip will hide on mouse leave, regardless of focus.
 * @param {string=} md-direction The direction that the tooltip is shown,
 *     relative to the parent element. Supports top, right, bottom, and left.
 *     Defaults to bottom.
 */
function MdTooltipDirective($timeout, $window, $$rAF, $document, $interpolate,
    $mdUtil, $mdPanel, $$mdTooltipRegistry) {

  var ENTER_EVENTS = 'focus touchstart mouseenter';
  var LEAVE_EVENTS = 'blur touchcancel mouseleave';
  var TOOLTIP_DEFAULT_Z_INDEX = 100;
  var TOOLTIP_DEFAULT_SHOW_DELAY = 0;
  var TOOLTIP_DEFAULT_DIRECTION = 'bottom';
  var TOOLTIP_DIRECTIONS = {
    top: { x: $mdPanel.xPosition.CENTER, y: $mdPanel.yPosition.ABOVE },
    right: { x: $mdPanel.xPosition.OFFSET_END, y: $mdPanel.yPosition.CENTER },
    bottom: { x: $mdPanel.xPosition.CENTER, y: $mdPanel.yPosition.BELOW },
    left: { x: $mdPanel.xPosition.OFFSET_START, y: $mdPanel.yPosition.CENTER }
  };

  return {
    restrict: 'E',
    priority: 210, // Before ngAria
    scope: {
      mdZIndex: '=?mdZIndex',
      mdDelay: '=?mdDelay',
      mdVisible: '=?mdVisible',
      mdAutohide: '=?mdAutohide',
      mdDirection: '@?mdDirection' // Do not expect expressions.
    },
    link: linkFunc
  };

  function linkFunc(scope, element, attr) {
    // Set constants.
    var parent = $mdUtil.getParentWithPointerEvents(element);
    var debouncedOnResize = $$rAF.throttle(updatePosition);
    var mouseActive = false;
    var origin, position, panelPosition, panelRef, autohide, showTimeout,
        elementFocusedOnWindowBlur = null;

    // Set defaults
    setDefaults();

    // Set parent aria-label.
    addAriaLabel();

    // Remove the element from its current DOM position.
    element.detach();

    updatePosition();
    bindEvents();
    configureWatchers();

    function setDefaults() {
      scope.mdZIndex = scope.mdZIndex || TOOLTIP_DEFAULT_Z_INDEX;
      scope.mdDelay = scope.mdDelay || TOOLTIP_DEFAULT_SHOW_DELAY;
      if (!TOOLTIP_DIRECTIONS[scope.mdDirection]) {
        scope.mdDirection = TOOLTIP_DEFAULT_DIRECTION;
      }
    }

    function addAriaLabel(override) {
      if (override || !parent.attr('aria-label')) {
        // Only interpolate the text from the HTML element because otherwise the custom text
        // could be interpolated twice and cause XSS violations.
        var interpolatedText = override || $interpolate(element.text().trim())(scope.$parent);
        parent.attr('aria-label', interpolatedText);
      }
    }

    function updatePosition() {
      setDefaults();

      // If the panel has already been created, remove the current origin
      // class from the panel element.
      if (panelRef && panelRef.panelEl) {
        panelRef.panelEl.removeClass(origin);
      }

      // Set the panel element origin class based off of the current
      // mdDirection.
      origin = 'md-origin-' + scope.mdDirection;

      // Create the position of the panel based off of the mdDirection.
      position = TOOLTIP_DIRECTIONS[scope.mdDirection];

      // Using the newly created position object, use the MdPanel
      // panelPosition API to build the panel's position.
      panelPosition = $mdPanel.newPanelPosition()
          .relativeTo(parent)
          .addPanelPosition(position.x, position.y);

      // If the panel has already been created, add the new origin class to
      // the panel element and update it's position with the panelPosition.
      if (panelRef && panelRef.panelEl) {
        panelRef.panelEl.addClass(origin);
        panelRef.updatePosition(panelPosition);
      }
    }

    function bindEvents() {
      // Add a mutationObserver where there is support for it and the need
      // for it in the form of viable host(parent[0]).
      if (parent[0] && 'MutationObserver' in $window) {
        // Use a mutationObserver to tackle #2602.
        var attributeObserver = new MutationObserver(function(mutations) {
          if (isDisabledMutation(mutations)) {
            $mdUtil.nextTick(function() {
              setVisible(false);
            });
          }
        });

        attributeObserver.observe(parent[0], {
          attributes: true
        });
      }

      elementFocusedOnWindowBlur = false;

      $$mdTooltipRegistry.register('scroll', windowScrollEventHandler, true);
      $$mdTooltipRegistry.register('blur', windowBlurEventHandler);
      $$mdTooltipRegistry.register('resize', debouncedOnResize);

      scope.$on('$destroy', onDestroy);

      // To avoid 'synthetic clicks', we listen to mousedown instead of
      // 'click'.
      parent.on('mousedown', mousedownEventHandler);
      parent.on(ENTER_EVENTS, enterEventHandler);

      function isDisabledMutation(mutations) {
        mutations.some(function(mutation) {
          return mutation.attributeName === 'disabled' && parent[0].disabled;
        });
        return false;
      }

      function windowScrollEventHandler() {
        setVisible(false);
      }

      function windowBlurEventHandler() {
        elementFocusedOnWindowBlur = document.activeElement === parent[0];
      }

      function enterEventHandler($event) {
        // Prevent the tooltip from showing when the window is receiving
        // focus.
        if ($event.type === 'focus' && elementFocusedOnWindowBlur) {
          elementFocusedOnWindowBlur = false;
        } else if (!scope.mdVisible) {
          parent.on(LEAVE_EVENTS, leaveEventHandler);
          setVisible(true);

          // If the user is on a touch device, we should bind the tap away
          // after the 'touched' in order to prevent the tooltip being
          // removed immediately.
          if ($event.type === 'touchstart') {
            parent.one('touchend', function() {
              $mdUtil.nextTick(function() {
                $document.one('touchend', leaveEventHandler);
              }, false);
            });
          }
        }
      }

      function leaveEventHandler() {
        autohide = scope.hasOwnProperty('mdAutohide') ?
            scope.mdAutohide :
            attr.hasOwnProperty('mdAutohide');

        if (autohide || mouseActive ||
            $document[0].activeElement !== parent[0]) {
          // When a show timeout is currently in progress, then we have
          // to cancel it, otherwise the tooltip will remain showing
          // without focus or hover.
          if (showTimeout) {
            $timeout.cancel(showTimeout);
            setVisible.queued = false;
            showTimeout = null;
          }

          parent.off(LEAVE_EVENTS, leaveEventHandler);
          parent.triggerHandler('blur');
          setVisible(false);
        }
        mouseActive = false;
      }

      function mousedownEventHandler() {
        mouseActive = true;
      }

      function onDestroy() {
        $$mdTooltipRegistry.deregister('scroll', windowScrollEventHandler, true);
        $$mdTooltipRegistry.deregister('blur', windowBlurEventHandler);
        $$mdTooltipRegistry.deregister('resize', debouncedOnResize);

        parent
            .off(ENTER_EVENTS, enterEventHandler)
            .off(LEAVE_EVENTS, leaveEventHandler)
            .off('mousedown', mousedownEventHandler);

        // Trigger the handler in case any of the tooltips are
        // still visible.
        leaveEventHandler();
        attributeObserver && attributeObserver.disconnect();
      }
    }

    function configureWatchers() {
      if (element[0] && 'MutationObserver' in $window) {
        var attributeObserver = new MutationObserver(function(mutations) {
          mutations.forEach(function(mutation) {
            if (mutation.attributeName === 'md-visible' &&
                !scope.visibleWatcher ) {
              scope.visibleWatcher = scope.$watch('mdVisible',
                  onVisibleChanged);
            }
          });
        });

        attributeObserver.observe(element[0], {
          attributes: true
        });

        // Build watcher only if mdVisible is being used.
        if (attr.hasOwnProperty('mdVisible')) {
          scope.visibleWatcher = scope.$watch('mdVisible',
              onVisibleChanged);
        }
      } else {
        // MutationObserver not supported
        scope.visibleWatcher = scope.$watch('mdVisible', onVisibleChanged);
      }

      // Direction watcher
      scope.$watch('mdDirection', updatePosition);

      // Clean up if the element or parent was removed via jqLite's .remove.
      // A couple of notes:
      //   - In these cases the scope might not have been destroyed, which
      //     is why we destroy it manually. An example of this can be having
      //     `md-visible="false"` and adding tooltips while they're
      //     invisible. If `md-visible` becomes true, at some point, you'd
      //     usually get a lot of tooltips.
      //   - We use `.one`, not `.on`, because this only needs to fire once.
      //     If we were using `.on`, it would get thrown into an infinite
      //     loop.
      //   - This kicks off the scope's `$destroy` event which finishes the
      //     cleanup.
      element.one('$destroy', onElementDestroy);
      parent.one('$destroy', onElementDestroy);
      scope.$on('$destroy', function() {
        setVisible(false);
        panelRef && panelRef.destroy();
        attributeObserver && attributeObserver.disconnect();
        element.remove();
      });

      // Updates the aria-label when the element text changes. This watch
      // doesn't need to be set up if the element doesn't have any data
      // bindings.
      if (element.text().indexOf($interpolate.startSymbol()) > -1) {
        scope.$watch(function() {
          return element.text().trim();
        }, addAriaLabel);
      }

      function onElementDestroy() {
        scope.$destroy();
      }
    }

    function setVisible(value) {
      // Break if passed value is already in queue or there is no queue and
      // passed value is current in the controller.
      if (setVisible.queued && setVisible.value === !!value ||
          !setVisible.queued && scope.mdVisible === !!value) {
        return;
      }
      setVisible.value = !!value;

      if (!setVisible.queued) {
        if (value) {
          setVisible.queued = true;
          showTimeout = $timeout(function() {
            scope.mdVisible = setVisible.value;
            setVisible.queued = false;
            showTimeout = null;
            if (!scope.visibleWatcher) {
              onVisibleChanged(scope.mdVisible);
            }
          }, scope.mdDelay);
        } else {
          $mdUtil.nextTick(function() {
            scope.mdVisible = false;
            if (!scope.visibleWatcher) {
              onVisibleChanged(false);
            }
          });
        }
      }
    }

    function onVisibleChanged(isVisible) {
      isVisible ? showTooltip() : hideTooltip();
    }

    function showTooltip() {
      // Do not show the tooltip if the text is empty.
      if (!element[0].textContent.trim()) {
        throw new Error('Text for the tooltip has not been provided. ' +
            'Please include text within the mdTooltip element.');
      }

      if (!panelRef) {
        var id = 'tooltip-' + $mdUtil.nextUid();
        var attachTo = angular.element(document.body);
        var panelAnimation = $mdPanel.newPanelAnimation()
            .openFrom(parent)
            .closeTo(parent)
            .withAnimation({
              open: 'md-show',
              close: 'md-hide'
            });

        var panelConfig = {
          id: id,
          attachTo: attachTo,
          contentElement: element,
          propagateContainerEvents: true,
          panelClass: 'md-tooltip ' + origin,
          animation: panelAnimation,
          position: panelPosition,
          zIndex: scope.mdZIndex,
          focusOnOpen: false
        };

        panelRef = $mdPanel.create(panelConfig);
      }

      panelRef.open().then(function() {
        panelRef.panelEl.attr('role', 'tooltip');
      });
    }

    function hideTooltip() {
      panelRef && panelRef.close();
    }
  }

}


/**
 * Service that is used to reduce the amount of listeners that are being
 * registered on the `window` by the tooltip component. Works by collecting
 * the individual event handlers and dispatching them from a global handler.
 *
 * @ngInject
 */
function MdTooltipRegistry() {
  var listeners = {};
  var ngWindow = angular.element(window);

  return {
    register: register,
    deregister: deregister
  };

  /**
   * Global event handler that dispatches the registered handlers in the
   * service.
   * @param {!Event} event Event object passed in by the browser
   */
  function globalEventHandler(event) {
    if (listeners[event.type]) {
      listeners[event.type].forEach(function(currentHandler) {
        currentHandler.call(this, event);
      }, this);
    }
  }

  /**
   * Registers a new handler with the service.
   * @param {string} type Type of event to be registered.
   * @param {!Function} handler Event handler.
   * @param {boolean} useCapture Whether to use event capturing.
   */
  function register(type, handler, useCapture) {
    var handlers = listeners[type] = listeners[type] || [];

    if (!handlers.length) {
      useCapture ? window.addEventListener(type, globalEventHandler, true) :
          ngWindow.on(type, globalEventHandler);
    }

    if (handlers.indexOf(handler) === -1) {
      handlers.push(handler);
    }
  }

  /**
   * Removes an event handler from the service.
   * @param {string} type Type of event handler.
   * @param {!Function} handler The event handler itself.
   * @param {boolean} useCapture Whether the event handler used event capturing.
   */
  function deregister(type, handler, useCapture) {
    var handlers = listeners[type];
    var index = handlers ? handlers.indexOf(handler) : -1;

    if (index > -1) {
      handlers.splice(index, 1);

      if (handlers.length === 0) {
        useCapture ? window.removeEventListener(type, globalEventHandler, true) :
            ngWindow.off(type, globalEventHandler);
      }
    }
  }
}
