/*
Argo - An HttpRequest remote method call framework.
Copyright (c) 2006-2007 Thomas Peri, http://www.tumuski.com/

This file is part of Argo.

Argo is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.

Argo is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with Argo; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA

*/

/**
 * Argo.js
 * version 2007-01-05
 *
 * Dependencies:
 *
 *   - HttpTerminal.js    XMLHttpRequest simplification layer,
 *                        available from http://www.tumuski.com/code/HttpTerminal/
 *
 *   - json.js            JSON encoder/decoder,
 *                        available from http://json.org/
 */
var Argo = (function()
{
	var isSupported,
		missing,
		requestCount;
	
	/*
	 * Set up the Argo environment.
	 *
	 * @access private
	 */
	function setUp()
	{
		requestCount = 0;
		
		if (typeof HttpTerminal == 'undefined') {
			missing = 'HttpTerminal';
		}
		else if (!HttpTerminal.isSupported) {
			missing = 'XMLHttpRequest';
		}
		else if (!jsonMethodsExist()) {
			missing = 'JSON';
		}
		else if (!Object.prototype.hasOwnProperty) {
			missing = 'Object.prototype.hasOwnProperty';
		}
		else if (!Array.prototype.push) {
			missing = 'Array.prototype.push';
		}
		else if (!jsonMethodsWork()) {

			if (!Array.prototype.join) {
				missing = 'Array.prototype.join';
			}
			else {
				missing = 'unknown';
			}
		}

		Argo.missingSupport = missing;
		Argo.isSupported = isSupported = !missing;
	}

	/**
	 * The Argo constructor.
	 * If no URI is specified, the current document is used.
	 *
	 * @access public
	 * @param uri   The URI to which requests should be sent.
	 */
	function Argo(uri)
	{
		var callBuffer,
			terminal,
			self = this;
		
		/*
		 * Set up this Argo instance.
		 *
		 * @access private
		 */
		function setUpArgo()
		{
			callBuffer = [];

			if (isSupported)
			{
				terminal = new HttpTerminal(uri);
				terminal.registerConsole(debug);
			}
			
			self.registerConsole = registerConsole;
			self.registerMonitor = registerMonitor;
			self.invoke = invoke;
			self.defer = defer;
			self.flush = flush;
		}
		
		/*
		 * A stub function to be overridden by the argument to registerConsole.
		 */
		function console(message) { }

		/**
		 * Register a function to pass debugging messages to.
		 *
		 * @access public
		 * @param function listener  A function to override console().
		 */
		function registerConsole(listener)
		{
			if (isFunction(listener))
			{
				console = listener;
			}
		}

		/*
		 * Send a debugging message to any registered console function.
		 * This is a persistent wrapper for the possibly-volatile console function.
		 * The monitor function doesn't need a wrapper because it doesn't get
		 * passed to RemoteMethodCall the way debug does.
		 *
		 * @access private
		 */
		function debug(message)
		{
			console(message);
		}
		
		/*
		 * A stub function to be overridden by the argument to registerMonitor.
		 * This function receives information about data sent to and from the
		 * server.
		 * 
		 * If the id argument is positive, the text is a request.
		 * If the id argument is negative, the text is a response.
		 * The id argument will never be zero.
		 * 
		 * The id of a request equals -1 times the id of its response.
		 *
		 * @access private
		 * @param string text    The text sent to or from the server
		 * @param integer id     A unique identifier
		 */
		function monitor(text, id) { }
		
		/**
		 * Register a function for monitoring traffic.
		 * The listener argument should be a function
		 * with the same parameters as the monitor() function.
		 *
		 * @access public
		 * @param function listener  A function to override monitor().
		 */
		function registerMonitor(listener)
		{
			if (isFunction(listener))
			{
				monitor = listener;
			}
		}
		
		/**
		 * Initiate a remote method call now.
		 *
		 * @access public
		 * @param string remoteClass  The name of the server-side class to use.
		 * @param string method       The name of the method of that class to invoke.
		 * @param array  args         The arguments to pass to that method.
		 * @param object listeners    Functions that handle events related to this call.
		 */
		function invoke(remoteClass, method, args, listeners)
		{
			debug("Invoking "+remoteClass+" method "+method+", passing "+args.length+" args.");
			
			// send the call as the only element in an array of calls
			send([new RemoteMethodCall(remoteClass, method, args, listeners, debug)]);
		}
		
		/**
		 * Specify a method call to be invoked later.
		 *
		 * @access public
		 * @param string remoteClass  The name of the server-side class to use.
		 * @param string method       The name of the method of that class to invoke.
		 * @param array  args         The arguments to pass to that method.
		 * @param object listeners    Functions that handle events related to this call.
		 */
		function defer(remoteClass, method, args, listeners)
		{
			debug("Deferring "+remoteClass+" method "+method+", passing "+args.length+" args.");
			
			callBuffer.push(
				new RemoteMethodCall(remoteClass, method, args, listeners, debug)
			);
		}
		
		/**
		 * Invoke all deferred method calls.
		 *
		 * @access public
		 */
		function flush()
		{
			var calls;
			
			if (callBuffer.length > 0)
			{
				debug("Flushing "+callBuffer.length+" deferred calls");
				
				calls = callBuffer;
				callBuffer = [];
				send(calls);
			}
			else
			{
				debug("Nothing to flush.");
			}
		}

		/*
		 * Send an array of method calls to Argo on the server.
		 *
		 * @param array calls  An array of objects that represent method calls.
		 * @access private
		 */
		function send(calls)
		{
			var id = ++requestCount,
				requestText = '';
			
			/*
			 * What to do when this HTTPRequest finishes successfully.
			 *
			 * @param string response  A JSON text from the server
			 */
			function succeed(responseText)
			{
				var response;
				var ok = false;

				monitor(responseText, -id);

				try
				{
					response = responseText.parseJSON();
					ok = true;
				}
				catch(e)
				{
					failAll({
						"type": "INVALID_JSON_RESPONSE",
						"detail": responseText
					});
				}
				if (ok)
				{
					if (isArray(response))
					{
						if (response.length === calls.length)
						{
							for (var i = 0; i < calls.length; i++)
							{
								if (isValidResponse(response[i]))
								{
									calls[i].handleResponse(response[i]);
								}
								else
								{
									calls[i].fail({
										"type": "INVALID_ARGO_CALL_RESPONSE",
										"detail": response[i].toJSONString()
									});
								}
							}
						}
						else
						{
							failAll({
								"type": "ARGO_CALL_COUNT_DISCREPANCY",
								"detail": {
									"calls": calls.length,
									"responses": response.length
								}
							});
						}
					}
					else
					{
						failAll({
							"type": "INVALID_ARGO_RESPONSE",
							"detail": responseText
						});
					}
				}
			}
			
			/*
			 * What to do when this HTTPRequest fails.
			 *
			 * @param object failure  An object describing the failure
			 */
			function failAll(failure)
			{
				for (var i = 0; i < calls.length; i++)
				{
					calls[i].fail(failure);
				}
			}
			
			function validateArguments()
			{
				var index;
				var validCalls = [];

				debug ("Validating "+calls.length+" method call(s).");
				
				for (var i = 0; i < calls.length; i++)
				{
					index = calls[i].cyclicalArgumentIndex();
					if (index < 0)
					{
						debug('All arguments are valid (non-cyclical).');
						validCalls.push(calls[i]);
					}
					else
					{
						debug('Argument '+index+' is invalid (cyclical).');
						calls[i].fail({
							"type": "CYCLICAL_ARGUMENT",
							"detail": index // report which argument was cyclical
						});
					}
				}
				
				calls = validCalls;
			}
			
			if (isSupported)
			{
				// fail the calls with cyclical arguments
				validateArguments();
			
				debug ("Sending a request with "+calls.length+" method call(s).");

				// make a request with the rest
				requestText = calls.toJSONString();
				terminal.send(requestText, succeed, failAll);
				monitor(requestText, id);
			}
			else
			{
				failAll({
					"type": "ARGO_NOT_SUPPORTED",
					"detail": missing
				});
			}
			
		}
		
		
		setUpArgo();
	}

	/*
	 * An method call.  Same args as Argo.invoke method.
	 *
	 * @access private
	 * @param string response  A JSON text from the server
	 */
	function RemoteMethodCall(remoteClass, method, args, listeners, debug)
	{
		var call,
			self = this;
		
		function setUpRemoteMethodCall()
		{
			var n,
				listenerNames;

			call = {
				"c": remoteClass,
				"m": method,
				"a": args
			};
			
			listenerNames = ["success","error","failure","debug"];
			
			for (n in listenerNames)
			{
				n = listenerNames[n];
				listeners[n] = ensureFunctionType(listeners[n]);
			}
			
			self.toJSONString = toJSONString;
			self.handleResponse = handleResponse;
			self.cyclicalArgumentIndex = cyclicalArgumentIndex;
			self.fail = fail;
		}
		
		/**
		 * Convert this RemoteMethodCall to a JSON string.
		 */
		function toJSONString()
		{
			return call.toJSONString();
		}
		
		/**
		 * Return the index of the first cyclical argument
		 * in this method call, or -1 if none are cyclical.
		 */
		function cyclicalArgumentIndex()
		{
			for (var index = 0; index < args.length; index++)
			{
				if (isCyclical(args[index]))
				{
					return index;
				}
			}
			return -1;
		}

		/**
		 * The Argo response object from the server-side method call.
		 */
		function handleResponse(response)
		{
			for (var i = 0; i < response.d.length; i++)
			{
				listeners.debug(response.d[i]);
			}
			if (response.e)
			{
				listeners.error(response.e);
			}
			else
			{
				listeners.success(response.r);
			}
		}
		
		/**
		 * Tell this RemoteMethodCall that it has failed.
		 */
		function fail(failure)
		{
			listeners.failure(failure);
		}
		
		
		setUpRemoteMethodCall();
	}
	

	/*
	 * Return the argument if it's a function, or an empty
	 * function if it's not.  This is to ensure that foreign
	 * foreign event listeners are functions.
	 */
	function ensureFunctionType(f)
	{
		return (isFunction(f)) ? f : function(){};
	}


	// functions for validating response values:
	
	/*
	 * Validate an Argo call response.
	 */
	function isValidResponse(value)
	{
		return (
			isJsonObject(value) &&
			!isUndefined(value.r) &&
			isValidErrorResponse(value.e) &&
			isValidDebuggingResponse(value.d) &&
			(isNull(value.r) || isNull(value.e))
		);
	}
	
	/*
	 * The supplied value is a valid error response component:
	 * null or an object with at least a 'type' property and
	 * a 'detail' property.  The 'type' property must be a string 
	 * or a null value.
	 */
	function isValidErrorResponse(value)
	{
		return (
			(isNull(value)) ||
			(
				isJsonObject(value) &&
				!isUndefined(value.detail) &&
				(
					isString(value.type) ||
					isNull(value.type)
				)
			)
		);
	}
	
	/*
	 * The supplied value is a valid debugging response component:
	 * an array with only string elements.
	 */
	function isValidDebuggingResponse(value)
	{
		return (
			(isArray(value)) &&
			((function()
			{
				for (var i = 0; i < value.length; i++)
				{
					if (!isString(value[i]))
					{
						return false;
					}
				}
				return true;
			})())
		);
	}
	
	
	// Functions for validating value types:
	
	function isObject(v)    { return v && typeof v === 'object'; }
	function isArray(v)     { return isObject(v) && v.constructor === Array; }
	function isDate(v)      { return isObject(v) && v.constructor === Date; }
	function isBoolean(v)   { return isTypeOrClass(v, 'boolean', Boolean); }
	function isFunction(v)  { return isTypeOrClass(v, 'function', Function); }
	function isNumber(v)    { return isTypeOrClass(v, 'number', Number); }
	function isString(v)    { return isTypeOrClass(v, 'string', String); }
	function isNull(v)      { return v === null; }
	function isUndefined(v) { return typeof v === 'undefined'; }
	
	/*
	 * Qualify a value as an object that JSON-stringifies as an object.
	 */
	function isJsonObject(v)
	{
		if (isObject(v))
		{
			switch (v.constructor)
			{
				// these types of objects get special json treatment.
				case Array:
				case Boolean:
				case Date:
				case Function:
				case Number:
				case String:
					return false;
				default:
					return true;
			}
		}
		return false;
	}
	
	/*
	 * The supplied value is either a primitive value of type t
	 * or an object constructed by the c.
	 */
	function isTypeOrClass(v, t, c)
	{
		return typeof v == t || (isObject(v) && v.constructor == c);
	}
	
	/*
	 * The supplied value is a cyclical data structure.
	 */
	function isCyclical(v)
	{
		var values = [];
		
		values.contains = function(v) {
			if (isJsonObject(v) || isArray(v)) {
				for (var i = 0; i < this.length; i++) {
					if (this[i] === v) {
						return true;
					}
				}
			}
			return false;
		};
		
		function check(v)
		{
			if (values.contains(v)) {
				return true;
			}
			values.push(v);
			
			if (isArray(v)) {
				for (var i = 0; i < v.length; i++) {
					if (check(v[i])) {
						return true;
					}
				}
			} else if (isJsonObject(v)) {
				for (var p in v) {
					if (v.hasOwnProperty(p) && check(v[p])) {
						return true;
					}
				}
			}
			return false;
		}
		
		return check(v);
	}


	/*
	 * Douglas Crockford says:
	 * "It is expected that these methods will formally become part of the
     * JavaScript Programming Language in the Fourth Edition of the
     * ECMAScript standard in 2007."
	 */
	function jsonMethodsExist()
	{
		return (
			Array.prototype.toJSONString &&
			Boolean.prototype.toJSONString &&
			Date.prototype.toJSONString &&
			Number.prototype.toJSONString &&
			Object.prototype.toJSONString &&
			String.prototype.toJSONString &&
			String.prototype.parseJSON);
	}

	/*
	 * Just a rudimentary check to see whether
	 * objects and arrays stay generally intact.
	 */
	function jsonMethodsWork()
	{
		try
		{
			// create
			var object = {signon: 'hello', signoff: 'goodbye'};
			var array = ['one', 2, 3, 4, 'five'];
			
			// encode
			var objectJSON = object.toJSONString();
			var arrayJSON = array.toJSONString();
			
			// decode
			var object2 = objectJSON.parseJSON();
			var array2 = arrayJSON.parseJSON();
			
			// compare objects
			for (var p in object) {
				if (object[p] !== object2[p]) {
					return false;
				}
			}
			for (var p in object2) {
				if (object[p] !== object2[p]) {
					return false;
				}
			}
			
			// compare arrays
			if (array.length !== array2.length) {
				return false;
			}
			for (var i = 0; i < array.length; i++) {
				if (array[i] !== array2[i]) {
					return false;
				}
			}
		}
		catch(e)
		{
			return false;
		}
		
		// no news is good news
		return true;
	}
	
	setUp();
	return Argo;

})();
