/**
 * Generic XML utilities (NodeType, XPath, attributes). Also adds some additional String functions.
 *
 * @requires prototype.js (http://www.prototypejs.org/)
 * @author Jasper van Veghel <jasper@seajas.com>
 */

/**
 * Extend the String instance with functionality to determine whether a String
 * starts or ends with a given String and to do case-insensitive comparisons.
 * 
 * @param match The start string to match against
 */
Object.extend (String.prototype, {
        startsWith: function (match) {
                return this.length > match.length &&
                        this.substring (0, match.length) == match;
        },

        endsWith: function (match) {
                return this.length > match.length &&
                        this.substring (this.length - match.length) == match;
        },

        equalsIgnoreCase: function (match) {
                return this.toLowerCase () == match.toLowerCase ();
        }
});

/**
 * Extend the Array instance with functionality to randomize a given Array.
 * 
 * @return Array
 */
Object.extend (Array.prototype, {
	randomize: function () {
		var arrayIndex = this.length;

		while (--arrayIndex) {
			var randomIndex = Math.floor (Math.random () * (arrayIndex + 1));
			var temporary = this[arrayIndex];
	
			this[arrayIndex] = this[randomIndex];;
			this[randomIndex] = temporary;
		}

		return this;
	}
});

/**
 * Determines whether a given node is of a given type.
 * 
 * @param node
 * @param type
 */
function hasNodeType (node, type) {
	if (!node.nodeType)
		return false;

	switch (type) {
		case 'Element': return node.nodeType == 1;
		case 'Document': return node.nodeType == 9;
		case 'Attr': return node.nodeType == 2;
		case 'Text': return node.nodeType == 3;
		case 'CDATASection': return node.nodeType == 4;
		case 'Comment': return node.nodeType == 8;
		case 'ProcessingInstruction': return node.nodeType == 7;
		default: return false;
	}
}

/**
 * Returns a node list based on a simple XPath expression.
 * 
 * @param content
 * @param expr
 * @return Node[]
 * @throws Error
 */
function getNodesFromSimpleXPath (content, expr) {
	var result = [ ], refNode = content;

	// Empty expressions, double slashes, expressions ending with '/' or '@' within node values are not allowed

	if (expr == null || expr == '')
		throw new Error ('Simple XPath expressions may not be empty.');
	else if (expr.indexOf ('//') != -1)
		throw new Error ('Simple XPath expressions do not allow double slashes.');
	else if (expr.endsWith ('/'))
		throw new Error ('Simple XPath expressions may not end with a slash.');
	else if (expr.endsWith ('@'))
		throw new Error ('Simple XPath expressions containing properties may not end with an @.');
	else if (expr.indexOf ('@') != -1 && expr.indexOf ('/', expr.indexOf ('@')) != -1)
		throw new Error ('Simple XPath expressions do not allow attributes in node definitions.');

	// Handle the case of a single attribute value; in case this is a processing instruction, return that

	if (expr.startsWith ('@') && expr.length > 1) {
		var singleAttribute = expr.substring (1);

		return getAttributeFromElement (content, singleAttribute);
	}

	// Handle any optional attribute definition

	var attribute = null;

	var attributeIndex = expr.lastIndexOf ('/');
	if (attributeIndex != -1 && expr.charAt (attributeIndex + 1) == '@')
		attribute = expr.substring (attributeIndex + 2, expr.length);
	else
		attributeIndex = expr.length;

	// Split up the rest of the string, discounting the attribute

	var parts = expr.substring (expr.startsWith ('/') ? 1 : 0, attributeIndex).split ('/');

	var i = 0;
	do {
		var list = null, nodeNum = -1, strippedPart = null;

		if (parts[i].lastIndexOf ('[') != -1 && parts[i].endsWith (']')) {
			nodeNum = parseInt (parts[i].substring (parts[i].lastIndexOf ('[') + 1, parts[i].lastIndexOf (']'))) - 1;
			strippedPart = parts[i].substring (0, parts[i].lastIndexOf ('['));
		} else
			strippedPart = parts[i];

		if (hasNodeType (refNode, 'Element') || hasNodeType (refNode, 'Document'))
			list = getDirectElementsByTagName (refNode, strippedPart);
		else
			throw new Error ('Unknown class for reference node: ' + refNode.getClass ().getName ());

		if (nodeNum != -1 && list.length <= nodeNum)
			throw new Error ('Could not retrieve ' + parts[i] + ' from ' + expr + ': Not enough nodes in sublist.');

		if (i == parts.length - 1) {
			if (nodeNum != -1) {
				if (attribute != null)
					result.push (getAttributeFromElement (list[nodeNum], attribute));
				else
					result.push (list[nodeNum]);
			} else
				for (var j = 0; j < list.length; j++)
					if (attribute != null)
						result.push (getAttributeFromElement (list[j], attribute));
					else
						result.push (list[j]);
		} else {
			if (nodeNum != -1)
				refNode = list[nodeNum];
			else if (list.length != 1)
				throw new Error ('Could not retrieve ' + parts[i] + ' from ' + expr + ': Expecting exactly one child node.');
			else
				refNode = list[0];
		}
	} while (++i < parts.length);

	return result;
}

/**
 * Returns a single node based on a simple XPath expression.
 * 
 * @param content
 * @param expr
 * @return Node
 * @throws Error
 */
function getSingleNodeFromSimpleXPath (content, expr) {
	var result = getNodesFromSimpleXPath (content, expr);

	if (result.length != 1)
		throw new Error ('XPath result does not contain exactly one node.');

	return result[0];
}

/**
 * Retrieves all directly decending child elements with the given tag name from the parent node.
 * 
 * @param parent
 * @param name
 * @return Node[]
 * @throws Error
 */
function getDirectElementsByTagName (parent, name) {
	var result = [ ];

	if (name == 'text()') {
		var child = parent.childNodes[0];

		if (parent.childNodes.length != 1 || child == null || !hasNodeType (child, 'Text'))
			throw new Error ('Could not retrieve exactly one Text node.');
		else
			result.push (child);
	} else if (hasNodeType (parent, 'Document')) {
		var child = parent.documentElement;

		if (child.nodeName.equalsIgnoreCase (name))
			result.push (child);
	} else
		for (var i = 0; i < parent.childNodes.length; i++) {
			var child = parent.childNodes[i];

			if (child.nodeName.equalsIgnoreCase (name))
				result.push (child);
		}

	return result;
}

/**
 * Retrieves an attribute from a node, requiring it is an element.
 * 
 * @param node
 * @param attribute
 * @return attr
 * @throws Error
 */
function getAttributeFromElement (node, attribute) {
	if (hasNodeType (node, 'Element')) {
		var attr = node.attributes.getNamedItem (attribute);

		if (!attr)
			throw new Error ('Attribute ' + attribute + ' could not be retrieved from element ' + node.nodeName + '.');
		else
			return attr;
	} else
		throw new Error ('Node must be an Element in order to retrieve attribute ' + attribute + '.');

	return null;
}
