module('lively.Persistence').requires('lively.persistence.Serializer', 'cop.Layers').toRun(function() {

Object.subclass('OfflineStorage',
{
	initialize: function() {
		if(!OfflineStorage.available()) {
			// don't fail hard
			console.error( "Offline Storage is not available!" );
			return;
		}
		this.storage = window.localStorage;
	},

	inspect: function() {
		return JSON.serialize(this.data());
	},

	toString: function() {
		return "OfflineStorage ("+this.length()+" items)";
	}
},

// Storage maps local methods to storage object for better inspection.
'storage', {

	length:			function() { return this.storage.length; },
	key:				function(index) { return this.storage.key(index); },
	getItem:		function(key) { return this.storage.getItem(key); },
	setItem:		function(key, data) { return this.storage.setItem(key, data); },
	removeItem:	function(key) { return this.storage.removeItem(key); },
	clear:			function() { return this.storage.clear(); }
});

Object.extend(OfflineStorage,
{
	changeSetPostfix: ".cs",
	jsonPostfix: ".json",
	offlineStorageEnabledFlagPostfix: ".enabled",
	autoLoadPostfix: ".auto"
});

OfflineStorage.addMethods(
'world', {

	// make relative to absolute URL
	getAbsoluteLocalUrl: function() {
		var url = URL.source;
		try {
			url = new URL(url);
		} catch(e) {
			url = URL.source.withFilename(url);
		}
		return url;
	},

	getLocalJSONData: function() {
		var key = this.getAbsoluteLocalUrl().localStorageHash() + OfflineStorage.jsonPostfix;
		return this.getItem(key);
	},

	getOfflineStorageEnabledKey: function() {
		var url = this.getAbsoluteLocalUrl();
		return url.localStorageHash() + OfflineStorage.offlineStorageEnabledFlagPostfix;
	},

	setOfflineStorageEnabled: function(enabled) {
		var key = this.getOfflineStorageEnabledKey();
		this.setItem(key, enabled);
		return enabled;
	},

	isOfflineStorageEnabled: function() {
		var key = this.getOfflineStorageEnabledKey();
		return this.truthinessOf(this.getItem(key));
	},

	getAutoLoadKey: function() {
		var url = this.getAbsoluteLocalUrl();
		return url.localStorageHash() + OfflineStorage.autoLoadPostfix;
	},

	setAutoLoad: function(auto) {
		var key = this.getAutoLoadKey();
		this.setItem(key, auto);
		return auto;
	},

	shouldAutoLoad: function() {
		var key = this.getAutoLoadKey();
		return this.truthinessOf(this.getItem(key));
	},

	loadWorldLocally: function() {
		if(this.isOfflineStorageEnabled()) {
			this.setAutoLoad(true);
			window.location.reload();
		} else {
			throw new Error("You cannot load, there does not seem to be a local copy available!");
    }
	},

	deserializeChangeSetFromLocalStorage: function() {
		var parser = new DOMParser(), // not available in IE, workaroung in XMLDocument.loadXML();
			key = this.getAbsoluteLocalUrl().localStorageHash() + OfflineStorage.changeSetPostfix,
			changeSetData;
		changeSetData = parser.parseFromString(this.getItem(key), "text/xml");
		return ChangeSet.fromNode(changeSetData);
	},

	saveWorldLocally: function() {
		console.log("starting local save operation.");

		var world = WorldMorph.current();
		var url = this.getAbsoluteLocalUrl();
		var serializer = new XMLSerializer(); // not available in IE, no workaround.

		// serialize changeset and world.
		var changeSetKey = url.localStorageHash() + OfflineStorage.changeSetPostfix,
			changeSetData = serializer.serializeToString(ChangeSet.fromWorld(world).getXMLElement());

		// serialize world
		var jsonKey = url.localStorageHash() + OfflineStorage.jsonPostfix,
			jsonData = lively.persistence.Serializer.serialize(world);

		// store changeset & world's json. this might fail due to quota limits.
		try {
			this.setItem(changeSetKey, changeSetData);
			this.setItem(jsonKey, jsonData);
			this.setOfflineStorageEnabled(true);
			this.setAutoLoad(false);
		} catch(e) {
			// this should be the most common cause (quota, chrome):
			if(e.code == DOMException.QUOTA_EXCEEDED_ERR) {
				alert("Your quota for offline storage was exceeded. Consider setting a new limit");
      }

			// all other exceptions are considered unknown:
			console.error("An unknown exception occurred: ", e);
		}

		console.log("local save operation finished.");
		return true;
	}
},

'helpers', {

	// Returns an array of keys.
	keys: function() {
		var length = this.length();
		var result = new Array(length);
		for(var i=0; i<length; i++) {
			result[i] = this.key(i);
		}
		return result;
	},

	// Returns an object of key => data.
	data: function() {
		var length = this.length();
		var result = {};
		for(var i=0; i<length; i++) {
			result[this.key(i)] = this.getItem(this.key(i));
    }
		return result;
	},

	truthinessOf: function(obj) {
		if(typeof(obj) == "string") {
			switch(obj.toLowerCase()) {
				case "true": case "yes": case "1": return true;
				case "false": case "no": case "0": case null: return false;
				default: return Boolean(obj);
			}
		} else {
			return Boolean(obj);
		}
	}
});

Object.extend(OfflineStorage,
{
	available: function() {
		try {
			return (window.localStorage !== undefined) &&
				(window.localStorage instanceof Storage);

		} catch(e) {
			// today, the names of security exceptions that can be thrown here
			// are not known for all browsers. therefore, we currently ignore
			// all of them and consider offline storage to be unavailable.
			return false;
		}
	},

	enableOfflineStorage: function() {

		var save, load, w = WorldMorph.current();
		var provideButton = function(label, source) {
			var b = new ScriptableButtonMorph(new Rectangle(96, 2, 96, 32));
			b.margin = new Rectangle(2, 0, 2, 0);
			b.setLabel(label);
			b.scriptSource = source;
			return b;
		};

		save = provideButton("Save Locally", '(new OfflineStorage()).saveWorldLocally()');
		load = provideButton("Load Locally", '(new OfflineStorage()).loadWorldLocally()');

		load.moveBy(pt(100, 0));

		w.addMorph(save);
		w.addMorph(load);
	}
});

Object.subclass('AutosaveAgent',
{
	initialize: function() {
		if(AutosaveAgent.singletonInstance !== undefined) {
			throw new Error("The AutosaveAgent is a singleton. Please obtain one with AutosaveAgent.get()");
    }
	}
});

Object.extend('AutosaveAgent',
{
	get: function() {
		if(AutosaveAgent.singletonInstance === undefined) {
			AutosaveAgent.singletonInstance = new AutosaveAgent();
    }
		return AutosaveAgent.singletonInstance;
	}
});

cop.create('AutosaveUserInteractionMonitorLayer').refineClass(Event, {
	initialize: function(rawEvent) {
		var proceed = cop.proceed(rawEvent);
		console.log("User interaction at " + (new Date()).toGMTString() + ": " + this.toString());
		return proceed;
	}
});
// AutosaveUserInteractionMonitorLayer.beGlobal();

cop.create('UUIDGenerationLayer')
	.beGlobal()
	.refineClass(ObjectGraphLinearizer, {
		newId: function() {
			// var proceed = cop.proceed();
			var id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
					var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8);
					return v.toString(16);
				}).toUpperCase();
			// console.log("Generated "+id); // +" instead of "+proceed);
			return id;
		}
	});
