/**
 * Animation
 * @projectDescription Animation Methods
 * @param {Object} options
 */

/**
 * The premise behind Animator is to utilize general animation state.
 * Animation state is between 0 and 1, ie: 0-100% complete.
 * This state is then changed via a transition method
 * and applied to the specified properties to modify them.
 * 
 * @param {Object} options	
 * An object of options for animation in the following format:
 * 	interval:Number	-	Time between frames
 * 	duration:Number	-	Length of animation in MS
 * 	onComplete:Function -	called on animation complete
 * 	onStep:Function	-	called on animation steps
 * 	transition:Function	-	the transition method
 */
function Animator(options) {
	this.setOptions(options);
	var _this = this;
	this.timerDelegate = function(){_this.onTimerEvent()};
	this.subjects = [];
	this.target = 0;	// the target state (0-1)
	this.state = 0;		// the current state (0-1)
};

Animator.prototype = {
	
	/**
	 * Sets the options for the animator
	 * @param {Object} options
	 */
	setOptions: function(options) {
		this.options = Object.extend({
			interval: 40,  // time between animation frames
			duration: 400, // length of animation
			onComplete: function(){},
			onStep: function(){},
			transition: Animator.tx.easeInOut
		}, options);
	},
	
	/**
	 * Animate from the current state to the provided value
	 * @param {Number} to		Any number between 0 and 1 
	 */
	seekTo: function(to) {
		this.seekFromTo(this.state, to);
	},
	
	/**
	 * animate from one state to another
	 * @param {Object} from		Any number between 0 and 1 
	 * @param {Object} to			Any number between 0 and 1 
	 */
	seekFromTo: function(from, to) {
		this.target = this.stateValue(to);
		this.state = this.stateValue(from);
		if ( this.intervalId === undefined ) {
			this.intervalId = setInterval(this.timerDelegate, this.options.interval);
		}
	},
	
	/**
	 * Returns a value between 0 and 1 
	 * if the value passed in is less than 0
	 * or greater than 1, we change it to 0 or 1
	 * respectively
	 * @param {Number} val	
	 * @return {Number} a number between 0 and 1
	 */
	stateValue: function(val)
	{
		return Math.max(0, Math.min(1, val));
	},
	
	/**
	 * Skip to a specific animation state
	 * @param {Object} to		Any number between 0 and 1 
	 */
	jumpTo: function(to) {
		this.target = this.state = this.stateValue(to);
		this.propagate();
	},
	
	/**
	 * Reverse from current state to beginning or end
	 * depending on play direction 
	 */
	toggle: function() {
		this.seekTo(1 - this.target);
	},
	
	/**
	 * Add a function or an object with a method "setState(state)" 
	 * will be called with a number between 0 and 1 on each frame
	 * @param {Array,Element} subject	An array of or one element to animate
	 * @return {Object} this
	 */
	addSubject: function(subject) {
		this.subjects.push(subject); //[this.subjects.length] = subject;
		return this;
	},
	
	/**
	 * remove an object that was added with addSubject
	 * @param {Object} subject
	 */ 
	removeSubject: function(subject) {
		this.subjects = this.subjects.reject(function(item){return item == subject;});
	},
	
	/**
	 * remove all subjects
	 */
	clearSubjects: function() {
		this.subjects = [];
	},
	
	/**
	 * push the current state to the animation subjects
	 */
	propagate: function() {
	//	Update the state via the transition method
		var value = this.options.transition(this.state);
	
	//	Loop through the subjects and apply the state
		for (var i=0; i<this.subjects.length; i++) {
			
		//	If the subject has a "setState" function,
		//	call it with the new value
			if (typeof this.subjects[i].setState == 'function') {
				this.subjects[i].setState(value);
			} else {
				this.subjects[i](value);
			}
		}
	},
	
	/**
	 * Called once per frame to update the current state
	 */
	onTimerEvent: function() {
		
	//	Get the movement unit ( interval / duration )
	//	and multiply it by the movement direction 
	//	( if state < target we are going forward, else backwards
		var movement = (this.options.interval / this.options.duration) * (this.state < this.target ? 1 : -1);
		
	//	If the suggested movement exceeds the target state
	//	set state = target, otherwise increment the state by the movement amount
		if (Math.abs(movement) >= Math.abs(this.state - this.target)) {
			this.state = this.target;
		} else {
			this.state += movement;
		}
	
	//	Propagate the new state to subjects
		try {
			this.propagate();
		} finally {
			
		//	Dispatch an "event" handler for onStep
		//	TODO: change this to a dispatcher 
			this.options.onStep.call(this);
		
		//	Check to see if we have reached the target state
			if (this.target == this.state) {
				
			//	Clear the interval
				clearInterval(this.intervalId);
				this.intervalId = undefined;
			
			//	Dispatch an "event" for onComplete
			//	TODO: change this to a dispatcher
				this.options.onComplete.call(this);
			}
		}
	},
	
	/**
	 * Shortcut to seekFromTo(0,1)
	 */
	play: function() {this.seekFromTo(0, 1)},
	
	/**
	 * Shortcut to seekFromTo(1,0)
	 */
	reverse: function() {this.seekFromTo(1, 0)}
}

