// FIXME:
// * fully make use of transitions in glide routines

define([
	'dojo/_base/declare',
	'dojo/on',
	'./util/touch',
	'./util/has-css3',
	'put-selector/put',
	'xstyle/css!./css/TouchScroll.css'
], function (declare, on, touchUtil, has, put) {
	var calcTimerRes = 50, // ms between drag velocity measurements
		glideTimerRes = 30, // ms between glide animation ticks
		current = {}, // records info for widget(s) currently being scrolled
		previous = {}, // records info for widget(s) that were mid-scroll when someone decided to scroll again
		glideThreshold = 1, // speed (in px) below which to stop glide - TODO: remove
		scrollbarAdjustment = 8, // number of px to adjust scrollbar dimension calculations
		// RegExps for parsing relevant x/y from translate and matrix values:
		translateRx = /^translate(?:3d)?\((-?\d+)(?:\.\d*)?(?:px)?, (-?\d+)/,
		matrixRx = /^matrix\(1, 0, 0, 1, (-?\d+)(?:\.\d*)?(?:px)?, (-?\d+)/,
		// store has-features we need, for computing property/function names:
		hasTransitions = has('css-transitions'),
		hasTransitionEnd = has('transitionend'),
		hasTransforms = has('css-transforms'),
		hasTransforms3d = has('css-transforms3d'),
		// and declare vars to store info on the properties/functions we'll need
		cssPrefix, transitionPrefix, transformProp, translatePrefix, translateSuffix;

	if (hasTransforms3d) {
		translatePrefix = 'translate3d(';
		translateSuffix = ',0)';
	}
	else if (hasTransforms) {
		translatePrefix = 'translate(';
		translateSuffix = ')';
	}

	if (!hasTransitions || !translatePrefix) {
		console.warn('CSS3 features unavailable for touch scroll effects.');
		return function () {};
	}

	// figure out strings for use later in events
	transformProp = hasTransforms3d || hasTransforms;
	transformProp = transformProp === true ? 'transform' : transformProp + 'Transform';
	transitionPrefix = hasTransitions === true ? 'transition' :
		hasTransitions + 'Transition';
	cssPrefix = hasTransforms === true ? '' :
		'-' + hasTransforms.toLowerCase() + '-';

	function showScrollbars(widget, curr) {
		// Handles displaying of X/Y scrollbars as appropriate when a touchstart
		// occurs.

		var node = widget.touchNode,
			parentNode = node.parentNode,
			adjustedParentWidth = parentNode.offsetWidth - scrollbarAdjustment,
			adjustedParentHeight = parentNode.offsetHeight - scrollbarAdjustment,
			// Also populate scroll/offset properties on curr for reuse,
			// to avoid having to repeatedly hit the DOM.
			scrollWidth = curr.scrollWidth = node.scrollWidth,
			scrollHeight = curr.scrollHeight = node.scrollHeight,
			parentWidth = curr.parentWidth = parentNode.offsetWidth,
			parentHeight = curr.parentHeight = parentNode.offsetHeight,
			scrollbarNode;

		if (scrollWidth > parentWidth) {
			if (!widget._scrollbarXNode) {
				scrollbarNode = put(parentNode, 'div.touchscroll-x');
			}
			scrollbarNode = widget._scrollbarXNode =
				widget._scrollbarXNode || put(scrollbarNode, 'div.touchscroll-bar');
			scrollbarNode.style.width =
				adjustedParentWidth * adjustedParentWidth / scrollWidth + 'px';
			scrollbarNode.style.left = node.offsetLeft + 'px';
			put(parentNode, '.touchscroll-scrollable-x');
			curr.scrollableX = true;
		}
		else {
			put(parentNode, '!touchscroll-scrollable-x');
		}
		if (scrollHeight > parentHeight) {
			if (!widget._scrollbarYNode) {
				scrollbarNode = put(parentNode, 'div.touchscroll-y');
			}
			scrollbarNode = widget._scrollbarYNode =
				widget._scrollbarYNode || put(scrollbarNode, 'div.touchscroll-bar');
			scrollbarNode.style.height =
				adjustedParentHeight * adjustedParentHeight / scrollHeight + 'px';
			scrollbarNode.style.top = node.offsetTop + 'px';
			put(parentNode, '.touchscroll-scrollable-y');
			curr.scrollableY = true;
		}
		else {
			put(parentNode, '!touchscroll-scrollable-y');
		}
		put(parentNode, '!touchscroll-fadeout');
	}

	function scroll(widget, options) {
		// Handles updating of scroll position (from touchmove or glide).
		var node = widget.touchNode,
			curr = current[widget.id],
			pos, hasX, hasY, x, y;

		if (typeof options !== 'object') {
			// Allow x, y to be passed directly w/o extra object creation.
			// (e.g. from ontouchmove)
			x = options;
			y = arguments[2];
			options = arguments[3];
			hasX = hasY = true;
		}
		else {
			hasX = 'x' in options;
			hasY = 'y' in options;

			// If either x or y weren't specified, pass through the current value.
			if (!hasX || !hasY) {
				pos = widget.getScrollPosition();
			}
			x = hasX ? options.x : pos.x;
			y = hasY ? options.y : pos.y;
		}

		// Update transform on touchNode
		node.style[transformProp] =
			translatePrefix + -x + 'px,' + -y + 'px' + translateSuffix;

		// Update scrollbar positions
		if (curr && hasX && widget._scrollbarXNode) {
			widget._scrollbarXNode.style[transformProp] = translatePrefix +
				(x * curr.parentWidth / curr.scrollWidth) + 'px,0' + translateSuffix;
		}
		if (curr && hasY && widget._scrollbarYNode) {
			widget._scrollbarYNode.style[transformProp] = translatePrefix + '0,' +
				(y * curr.parentHeight / curr.scrollHeight) + 'px' + translateSuffix;
		}

		// Emit a scroll event that can be captured by handlers, passing along
		// scroll information in the event itself (since we already have the info,
		// and it'd be difficult to get from the node).
		on.emit(widget.touchNode.parentNode, 'scroll', {
			scrollLeft: x,
			scrollTop: y
		});
	}

	function getScrollStyle(widget) {
		// Returns match object for current scroll position based on transform.
		if (current[widget.id]) {
			// Mid-transition: determine current X/Y from computed values.
			return matrixRx.exec(window.getComputedStyle(widget.touchNode)[transformProp]);
		}
		// Otherwise, determine current X/Y from applied style.
		return translateRx.exec(widget.touchNode.style[transformProp]);
	}

	function resetEffects(options) {
		// Function to cut glide/bounce short, called in context of an object
		// from the current hash; attached only when a glide or bounce occurs.
		// Called on touchstart, when touch scrolling is canceled, when
		// momentum/bounce finishes, and by scrollTo on instances (in case it's
		// called directly during a glide/bounce).

		var widget = this.widget,
			nodes = [this.node, widget._scrollbarXNode, widget._scrollbarYNode],
			i = nodes.length;

		// Clear glide timer.
		if (this.timer) {
			clearTimeout(this.timer);
			this.timer = null;
		}

		// Clear transition handlers, as we're about to cut it short.
		if (this.transitionHandler) {
			// Unhook any existing transitionend handler, since we'll be
			// canceling the transition.
			this.transitionHandler.remove();
		}

		// Clear transition duration on main node and scrollbars.
		while (i--) {
			if (nodes[i]) { nodes[i].style[transitionPrefix + 'Duration'] = '0'; }
		}

		// Fade out scrollbars unless indicated otherwise (e.g. re-touch).
		if (!options || !options.preserveScrollbars) {
			put(this.node.parentNode, '.touchscroll-fadeout');
		}

		// Remove this method so it can't be called again.
		delete this.resetEffects;
	}

	// functions for handling touch events on node to be scrolled

	function ontouchstart(evt) {
		var widget = evt.widget,
			node = widget.touchNode,
			id = widget.id,
			posX = 0,
			posY = 0,
			touch, match, curr;

		// Check touches count (which hasn't counted this event yet);
		// ignore touch events on inappropriate number of contact points.
		if (touchUtil.countCurrentTouches(evt, node) !== widget.touchesToScroll) {
			return;
		}

		match = getScrollStyle(widget);
		if (match) {
			posX = +match[1];
			posY = +match[2];
		}
		if ((curr = current[id])) {
			// stop any active glide or bounce, since it's been re-touched
			if (curr.resetEffects) {
				curr.resetEffects({ preserveScrollbars: true });
			}

			node.style[transformProp] =
				translatePrefix + posX + 'px,' + posY + 'px' + translateSuffix;

			previous[id] = curr;
		}

		touch = evt.targetTouches[0];
		curr = current[id] = {
			widget: widget,
			node: node,
			// Subtract touch coords now, then add back later, so that translation
			// goes further negative when moving upwards.
			startX: posX - touch.pageX,
			startY: posY - touch.pageY,
			// Initialize lastX/Y, in case of a fast flick (< 1 full calc cycle).
			lastX: posX,
			lastY: posY,
			// Also store original pageX/Y for threshold check.
			pageX: touch.pageX,
			pageY: touch.pageY,
			tickFunc: function () { calcTick(id); }
		};
		curr.timer = setTimeout(curr.tickFunc, calcTimerRes);
	}
	function ontouchmove(evt) {
		var widget = evt.widget,
			id = widget.id,
			touchesToScroll = widget.touchesToScroll,
			curr = current[id],
			activeTouches, targetTouches, touch, nx, ny, minX, minY, i;

		// Ignore touchmove events with inappropriate number of contact points.
		if (!curr || (activeTouches = touchUtil.countCurrentTouches(evt, widget.touchNode)) !== touchesToScroll) {
			// Also cancel touch scrolling if there are too many contact points.
			if (activeTouches > touchesToScroll) {
				widget.cancelTouchScroll();
			}
			return;
		}

		targetTouches = evt.targetTouches;
		touch = targetTouches[0];

		// Show touch scrollbars on first sign of drag.
		if (!curr.scrollbarsShown) {
			if (previous[id] || (
					Math.abs(touch.pageX - curr.pageX) > widget.scrollThreshold ||
					Math.abs(touch.pageY - curr.pageY) > widget.scrollThreshold)) {
				showScrollbars(widget, curr);
				curr.scrollbarsShown = true;

				// Add flag to involved touches to provide indication to other handlers.
				for (i = targetTouches.length; i--;) {
					targetTouches[i].touchScrolled = true;
				}
			}
		}

		if (curr.scrollbarsShown && (curr.scrollableX || curr.scrollableY)) {
			// If area can be scrolled, prevent default behavior and perform scroll.
			evt.preventDefault();

			nx = curr.scrollableX ? curr.startX + touch.pageX : 0;
			ny = curr.scrollableY ? curr.startY + touch.pageY : 0;

			minX = curr.scrollableX ? -(curr.scrollWidth - curr.parentWidth) : 0;
			minY = curr.scrollableY ? -(curr.scrollHeight - curr.parentHeight) : 0;

			// If dragged beyond edge, halve the distance between.
			if (nx > 0) {
				nx = nx / 2;
			}
			else if (nx < minX) {
				nx = minX - (minX - nx) / 2;
			}
			if (ny > 0) {
				ny = ny / 2;
			}
			else if (ny < minY) {
				ny = minY - (minY - ny) / 2;
			}

			scroll(widget, -nx, -ny); // call scroll with positive coordinates
		}
	}
	function ontouchend(evt) {
		var widget = evt.widget,
			id = widget.id,
			curr = current[id];

		if (!curr || touchUtil.countCurrentTouches(evt, widget.touchNode) !== widget.touchesToScroll - 1) {
			return;
		}
		startGlide(id);
	}

	// glide-related functions

	function calcTick(id) {
		// Calculates current speed of touch drag
		var curr = current[id],
			node, match, x, y;

		if (!curr) { return; } // no currently-scrolling widget; abort

		node = curr.node;
		match = translateRx.exec(node.style[transformProp]);

		if (match) {
			x = +match[1];
			y = +match[2];

			// If previous reference point already exists, calculate velocity
			curr.velX = x - curr.lastX;
			curr.velY = y - curr.lastY;

			// set previous reference point for future iteration or calculation
			curr.lastX = x;
			curr.lastY = y;
		} else {
			curr.lastX = curr.lastY = 0;
		}
		curr.timer = setTimeout(curr.tickFunc, calcTimerRes);
	}

	function bounce(id, lastX, lastY) {
		// Function called when a scroll ends, to handle rubber-banding beyond edges.
		var curr = current[id],
			widget = curr.widget,
			node = curr.node,
			scrollbarNode,
			x = curr.scrollableX ?
				Math.max(Math.min(0, lastX), -(curr.scrollWidth - curr.parentWidth)) :
				lastX,
			y = curr.scrollableY ?
				Math.max(Math.min(0, lastY), -(curr.scrollHeight - curr.parentHeight)) :
				lastY;

		function end() {
			// Performs reset operations upon end of scroll process.

			// Since transitions have run, delete transitionHandler up-front
			// (since it auto-removed itself anyway), then let
			// resetEffects do the rest of its usual job.
			delete curr.transitionHandler;
			curr.resetEffects();
			delete current[id];
		}

		// Timeout will have been cleared before bounce call, so remove timer.
		delete curr.timer;

		if (x !== lastX || y !== lastY) {
			curr.transitionHandler = on.once(node, hasTransitionEnd, end);
			node.style[transitionPrefix + 'Duration'] = widget.bounceDuration + 'ms';
			node.style[transformProp] =
				translatePrefix + x + 'px,' + y + 'px' + translateSuffix;

			// Also handle transitions for scrollbars.
			if (x !== lastX && curr.scrollableX) {
				scrollbarNode = curr.widget._scrollbarXNode;
				scrollbarNode.style[transitionPrefix + 'Duration'] =
					widget.bounceDuration + 'ms';
				if (lastX > x) {
					// Further left; bounce back right
					scrollbarNode.style[transformProp] =
						translatePrefix + '0,0' + translateSuffix;
				}
				else {
					// Further right; bounce back left
					scrollbarNode.style[transformProp] =
						translatePrefix +
						(scrollbarNode.parentNode.offsetWidth - scrollbarNode.offsetWidth) +
						'px,0' + translateSuffix;
				}
			}
			if (y !== lastY && curr.scrollableY) {
				scrollbarNode = curr.widget._scrollbarYNode;
				scrollbarNode.style[transitionPrefix + 'Duration'] =
					widget.bounceDuration + 'ms';
				if (lastY > y) {
					// Above top; bounce back down
					scrollbarNode.style[transformProp] =
						translatePrefix + '0,0' + translateSuffix;
				}
				else {
					// Below bottom; bounce back up
					scrollbarNode.style[transformProp] =
						translatePrefix + '0,' +
						(scrollbarNode.parentNode.offsetHeight - scrollbarNode.offsetHeight) +
						'px' + translateSuffix;
				}
			}
		}
		else {
			end(); // no rubber-banding necessary; just reset
		}
	}

	function startGlide(id) {
		// starts glide operation when drag ends
		var curr = current[id],
			prev = previous[id],
			match, posX, posY,
			INERTIA_ACCELERATION = 1.15;

		delete previous[id];

		if (curr.timer) { clearTimeout(curr.timer); }

		// Enable usage of resetEffects during glide or bounce.
		curr.resetEffects = resetEffects;

		// calculate velocity based on time and displacement since last tick
		match = translateRx.exec(curr.node.style[transformProp]);
		if (match) {
			posX = +match[1];
			posY = +match[2];
		} else {
			posX = posY = 0;
		}

		// If there is no glide to perform (no exit velocity), or if we are
		// beyond boundaries on all applicable edges, immediately bounce back.
		if ((!curr.velX && !curr.velY) ||
				((posX >= 0 || posX <= -(curr.scrollWidth - curr.parentWidth)) &&
				(posY >= 0 || posY <= -(curr.scrollHeight - curr.parentHeight)))) {
			bounce(id, posX, posY);
			return;
		}

		function sameSign(a, b) {
			return ((a.velX <= 0 && b.velX <= 0) || (a.velX >= 0 && b.velX >= 0)) &&
					((a.velY <= 0 && b.velY <= 0) || (a.velY >= 0 && b.velY >= 0));
		}

		if (prev && (prev.velX || prev.velY) && sameSign(curr, prev)) {
			curr.velX = (curr.velX + prev.velX) * INERTIA_ACCELERATION;
			curr.velY = (curr.velY + prev.velY) * INERTIA_ACCELERATION;
		}

		// update lastX/Y with current position, for glide calculations
		curr.lastX = posX;
		curr.lastY = posY;
		curr.calcFunc = function () { calcGlide(id); };
		curr.timer = setTimeout(curr.calcFunc, glideTimerRes);
	}
	function calcGlide(id) {
		// performs glide and decelerates according to widget's glideDecel method
		var curr = current[id],
			node, parentNode, widget, i,
			nx, ny, nvx, nvy, // old/new coords and new velocities
			BOUNCE_DECELERATION_AMOUNT = 6;

		if (!curr) { return; }

		node = curr.node;
		parentNode = node.parentNode;
		widget = curr.widget;
		nvx = widget.glideDecel(curr.velX);
		nvy = widget.glideDecel(curr.velY);

		if (Math.abs(nvx) >= glideThreshold || Math.abs(nvy) >= glideThreshold) {
			// still above stop threshold; update transformation
			nx = curr.lastX + nvx;
			ny = curr.lastY + nvy;

			// If glide has traveled beyond any edges, institute rubber-band effect
			// by further decelerating.
			if (nx > 0 || nx < -(curr.scrollWidth - curr.parentWidth)) {
				for (i = BOUNCE_DECELERATION_AMOUNT; i--;) {
					nvx = widget.glideDecel(nvx);
				}
			}
			if (ny > 0 || ny < -(curr.scrollHeight - curr.parentHeight)) {
				for (i = BOUNCE_DECELERATION_AMOUNT; i--;) {
					nvy = widget.glideDecel(nvy);
				}
			}

			// still scrollable; update offsets/velocities and schedule next tick
			scroll(widget, -nx, -ny); // call scroll with positive coordinates
			// update information
			curr.lastX = nx;
			curr.lastY = ny;
			curr.velX = nvx;
			curr.velY = nvy;
			curr.timer = setTimeout(curr.calcFunc, glideTimerRes);
		}
		else {
			bounce(id, curr.lastX, curr.lastY);
		}
	}

	return declare(null, {
		// touchesToScroll: Number
		//		Number of touches to require on the component's touch target node
		//		in order to trigger scrolling behavior.
		touchesToScroll: 1,

		// touchNode: DOMNode?
		//		Node upon which scroll behavior will be based; transformations will be
		//		applied to this node, and events and some DOM/styles will be applied
		//		to its *parent*.  If not specified, defaults to containerNode.
		touchNode: null,

		// scrollThreshold: Number
		//		Minimum number of pixels to wait for user to scroll (in any direction)
		//		before initiating scroll.
		scrollThreshold: 10,

		// bounceDuration: Number
		//		Number of milliseconds which "rubber-banding" transitions
		//		(i.e. bouncing back from beyond edges) should take.
		bounceDuration: 300,

		postCreate: function () {
			this._initTouch();
			this.inherited(arguments);
		},

		_initTouch: function () {
			var node = this.touchNode = this.touchNode || this.containerNode,
				widget = this,
				parentNode;

			if (!node || !node.parentNode) {
				// Bail out if we have no touchNode or containerNode, or if we don't
				// seem to have a parent node to work with.
				console.warn('TouchScroll requires a nested node upon which to operate.');
				return;
			}

			parentNode = node.parentNode;

			// Set overflow to hidden in order to prevent any native scroll logic.
			parentNode.style.overflow = 'hidden';

			node.style[transitionPrefix + 'Property'] = cssPrefix + 'transform';
			node.style[transitionPrefix + 'TimingFunction'] =
				'cubic-bezier(0.33, 0.66, 0.66, 1)';

			function cancelTouchScroll() {
				widget.cancelTouchScroll();
			}

			function wrapHandler(func) {
				return function (evt) {
					evt.widget = widget;
					evt.cancelTouchScroll = cancelTouchScroll;
					func.call(this, evt);
				};
			}

			this._touchScrollListeners = [
				on(parentNode, 'touchstart', wrapHandler(ontouchstart)),
				on(parentNode, 'touchmove', wrapHandler(ontouchmove)),
				on(parentNode, 'touchend,touchcancel', wrapHandler(ontouchend))
			];
		},

		destroy: function () {
			var i = this._touchScrollListeners.length;
			while (i--) {
				this._touchScrollListeners[i].remove();
			}
			delete current[this.id];

			this.inherited(arguments);
		},

		scrollTo: function (options) {
			// summary:
			//      Scrolls the widget to a specific position.
			// options: Object
			//		Object containing target x and/or y position to scroll to
			//		(if unspecified, scroll in that direction will be preserved).
			//		Also supports the following other options:
			//		* preserveMomentum: if true, will not reset any active
			//			momentum or bounce on the widget

			var curr = current[this.id],
				touchNode = this.touchNode,
				parentNode = touchNode.parentNode;

			if (!options.preserveMomentum && curr && curr.resetEffects) {
				// Stop any glide or bounce occurring before scrolling.
				curr.resetEffects();
			}

			// Constrain coordinates within scrollable boundaries.
			if (options.x) {
				options.x = Math.max(0, Math.min(options.x,
					touchNode.scrollWidth - parentNode.offsetWidth));
			}
			if (options.y) {
				options.y = Math.max(0, Math.min(options.y,
					touchNode.scrollHeight - parentNode.offsetHeight));
			}

			scroll(this, options);
		},

		getScrollPosition: function () {
			// summary:
			//      Determines current translation from computed style
			//      (if mid-transition), or applied style.
			var match = getScrollStyle(this);
			return match ? { x: -match[1], y: -match[2] } : { x: 0, y: 0 };
		},

		cancelTouchScroll: function () {
			// summary:
			//		Removes any existing scroll information for this component from the
			//		current map, effectively canceling any TouchScroll behavior for
			//		that particular touch gesture.

			var curr = current[this.id];
			if (!curr) { return; }

			if (curr.resetEffects) { curr.resetEffects(); }
			else {
				if (curr.timer) { clearTimeout(curr.timer); }
				put(curr.node.parentNode, '.touchscroll-fadeout');
			}

			delete current[this.id];
		},

		glideDecel: function (n) {
			// summary:
			//		Deceleration algorithm. Given a number representing velocity,
			//		returns a new velocity to impose for the next "tick".
			//		(Don't forget that velocity can be positive or negative!)
			return n * 0.9; // Number
		}
	});
});
