/*
 * Copyright (c) 2015
 *
 * This file is licensed under the Affero General Public License version 3
 * or later.
 *
 * See the COPYING-README file.
 *
 */

/**
 * Webdav transport for Backbone.
 *
 * This makes it possible to use Webdav endpoints when
 * working with Backbone models and collections.
 *
 * Requires the davclient.js library.
 *
 * Usage example:
 *
 *     var PersonModel = OC.Backbone.Model.extend({
 *         // make it use the DAV transport
 *         sync: OC.Backbone.davSync,
 *
 *         // DAV properties mapping
 *         davProperties: {
 *             'id': '{http://example.com/ns}id',
 *             'firstName': '{http://example.com/ns}first-name',
 *             'lastName': '{http://example.com/ns}last-name',
 *             'age': '{http://example.com/ns}age'
 *         },
 *
 *         // additional parsing, if needed
 *         parse: function(props) {
 *             // additional parsing (DAV property values are always strings)
 *             props.age = parseInt(props.age, 10);
 *             return props;
 *         }
 *     });
 *
 *     var PersonCollection = OC.Backbone.Collection.extend({
 *         // make it use the DAV transport
 *         sync: OC.Backbone.davSync,
 *
 *         // use person model
 *         // note that davProperties will be inherited
 *         model: PersonModel,
 *
 *         // DAV collection URL
 *         url: function() {
 *             return OC.linkToRemote('dav') + '/person/';
 *         },
 *     });
 */

/* global dav */