/**
 * make an array from any object 
 * @param {Object} o	An object to convert to an array
 */
Animator.makeArray = function(o) {
	if (o == null) return [];
	if (!o.length) return [o];
	var result = [];
	for (var i=0; i<o.length; i++) result[i] = o[i];
	return result;
}

/**
 * convert a dash-delimited-property to a camelCaseProperty (c/o Prototype, thanks Sam!) 
 * @param {String} string	The string to camelize
 */
Animator.camelize = function(string) {
	return new String(string).camelize();
}

/**
 * syntactic sugar for creating CSSStyleSubjects
 * @param {Object} el
 * @param {Object} style
 * @param {Object} options
 * TODO: can remove this and add to utility class
 */
Animator.apply = function(el, style, options) {
	if (style instanceof Array) {
		return new Animator(options).addSubject(new CSSStyleSubject(el, style[0], style[1]));
	}
	return new Animator(options).addSubject(new CSSStyleSubject(el, style));
}

/**
 * Create an easeIn transition 
 * @param {Number} a	pass a=1 for smooth gravitational acceleration, 
 * higher values for an exaggerated effect
 * @return {Function} transition method
 */
Animator.makeEaseIn = function(a) {
	return function(state) {
		return Math.pow(state, a*2); 
	}
}

/**
 * Create an easeOut transition 
 * @param {Number} a	pass a=1 for smooth gravitational acceleration, 
 * higher values for an exaggerated effect
 * @return {Function} transition method
 */
Animator.makeEaseOut = function(a) {
	return function(state) {
		return 1 - Math.pow(1 - state, a*2); 
	}
}

/**
 * Create an elastic transition
 * @param {Number} bounces	Number of "bounces"
 * @return {Function} transition method
 */
Animator.makeElastic = function(bounces) {
	return function(state) {
		state = Animator.tx.easeInOut(state);
		return ((1-Math.cos(state * Math.PI * bounces)) * (1 - state)) + state; 
	}
}

/**
 * Create a bounce transition
 * @param {Number} bounces	Number of "bounces"
 * @return {Function} transition method
 */
Animator.makeBounce = function(bounces) {
	fn = Animator.makeElastic(bounces);
	return function(state) {
		state = fn(state); 
		return state <= 1 ? state : 2-state;
	}
}
 
/**
 * Premade transitions object
 * Contains the following transitions:
 * easeInOut, linear
 * easeIn, easeOut, strongEaseIn, strongEaseOut
 * elastic, veryElastic, bouncy, veryBouncy
 */
Animator.tx = {
	easeInOut: function(pos){
		return ((-Math.cos(pos*Math.PI)/2) + 0.5); 
	},
	linear: function(x) {
		return x;
	},
	easeIn: Animator.makeEaseIn(1.5),
	easeOut: Animator.makeEaseOut(1.5),
	strongEaseIn: Animator.makeEaseIn(2.5),
	strongEaseOut: Animator.makeEaseOut(2.5),
	elastic: Animator.makeElastic(1),
	veryElastic: Animator.makeElastic(3),
	bouncy: Animator.makeBounce(1),
	veryBouncy: Animator.makeBounce(3)
}

/**
 * Animate any numerical based style property
 * @param {Object} els			The element to style
 * @param {Object} property	The css property to style
 * @param {Object} from			Original value
 * @param {Object} to				End Value
 * @param {Object} units		"px", "%", etc
 */
function NumericalStyleSubject(els, property, from, to, units) {
	this.els = Animator.makeArray(els);
	if (property == 'opacity' && window.ActiveXObject) {
		this.property = 'filter';
	} else {
		this.property = Animator.camelize(property);
	}
	this.from = parseFloat(from);
	this.to = parseFloat(to);
	this.units = units || 'px';
}

