//
// backbone.trackit - 0.1.0
// The MIT License
// Copyright (c) 2013 The New York Times, CMS Group, Matthew DeLambo <delambo@gmail.com>
//
(function() {

	// Unsaved Record Keeping
	// ----------------------

	// Collection of all models in an app that have unsaved changes.
	var unsavedModels = [];

	// If the given model has unsaved changes then add it to
	// the `unsavedModels` collection, otherwise remove it.
	var updateUnsavedModels = function(model) {
		if (!_.isEmpty(model._unsavedChanges)) {
			if (!_.findWhere(unsavedModels, {cid:model.cid})) unsavedModels.push(model);
		} else {
			unsavedModels = _.filter(unsavedModels, function(m) { return model.cid != m.cid; });
		}
	};

	// Unload Handlers
	// ---------------

	// Helper which returns a prompt message for an unload handler.
	// Uses the given function name (one of the callback names
	// from the `model.unsaved` configuration hash) to evaluate
	// whether a prompt is needed/returned.
	var getPrompt = function(fnName) {
		var prompt, args = _.rest(arguments);
		// Evaluate and return a boolean result. The given `fn` may be a
		// boolean value, a function, or the name of a function on the model.
		var evaluateModelFn = function(model, fn) {
			if (_.isBoolean(fn)) return fn;
			return (_.isString(fn) ? model[fn] : fn).apply(model, args);
		};
		_.each(unsavedModels, function(model) {
			if (!prompt && evaluateModelFn(model, model._unsavedConfig[fnName]))
				prompt = model._unsavedConfig.prompt;
		});
		return prompt;
	};

	// Wrap Backbone.History.navigate so that in-app routing
	// (`router.navigate('/path')`) can be intercepted with a
	// confirmation if there are any unsaved models.
	Backbone.History.prototype.navigate = _.wrap(Backbone.History.prototype.navigate, function(oldNav, fragment, options) {
		var prompt = getPrompt('unloadRouterPrompt', fragment, options);
		if (prompt) {
			if (confirm(prompt + ' \n\nAre you sure you want to leave this page?')) {
				oldNav.call(this, fragment, options);
			}
		} else {
			oldNav.call(this, fragment, options);
		}
	});

	// Create a browser unload handler which is triggered
	// on the refresh, back, or forward button.
	window.onbeforeunload = function(e) {
		return getPrompt('unloadWindowPrompt', e);
	};

	// Backbone.Model API
	// ------------------

	_.extend(Backbone.Model.prototype, {

		unsaved: {},
		_trackingChanges: false,
		_originalAttrs: {},
		_unsavedChanges: {},

		// Opt in to tracking attribute changes
		// between saves.
		startTracking: function() {
			this._unsavedConfig = _.extend({}, {
				prompt: 'You have unsaved changes!',
				unloadRouterPrompt: false,
				unloadWindowPrompt: false
			}, this.unsaved || {});
			this._trackingChanges = true;
			this._resetTracking();
			this._triggerUnsavedChanges();
			return this;
		},

		// Resets the default tracking values
		// and stops tracking attribute changes.
		stopTracking: function() {
			this._trackingChanges = false;
			this._originalAttrs = {};
			this._unsavedChanges = {};
			this._triggerUnsavedChanges();
			return this;
		},

		// Gets rid of accrued changes and
		// resets state.
		restartTracking: function() {
			this._resetTracking();
			this._triggerUnsavedChanges();
			return this;
		},

		// Restores this model's attributes to
		// their original values since tracking
		// started, the last save, or last restart.
		resetAttributes: function() {
			if (!this._trackingChanges) return;
			this.attributes = this._originalAttrs;
			this._resetTracking();
			this._triggerUnsavedChanges();
			return this;
		},

		// Symmetric to Backbone's `model.changedAttributes()`,
		// except that this returns a hash of the model's attributes that
		// have changed since the last save, or `false` if there are none.
		// Like `changedAttributes`, an external attributes hash can be
		// passed in, returning the attributes in that hash which differ
		// from the model.
		unsavedAttributes: function(attrs) {
			if (!attrs) return _.isEmpty(this._unsavedChanges) ? false : _.clone(this._unsavedChanges);
			var val, changed = false, old = this._unsavedChanges;
			for (var attr in attrs) {
				if (_.isEqual(old[attr], (val = attrs[attr]))) continue;
				(changed || (changed = {}))[attr] = val;
			}
			return changed;
		},

		_resetTracking: function() {
			this._originalAttrs = _.clone(this.attributes);
			this._unsavedChanges = {};
		},

		// Trigger an `unsavedChanges` event on this model,
		// supplying the result of whether there are unsaved
		// changes and a changed attributes hash.
		_triggerUnsavedChanges: function() {
			this.trigger('unsavedChanges', !_.isEmpty(this._unsavedChanges), _.clone(this._unsavedChanges));
			if (this.unsaved) updateUnsavedModels(this);
		}
	});

	// Wrap `model.set()` and update the internal
	// unsaved changes record keeping.
	Backbone.Model.prototype.set = _.wrap(Backbone.Model.prototype.set, function(oldSet, key, val, options) {
		var attrs, ret;
		if (key == null) return this;
		// Handle both `"key", value` and `{key: value}` -style arguments.
		if (typeof key === 'object') {
			attrs = key;
			options = val;
		} else {
			(attrs = {})[key] = val;
		}
		options || (options = {});

		// Delegate to Backbone's set.
		ret = oldSet.call(this, attrs, options);

		if (this._trackingChanges && !options.silent) {
			_.each(attrs, _.bind(function(val, key) {
				if (_.isEqual(this._originalAttrs[key], val))
					delete this._unsavedChanges[key];
				else
					this._unsavedChanges[key] = val;
			}, this));
			this._triggerUnsavedChanges();
		}
		return ret;
	});

	// Intercept `model.save()` and reset tracking/unsaved
	// changes if it was successful.
	Backbone.sync = _.wrap(Backbone.sync, function(oldSync, method, model, options) {
		options || (options = {});

		if (method == 'update') {
			options.success = _.wrap(options.success, _.bind(function(oldSuccess, data, textStatus, jqXHR) {
				var ret;
				if (oldSuccess) ret = oldSuccess.call(this, data, textStatus, jqXHR);
				if (model._trackingChanges) {
					model._resetTracking();
					model._triggerUnsavedChanges();
				}
				return ret;
			}, this));
		}
		return oldSync(method, model, options);
	});

})();