Object.subclass('UUID',
'generation', {

	initialize: function() {
		this.id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
			var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8);
			return v.toString(16);
		}).toUpperCase();
	},

},
'helpers & accessors', {

	getId: function() {
		return this.id;
	},

	getShortHashLength: function(uuids) {

		if(!( (uuids instanceof Array) && (uuids.length > 1) && (uuids[0] instanceof UUID) )) {
			throw new Error("You have to pass an array of UUIDs.");
		}

		var stringArray = [];
		for(var i = 0; i<uuids.length; i++) {
			stringArray.push(uuids[i].getId());
		}

		var currentLength = Math.floor( Math.sqrt( Math.sqrt( Math.sqrt( stringArray.length ) ) ) ) + 2;

		return this.getShortHashLengthForStringArray(stringArray, currentLength);
	},

	getShortHashLengthForStringArray: function(strings, currentLength) {

		var currentLength = currentLength || 1;

		if(!( (strings instanceof Array) && (strings.length > 1) && (typeof(strings[0]) === "string") )) {
			throw new Error("You have to pass an array of Strings.");
		}

		console.log("starting length calculation:", strings.length, currentLength);

		var shortHashMap = {};

		for(var i = 0; i<strings.length; i++) {
			var k = strings[i].substr(strings[i].length - currentLength);
			if(!shortHashMap.hasOwnProperty(k)) { shortHashMap[k] = []; }
			shortHashMap[k].push(strings[i]);
		}

		var newStrings = [];

		for(k in shortHashMap) {
			if(!shortHashMap.hasOwnProperty(k)) { continue; }
			if(shortHashMap[k].length > 2) {
				newStrings = newStrings.concat(shortHashMap[k]);
			}
		}

		console.log("ending length calculation:", newStrings.length);

		return newStrings.length == 0 ? currentLength : this.getShortHashLengthForStringArray(newStrings, ++currentLength);
	}
});