NumericalStyleSubject.prototype = {
	
	/**
	 * set the state of the animation
	 * @param {Object} state
	 */
	setState: function(state) {
		var style = this.getStyle(state);
		var visibility = (this.property == 'opacity' && state == 0) ? 'hidden' : '';
		var j=0;
		for (var i=0; i<this.els.length; i++) {
			this.els[i].style[this.property] = style;
			if (j++ > 20) return;
		}
	},
	
	/**
	 * get the style of an element
	 * @param {Object} state
	 */
	getStyle: function(state) {
		state = this.from + ((this.to - this.from) * state);
		if (this.property == 'filter') return "alpha(opacity=" + Math.round(state*100) + ")";
		if (this.property == 'opacity') return state;
		return Math.round(state) + this.units;
	}
}

/**
 * Animate a color property between two hex values
 * @param {Object} els			The element to style
 * @param {Object} property	The element property to style
 * @param {Object} from			Original Value
 * @param {Object} to				End Value
 */
// animates a colour based style property between two hex values
function ColorStyleSubject(els, property, from, to) {
	this.els = Animator.makeArray(els);
	this.property = Animator.camelize(property);
	this.to = this.expandColor(to);
	this.from = this.expandColor(from);
}

ColorStyleSubject.prototype = {

	/**
	 * Parse a hex value into an array of RGB
	 * @param {Object} color	the color to parse
	 * @return {Array} an RGB array
	 * TODO: Does prototype do this?
	 */
	expandColor: function(color) {
		var hexColor, red, green, blue;
		hexColor = ColorStyleSubject.parseColor(color);
		if (hexColor) {
			red = parseInt(hexColor.slice(1, 3), 16);
			green = parseInt(hexColor.slice(3, 5), 16);
			blue = parseInt(hexColor.slice(5, 7), 16);
			return [red,green,blue]
		}
		if (window.DEBUG) {
			alert("Invalid colour: '" + color + "'");
		}
	},
	
	/**
	 * Get the value for the current state
	 * @param {Object} color
	 * @param {Object} state
	 */
	getValueForState: function(color, state) {
		return Math.round(this.from[color] + ((this.to[color] - this.from[color]) * state));
	},
	
	/**
	 * Set the current state
	 * @param {Object} state
	 */
	setState: function(state) {
		var color = '#'
				+ ColorStyleSubject.toColorPart(this.getValueForState(0, state))
				+ ColorStyleSubject.toColorPart(this.getValueForState(1, state))
				+ ColorStyleSubject.toColorPart(this.getValueForState(2, state));
		for (var i=0; i<this.els.length; i++) {
			this.els[i].style[this.property] = color;
		}
	}
}

/**
 * Return a properly formated 6-digit hex color 
 * @param {String} string
 * @return {String,False}	The color in #FFFFFF format, or false
 */
ColorStyleSubject.parseColor = function(string) {
	
	var color = '#', match;
	
	if(match = ColorStyleSubject.parseColor.rgbRe.exec(string)) {
		var part;
		for (var i=1; i<=3; i++) {
			part = Math.max(0, Math.min(255, parseInt(match[i])));
			color += ColorStyleSubject.toColorPart(part);
		}
		return color;
	}
	if (match = ColorStyleSubject.parseColor.hexRe.exec(string)) {
		if(match[1].length == 3) {
			for (var i=0; i<3; i++) {
				color += match[1].charAt(i) + match[1].charAt(i);
			}
			return color;
		}
		return '#' + match[1];
	}
	return false;
}

/**
 * convert a number to a 2 digit hex string
 * @param {Number} number
 */
ColorStyleSubject.toColorPart = function(number) {
	if (number > 255) number = 255;
	var digits = number.toString(16);
	if (number < 16) return '0' + digits;
	return digits;
}
ColorStyleSubject.parseColor.rgbRe = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i;
ColorStyleSubject.parseColor.hexRe = /^\#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;

/**
 * Animate between two CSS styles
 * @param {Array,Element} els		Element or element array
 * @param {String} style1				Start style or if style2 is not present, end style		
 * @param {String} [style2]			End style
 */
