<?php
/*
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.php
 * version 2007-01-05
 *
 * Dependencies:
 *
 *   - HttpTerminal.php     XMLHttpRequest simplification layer,
 *                          available from http://www.tumuski.com/code/HttpTerminal/
 * 
 *   - A JSON encoder/decoder.
 *     Argo comes with support for Services_JSON and PHP-JSON:
 *   
 *       Services_JSON      Available from http://mike.teczno.com/json.html
 *
 *       PHP-JSON           Included in PHP 5.2.0, and 
 *                          available from http://www.aurore.net/projects/php-json/
 */
class Argo implements HttpTerminalListener
{
	private $json;	// the json encoder/decoder
	private $exception_reporter;
	private $debugging_console;
	private $argonaut_reflection;
	
	private static $singleton;
	
	/**
	 * A shortcut to simplest behavior, which is:
	 * If there's POST data, use it, respond, and exit.
	 * If not, just continue rendering the page.
	 *
	 * This method takes zero to three arguments, for
	 * optionally specifying objects to use internally.  Each
	 * argument can be an ArgoJSON, an ArgoExceptionReporter, or
	 * an ArgoDebuggingConsole.
	 * 
	 * @param mixed $a  An optional object
	 * @param mixed $b  An optional object
	 * @param mixed $c  An optional object
	 */
	public static function run($a=null, $b=null, $c=null)
	{
		self::$singleton = new Argo($a, $b, $c);
		
		$terminal = new HttpTerminal(
			self::$singleton
		);
		
		if ($terminal->got_request())
		{
			$terminal->send_response();
		}
	}
	
	/**
	 * Send a debugging message statically.
	 */
	public static function debug($message)
	{
		self::$singleton->debug_instance($message);
	}
	
	/**
	 * Send a debugging message.
	 */
	public function debug_instance($message)
	{
		$this->debugging_console->message($message.'');
	}
	
	/**
	 * Create a new Argo.
	 * This constructor takes zero to three arguments, for
	 * optionally specifying objects to use internally.  Each
	 * argument can be an ArgoJSON, an ArgoExceptionReporter, or
	 * an ArgoDebuggingConsole.
	 * 
	 * @param mixed $a  An optional object
	 * @param mixed $b  An optional object
	 * @param mixed $c  An optional object
	 */
	public function __construct($a=null, $b=null, $c=null)
	{
		$this->argonaut_reflection = new ReflectionClass('Argonaut');
	
		foreach (array($a, $b, $c) as $arg)
		{
			if (is_a($arg, 'ArgoJSON'))
			{
				$this->json = $arg;
			}
			if (is_a($arg, 'ArgoExceptionReporter'))
			{
				$this->exception_reporter = $arg;
			}
			if (is_a($arg, 'ArgoDebuggingConsole'))
			{
				$this->debugging_console = $arg;
			}
		}
		
		if (!$this->json)
		{
			$this->json = new ArgoJSON_Automatic();
		}
		if (!$this->exception_reporter)
		{
			$this->exception_reporter = new ArgoExceptionReporter_Silent();
		}
		if (!$this->debugging_console)
		{
			$this->debugging_console = new ArgoDebuggingConsole_Silent();
		}
	}
	