WindowMorph.subclass('StorageMonitorMorph',
{
	initialize: function($super, rect) {

		// init offline storage
		if(!OfflineStorage.available()) {
			throw new Error("Offline Storage is not available!");
		}
		this.os = new OfflineStorage();

		var pos = (rect instanceof Rectangle) ? rect.origin : ps;

		// prepare textmorph and scrollpane
		this.textMorph = new TextMorph(rect, "Unknown", true);
		this.scrollPane = new ScrollPane(this.textMorph, rect);
		this.scrollPane.setBorderWidth(2);
		this.scrollPane.setBorderColor(Color.black);

		$super(this.scrollPane, "Storage Monitor", false);
		this.update();
	},

	update: function() {
		return this.textMorph.setTextString(
			JSON.serialize(this.os.data()));
	},

	startSteppingScripts: function() {
		this.startStepping( 1000, 'nextStep' );
	},

	nextStep: function() { this.update(); }
});

URL.addMethods({

	localStorageHash: function() {
		return this.pathname;
	}
});

Object.subclass('JSONDiff',

'object lifecycle', {
	initialize: function(diff) {
		this.normalStyle = {style: 'normal', color: Color.black};
		this.changedStyle = {style: 'normal', color: Color.blue};
		this.addedStyle = {style: 'normal', color: Color.green};
		this.removedStyle = {style: 'normal', color: Color.red};

		// default to null.
		this.diffObject = diff || null;
	}
},

'application', {
	applyTo: function(o, diff) {
		//TODO read http://perfectionkills.com/understanding-delete/
		diff = diff || this.diffObject;
		var x, d;

		// add stuff
		if(d = diff[JSONDiffer.ADDED]) {
			for(x in d) {
				if(d.hasOwnProperty(x)) {
					o[x] = d[x];
				}
			}
		}

		// remove stuff
		if(d = diff[JSONDiffer.REMOVED]) {
			for(x = 0; x < d.length; x++) {
				delete o[d[x]];
			}
		}

		// recursively change stuff
		if(d = diff[JSONDiffer.CHANGED]) {
			for(x in d) {
				if(d.hasOwnProperty(x)) {
					o[x] = (typeof d[x] === "object") ? this.applyTo(o[x], d[x]) : d[x]
				}
			}
		}

		return o;
	}
},

'convenience methods', {
	isEmpty: function() {
		// console.log("checking whether diff is empty:", this.diffObject);
		return (this.diffObject === null);
	},

	getRichText: function() {
		var result = this.prettyPrintDiff(this.diffObject);
		return result;
	},

	getExplorableDiff: function(world) {
	
		var registry = world['registry'];
		var initialId = world['id'];
		var alreadyTraversed = [initialId];

		// var registry = this.diffObject[JSONDiffer.CHANGED].registry;
		var s = this.constructDiffedWorldTree(registry[initialId], registry, alreadyTraversed);
		s = this.stripWorldTreeFromDiff(s);
		
		return s;
	},

	constructDiffedWorldTree: function(serialization, registry, traversed) {

		// resolve smart ids.
		if (serialization && serialization['__isSmartRef__'] === true) {
			var nextId = serialization['id'];
			// console.log(nextId);
			for(var i=0; i<traversed.length; i++) {
				if(traversed[i] === nextId) {
					return "R:" + nextId;
				}
			}
			traversed.push(nextId);
			return this.constructDiffedWorldTree(registry[nextId], registry, traversed);
		}

		// recursively go into objects.
		for(x in serialization) {
			if(serialization.hasOwnProperty(x)) {
				if(typeof serialization[x] === 'object') {
					serialization[x] = this.constructDiffedWorldTree(serialization[x], registry, traversed);
				}
			}
		}

		return serialization;
	},

	stripWorldTreeFromDiff: function(serialization, currentDiff) {
		var x, id, isInDiff, subDiff;

		// if this is an object with smart id, get the corresponding segment of the diff.
		if (serialization && serialization.hasOwnProperty('__SmartId__')) {
			id = serialization['__SmartId__'];
			var registryDiff = this.diffObject[JSONDiffer.CHANGED]['registry'];
			if(this.wasChangedInDiff(registryDiff, id)) {
				currentDiff = this.getSubDiffForChangedKey(registryDiff, id);
			} else if(this.wasAddedInDiff(registryDiff, id)) {
				serialization = this.getSubDiffForAddedKey(registryDiff, id);
				currentDiff = {};
			} else if(this.wasRemovedInDiff(registryDiff, id)) {
				return "REMOVED!";
			}
		}

		// recursively go into objects
		for(x in serialization) {
			if(serialization.hasOwnProperty(x)) {
				
				isInDiff = false;
				subDiff = {};

				if(this.wasAddedInDiff(currentDiff, x)) {
					isInDiff = true;
					subDiff = this.getSubDiffForAddedKey(currentDiff, x);
				} else if(this.wasChangedInDiff(currentDiff, x)) {
					isInDiff = true;
					subDiff = this.getSubDiffForChangedKey(currentDiff, x);
				} else if(this.wasRemovedInDiff(currentDiff, x)) {
					isInDiff = true;
					console.log("Removed: ", x);
				}

				// if not in diff, remove.
				if(serialization[x] && typeof serialization[x] === 'object') {
					var subSerialization = this.stripWorldTreeFromDiff(serialization[x], subDiff);
					var empty = true;
					for(x in subSerialization) {
						if(subSerialization.hasOwnProperty(x)) {
							empty = true;
						}
					}
					if(empty) {
						delete serialization[x];
					} else {
						serialization[x] = this.stripWorldTreeFromDiff(serialization[x], subDiff);
					}
				} else if (!isInDiff) {
					delete serialization[x];
				}
			}
		}

		for(x in serialization) {
		}

		return serialization;
	}
},

'helpers', {

	wasAddedInDiff: function(diff, key) {
		var d = diff[JSONDiffer.ADDED];
		return d && d.hasOwnProperty(key);
	},

	wasRemovedInDiff: function(diff, key) {
		var d = diff[JSONDiffer.REMOVED];
		if(d) {
			for(x = 0; x < d.length; x++) {
				if(d.hasOwnProperty(x) && (d[x] === key)) {
					return true;
				}
			}
		}
		return false;
	},

	wasChangedInDiff: function(diff, key) {
		var d = diff[JSONDiffer.CHANGED];
		return d && d.hasOwnProperty(key);
	},

	getSubDiffForAddedKey: function(diff, key) {
		var d = diff[JSONDiffer.ADDED];
		if(d && d.hasOwnProperty(key)) return d[key];
		throw "Key is not in diff.";
	},

	getSubDiffForChangedKey: function(diff, key) {
		var d = diff[JSONDiffer.CHANGED];
		if(d && d.hasOwnProperty(key)) return d[key];
		throw "Key is not in diff.";
	},

	coloredKeyValuePair: function(key, value, keyStyle, first, indent, indentation) {
		return (new lively.Text.Text(first? "\n" : ",\n", this.normalStyle))
			.concat(indent)
			.concat(new lively.Text.Text( '\t"' + key + '": ', keyStyle))
			.concat(this.prettyPrintDiff(value, indentation + 1));
	},

	prettyPrintDiff: function(diff, indentation) {
		indentation = indentation || 0;

		var x, y, f, i = "", result;

		for(x=0; x<indentation; x++)
			i += "\t";
		i = new lively.Text.Text(i, this.normalStyle);

		switch(typeof diff) {
		case "object":

			f = true;
			result = new lively.Text.Text("{", this.normalStyle);

			for(x in diff) {
				if(!diff.hasOwnProperty(x)) continue;

				switch(x) {

				case JSONDiffer.ADDED:
					for(y in diff[x]) {
						if(!diff[x].hasOwnProperty(y)) continue;
						result = result.concat(this.coloredKeyValuePair(y, diff[x][y], this.addedStyle, f, i, indentation));
						f = false;
					}
					break;

				case JSONDiffer.CHANGED:
					for(y in diff[x]) {
						if(!diff[x].hasOwnProperty(y)) continue;
						result = result.concat(this.coloredKeyValuePair(y, diff[x][y], this.changedStyle, f, i, indentation));
						f = false;
					}
					break;

				case JSONDiffer.REMOVED:
					for(y in diff[x]) {
						if(!diff[x].hasOwnProperty(y)) continue;
						result = result.concat(this.coloredKeyValuePair(diff[x][y], "???", this.removedStyle, f, i, indentation));
						f = false;
					}
					break;

				default:
					result = result.concat(this.coloredKeyValuePair(x, diff[x], this.normalStyle, f, i, indentation));
				}

				f = false;
			}

			result = result.concat(new lively.Text.Text("\n", this.normalStyle)).concat(i)
				.concat(new lively.Text.Text("}", this.normalStyle));
			return result;

		default:
			return new lively.Text.Text(JSON.stringify(diff), this.normalStyle);
		}
	}
});

