‘use strict’;

angular.module(‘mgcrea.ngStrap.tooltip’, [‘mgcrea.ngStrap.helpers.dimensions’])

.provider('$tooltip', function() {

  var defaults = this.defaults = {
    animation: 'am-fade',
    customClass: '',
    prefixClass: 'tooltip',
    prefixEvent: 'tooltip',
    container: false,
    target: false,
    placement: 'top',
    template: 'tooltip/tooltip.tpl.html',
    contentTemplate: false,
    trigger: 'hover focus',
    keyboard: false,
    html: false,
    show: false,
    title: '',
    type: '',
    delay: 0,
    autoClose: false,
    bsEnabled: true,
    viewport: {
     selector: 'body',
     padding: 0
    }
  };

  this.$get = function($window, $rootScope, $compile, $q, $templateCache, $http, $animate, $sce, dimensions, $$rAF, $timeout) {

    var trim = String.prototype.trim;
    var isTouch = 'createTouch' in $window.document;
    var htmlReplaceRegExp = /ng-bind="/ig;
    var $body = angular.element($window.document);

    function TooltipFactory(element, config) {

      var $tooltip = {};

      // Common vars
      var nodeName = element[0].nodeName.toLowerCase();
      var options = $tooltip.$options = angular.extend({}, defaults, config);
      $tooltip.$promise = fetchTemplate(options.template);
      var scope = $tooltip.$scope = options.scope && options.scope.$new() || $rootScope.$new();
      if(options.delay && angular.isString(options.delay)) {
        var split = options.delay.split(',').map(parseFloat);
        options.delay = split.length > 1 ? {show: split[0], hide: split[1]} : split[0];
      }

      // store $id to identify the triggering element in events
      // give priority to options.id, otherwise, try to use
      // element id if defined
      $tooltip.$id = options.id || element.attr('id') || '';

      // Support scope as string options
      if(options.title) {
        scope.title = $sce.trustAsHtml(options.title);
      }

      // Provide scope helpers
      scope.$setEnabled = function(isEnabled) {
        scope.$$postDigest(function() {
          $tooltip.setEnabled(isEnabled);
        });
      };
      scope.$hide = function() {
        scope.$$postDigest(function() {
          $tooltip.hide();
        });
      };
      scope.$show = function() {
        scope.$$postDigest(function() {
          $tooltip.show();
        });
      };
      scope.$toggle = function() {
        scope.$$postDigest(function() {
          $tooltip.toggle();
        });
      };
      // Publish isShown as a protected var on scope
      $tooltip.$isShown = scope.$isShown = false;

      // Private vars
      var timeout, hoverState;

      // Support contentTemplate option
      if(options.contentTemplate) {
        $tooltip.$promise = $tooltip.$promise.then(function(template) {
          var templateEl = angular.element(template);
          return fetchTemplate(options.contentTemplate)
          .then(function(contentTemplate) {
            var contentEl = findElement('[ng-bind="content"]', templateEl[0]);
            if(!contentEl.length) contentEl = findElement('[ng-bind="title"]', templateEl[0]);
            contentEl.removeAttr('ng-bind').html(contentTemplate);
            return templateEl[0].outerHTML;
          });
        });
      }

      // Fetch, compile then initialize tooltip
      var tipLinker, tipElement, tipTemplate, tipContainer, tipScope;
      $tooltip.$promise.then(function(template) {
        if(angular.isObject(template)) template = template.data;
        if(options.html) template = template.replace(htmlReplaceRegExp, 'ng-bind-html="');
        template = trim.apply(template);
        tipTemplate = template;
        tipLinker = $compile(template);
        $tooltip.init();
      });

      $tooltip.init = function() {

        // Options: delay
        if (options.delay && angular.isNumber(options.delay)) {
          options.delay = {
            show: options.delay,
            hide: options.delay
          };
        }

        // Replace trigger on touch devices ?
        // if(isTouch && options.trigger === defaults.trigger) {
        //   options.trigger.replace(/hover/g, 'click');
        // }

        // Options : container
        if(options.container === 'self') {
          tipContainer = element;
        } else if(angular.isElement(options.container)) {
          tipContainer = options.container;
        } else if(options.container) {
          tipContainer = findElement(options.container);
        }

        // Options: trigger
        bindTriggerEvents();

        // Options: target
        if(options.target) {
          options.target = angular.isElement(options.target) ? options.target : findElement(options.target);
        }

        // Options: show
        if(options.show) {
          scope.$$postDigest(function() {
            options.trigger === 'focus' ? element[0].focus() : $tooltip.show();
          });
        }

      };

      $tooltip.destroy = function() {

        // Unbind events
        unbindTriggerEvents();

        // Remove element
        destroyTipElement();

        // Destroy scope
        scope.$destroy();

      };

      $tooltip.enter = function() {

        clearTimeout(timeout);
        hoverState = 'in';
        if (!options.delay || !options.delay.show) {
          return $tooltip.show();
        }

        timeout = setTimeout(function() {
          if (hoverState ==='in') $tooltip.show();
        }, options.delay.show);

      };

      $tooltip.show = function() {
        if (!options.bsEnabled || $tooltip.$isShown) return;

        scope.$emit(options.prefixEvent + '.show.before', $tooltip);
        var parent, after;
        if (options.container) {
          parent = tipContainer;
          if (tipContainer[0].lastChild) {
            after = angular.element(tipContainer[0].lastChild);
          } else {
            after = null;
          }
        } else {
          parent = null;
          after = element;
        }

        // Hide any existing tipElement
        if(tipElement) destroyTipElement();
        // Fetch a cloned element linked from template
        tipScope = $tooltip.$scope.$new();
        tipElement = $tooltip.$element = tipLinker(tipScope, function(clonedElement, scope) {});

        // Set the initial positioning.  Make the tooltip invisible
        // so IE doesn't try to focus on it off screen.
        tipElement.css({top: '-9999px', left: '-9999px', display: 'block', visibility: 'hidden'});

        // Options: animation
        if(options.animation) tipElement.addClass(options.animation);
        // Options: type
        if(options.type) tipElement.addClass(options.prefixClass + '-' + options.type);
        // Options: custom classes
        if(options.customClass) tipElement.addClass(options.customClass);

        // Append the element, without any animations.  If we append
        // using $animate.enter, some of the animations cause the placement
        // to be off due to the transforms.
        after ? after.after(tipElement) : parent.prepend(tipElement);

        $tooltip.$isShown = scope.$isShown = true;
        safeDigest(scope);

        // Now, apply placement
        $tooltip.$applyPlacement();

        // Once placed, animate it.
        // Support v1.3+ $animate
        // https://github.com/angular/angular.js/commit/bf0f5502b1bbfddc5cdd2f138efd9188b8c652a9
        var promise = $animate.enter(tipElement, parent, after, enterAnimateCallback);
        if(promise && promise.then) promise.then(enterAnimateCallback);
        safeDigest(scope);

        $$rAF(function () {
          // Once the tooltip is placed and the animation starts, make the tooltip visible
          if(tipElement) tipElement.css({visibility: 'visible'});
        });

        // Bind events
        if(options.keyboard) {
          if(options.trigger !== 'focus') {
            $tooltip.focus();
          }
          bindKeyboardEvents();
        }

        if(options.autoClose) {
          bindAutoCloseEvents();
        }

      };

      function enterAnimateCallback() {
        scope.$emit(options.prefixEvent + '.show', $tooltip);
      }

      $tooltip.leave = function() {

        clearTimeout(timeout);
        hoverState = 'out';
        if (!options.delay || !options.delay.hide) {
          return $tooltip.hide();
        }
        timeout = setTimeout(function () {
          if (hoverState === 'out') {
            $tooltip.hide();
          }
        }, options.delay.hide);

      };

      var _blur;
      var _tipToHide;
      $tooltip.hide = function(blur) {

        if(!$tooltip.$isShown) return;
        scope.$emit(options.prefixEvent + '.hide.before', $tooltip);

        // store blur value for leaveAnimateCallback to use
        _blur = blur;

        // store current tipElement reference to use
        // in leaveAnimateCallback
        _tipToHide = tipElement;

        // Support v1.3+ $animate
        // https://github.com/angular/angular.js/commit/bf0f5502b1bbfddc5cdd2f138efd9188b8c652a9
        var promise = $animate.leave(tipElement, leaveAnimateCallback);
        if(promise && promise.then) promise.then(leaveAnimateCallback);

        $tooltip.$isShown = scope.$isShown = false;
        safeDigest(scope);

        // Unbind events
        if(options.keyboard && tipElement !== null) {
          unbindKeyboardEvents();
        }

        if(options.autoClose && tipElement !== null) {
          unbindAutoCloseEvents();
        }
      };

      function leaveAnimateCallback() {
        scope.$emit(options.prefixEvent + '.hide', $tooltip);

        // check if current tipElement still references
        // the same element when hide was called
        if (tipElement === _tipToHide) {
          // Allow to blur the input when hidden, like when pressing enter key
          if(_blur && options.trigger === 'focus') {
            return element[0].blur();
          }

          // clean up child scopes
          destroyTipElement();
        }
      }

      $tooltip.toggle = function() {
        $tooltip.$isShown ? $tooltip.leave() : $tooltip.enter();
      };

      $tooltip.focus = function() {
        tipElement[0].focus();
      };

      $tooltip.setEnabled = function(isEnabled) {
        options.bsEnabled = isEnabled;
      };

      $tooltip.setViewport = function(viewport) {
        options.viewport = viewport;
      };

      // Protected methods

      $tooltip.$applyPlacement = function() {
        if(!tipElement) return;

        // Determine if we're doing an auto or normal placement
        var placement = options.placement,
            autoToken = /\s?auto?\s?/i,
            autoPlace  = autoToken.test(placement);

        if (autoPlace) {
          placement = placement.replace(autoToken, '') || defaults.placement;
        }

        // Need to add the position class before we get
        // the offsets
        tipElement.addClass(options.placement);

        // Get the position of the target element
        // and the height and width of the tooltip so we can center it.
        var elementPosition = getPosition(),
            tipWidth = tipElement.prop('offsetWidth'),
            tipHeight = tipElement.prop('offsetHeight');

        // If we're auto placing, we need to check the positioning
        if (autoPlace) {
          var originalPlacement = placement;
          var container = options.container ? findElement(options.container) : element.parent();
          var containerPosition = getPosition(container);

          // Determine if the vertical placement
          if (originalPlacement.indexOf('bottom') >= 0 && elementPosition.bottom + tipHeight > containerPosition.bottom) {
            placement = originalPlacement.replace('bottom', 'top');
          } else if (originalPlacement.indexOf('top') >= 0 && elementPosition.top - tipHeight < containerPosition.top) {
            placement = originalPlacement.replace('top', 'bottom');
          }

          // Determine the horizontal placement
          // The exotic placements of left and right are opposite of the standard placements.  Their arrows are put on the left/right
          // and flow in the opposite direction of their placement.
          if ((originalPlacement === 'right' || originalPlacement === 'bottom-left' || originalPlacement === 'top-left') &&
              elementPosition.right + tipWidth > containerPosition.width) {

            placement = originalPlacement === 'right' ? 'left' : placement.replace('left', 'right');
          } else if ((originalPlacement === 'left' || originalPlacement === 'bottom-right' || originalPlacement === 'top-right') &&
              elementPosition.left - tipWidth < containerPosition.left) {

            placement = originalPlacement === 'left' ? 'right' : placement.replace('right', 'left');
          }

          tipElement.removeClass(originalPlacement).addClass(placement);
        }

        // Get the tooltip's top and left coordinates to center it with this directive.
        var tipPosition = getCalculatedOffset(placement, elementPosition, tipWidth, tipHeight);
        applyPlacement(tipPosition, placement);
      };

      $tooltip.$onKeyUp = function(evt) {
        if (evt.which === 27 && $tooltip.$isShown) {
          $tooltip.hide();
          evt.stopPropagation();
        }
      };

      $tooltip.$onFocusKeyUp = function(evt) {
        if (evt.which === 27) {
          element[0].blur();
          evt.stopPropagation();
        }
      };

      $tooltip.$onFocusElementMouseDown = function(evt) {
        evt.preventDefault();
        evt.stopPropagation();
        // Some browsers do not auto-focus buttons (eg. Safari)
        $tooltip.$isShown ? element[0].blur() : element[0].focus();
      };

      // bind/unbind events
      function bindTriggerEvents() {
        var triggers = options.trigger.split(' ');
        angular.forEach(triggers, function(trigger) {
          if(trigger === 'click') {
            element.on('click', $tooltip.toggle);
          } else if(trigger !== 'manual') {
            element.on(trigger === 'hover' ? 'mouseenter' : 'focus', $tooltip.enter);
            element.on(trigger === 'hover' ? 'mouseleave' : 'blur', $tooltip.leave);
            nodeName === 'button' && trigger !== 'hover' && element.on(isTouch ? 'touchstart' : 'mousedown', $tooltip.$onFocusElementMouseDown);
          }
        });
      }

      function unbindTriggerEvents() {
        var triggers = options.trigger.split(' ');
        for (var i = triggers.length; i--;) {
          var trigger = triggers[i];
          if(trigger === 'click') {
            element.off('click', $tooltip.toggle);
          } else if(trigger !== 'manual') {
            element.off(trigger === 'hover' ? 'mouseenter' : 'focus', $tooltip.enter);
            element.off(trigger === 'hover' ? 'mouseleave' : 'blur', $tooltip.leave);
            nodeName === 'button' && trigger !== 'hover' && element.off(isTouch ? 'touchstart' : 'mousedown', $tooltip.$onFocusElementMouseDown);
          }
        }
      }

      function bindKeyboardEvents() {
        if(options.trigger !== 'focus') {
          tipElement.on('keyup', $tooltip.$onKeyUp);
        } else {
          element.on('keyup', $tooltip.$onFocusKeyUp);
        }
      }

      function unbindKeyboardEvents() {
        if(options.trigger !== 'focus') {
          tipElement.off('keyup', $tooltip.$onKeyUp);
        } else {
          element.off('keyup', $tooltip.$onFocusKeyUp);
        }
      }

      var _autoCloseEventsBinded = false;
      function bindAutoCloseEvents() {
        // use timeout to hookup the events to prevent
        // event bubbling from being processed imediately.
        $timeout(function() {
          // Stop propagation when clicking inside tooltip
          tipElement.on('click', stopEventPropagation);

          // Hide when clicking outside tooltip
          $body.on('click', $tooltip.hide);

          _autoCloseEventsBinded = true;
        }, 0, false);
      }

      function unbindAutoCloseEvents() {
        if (_autoCloseEventsBinded) {
          tipElement.off('click', stopEventPropagation);
          $body.off('click', $tooltip.hide);
          _autoCloseEventsBinded = false;
        }
      }

      function stopEventPropagation(event) {
        event.stopPropagation();
      }

      // Private methods

      function getPosition($element) {
        $element = $element || (options.target || element);

        var el = $element[0],
            isBody = el.tagName === 'BODY';

        var elRect = el.getBoundingClientRect();
        var rect = {};

        // IE8 has issues with angular.extend and using elRect directly.
        // By coping the values of elRect into a new object, we can continue to use extend
        for (var p in elRect) {
          if (elRect.hasOwnProperty(p)) {
            rect[p] = elRect[p];
          }
        }

        if (rect.width === null) {
          // width and height are missing in IE8, so compute them manually; see https://github.com/twbs/bootstrap/issues/14093
          rect = angular.extend({}, rect, { width: elRect.right - elRect.left, height: elRect.bottom - elRect.top });
        }
        var elOffset = isBody ? { top: 0, left: 0 } : dimensions.offset(el),
            scroll = { scroll:  isBody ? document.documentElement.scrollTop || document.body.scrollTop : $element.prop('scrollTop') || 0 },
            outerDims = isBody ? { width: document.documentElement.clientWidth, height: $window.innerHeight } : null;

        return angular.extend({}, rect, scroll, outerDims, elOffset);
      }

      function getCalculatedOffset(placement, position, actualWidth, actualHeight) {
        var offset;
        var split = placement.split('-');

        switch (split[0]) {
        case 'right':
          offset = {
            top: position.top + position.height / 2 - actualHeight / 2,
            left: position.left + position.width
          };
          break;
        case 'bottom':
          offset = {
            top: position.top + position.height,
            left: position.left + position.width / 2 - actualWidth / 2
          };
          break;
        case 'left':
          offset = {
            top: position.top + position.height / 2 - actualHeight / 2,
            left: position.left - actualWidth
          };
          break;
        default:
          offset = {
            top: position.top - actualHeight,
            left: position.left + position.width / 2 - actualWidth / 2
          };
          break;
        }

        if(!split[1]) {
          return offset;
        }

        // Add support for corners @todo css
        if(split[0] === 'top' || split[0] === 'bottom') {
          switch (split[1]) {
          case 'left':
            offset.left = position.left;
            break;
          case 'right':
            offset.left =  position.left + position.width - actualWidth;
          }
        } else if(split[0] === 'left' || split[0] === 'right') {
          switch (split[1]) {
          case 'top':
            offset.top = position.top - actualHeight;
            break;
          case 'bottom':
            offset.top = position.top + position.height;
          }
        }

        return offset;
      }

      function applyPlacement(offset, placement) {
        var tip = tipElement[0],
            width = tip.offsetWidth,
            height = tip.offsetHeight;

        // manually read margins because getBoundingClientRect includes difference
        var marginTop = parseInt(dimensions.css(tip, 'margin-top'), 10),
            marginLeft = parseInt(dimensions.css(tip, 'margin-left'), 10);

        // we must check for NaN for ie 8/9
        if (isNaN(marginTop)) marginTop  = 0;
        if (isNaN(marginLeft)) marginLeft = 0;

        offset.top  = offset.top + marginTop;
        offset.left = offset.left + marginLeft;

        // dimensions setOffset doesn't round pixel values
        // so we use setOffset directly with our own function
        dimensions.setOffset(tip, angular.extend({
          using: function (props) {
            tipElement.css({
              top: Math.round(props.top) + 'px',
              left: Math.round(props.left) + 'px'
            });
          }
        }, offset), 0);

        // check to see if placing tip in new offset caused the tip to resize itself
        var actualWidth = tip.offsetWidth,
            actualHeight = tip.offsetHeight;

        if (placement === 'top' && actualHeight !== height) {
          offset.top = offset.top + height - actualHeight;
        }

        // If it's an exotic placement, exit now instead of
        // applying a delta and changing the arrow
        if (/top-left|top-right|bottom-left|bottom-right/.test(placement)) return;

        var delta = getViewportAdjustedDelta(placement, offset, actualWidth, actualHeight);

        if (delta.left) {
          offset.left += delta.left;
        } else {
          offset.top += delta.top;
        }

        dimensions.setOffset(tip, offset);

        if (/top|right|bottom|left/.test(placement)) {
          var isVertical = /top|bottom/.test(placement),
              arrowDelta = isVertical ? delta.left * 2 - width + actualWidth : delta.top * 2 - height + actualHeight,
              arrowOffsetPosition = isVertical ? 'offsetWidth' : 'offsetHeight';

          replaceArrow(arrowDelta, tip[arrowOffsetPosition], isVertical);
        }
      }

      function getViewportAdjustedDelta(placement, position, actualWidth, actualHeight) {
        var delta = { top: 0, left: 0 },
            $viewport = options.viewport && findElement(options.viewport.selector || options.viewport);

        if (!$viewport) {
         return delta;
        }

        var viewportPadding = options.viewport && options.viewport.padding || 0,
            viewportDimensions = getPosition($viewport);

        if (/right|left/.test(placement)) {
          var topEdgeOffset    = position.top - viewportPadding - viewportDimensions.scroll,
              bottomEdgeOffset = position.top + viewportPadding - viewportDimensions.scroll + actualHeight;
          if (topEdgeOffset < viewportDimensions.top) { // top overflow
            delta.top = viewportDimensions.top - topEdgeOffset;
          } else if (bottomEdgeOffset > viewportDimensions.top + viewportDimensions.height) { // bottom overflow
            delta.top = viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset;
          }
        } else {
          var leftEdgeOffset  = position.left - viewportPadding,
              rightEdgeOffset = position.left + viewportPadding + actualWidth;
          if (leftEdgeOffset < viewportDimensions.left) { // left overflow
            delta.left = viewportDimensions.left - leftEdgeOffset;
          } else if (rightEdgeOffset > viewportDimensions.width) { // right overflow
            delta.left = viewportDimensions.left + viewportDimensions.width - rightEdgeOffset;
          }
        }

        return delta;
      }

      function replaceArrow(delta, dimension, isHorizontal) {
        var $arrow = findElement('.tooltip-arrow, .arrow', tipElement[0]);

        $arrow.css(isHorizontal ? 'left' : 'top', 50 * (1 - delta / dimension) + '%')
              .css(isHorizontal ? 'top' : 'left', '');
      }

      function destroyTipElement() {
        // Cancel pending callbacks
        clearTimeout(timeout);

        if($tooltip.$isShown && tipElement !== null) {
          if(options.autoClose) {
            unbindAutoCloseEvents();
          }

          if(options.keyboard) {
            unbindKeyboardEvents();
          }
        }

        if(tipScope) {
          tipScope.$destroy();
          tipScope = null;
        }

        if(tipElement) {
          tipElement.remove();
          tipElement = $tooltip.$element = null;
        }
      }

      return $tooltip;

    }

    // Helper functions

    function safeDigest(scope) {
      scope.$$phase || (scope.$root && scope.$root.$$phase) || scope.$digest();
    }

    function findElement(query, element) {
      return angular.element((element || document).querySelectorAll(query));
    }

    var fetchPromises = {};
    function fetchTemplate(template) {
      if(fetchPromises[template]) return fetchPromises[template];
      return (fetchPromises[template] = $http.get(template, {cache: $templateCache}).then(function(res) {
        return res.data;
      }));
    }

    return TooltipFactory;

  };

})