function CSSStyleSubject(els, style1, style2) {
	
	var fromStyle;
	var toStyle;
	var prop;
	this.subjects = [];
	
//	Get an array of elements
	els = Animator.makeArray(els);
	
//	Return if no elements in the array
	if (els.length == 0) return;
	
//	If they passed a second style,	animate from style1 to style2
	if (style2) 
	{
		fromStyle = this.parseStyle( style1, els[0] );
		toStyle = this.parseStyle( style2, els[0] );
	} 
//	Otherwise animate from the element's current style to style1
	else 
	{
		fromStyle = {};
		toStyle = this.parseStyle(style1, els[0]);
		
	//	loop through the styles in toStyle and 
	//	add their current values to fromStyle
		for (prop in toStyle) 
		{
			fromStyle[prop] = CSSStyleSubject.getStyle(els[0], prop);
		}
	}

// remove unchanging properties
	for (prop in fromStyle) 
	{
		if (fromStyle[prop] == toStyle[prop]) {
			delete fromStyle[prop];
			delete toStyle[prop];
		}
	}
	
	// discover the type (numerical or colour) of each style
	var prop, from, to, units, match, type;
	
	for (prop in fromStyle) 
	{
		if ( !toStyle[prop] ) 
		{
			if (window.DEBUG) alert("No to style provided for '" + prop + '"');
			continue;
		}
		
		if ( from = ColorStyleSubject.parseColor(fromStyle[prop]) ) 
		{
			to = ColorStyleSubject.parseColor(toStyle[prop]);
			type = ColorStyleSubject;
		} 
		else if(match = CSSStyleSubject.numericalRe.exec(fromStyle[prop])) 
		{
			from = parseFloat(fromStyle[prop]);
			to = parseFloat(toStyle[prop]);
			type = NumericalStyleSubject;
			units = match[1] || CSSStyleSubject.numericalRe.exec(toStyle[prop])[1];
		} 
		else 
		{
			if (window.DEBUG) {
				alert("Unrecognized format for value of "
					+ prop + ": '" + fromStyle[prop] + "'");
			}
			continue;
		}
		this.subjects[this.subjects.length] = new type(els, prop, from, to, units);
	}
}

CSSStyleSubject.prototype = {
	
	/**
	 * Parse the style on an element into an object
	 * @param {Object} style
	 * @param {Object} el
	 */
	parseStyle: function(style, el) {
		var rtn = {};
		
	// Check to see if the style is a ruleset in a string
		if ( typeof style == 'string' && style.indexOf(":") != -1) 
		{
		//	Get an array of the styles
			var styles = style.split(";");
			
		//	Loop through the styles and create a return object 
			for (var i=0; i<styles.length; i++) 
			{
			//	Run a regular expression to find a property and value
				var parts = CSSStyleSubject.ruleRe.exec(styles[i]);
				if (parts) 
				{
					rtn[parts[1]] = parts[2];
				}
				else
				{
					if(window.DEBUG) alert("cannot evaluate " + styles[i] + " as CSS");
				}
			}
		}
		
	// Is the "style" passed a classname?
		else if( typeof style == 'string' ) 
		{
		//	set some vars
			var prop, value, oldClass;
			
		//	Get the original classname then apply the new classname
		//	so that we can get a list of styles
			oldClass = el.className;
			el.className = style;
			
		//	Loop through the css styles listed in cssProperties array
			for ( var i=0, len=CSSStyleSubject.cssProperties.length; i<len; i++ ) 
			{
				prop = CSSStyleSubject.cssProperties[i];
				if( prop == 'padding' ) alert("padding is crap");
				
			//	look for a style value associated with the 
			//	current property from the cssProperties array 
				value = CSSStyleSubject.getStyle(el, prop);
				if (value) rtn[prop] = value;
			}
			
		//	reset the element className
			el.className = oldClass;
		}
		else 
		{
			if( window.DEBUG ) alert( "Cannot parse " + style );
		}
		return rtn;
		
	},
	setState: function(state) {
		for (var i=0; i<this.subjects.length; i++) {
			this.subjects[i].setState(state);
		}
	}
}
CSSStyleSubject.getStyle = function(el, rule){
	var style;
	if(document.defaultView && document.defaultView.getComputedStyle){
		style = document.defaultView.getComputedStyle(el, "").getPropertyValue(rule);
		if (style) {
			return style;
		}
	}
	rule = Animator.camelize(rule);
	if(el.currentStyle){
		style = el.currentStyle[rule];
	}
	return style || el.style[rule]
}


CSSStyleSubject.ruleRe = /^\s*([a-zA-Z\-]+)\s*:\s*(\S(.+\S)?)\s*$/;
CSSStyleSubject.numericalRe = /^-?\d+(?:\.\d+)?(%|[a-zA-Z]{2})?$/;