	/**
	 * Accept an Argo request, call the appropriate methods, and
	 * return an Argo response.
	 *
	 * @param string $request  The Argo request
	 * @return string
	 */
	public function respond($request)
	{
		$request = $this->json->decode_json($request);
		
		if (!Argo::is_json_array($request) || count($request) === 0)
		{
			// Take a "zero tolerance" approach.
			return "The request is not in the proper format.";
		}

		// an array of JSON texts, each representing a method call response
		$response = array();
		
		for ($i = 0; $i < count($request); $i++)
		{
			$method_call = $request[$i];
			$method_call_response = array(
				'r' => null,
				'e' => null,
				'd' => null
			);

			try
			{
				$this->validate_method_call($method_call);
				$c = $method_call['c'];
				$m = $method_call['m'];
				$a = $method_call['a'];
				
				// method reflection
				$reflection = $this->validate_command($c, $m, $a);
				
				ob_start();
				try
				{
					$result = $reflection->invokeArgs(NULL, $a);
					$output = ob_get_contents();
					if ($output)
					{
						throw new ArgoException_Output(
							$c.'::'.$m.'() produced output.',
							$output
						);
					}
					ob_end_clean();
				}
				catch(Exception $e)
				{
					ob_end_clean();
					throw $e;
				}
				
				$method_call_response['r'] = $result;
			}
			catch(Exception $e)
			{
				$method_call_response['e'] = $this->get_error_for_client($e);
			}
			
			$method_call_response['d'] = $this->debugging_console->get_messages_for_client();
			$this->debugging_console->clear_messages();
			
			$response[] = $method_call_response;
			
		}
		
		return $this->json->encode_json($response);
	}
	
	/*
	 * Get a report from the exception reporter, and ensure that 
	 * what gets sent to the client is a valid error object.
	 */
	private function get_error_for_client(Exception $e)
	{
		$report = $this->exception_reporter->report_exception($e);
		if (is_array($report) &&
			array_key_exists('detail', $report) &&
			array_key_exists('type', $report) &&
			(is_string($report['type']) || is_null($report['type'])) )
		{
			return $report;
		}
		else
		{
			return array(
				'type' => 'NONSTANDARD_ERROR_OBJECT',
				'detail' => null
			);
		}
	}
	
	/*
	 * If the method call is not in proper form, throw an exception.
	 */
	private function validate_method_call($method_call)
	{
		if (!Argo::is_json_object($method_call)){throw new ArgoException_InvalidRequest(
			"The method call is not a JSON object value.");}
			
		if (!array_key_exists('c', $method_call)){throw new ArgoException_InvalidRequest(
			"The method call does not specify a class.");}
			
		if (!array_key_exists('m', $method_call)){throw new ArgoException_InvalidRequest(
			"The method call does not specify a method.");}
			
		if (!array_key_exists('a', $method_call)){throw new ArgoException_InvalidRequest(
			"The method call does not specify an argument list.");}
			
		if (!is_string($method_call['c'])){throw new ArgoException_InvalidRequest(
			"The supplied class name is not a string.");}
			
		if (!is_string($method_call['m'])){throw new ArgoException_InvalidRequest(
			"The supplied method name is not a string.");}
			
		if (!Argo::is_json_array($method_call['a'])){throw new ArgoException_InvalidRequest(
			"The supplied argument list is not an array.");}
	}

	/*
	 * If the command will not work, throw an exception.
	 */
	private function validate_command($c, $m, $a)
	{
		if (!class_exists($c)){throw new ArgoException_InvalidCommand(
			"The requested class does not exist.");}
		
		$class = new ReflectionClass($c);
		
		if (!$class->isSubclassOf($this->argonaut_reflection)) {
			throw new ArgoException_InvalidCommand(
				"The requested class is not a subclass of Argonaut."
			);
		}
		
		if (!$class->hasMethod($m)) {
			throw new ArgoException_InvalidCommand(
				"The requested class does not offer the requested method."
			);
		}
		
		$method = $class->getMethod($m);
		$isValid = 
			$method->isPublic() &&
			$method->isStatic() &&
			!$method->isAbstract() &&
			!$method->isConstructor() &&
			!$method->isDestructor();
			
		if (!$isValid) {
			throw new ArgoException_InvalidCommand(
				"The requested method must be public and static, and must not be abstract, constructor, or destructor."
			);
		}
		
		$need = $method->getNumberOfRequiredParameters();
		$got = count($a);
		
		if ($need > $got) {
			throw new ArgoException_InvalidCommand(
				$c.'::'.$m.'() requires '.$need.' arguments but only got '.$got.'.'
			);
		}
		
		$parameters = $method->getParameters();
		for ($i = 0; $i < $got; $i++) {
			$p = $parameters[$i];
			$pClass = $p->getClass();
			if ($pClass)
			{
				$pClassName = $pClass->getName();
				if (!is_a($a[$i], $pClassName))
				{
					throw new ArgoException_InvalidCommand(
						'Argument $'.$p->getName().' to '.$c.'::'.$m.'() must be a(n) '.
							$pClassName.'.  To satisfy that requirement is probably impossible.'
					);
				}
			}
		}
		
		return $method;
	}
	