.directive('bsTooltip', function($window, $location, $sce, $tooltip, $$rAF) {

  return {
    restrict: 'EAC',
    scope: true,
    link: function postLink(scope, element, attr, transclusion) {

      // Directive options
      var options = {scope: scope};
      angular.forEach(['template', 'contentTemplate', 'placement', 'container', 'delay', 'trigger', 'keyboard', 'html', 'animation', 'backdropAnimation', 'type', 'customClass', 'id'], function(key) {
        if(angular.isDefined(attr[key])) options[key] = attr[key];
      });

      // should not parse target attribute, only data-target
      if(element.attr('data-target')) {
        options.target = element.attr('data-target');
      }

      // overwrite inherited title value when no value specified
      // fix for angular 1.3.1 531a8de72c439d8ddd064874bf364c00cedabb11
      if (!scope.hasOwnProperty('title')){
        scope.title = '';
      }

      // Observe scope attributes for change
      attr.$observe('title', function(newValue) {
        if (angular.isDefined(newValue) || !scope.hasOwnProperty('title')) {
          var oldValue = scope.title;
          scope.title = $sce.trustAsHtml(newValue);
          angular.isDefined(oldValue) && $$rAF(function() {
            tooltip && tooltip.$applyPlacement();
          });
        }
      });

      // Support scope as an object
      attr.bsTooltip && scope.$watch(attr.bsTooltip, function(newValue, oldValue) {
        if(angular.isObject(newValue)) {
          angular.extend(scope, newValue);
        } else {
          scope.title = newValue;
        }
        angular.isDefined(oldValue) && $$rAF(function() {
          tooltip && tooltip.$applyPlacement();
        });
      }, true);

      // Visibility binding support
      attr.bsShow && scope.$watch(attr.bsShow, function(newValue, oldValue) {
        if(!tooltip || !angular.isDefined(newValue)) return;
        if(angular.isString(newValue)) newValue = !!newValue.match(/true|,?(tooltip),?/i);
        newValue === true ? tooltip.show() : tooltip.hide();
      });

      // Enabled binding support
      attr.bsEnabled && scope.$watch(attr.bsEnabled, function(newValue, oldValue) {
        // console.warn('scope.$watch(%s)', attr.bsEnabled, newValue, oldValue);
        if(!tooltip || !angular.isDefined(newValue)) return;
        if(angular.isString(newValue)) newValue = !!newValue.match(/true|1|,?(tooltip),?/i);
        newValue === false ? tooltip.setEnabled(false) : tooltip.setEnabled(true);
      });

      // Viewport support
      attr.viewport && scope.$watch(attr.viewport, function (newValue) {
        if(!tooltip || !angular.isDefined(newValue)) return;
        tooltip.setViewport(newValue);
      });

      // Initialize popover
      var tooltip = $tooltip(element, options);

      // Garbage collection
      scope.$on('$destroy', function() {
        if(tooltip) tooltip.destroy();
        options = null;
        tooltip = null;
      });

    }
  };

});