/**
 * File sticky-states.js.
 *
 * Implement sticky elements based on scroll position.
 */

 (function (root, factory) {
	if ( typeof define === 'function' && define.amd ) {
	  define([], factory(root));
	} else if ( typeof exports === 'object' ) {
	  module.exports = factory(root);
	} else {
	  root.StickyStates = factory(root);
	}
})(typeof global !== 'undefined' ? global : this.window || this.global, function (root) {

	'use strict';

	var _hasInitialized = false;
	var _publicMethods = {
		managers: [],
	};
	var _settings = {};
	var _defaults = {
		elementSelector: '[data-sticky-states]',
		innerElementSelector: '[data-sticky-states-inner]',

		isEndPositionClass: 'is-end-position',
		isStickyClass: 'is-sticky',
		isStickyTopClass: 'is-sticky--top',
		isStickyBottomClass: 'is-sticky--bottom',
		isActivatedClass: 'is-activated',

		positionAttribute: 'data-sticky-position',
		thresholdAttribute: 'data-sticky-threshold',
		stickyRelativeToAttribute: 'data-sticky-relative-to',
		staticAtEndAttribute: 'data-sticky-static-at-end',
		containerAttribute: 'data-sticky-container',

		position: 'top', // Accepted values: `top`, `bottom`
		threshold: 0,

		refreshRate: 50,
	};
	var _resizeObserver;



	/*!
	* Merge two or more objects together.
	* (c) 2017 Chris Ferdinandi, MIT License, https://gomakethings.com
	* @param   {Boolean}  deep     If true, do a deep (or recursive) merge [optional]
	* @param   {Object}   objects  The objects to merge together
	* @returns {Object}            Merged values of defaults and options
	*/
	var extend = function () {
		// Variables
		var extended = {};
		var deep = false;
		var i = 0;

		// Check if a deep merge
		if ( Object.prototype.toString.call( arguments[0] ) === '[object Boolean]' ) {
			deep = arguments[0];
			i++;
		}

		// Merge the object into the extended object
		var merge = function (obj) {
			for (var prop in obj) {
				if (obj.hasOwnProperty(prop)) {
					// If property is an object, merge properties
					if (deep && Object.prototype.toString.call(obj[prop]) === '[object Object]') {
						extended[prop] = extend(extended[prop], obj[prop]);
					} else {
						extended[prop] = obj[prop];
					}
				}
			}
		};

		// Loop through each object and conduct a merge
		for (; i < arguments.length; i++) {
			var obj = arguments[i];
			merge(obj);
		}

		return extended;
	};



	/**
	 * Execute a function immediately and prevent execution of the same method again for the amount of time defined as the threshold.
	 *
	 * @param   function  fn          Function to be executed.
	 * @param   int       threshhold  Threshold time in milliseconds. Default 250ms.
	 * @param   object    scope       Scope of execution of the function.
	 *
	 * @return  function              Function to be executed, incapsulated in a timed function.
	 */
	var _throttle = function( fn, threshhold, scope ) {
		threshhold || (threshhold = 250);

		var last,
			deferTimer;

		return function () {
			var context = scope || this;

			var now = +new Date,
				args = arguments;

			if ( last && now < last + threshhold ) {
				// hold on to it
				clearTimeout( deferTimer );
				deferTimer = setTimeout( function () {
					last = now;
					fn.apply( context, args );
				}, threshhold );
			}
			else {
				last = now;
				fn.apply( context, args );
			}
		};
	}



	/**
	 * Returns a function, that, as long as it continues to be invoked, will not
	 * be triggered. The function will be called after it stops being called for
	 * N milliseconds. If `immediate` is passed, trigger the function on the
	 * leading edge, instead of the trailing.
	 *
	 * @param   {[type]}  func       Function to be executed.
	 * @param   {[type]}  wait       Wait time in milliseconds.
	 * @param   {[type]}  immediate  Trigger the function on the leading edge.
	 *
	 * @return  function              Function to be executed, incapsulated in a timed function.
	 */
	var _debounce = function ( func, wait, immediate ) {
		var timeout;

		return function() {
		  var context = this, args = arguments;
		  var later = function() {
			timeout = null;
			if (!immediate) func.apply( context, args );
		  };

		  var callNow = immediate && !timeout;
		  clearTimeout( timeout );
		  timeout = setTimeout( later, wait );

		  if ( callNow ) func.apply( context, args );
		};
	};



	/**
	 * Check if a string is a JSON object.
	 */
	var _isJSON = function( string ) {
		try { JSON.parse( string ); }
		catch ( e ) { return false; }
		return true;
	}



	/**
	 * Get the offset position of the element recursively adding the offset position of parent offset elements until the `stopElement` (or the `body` element).
	 *
	 * @param   HTMLElement  element      Element to get the offset position for.
	 * @param   HTMLElement  stopElement  Parent offset element where to stop adding the offset position to the total offset top position of the element.
	 *
	 * @return  int                       Offset position of the element until the `stopElement` or the `body` element.
	 */
	var getOffsetTop = function( element, stopElement ) {
		var offsetTop = 0;

		while( element ) {
			// Reached the stopElement
			if ( stopElement && stopElement == element ) {
				break;
			}

			offsetTop += element.offsetTop;
			element = element.offsetParent;
		}

		return offsetTop;
	}



	/**
	 * Check if the element is considered visible. Does not consider the CSS property `visibility: hidden;`.
	 */
	var isVisible = function( element ) {
		return !!( element.offsetWidth || element.offsetHeight || element.getClientRects().length );
	}



	/**
	 * Check if the element is considered sticky.
	 */
	 _publicMethods.isStickyPosition = function( element ) {
		// Try checking for the sticky class
		if ( element.matches( '.' + _settings.isStickyClass ) ) { return true; }

		// Try checking for computed styles (slower)
		// Used for elements set to sticky position by other means other than using sticky-states
		var elementComputedPosition = window.getComputedStyle( element ).position;
		return elementComputedPosition == 'fixed' || elementComputedPosition == 'sticky';
	}



	/**
	 * Get the relative element to which the sticky element position will be calculated.
	 *
	 * @param   {string}  selectorOrJson  A string with a single CSS selector for the relative sticky element, or multiple selectors for various breakpoints as a JSON object.
	 *
	 * @return  {HTMLElement}             The relative sticky element, or `null` if not found.
	 */
	var getRelativeElement = function( selectorOrJson ) {
		var relativeElement = null;
		var windowWidth = window.innerWidth;

		// Multiple selectors for various breakpoints
		if ( _isJSON( selectorOrJson ) ) {
			var breakpointSelectors = Object.entries( JSON.parse( selectorOrJson ) );

			for ( var i = 0; i < breakpointSelectors.length; i++) {
				var values = breakpointSelectors[ i ][ 1 ];
				if ( windowWidth >= values.breakpointInitial && windowWidth <= values.breakpointFinal ) {
					relativeElement = document.querySelector( values.selector );
					break;
				}
			}
		}
		// Single selector
		else {
			relativeElement = document.querySelector( selectorOrJson );
		}

		return relativeElement;
	}



	/**
	 * Maybe change state of sticky elements.
	 */
	var maybeChangeState = function() {
		var currentScrollPosition = window.pageYOffset || document.body.scrollTop;

		// Iterate sticky elements
		for ( var i = 0; i < _publicMethods.managers.length; i++ ) {
			var manager = _publicMethods.managers[ i ];
			var containerStyles = window.getComputedStyle( manager.containerElement );
			var stickyStyles = window.getComputedStyle( manager.stickyElement );
			var containerHeight = parseInt( containerStyles.height.replace( 'px' ) );
			var stickyHeight = parseInt( stickyStyles.height.replace( 'px' ) );
			var isStickyShorterThanContainer = stickyHeight < containerHeight;
			var isSticky = currentScrollPosition >= manager.settings.threshold;
			var relativeHeight = 0;
			var isEndThreshold = currentScrollPosition >= manager.settings.endThreshold;
			var isStaticAtEnd = manager.stickyElement.hasAttribute( manager.settings.staticAtEndAttribute );

			// Maybe set isSticky value based on the relative element
			if ( manager.relativeElement && isVisible( manager.relativeElement ) && _publicMethods.isStickyPosition( manager.relativeElement ) ) {
 				relativeHeight = manager.relativeElement.getBoundingClientRect().height;
				isSticky = currentScrollPosition >= ( manager.settings.threshold - relativeHeight );
				isEndThreshold = currentScrollPosition >= ( manager.settings.endThreshold - relativeHeight );
			}

			// Sticky
			if ( isSticky && isStickyShorterThanContainer && ! isEndThreshold ) {
				var stickyInnerWidth = window.getComputedStyle( manager.innerElement ).width;
				manager.innerElement.style.top = relativeHeight > 0 ? relativeHeight + 'px' : '';
				manager.innerElement.style.width = stickyInnerWidth; // variable already has unit `px`
				manager.stickyElement.style.height = stickyHeight + 'px';
				manager.stickyElement.style.position = '';
				manager.stickyElement.classList.add( manager.settings.isStickyClass, ( manager.settings.position == 'top' ? manager.settings.isStickyTopClass : manager.settings.isStickyBottomClass ) );
				manager.stickyElement.classList.remove( manager.settings.isEndPositionClass );
			}
			// Absolute ( at end position )
			else if ( isEndThreshold && isStickyShorterThanContainer && ! isStaticAtEnd ) {
				var elementOffsetToContainer = getOffsetTop( manager.stickyElement ) - getOffsetTop( manager.containerElement );
				manager.innerElement.style.top = ( containerHeight - stickyHeight - elementOffsetToContainer ) + 'px';
				manager.stickyElement.style.height = stickyHeight + 'px';
				manager.stickyElement.classList.remove( manager.settings.isStickyClass, manager.settings.isStickyTopClass, manager.settings.isStickyBottomClass );
				manager.stickyElement.classList.add( manager.settings.isEndPositionClass );
			}
			// Static
			else {
				manager.stickyElement.classList.remove( manager.settings.isStickyClass, manager.settings.isStickyTopClass, manager.settings.isStickyBottomClass, manager.settings.isEndPositionClass );
				manager.stickyElement.style.height = '';
				manager.innerElement.style.width = '';
				manager.innerElement.style.top = '';
			}
		}
	};



	/**
	 * Loop function to changes visibility of the variation switcher.
	 */
	var throttledChangeState;
	var loop = function() {
		throttledChangeState();
		// Loop this function indefinitely
		window.requestAnimationFrame( loop );
	};



	/**
	 * Trigger recalculate threshold values when resizing.
	 */
	var resetStickyLimitsOnResize = function() {
		// Iterate managers
		for ( var i = 0; i < _publicMethods.managers.length; i++ ) {
			var manager = _publicMethods.managers[i];
			if ( manager ) {
				_publicMethods.resetStickyLimits( manager );
			}
		}
	};



	/**
	 * Recalculate threshold values.
	 */
	_publicMethods.resetStickyLimits = function( manager ) {
		var windowHeight = Math.max( document.documentElement.clientHeight, window.innerHeight || 0 );
		var thresholdAttrValue = manager.stickyElement.getAttribute( manager.settings.thresholdAttribute );
		var elementRect = manager.stickyElement.getBoundingClientRect();
		var elementOffset = getOffsetTop( manager.stickyElement );

		// Threshold
		manager.settings.threshold = isNaN( parseInt( thresholdAttrValue ) ) ? elementOffset : parseInt( thresholdAttrValue );

		// Calculate threshold for elements sticky to the bottom
		if ( manager.settings.position == 'bottom' ) {
			manager.settings.threshold = Math.max( manager.settings.threshold - windowHeight + elementRect.height, 0 );
		}

		// Maybe get relativeElement set via attribute
		var relativeElementAttribute = manager.stickyElement.getAttribute( manager.settings.stickyRelativeToAttribute );
		if ( relativeElementAttribute != null && relativeElementAttribute != '' ) {
			// Get relative sticky elements in the page
			manager.relativeElement = getRelativeElement( relativeElementAttribute );
		}

		// Use the parent element as the container element
		manager.containerElement = manager.stickyElement.parentNode;

		// Maybe get containerElement set via attribute
		var containerSelector = manager.stickyElement.getAttribute( manager.settings.containerAttribute );
		if ( containerSelector != null && containerSelector != '' ) {

			// Try to find the containerElement in the element's hierarchy first
			manager.containerElement = manager.stickyElement.closest( containerSelector );

			// Try to find the containerElement on the entire document and set to the first found element that matches the selector
			if ( ! manager.containerElement ) {
				manager.containerElement = document.querySelector( containerSelector );
			}
		}

		// Maybe set endThreshold
		if ( manager.containerElement ) {
			var containerHeight = parseInt( window.getComputedStyle( manager.containerElement ).height.replace( 'px' ) );
			var elementHeight = parseInt( window.getComputedStyle( manager.stickyElement ).height.replace( 'px' ) );
			var elementOffsetToContainer = getOffsetTop( manager.stickyElement ) - getOffsetTop( manager.containerElement );

			// Set endThreshold to bottom of containerElement
			manager.settings.endThreshold = manager.settings.threshold + containerHeight - elementHeight - elementOffsetToContainer;

			// Maybe calculate endThreshold for elements sticky to the bottom
			if ( manager.settings.position == 'bottom' ) {
				var endThreshold = getOffsetTop( manager.stickyElement ) - windowHeight + elementRect.height;

				// Maybe set endThreshold to stop sticky state at the element's normal position
				if ( endThreshold > manager.settings.threshold ) {
					manager.settings.endThreshold = endThreshold;
				}
			}
		}
	}



	/**
	 * Get manager instance from an element.
	 *
	 * @param    HTMLElement   An element that is a `stickyElement`, `stickyInner` or `containerElement`.
	 */
	_publicMethods.getInstance = function ( element ) {
		var instance;
		// Try getting instance from the element
		for ( var i = 0; i < _publicMethods.managers.length; i++ ) {
			var manager = _publicMethods.managers[i];
			if ( manager.stickyElement == element ) { instance = manager; break; }
		}

		// Try getting instace from other types of elements
		if ( ! instance )  { instance = _publicMethods.getInstanceFromInner( element ); }

		return instance;
	}

	/**
	 * Get manager instance for innerElement.
	 */
	_publicMethods.getInstanceFromInner = function ( innerElement ) {
		var instance;
		for ( var i = 0; i < _publicMethods.managers.length; i++ ) {
			var manager = _publicMethods.managers[i];
			if ( manager.innerElement == innerElement ) { instance = manager; break; }
		}
		return instance;
	}



	/**
	 * Initialize an sticky element.
	 */
	_publicMethods.initializeElement = function( stickyElement ) {
		var manager = {};
		manager.settings = extend( _settings );

		// Get elements
		manager.stickyElement = stickyElement;
		manager.innerElement = manager.stickyElement.querySelector( manager.settings.innerElementSelector );

		var positionAttrValue = manager.stickyElement.getAttribute( manager.settings.positionAttribute );
		manager.settings.position = positionAttrValue == 'top' || positionAttrValue == 'bottom' ? positionAttrValue : _settings.position;

		// Calculate threshold values, recalculate when resize window
		_publicMethods.resetStickyLimits( manager );

		// Start observing resize of relevant elements
		if ( _resizeObserver ) {
			_resizeObserver.observe( manager.containerElement );
			_resizeObserver.observe( manager.stickyElement );
			_resizeObserver.observe( manager.innerElement );
		}

		// Set element as activated
		manager.isActivated = true;
		manager.stickyElement.classList.add( manager.settings.isActivatedClass );

		// Add manager to public methods
		_publicMethods.managers.push( manager );
	}



	/**
	 * Initialize.
	 */
	_publicMethods.init = function( options ) {
		if ( _hasInitialized ) return;

		// Merge with general settings with options
		_settings = extend( _defaults, options );

		// Initialize resize observer
		if ( window.ResizeObserver ) {
			_resizeObserver = new ResizeObserver( _debounce( resetStickyLimitsOnResize, _settings.refreshRate ) );
		}

		// Initialize each sticky element
		var stickyElements = document.querySelectorAll( _settings.elementSelector );
		for ( var i = 0; i < stickyElements.length; i++ ) {
			_publicMethods.initializeElement( stickyElements[ i ] );
		}

		// Start handling sticky states
		throttledChangeState = _throttle( maybeChangeState, _settings.refreshRate );
		requestAnimationFrame( loop );

		_hasInitialized = true;
	};



	//
	// Public APIs
	//
	return _publicMethods;

});