(function(Backbone) {
	var methodMap = {
		'create': 'POST',
		'update': 'PROPPATCH',
		'patch':  'PROPPATCH',
		'delete': 'DELETE',
		'read':   'PROPFIND'
	};

	// Throw an error when a URL is needed, and none is supplied.
	function urlError() {
		throw new Error('A "url" property or function must be specified');
	}

	/**
	 * Convert a single propfind result to JSON
	 *
	 * @param {Object} result
	 * @param {Object} davProperties properties mapping
	 */
	function parsePropFindResult(result, davProperties) {
		if (_.isArray(result)) {
			return _.map(result, function(subResult) {
				return parsePropFindResult(subResult, davProperties);
			});
		}
		var props = {
			href: result.href
		};

		_.each(result.propStat, function(propStat) {
			if (propStat.status !== 'HTTP/1.1 200 OK') {
				return;
			}

			for (var key in propStat.properties) {
				var propKey = key;
				if (key in davProperties) {
					propKey = davProperties[key];
				}
				props[propKey] = propStat.properties[key];
			}
		});

		if (!props.id) {
			// parse id from href
			props.id = parseIdFromLocation(props.href);
		}

		return props;
	}

	/**
	 * Parse ID from location
	 *
	 * @param {string} url url
	 * @return {string} id
	 */
	function parseIdFromLocation(url) {
		var queryPos = url.indexOf('?');
		if (queryPos > 0) {
			url = url.substr(0, queryPos);
		}

		var parts = url.split('/');
		var result;
		do {
			result = parts[parts.length - 1];
			parts.pop();
			// note: first result can be empty when there is a trailing slash,
			// so we take the part before that
		} while (!result && parts.length > 0);

		return decodeURIComponent(result);
	}

	function isSuccessStatus(status) {
		return status >= 200 && status <= 299;
	}

	function convertModelAttributesToDavProperties(attrs, davProperties) {
		var props = {};
		var key;
		for (key in attrs) {
			var changedProp = davProperties[key];
			var value = attrs[key];
			if (!changedProp) {
				// no matching DAV property for property, skip
				continue;
			}
			if (_.isBoolean(value) || _.isNumber(value)) {
				// convert to string
				value = '' + value;
			}
			props[changedProp] = value;
		}
		return props;
	}

	function callPropFind(client, options, model, headers) {
		return client.propFind(
			options.url,
			_.values(options.davProperties) || [],
			options.depth,
			headers
		).then(function(response) {
			if (isSuccessStatus(response.status)) {
				if (_.isFunction(options.success)) {
					var propsMapping = _.invert(options.davProperties);
					var results = parsePropFindResult(response.body, propsMapping);
					if (options.depth > 0) {
						// discard root entry
						results.shift();
					}

					options.success(results);
					return;
				}
			} else if (_.isFunction(options.error)) {
				options.error(response);
			}
		});
	}

	function callPropPatch(client, options, model, headers) {
		var changes = model.changed;
		if (options.wait && _.isEmpty(changes)) {
			// usually with "wait" mode, the changes aren't set yet,
			changes = options.data;

			// if options.patch is not set, then data contains all the data
			// instead of just the properties to patch
			if (!options.patch) {
				// remove reserved properties
				delete changes.href;
				delete changes[_.result(model, 'idAttribute')];

				// note: there is no way to diff with previous values here so
				// we just send everything
			}
		}
		return client.propPatch(
			options.url,
			convertModelAttributesToDavProperties(changes, options.davProperties),
			headers
		).then(function(result) {
			if (result.status === 207 && result.body && result.body.length > 0) {
				if (_.find(result.body[0].propStat, function(propStat) {
					var statusCode = parseInt(propStat.status.split(' ')[1], 10);
					return statusCode >= 400;
				})) {
					// in REST, validation errors are usually represented with 422 Unprocessable Entity,
					result.status = 422;
				}
			}

			if (isSuccessStatus(result.status)) {
				// with wait, we set the changes only after success
				if (options.wait) {
					model.set(changes, options);
				}

				if (_.isFunction(options.success)) {
					// pass the object's own values because the server
					// does not return the updated model
					options.success(model.toJSON());
				}
			} else if (_.isFunction(options.error)) {
				options.error(result);
			}
		});

	}

	function callMkCol(client, options, model, headers) {
		var props = convertModelAttributesToDavProperties(model.attributes, options.davProperties);
		if (!props['{DAV:}resourcetype']) {
			props['{DAV:}resourcetype'] = '<d:collection/>';
		}
		return client.mkcol(
			options.url,
			props,
			headers
		).then(function(result) {
			if (isSuccessStatus(result.status)) {
				if (_.isFunction(options.success)) {
					// pass the object's own values because the server
					// does not return the updated model
					options.success(model.toJSON());
				}
			} else if (_.isFunction(options.error)) {
				options.error(result);
			}
		});
	}

	function callMethod(client, options, model, headers) {
		var data = options.data;
		if (_.isObject(data)) {
			headers['Content-Type'] = 'application/json';
			data = JSON.stringify(data);
		} else if (_.isString(data) && data.substr(0, 6) === '<?xml ') {
			headers['Content-Type'] = 'application/xml';
		} else {
			headers['Content-Type'] = 'text/plain';
		}
		return client.request(
			options.type,
			options.url,
			headers,
			data
		).then(function(result) {
			if (!isSuccessStatus(result.status)) {
				if (_.isFunction(options.error)) {
					options.error(result);
				}
				return;
			}

			if (_.isFunction(options.success)) {
				if (options.type === 'PUT' || options.type === 'POST') {
					// pass the object's own values because the server
					// does not return anything
					var responseJson = result.body || model.toJSON();
					var locationHeader = result.xhr.getResponseHeader('Content-Location');
					if (options.type === 'POST' && locationHeader) {
						responseJson.id = parseIdFromLocation(locationHeader);
					}
					options.success(responseJson);
					return;
				}
				// if multi-status, parse
				if (result.status === 207) {
					var propsMapping = _.invert(options.davProperties);
					options.success(parsePropFindResult(result.body, propsMapping));
				} else {
					options.success(result.body);
				}
			}
		});
	}

	function davCall(options, model) {
		var client = new dav.Client({
			baseUrl: options.url,
			xmlNamespaces: _.extend({
				'DAV:': 'd',
				'http://owncloud.org/ns': 'oc'
			}, options.xmlNamespaces || {})
		});
		client.resolveUrl = function() {
			return options.url;
		};
		var headers = _.extend({
			'X-Requested-With': 'XMLHttpRequest',
			'requesttoken': OC.requestToken
		}, options.headers);
		if (options.type === 'PROPFIND') {
			return callPropFind(client, options, model, headers);
		} else if (options.type === 'PROPPATCH') {
			return callPropPatch(client, options, model, headers);
		} else if (options.type === 'MKCOL') {
			return callMkCol(client, options, model, headers);
		} else {
			return callMethod(client, options, model, headers);
		}
	}

	/**
	 * 
	 */
	function getTypeForMethod(method, model) {
		var type = methodMap[method];

		if (!type) {
			// return method directly
			return method;
		}

		// TODO: use special attribute "resourceType" instead
		var isWebdavCollection = model instanceof WebdavCollectionNode;

		// need to override default behavior and decide what to do
		if (method === 'create') {
			if (isWebdavCollection) {
				if (!_.isUndefined(model.id)) {
					// create new collection with known id
					type = 'MKCOL';
				} else {
					// unsupported
					throw 'Cannot create Webdav collection without id';
				}
			} else {
				if (!_.isUndefined(model.id)) {
					// need to create it first
					type = 'PUT';
				} else {
					// creating without known id, will receive it after creation
					type = 'POST';
				}
			}
		} else if (method === 'update') {
			// it exists, only update properties
			type = 'PROPPATCH';
			// force PUT usage ?
			if (model.usePUT || (model.collection && model.collection.usePUT)) {
				type = 'PUT';
			}
		}

		return type;
	}

	/**
	 * DAV transport
	 */
	function davSync(method, model, options) {
		var params = {type: getTypeForMethod(method, model)};
		var isCollection = (model instanceof Backbone.Collection);

		// Ensure that we have a URL.
		if (!options.url) {
			params.url = _.result(model, 'url') || urlError();
		}

		// Ensure that we have the appropriate request data.
		if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) {
			params.data = options.attrs || model.toJSON(options);
		}

		// Don't process data on a non-GET request.
		if (params.type !== 'PROPFIND') {
			params.processData = false;
		}

		if (params.type === 'PROPFIND' || params.type === 'PROPPATCH' || params.type === 'MKCOL') {
			var davProperties = model.davProperties;
			if (!davProperties && model.model) {
				// use dav properties from model in case of collection
				davProperties = model.model.prototype.davProperties;
			}
			if (davProperties) {
				if (_.isFunction(davProperties)) {
					params.davProperties = davProperties.call(model);
				} else {
					params.davProperties = davProperties;
				}
			}

			params.davProperties = _.extend(params.davProperties || {}, options.davProperties);

			if (_.isUndefined(options.depth)) {
				if (isCollection) {
					options.depth = 1;
				} else {
					options.depth = 0;
				}
			}
		}

		// Pass along `textStatus` and `errorThrown` from jQuery.
		var error = options.error;
		options.error = function(xhr, textStatus, errorThrown) {
			options.textStatus = textStatus;
			options.errorThrown = errorThrown;
			if (error) {
				error.call(options.context, xhr, textStatus, errorThrown);
			}
		};

		// Make the request, allowing the user to override any Ajax options.
		var xhr = options.xhr = Backbone.davCall(_.extend(params, options), model);
		model.trigger('request', model, xhr, options);
		return xhr;
	}


	/**
	 * Regular Webdav leaf node
	 */
	var WebdavNode = Backbone.Model.extend({
		sync: davSync,

		constructor: function() {
			this.on('sync', this._onSync, this);
			this._isNew = true;
			Backbone.Model.prototype.constructor.apply(this, arguments);
		},

		_onSync: function() {
			this._isNew = false;
		},

		isNew: function() {
			// we can't rely on the id so use a dummy attribute
			return !!this._isNew;
		}
	});

	/**
	 * Children collection for a Webdav collection node
	 */
	var WebdavChildrenCollection = Backbone.Collection.extend({
		sync: davSync,

		collectionNode: null,
		model: WebdavNode,

		constructor: function() {
			this.on('sync', this._onSync, this);
			Backbone.Collection.prototype.constructor.apply(this, arguments);
		},

		initialize: function(models, options) {
			options = options || {};

			this.collectionNode = options.collectionNode;

			return Backbone.Collection.prototype.initialize.apply(this, arguments);
		},

		_onSync: function(model) {
			if (model instanceof Backbone.Model) {
				// since we saved, mark as non-new
				if (!_.isUndefined(model._isNew)) {
					model._isNew = false;
				}
			} else {
				// since we fetched, mark models as non-new
				model.each(function(model) {
					if (!_.isUndefined(model._isNew)) {
						model._isNew = false;
					}
				});
			}
		},

		url: function() {
			return this.collectionNode.url();
		}
	});

	/**
	 * Webdav collection which is a special node, represented by a backbone model
	 * and a sub-collection for its children.
	 */
	var WebdavCollectionNode = WebdavNode.extend({
		sync: davSync,

		childrenCollectionClass: WebdavChildrenCollection,

		_childrenCollection: null,

		getChildrenCollection: function() {
			if (!this._childrenCollection) {
				this._childrenCollection = new this.childrenCollectionClass([], {collectionNode: this});
			}
			return this._childrenCollection;
		}
	});

	// exports
	Backbone.davCall = davCall;
	Backbone.davSync = davSync;

	Backbone.WebdavNode = WebdavNode;
	Backbone.WebdavChildrenCollection = WebdavChildrenCollection;
	Backbone.WebdavCollectionNode = WebdavCollectionNode;

})(OC.Backbone);