	/*
	 * The supplied array has only string keys.
	 * This method helps distinguish between JSON arrays and JSON objects.
	 *
	 * @param array $value  The array to test
	 * @return boolean
	 */
	private static function is_json_object($value)
	{
		if (!is_array($value))
		{
			return false;
		}
		$keys = array_keys($value);
		foreach ($keys as $k)
		{
			if (!is_string($k))
			{
				return false;
			}
		}
		return true;
	}

	/*
	 * The supplied array has only integer indices, contiguous and in order.
	 * This method helps distinguish between JSON arrays and JSON objects.
	 *
	 * @param array $value  The array to test
	 * @return boolean
	 */
	private static function is_json_array($value)
	{
		if (!is_array($value))
		{
			return false;
		}
		$keys = array_keys($value);
		for ($i = 0; $i < count($keys); $i++)
		{
			if ($keys[$i] !== $i)
			{
				return false;
			}
		}
		return true;
	}
	
}

/**
 * A class to be extended by classes that need to be accessible from
 * the client side.  Nothing an Argonaut does should print any output.
 */
abstract class Argonaut {}


/**
 * Interface for debugging consoles for Argonauts to send messages to.
 */
interface ArgoDebuggingConsole
{
	/**
	 * Send a message to this console's Argo.
	 * 
	 * @param string $m  The message to send.
	 */
	public function message($message);
	
	/**
	 * An array of messages that have been sent to this console.
	 * 
	 * @return array
	 */
	public function get_messages_for_client();
	
	/**
	 * Remove all the messages that have been sent to this console.
	 * 
	 * @ensure count(get_messages_for_client()) == 0
	 */
	public function clear_messages();
}

/**
 * A debugging console that sends its messages to the client side.
 */
class ArgoDebuggingConsole_Verbose implements ArgoDebuggingConsole
{
	private $messages = array();
	
	public function message($message)
	{
		$this->messages[] = $message;
	}
	
	public function get_messages_for_client()
	{
		return $this->messages;
	}
	
	public function clear_messages()
	{
		$this->messages = array();
	}
}

/**
 * A debugging console that sends its messages nowhere.
 */
class ArgoDebuggingConsole_Silent implements ArgoDebuggingConsole
{
	public function message($message)
	{
	}
	
	public function get_messages_for_client()
	{
		return array();
	}

	public function clear_messages()
	{
	}
}


/**
 * A class for converting Exceptions into a JSON objects
 * to return to the client side.
 */
interface ArgoExceptionReporter
{
	/**
	 * Convert the supplied exception 
	 * into a JSON-object-friendly associative array.
	 *
	 * @param Exception $e  The exception to convert.
	 * @return array
	 */
	function report_exception(Exception $e);
}

/**
 * An ArgoExceptionReporter that presents no information to the client.
 */
class ArgoExceptionReporter_Silent implements ArgoExceptionReporter
{
	/**
	 * I know nothing!
	 *
	 * @param Exception $e  The exception to convert.
	 * @return array
	 */
	public function report_exception(Exception $e)
	{
		return array(
			'type' => null,
			'detail' => null
		);
	}
}

/**
 * An ArgoExceptionReporter that presents only 
 * the Exception's class name.
 */
class ArgoExceptionReporter_Minimal implements ArgoExceptionReporter
{
	/**
	 * Give just the name of the exception.
	 *
	 * @param Exception $e  The exception to convert.
	 * @return array
	 */
	public function report_exception(Exception $e)
	{
		return array(
			'type' => get_class($e),
			'detail' => null
		);
	}
}

/**
 * An ArgoExceptionReporter that presents only 
 * the Exception's class name and message.
 */