Object.subclass('JSONDiffer',

'object lifecycle', {
	initialize: function() { }
},

'diffing', {

	diff: function diff(a, b) {
		return new JSONDiff(this.rawDiff(a, b));
	},

	rawDiff: function rawDiff(from, to) {

		var x, empty = true, result = {};

		result[JSONDiffer.CHANGED] = {};
		result[JSONDiffer.ADDED] = {};
		result[JSONDiffer.REMOVED] = [];

		// compose result if both are objects.
		if(from && to && (typeof from == typeof to) && (typeof to == "object")) {

			for(x in from) {
				if(from && from.hasOwnProperty(x)) {

					// find everything in from that is not in to, put into REMOVED.
					if(to && !to.hasOwnProperty(x)) {
						result[JSONDiffer.REMOVED].push(x);
						empty = false;

					// find everything that changed.
					} else {
						var y = this.rawDiff(from[x], to[x]);
						if(y !== null) {
							result[JSONDiffer.CHANGED][x] = y;
							empty = false;
						}
					}
				}
			}

			// find everything in to that is not in from, and put into ADDED.
			for(x in to) {
				if(from && !from.hasOwnProperty(x) && to && to.hasOwnProperty(x)) {
					result[JSONDiffer.ADDED][x] = to[x];
					empty = false;
				}
			}

			return empty? null : result;

		} else if(from !== to) {

			// different type
			return to;

    } else {
			// when equal, return null.
			return null;
    }
	}
});

Object.extend(JSONDiffer, {
	CHANGED: "__changed",
	ADDED: "__added",
	REMOVED: "__removed"
});

}); // end of module