// Viewport Helper
// --------------------------------------------
//
// * **Class:** Viewport
// * **Version:** 1.0
// * **Modified:** 06/26/2013
// * **Author:** Glen Cheney
// * **Dependencies:** jQuery 1.7+, SONY Settings, throttle/debounce
//
// *Notes:*
//
// If you need to be notified when an element is scrolled into view, use this module.
// This module keeps track of all elements that want to be watched and caches their offsets
// and dimensions in order to keep scrolling as smooth as possible.
//
// *Example Usage:*
//
//      Viewport.add({
//        element: document.getElementById('some-wrapper'),
//        threshold: '50%',
//        enter: function( element ) {
//          console.log('the top of "element" is 50% in view');
//        },
//        leave: function() {
//          console.log('bottom of element has left the viewport');
//        }
//      });
//
// *Viewport.add parameters:*
//
// * `element` is a DOM element and `callback` is a function. `this` in the callback is the element.
// * Using an options object, a `threshold` can be set.
//   It is either an integer value from the bottom of the window, a string percentage, or a float
//   between 0 and 1 which represents the percent.
//

(function( $ ) {

  'use strict';

  var instance = null;
  var $window = $(window);

  var ViewportItem = function( options ) {
    var self = this;

    // Get defaults
    $.extend( self, ViewportItem.options, options, ViewportItem.settings );

    // The whole point is to have a callback function.
    // Don't do anything if it's not given
    if ( !$.isFunction( self.enter ) ) {
      throw new TypeError('Viewport.add :: No `enter` function provided in Viewport options.');
    }

    // Threshold can be a percentage. Parse it.
    if ( (typeof self.threshold === 'string' && self.threshold.indexOf('%') > -1 ) ) {
      self.isThresholdPercentage = true;
      self.threshold = parseFloat( self.threshold ) / 100;

    } else if ( self.threshold < 1 && self.threshold > 0 ) {
      self.isThresholdPercentage = true;
    }

    self.hasLeaveCallback = $.isFunction( self.leave );
    self.$element = $( self.element );

    // Cache element's offsets and dimensions
    self.update();
  };

  ViewportItem.prototype.update = function() {
    var self = this;

    self.offset = self.$element.offset();
    self.height = self.$element.height();
    self.width = self.$element.width();
  };

  ViewportItem.options = {
    threshold: 200,
    delay: 0
  };

  ViewportItem.settings = {
    triggered: false,
    isThresholdPercentage: false
  };

  var Viewport = function() {
    this.init();
  };

  Viewport.prototype = {

    init : function() {
      var self = this;

      self.list = [];
      self.lastScrollY = 0;
      self.windowHeight = $window.height();
      self.windowWidth = $window.width();
      self.throttleTime = 100;

      self.onResize();
      self.bindEvents();

      // What's nice here is that rAF won't execute until the user is on this tab,
      // so if they open the page in a new tab which they aren't looking at,
      // this will execute when they come back to that tab
      self.willProcessNextFrame = true;
      requestAnimationFrame(function() {
        self.setScrollTop();
        self.process();
        self.willProcessNextFrame = false;
      });
    },

    bindEvents : function() {
      var self = this,
          refresh;

      // Updates offsets after a zero timeout
      refresh = function() {
        setTimeout(function refreshWithDelay() {
          self.refresh();
        }, 0);
      };

      // Listen for global resize
      $window.on('resize.viewport', $.proxy( self.onResize, self ));

      // Throttle scrolling because it doesn't need to be super accurate
      $window.on('scroll.viewport', $.throttle( self.throttleTime, $.proxy( self.onScroll, self ) ));

      self.hasActiveHandlers = true;
    },

    unbindEvents : function() {
      $window.off('.viewport');

      this.hasActiveHandlers = false;
    },

    maybeUnbindEvents : function() {
      var self = this;

      // Not currently watching anything, unbind events
      if ( !self.list.length ) {
        self.unbindEvents();
      }
    },

    add : function( viewportItem ) {
      var self = this;

      self.list.push( viewportItem );

      // Event handlers are removed if a callback is triggered and the
      // watch list is empty. Because modules are instantiated asynchronously,
      // another module could potentially add itself to the watch list when the events
      // have been unbound.
      // Check here if events have been unbound and bind them again if they have
      if ( !self.hasActiveHandlers ) {
        self.bindEvents();
      }

      if ( !self.willProcessNextFrame ) {
        self.willProcessNextFrame = true;
        requestAnimationFrame(function() {
          self.willProcessNextFrame = false;
          self.process();
        });
      }
    },

    saveDimensions : function() {
      var self = this;

      $.each(self.list, function( i, viewportItem ) {
        viewportItem.update();
      });

      // self.documentHeight
      self.windowHeight = $window.height();
      self.windowWidth = $window.width();
    },

    // Throttled scroll event
    onScroll : function() {
      var self = this;

      // No point in doing anything if there aren't any viewports to watch
      if ( !self.list.length ) {
        return;
      }

      // Save the new scroll top
      self.setScrollTop();

      self.process();
    },

    // Debounced resize event
    onResize : function() {
      this.refresh();
    },

    refresh : function() {

      // No point in doing anything if there aren't any viewports to watch
      if ( !this.list.length ) {
        return;
      }

      // Update offsets and width/height for each viewport item
      this.saveDimensions();

    },

    isInViewport : function( viewportItem ) {
      var self = this,
          offset = viewportItem.offset,
          threshold = viewportItem.threshold,
          percentage = threshold,
          st = self.lastScrollY,
          isTopInView;


      if ( viewportItem.isThresholdPercentage ) {
        threshold = 0;
      }

      // Other checks could be added here in the future
      isTopInView = self.isTopInView( st, self.windowHeight, offset.top, viewportItem.height, threshold );

      // If the top isn't in view with zero threshold,
      // don't bother checking if it's at a percent of the window
      if ( isTopInView && viewportItem.isThresholdPercentage ) {
        isTopInView = self.isTopPastPercent( st, self.windowHeight, offset.top, viewportItem.height, percentage );
      }

      return isTopInView;
    },

    // If  the top of the element (plus the threshold) is past the viewport's top
    // and the top of the element (plus the threshold) is not past the viewport's bottom.
    // Then the top is in view.
    isTopInView : function( viewportTop, viewportHeight, elementTop, elementHeight, threshold ) {
      var viewportBottom = viewportTop + viewportHeight;
      return (elementTop + threshold) >= viewportTop && (elementTop + threshold) < viewportBottom;
    },

    isTopPastPercent : function( viewportTop, viewportHeight, elementTop, elementHeight, percentage ) {
      var viewportBottom = viewportTop + viewportHeight,
          distFromViewportBottomToElementTop = viewportBottom - elementTop,
          percentFromBottom = distFromViewportBottomToElementTop / viewportHeight;
      return percentFromBottom >= percentage;
    },

    isOutOfViewport : function( viewport, side ) {
      var self = this,
          offset = viewport.offset,
          st = self.lastScrollY,
          bool;

      if ( side === 'bottom' ) {
        bool = !self.isBottomInView( st, self.windowHeight, offset.top, viewport.height );
      }

      return bool;
    },

    isBottomInView : function( viewportTop, viewportHeight, elementTop, elementHeight ) {
      var viewportBottom = viewportTop + viewportHeight,
          elementBottom = elementTop + elementHeight;
      return elementBottom > viewportTop && elementBottom <= viewportBottom;
    },

    triggerEnter : function( viewportItem ) {
      var self = this;

      // Queue up the callback with the delay. Default is 0
      setTimeout(function() {
        viewportItem.enter.call( viewportItem.element, viewportItem );
      }, viewportItem.delay);


      if ( $.isFunction( viewportItem.leave ) ) {
        viewportItem.triggered = true;

      // If the leave property is not a function,
      // The module no longer needs to watch it, so remove from list
      // However, the list may have been modified already in this loop, so find the
      // index of the viewport item instead of using the loop index.
      } else {
        self.list.splice( $.inArray( viewportItem, self.list ), 1 );
      }

      // If there are no more, unbind from scroll and resize events
      self.maybeUnbindEvents();
    },

    triggerLeave : function( viewportItem ) {
      // var self = this;

      // Queue up the callback with the delay. Default is 0
      setTimeout(function() {
        viewportItem.leave.call( viewportItem.element, viewportItem );
      }, viewportItem.delay);

      viewportItem.triggered = false;
    },

    setScrollTop : function() {
      // Save the new scroll top
      this.lastScrollY = $window.scrollTop();
    },

    process : function() {
      var self = this,

          // The list can possibly be modified mid loop,
          // so the loop needs a copy of the variable which won't be modified
          list = $.extend( [], self.list );

      $.each(list, function( i, viewportItem ) {
        var isInViewport = self.isInViewport( viewportItem ),
            isBottomOutOfView = viewportItem.hasLeaveCallback && self.isOutOfViewport( viewportItem, 'bottom' );

        // If the enter callback hasn't been triggerd and it's in the viewport,
        // trigger the enter callback
        if ( !viewportItem.triggered && isInViewport ) {
          return self.triggerEnter( viewportItem );
        }

        // This viewport has already come into view once and now it is out of view
        // It's not in view, the bottom is out of view, the list item's enter has been triggered
        if ( !isInViewport && isBottomOutOfView && viewportItem.triggered ) {
          return self.triggerLeave( viewportItem );
        }
      });
    }
  };


  Viewport.add = function( options ) {
    var instance = Viewport.getInstance();

    return instance.add( new ViewportItem( options ) );
  };


  Viewport.refresh = function() {
    Viewport.getInstance().refresh();
  };


  Viewport.getInstance = function() {
    if ( !instance ) {
      instance = new Viewport();
    }

    return instance;
  };


  window.Viewport = Viewport;
}( jQuery ));