// required because the style object of elements isn't enumerable in Safari
CSSStyleSubject.cssProperties = ['background-color','border','border-color','border-spacing',
'border-style','border-top','border-right','border-bottom','border-left','border-top-color',
'border-right-color','border-bottom-color','border-left-color','border-top-width','border-right-width',
'border-bottom-width','border-left-width','border-width','bottom','color','font-size','font-size-adjust',
'font-stretch','font-style','height','left','letter-spacing','line-height','margin','margin-top',
'margin-right','margin-bottom','margin-left','marker-offset','max-height','max-width','min-height',
'min-width','orphans','outline','outline-color','outline-style','outline-width','overflow','padding',
'padding-top','padding-right','padding-bottom','padding-left','quotes','right','size','text-indent',
'top','width','word-spacing','z-index','opacity','outline-offset'];


// chains several Animator objects together
function AnimatorChain(animators) {
	this.animators = animators;
	for (var i=0; i<this.animators.length; i++) {
		this.listenTo(this.animators[i]);
	}
	this.forwards = false;
	this.current = 0;
}

AnimatorChain.prototype = {
	play: function() {
		this.forwards = true;
		this.current = -1;
		for (var i=0; i<this.animators.length; i++) {
			this.animators[i].jumpTo(0);
		}
		this.advance();
	},
	reverse: function() {
		this.forwards = false;
		this.current = this.animators.length;
		for (var i=0; i<this.animators.length; i++) {
			this.animators[i].jumpTo(1);
		}
		this.advance();
	},
	toggle: function() {
		if (this.forwards) {
			this.reverse();
		} else {
			this.play();
		}
	},
	listenTo: function(animator) {
		var oldOnComplete = animator.options.onComplete;
		var _this = this;
		animator.options.onComplete = function() {
			if (oldOnComplete) oldOnComplete.call(animator);
			_this.advance();
		}
	},
	// play the next animator
	advance: function() {
		if (this.forwards) {
			if (this.animators[this.current + 1] == null) return;
			this.current++;
			this.animators[this.current].play();
		} else {
			if (this.animators[this.current - 1] == null) return;
			this.current--;
			this.animators[this.current].reverse();
		}
	}
}


// an Accordion is a class that creates and controls a number of Animators. An array of elements is passed in,
// and for each element an Animator and a activator button is created. When an Animator's activator button is
// clicked, the Animator and all before it seek to 0, and all Animators after it seek to 1. This can be used to
// create the classic Accordion effect, hence the name.
function Accordion(options) {
	this.setOptions(options);
	var selected = this.options.initialSection, current;
	if (this.options.rememberance) {
		current = document.location.hash.substring(1);
	}
	this.rememberanceTexts = [];
	this.ans = [];
	var _this = this;
	for (var i=0; i<this.options.sections.length; i++) {
		var el = this.options.sections[i];
		var an = new Animator(this.options.animatorOptions);
		var from = this.options.from + (this.options.shift * i);
		var to = this.options.to + (this.options.shift * i);
		an.addSubject(new NumericalStyleSubject(el, this.options.property, from, to, this.options.units));
		an.jumpTo(0);
		var activator = this.options.getActivator(el);
		activator.index = i;
		activator.onclick = function(){_this.show(this.index)};
		this.ans[this.ans.length] = an;
		this.rememberanceTexts[i] = activator.innerHTML.replace(/\s/g, "");
		if (this.rememberanceTexts[i] === current) {
			selected = i;
		}
	}
	this.show(selected);
}

Accordion.prototype = {
	// apply defaults
	setOptions: function(options) {
		this.options = Object.extend({
			// an array of elements to use as the accordion sections
			sections: null,
			// a function that locates an activator button element given a section element.
			// by default it takes a button id from the section's "activator" attibute
			getActivator: function(el) {return $(el.getAttribute("activator"))},
			// shifts each animator's range, for example with options {from:0,to:100,shift:20}
			// the animators' ranges will be 0-100, 20-120, 40-140 etc.
			shift: 0,
			// the first page to show
			initialSection: 0,
			// if set to true, document.location.hash will be used to preserve the open section across page reloads 
			rememberance: true,
			// constructor arguments to the Animator objects
			animatorOptions: {}
		}, options || {});
	},
	show: function(section) {
		for (var i=0; i<this.ans.length; i++) {
			this.ans[i].seekTo(i > section ? 1 : 0);
		}
		if (this.options.rememberance) {
			document.location.hash = this.rememberanceTexts[section];
		}
	}
}