class ArgoExceptionReporter_Informative implements ArgoExceptionReporter
{
	/**
	 * Give just the name of the exception and the message.
	 *
	 * @param Exception $e  The exception to convert.
	 * @return array
	 */
	public function report_exception(Exception $e)
	{
		return array(
			'type' => get_class($e),
			'detail' => array(
				'message' => $e->getMessage()
			)
		);
	}
}

/**
 * An ArgoExceptionReporter that presents the values
 * returned by all the 'get' methods of the supplied Exception.
 */
class ArgoExceptionReporter_Verbose implements ArgoExceptionReporter
{
	/**
	 * Give all the properties accessible through methods
	 * whose names begin with 'get'.
	 *
	 * @param ArgoException $e  The exception to convert.
	 * @return array
	 */
	public function report_exception(Exception $e)
	{
		$vars = array();
		$methods = get_class_methods($e);
		foreach ($methods as $m)
		{
			if (strlen($m) > 3)
			{
				$prefix = strtolower(substr($m, 0, 4));
				$suffix = substr($m, 4);
				
				if (substr($prefix, 0, 3) === 'get')
				{
					$vars[substr($prefix, 3).$suffix] = $e->$m();
				}
			}
		}
		return array(
			'type' => get_class($e),
			'detail' => $vars
		);
	}
}


/**
 * A simple interface for a JSON encoder/decoder to be used with Argo.
 * Classes implementing this interface will most likely just be wrappers
 * for real JSON classes.
 */
interface ArgoJSON
{
	/**
	 * A JSON text representing the value provided.
	 *
	 * @param mixed $value  The value to be encoded into a JSON text.
	 * @return string
	 */
	public function encode_json($value);

	/**
	 * Parse a JSON text into PHP value,
	 * with objects decoded as associative arrays.
	 *
	 * @param string $text  The JSON text to be decoded.
	 * @return mixed
	 */
	public function decode_json($text);
}

/**
 * An ArgoJSON wrapper that selects Services_JSON if it
 * is available, and falls back to php-json.
 * This is the class of ArgoJSON used by default in Argo.
 */
class ArgoJSON_Automatic implements ArgoJSON
{
	private $json = null;

	public function __construct()
	{
		if (class_exists('Services_JSON'))
		{
			$this->json = new ArgoJSON_ServicesJSON();
		}
		else
		{
			$this->json = new ArgoJSON_PHPJSON();
		}
	}
	
	public function encode_json($value)
	{
		return $this->json->encode_json($value);
	}
	
	public function decode_json($text)
	{
		return $this->json->decode_json($text);
	}
}


/**
 * A wrapper for the php-json PHP module included in PHP 5.2.0
 */
class ArgoJSON_PHPJSON implements ArgoJSON
{
	public function encode_json($value)
	{
		// supress errors
		return @json_encode($value);
	}
	
	public function decode_json($text)
	{
		return json_decode($text, true);
	}
}

/**
 * A wrapper for the Services_JSON PHP class, which is slower
 * than php-json, but provides better number precision,
 * and is much easier to install.
 */
class ArgoJSON_ServicesJSON implements ArgoJSON
{
	private $json = null;
	
	public function __construct()
	{
		$this->json = new Services_JSON(
			SERVICES_JSON_LOOSE_TYPE + 
			SERVICES_JSON_SUPPRESS_ERRORS
		);
	}
	
	public function encode_json($value)
	{
		return $this->json->encode($value);
	}
	
	public function decode_json($text)
	{
		return $this->json->decode($text);
	}
}

/**
 * An exception to throw when the Argo request is invalid.
 */
class ArgoException_InvalidRequest extends Exception {}

/**
 * An exception to throw when the Argo command is invalid.
 */
class ArgoException_InvalidCommand extends Exception {}

/**
 * An exception to throw when an Argonaut prints output.
 */
class ArgoException_Output extends Exception
{
	private $output;
	
	public function __construct($message = '', $output = '')
	{
		parent::__construct($message);
		$this->output = $output;
	}
	
	public function getOutput()
	{
		return $this->output;
	}
}

?>