// dsHistory, v.9
// Copyright (c) 2007 Andrew Mattie (http://www.akmattie.net)
//
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
// 
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.

var dsHistory = {
	isIE: window.navigator.userAgent.indexOf('MSIE') !== -1,
	QueryElements: {}, // name/value collection to hold the values in the window hash
	lastFrameIteration: 0,
	lastHash: '',
	dirtyHash: window.location.hash,
	initialHash: window.location.hash,
	hashCache: [], // holds all previous hashes
	forwardHashCache: [], // hashes that are removed from hashCache as the user goes back are concat'd here
	eventCache: [], // holds all events
	forwardEventCache: [], // events that are removed from eventCache as the user goes back are concat'd here
	isInHistory: false, // if we're somewhere in the middle of the history stack, this will be set to true
	pauseWatch: false, // we'll use a single property to pause and resume the checking of our hash / frame iteration
	initialize: function(dispatchFnc) {
		var i, historyFrame;
		
		// we use a frame to track history all the time in IE since window.location.hash doesn't update the history.
		// in Gecko, we only use a frame to track history when we're not also trying to update the window hash
		historyFrame = document.createElement('iframe');
		historyFrame.name = 'dsHistoryFrame';
		historyFrame.id = 'dsHistoryFrame';
		historyFrame.src = 'dshistory.html?0';
		historyFrame.style.display = 'none';
		document.body.appendChild(historyFrame);
		
		// one to access the "real" source, since IE has problems reading it from the src prop when it's in a frame
		this.frameWindow = window.frames['dsHistoryFrame'];
		// and one to set the source...
		this.frameObject = historyFrame;
		if (this.isIE)
			this.hashCache.push(window.location.hash);
		this.watcherInterval = window.setInterval(function() { dsHistory._frameWatcher(); }, 15);
		
		// initialize the QueryElements object
		this._loadQueryVars();
		
		if (dispatchFnc) dispatchFnc();
		
		// make sure we don't leave any memory leaks when the visitor leaves
		if (window.addEventListener)
			window.addEventListener('unload', this._unload, false);
		else if (window.attachEvent)
			window.attachEvent('onunload', this._unload);
	},
	addFunction: function(fnc) {
		this.pauseWatch = true;
		
		// flush out anything that would have been used for the forward action if the user had used the back action
		this.isInHistory = false;
		this.forwardEventCache = [];
		this.forwardHashCache = [];
		
		// if the first event on the stack was put on when we changed the window hash and the visitor has gone back, we're going to 
		// go forward once to keep the implementation the same across the board. see my blog post for more info
		if (
			!this.isIE
			&& this.hashCache.length == 1
			&& this.eventCache.length == 1
			&& (window.location.hash == '' || window.location.hash == '#')
			)
			window.history.forward();
		
		// with IE, we want to make sure they're a hash entry put into the cache every time we change the frame
		// since moving back won't change the location hash. we'll use the hash cache to manually change the cache then.
		if (this.isIE)
			this.hashCache.push(window.location.hash);
		
		this.eventCache.push(fnc);
		this._updateFrameIteration();
		
		this.pauseWatch = false;
	},
	// this will conditionally add or update the name / value that was passed in. it will also add / update the QueryElements object
	setQueryVar: function(key, value) {
		// these used to be encoded with encodeURIComponent, but it turns out that encodings are performed automatically.
		var encodedKey = String(key);
		var encodedValue = String( ((typeof value == 'undefined') ? '' : value) );
		
		if (this.dirtyHash.indexOf('#') == -1 || this.dirtyHash.indexOf('#_serial') == 0) {
			if (encodedValue != '')
				this.dirtyHash = '#' + encodedKey + '=' + encodedValue;
			else
				this.dirtyHash = '#' + encodedKey;
		} else {
			if (typeof this.QueryElements[encodedKey] != 'undefined' && encodedValue != '') {
				this.dirtyHash = this.dirtyHash.substr(0, this.dirtyHash.indexOf(encodedKey) + encodedKey.length + 1) + encodedValue + this.dirtyHash.substr(this.dirtyHash.indexOf(encodedKey) + encodedKey.length + 1 + this.QueryElements[encodedKey].length);
			} else if (typeof this.QueryElements[encodedKey] == 'undefined') {
				if (encodedValue == '')
					this.dirtyHash += '&' + encodedKey;
				else
					this.dirtyHash += '&' + encodedKey + '=' + encodedValue;
			}
		}
		
		this.QueryElements[key] = value;
		
		if (this.hashCache > 1 && this.hashCache[this.hashCache.length - 2] == this.dirtyHash)
			this.dirtyHash += '_serial=' + this.hashCache.length;
		else if (this.dirtyHash.indexOf('_serial') != -1)
			this.removeQueryVar('_serial');
	},
	// this will remove the property of the QueryElements object and remove the name and value of the object in the dirtyHash
	removeQueryVar: function(key) {
		if (!this.QueryElements[key] && key != '_serial') return;
		
		var dataToStrip;
		
		if (this.QueryElements[key] == '')
			dataToStrip = key;
		else
			dataToStrip = key + '=' + this.QueryElements[key];
		
		this.dirtyHash = this.dirtyHash.substr(0, this.dirtyHash.indexOf(dataToStrip)) + this.dirtyHash.substr(this.dirtyHash.indexOf(dataToStrip) + dataToStrip.length);
		if (this.dirtyHash.substr(this.dirtyHash.length - 1) == '&')
			this.dirtyHash = this.dirtyHash.substr(0, this.dirtyHash.length - 1);
		
		// no need to have the actual ''query' part start with an ampersand if the very first element was removed
		if (this.dirtyHash.indexOf('#&') == 0) this.dirtyHash = '#' + this.dirtyHash.substr(2);
		
		delete(this.QueryElements[key]);
		
		// if the hash is empty, a serial number is appended to it so we can keep track of whether we're going forward or backward in the
		// frame watcher. this is needed specifically when a value is added, removed, and added again.
		if (this.dirtyHash == '#') this.dirtyHash = '#_serial=' + this.hashCache.length;
	},
	// we don't want to update the window has until this function is called since, otherwise, the history will change all
	// the time in Gecko browsers.
	bindQueryVars: function(fnc) {
		if (window.location.hash == this.dirtyHash) return;
		
		this.pauseWatch = true;
		
		// flush out anything that would have been used for the forward action if the user had used the back action
		this.isInHistory = false;
		this.forwardEventCache = [];
		this.forwardHashCache = [];
		
		if (
			!this.isIE
			&& this.hashCache.length == 1
			&& this.eventCache.length == 1
			&& (window.location.hash == '' || window.location.hash == '#')
			)
			window.history.forward();
		
		// so we have an empty hash to go back to on our first time around (but only if we're not using IE since otherwise we're adding
		// to the hashCache every single time anyway
		if (this.hashCache.length == 0 && this.eventCache.length > 0 && !this.isIE)
			this.hashCache.push(window.location.hash);
			
		window.location.hash = this.dirtyHash;
		this.lastHash = window.location.hash;
		
		this.hashCache.push(this.lastHash);
		this.eventCache.push(fnc);
		
		if (this.isIE)
			this._updateFrameIteration(true);
		
		this._loadQueryVars();
		this.pauseWatch = false;
	},
	setFirstEvent: function(fnc) {
		if (this.eventCache.length > 0)
			this.eventCache[0] = fnc;
	},
	// internal function to make sure we don't leave any memory leaks when the visitor leaves
	_unload: function() {
		// the scope here will be the window's and not the object since we didn't curry it, so we'll have to specify the object instead of using <this.>
		window.clearInterval(dsHistory.watcherInterval);
		dsHistory.frameWindow = null;
		dsHistory.frameObject = null;
		dsHistory.eventCache = null;
	},
	// internal function to load and split our query vars into our QueryElements object
	_loadQueryVars: function() {
		// flush out the object each time this is called
		this.QueryElements = {};
		
		if (window.location.hash == '' || window.location.hash == '#') return;
		
		var hashItems = window.location.hash.substring(1).split('&');
		for (i = 0; i < hashItems.length; i++) {
			if (hashItems[i].indexOf('=') != -1)
				// the encoding and decoding of the hash is taken care of by the browser
				this.QueryElements[hashItems[i].split('=')[0]] = hashItems[i].split('=')[1];
			else
				this.QueryElements[hashItems[i]] = '';
		}
		
		this.lastHash = window.location.hash;
	},
	// internal function to be called when we want to actually add something to the browser's history
	_updateFrameIteration: function(comingFromQueryBind) {
		var currentSrc = this.frameWindow.document.body.innerHTML;
		var currentIteration = parseInt( currentSrc.substr(currentSrc.indexOf('?') + 1) );
		var lastEvent, newEvent, lastHash;
		
		// it seems that gecko has a sweet bug / feature / something that prevents the history from changing with a frame iteration after a hash has changed the history
		// therefore, we have to mess with the hash enough to get it to add to the browser's history and then change it back so we don't screw up any values in the hash
		if (this.hashCache.length > 0 && !this.isIE) {
			// splice the event off the stack so we can add it on later
			lastEvent = this.eventCache.splice(this.eventCache.length - 1, 1)[0];
			lastHash = this.lastHash;
			
			if (lastHash == '') lastHash = '#';
			
			// use the dirty hash here since it's encoded and window.location.hash isn't encoded. otherwise, the history could be updated inadvertently
			window.location.hash = lastHash + String(this.hashCache.length); // this can be anything, as long as the hash changes
			this.hashCache.push(window.location.hash);
			
			window.location.hash = lastHash;
			this.hashCache.push(window.location.hash);
			
			// since we popped off the last event on the history stack, we're going to add it back on _after_ we add on a function to get back to our unadultered hash
			this.eventCache.push(function(direction) {
				if (direction == 'back') {
					dsHistory.isGoingBackward = true;
					window.history.back();
				} else {
					dsHistory.isGoingForward = true;
					window.history.forward();
				}
			});
			this.eventCache.push(lastEvent);
			
			return;
		}
		
		// there's no reason to change the frame source if we're only adding the first event to the history
		if (
			currentIteration == 0
			&& ( (this.hashCache.length == (comingFromQueryBind ? 1 : 0) && !this.isIE) || (this.hashCache.length == 2 && this.isIE) ) // extra hash for ie
			&& this.eventCache.length <= 1) // && !this.isIE)
			this.frameWindow.document.body.innerHTML = currentSrc.substr(0, currentSrc.indexOf('?')) + '?1';
		else
			this.frameObject.src = currentSrc.substr(0, currentSrc.indexOf('?')) + '?' + (currentIteration + 1);
	},
	// internal function that is called every 10 ms to check to see if we've gone back in time
	_frameWatcher: function() {
		if (!this.frameWindow.document.body) return; // this may take more than 10 ms to initialize in IE, so until it does, don't continue
		
		var frameSrc = this.frameWindow.document.body.innerHTML;
		var frameIteration = parseInt( frameSrc.substr(frameSrc.indexOf('?') + 1) );
		var windowHash = window.location.hash;
		
		// we don't want to pause while processing this since one of the events that could be called if the processing occurs
		// might be history.back()
		if (this.pauseWatch) return;
		
		if (this.isInHistory && this.hashCache.length > 1 && window.location.hash == this.hashCache[0] && this.dirtyHash != this.initialHash)
			this.dirtyHash = this.initialHash;
		
		// if the frame iteration is different or the window hash is different, we'll start a sequence of events to go back in time
		if (
			!this.isGoingForward
			&& (
				frameIteration < this.lastFrameIteration
				|| (this.lastHash != windowHash && this.hashCache[this.hashCache.length - 2] == windowHash && !this.isIE)
				)
			) {
			
			// this will be the pre-qual for our people hitting the forward button
			this.isInHistory = true;
			this.isGoingBackward = false;
			
			// if the hash has changed, or if we're using IE (in which case we change the hash with every event), make sure we
			// keep the hashCache and related items up-to-date
			if ((this.lastHash != windowHash && this.hashCache[this.hashCache.length - 2] == windowHash) || this.isIE) {
				this.forwardHashCache = this.forwardHashCache.concat(this.hashCache.splice(this.hashCache.length - 1, 1));
				
				// IE doesn't change the window hash when the user goes back, so we have to do it manually from our hashCache
				if (this.isIE)
					window.location.hash = this.hashCache[this.hashCache.length - 1];
				
				// we need to set this here so that if history.back() is one of the functions on the eventCache,
				// it will know we're on a different hash
				this.lastHash = window.location.hash;
				this.dirtyHash = window.location.hash;
			}
			
			// subtract 2 from this.eventCache.length since we're gonna end up calling the second function from the end when someone clicks the
			// back button. we can assume that another function was pushed onto the stack since the time the function we are going to call was
			// added. essentially, the last function in the array is the function we are on now, so we need to ignore it in here.
			
			// all functions that are pushed onto the history stack should consume the 'true' parameter that indicates that the call
			// came from here. this is done to prevent the object from pushing itself back onto the history stack a second time.
			
			if (this.eventCache.length > 1) {
				this.eventCache[this.eventCache.length - 2]('back');
				this.forwardEventCache = this.forwardEventCache.concat(this.eventCache.splice(this.eventCache.length - 1, 1));
			}
		}
		
		// handle the forward button. we determine whether we're moving forward if 1) the user hit the back button and we haven't added a
		// function or bound the query vars since, 2) we haven't hit our built-in history.back function to work around gecko's
		// updating-frame-doesnt-update-history-after-hash-has-been-added bug, and 3) the secondary conditions that allow us to know
		// whether we're going back in our history are inversed
		
		else if (
			this.isInHistory
			&& !this.isGoingBackward
			&& (
				frameIteration > this.lastFrameIteration
				|| (this.lastHash != windowHash && this.forwardHashCache[this.forwardHashCache.length - 1] == windowHash && !this.isIE)
				)
			) {
			this.isGoingForward = false;
			
			// the internals of this are nearly the same as the way we handle the visitor going back, except we use different caches
			// for reading the events and hashes we spliced off as we went back
			if ((this.lastHash != windowHash && this.forwardHashCache[this.forwardHashCache.length - 1] == windowHash) || this.isIE) {
				if (this.isIE)
					window.location.hash = this.forwardHashCache[this.forwardHashCache.length - 1];
				this.lastHash = window.location.hash;
				this.dirtyHash = window.location.hash;
				this.hashCache = this.hashCache.concat(this.forwardHashCache.splice(this.forwardHashCache.length - 1, 1));
			}
			
			this.forwardEventCache[this.forwardEventCache.length - 1]('forward');
			this.eventCache = this.eventCache.concat(this.forwardEventCache.splice(this.forwardEventCache.length - 1, 1));
		}
		
		// so we always have something to compare to the next time this is called
		this.lastFrameIteration = frameIteration;
	}
}
