<?php
/**
 * A ViewableData object is any object that can be rendered into a template/view.
 *
 * A view interrogates the object being currently rendered in order to get data to render into the template. This data
 * is provided and automatically escaped by ViewableData. Any class that needs to be available to a view (controllers,
 * {@link DataObject}s, page controls) should inherit from this class.
 *
 * @package framework
 * @subpackage view
 */
class ViewableData extends Object implements IteratorAggregate {
	
	/**
	 * An array of objects to cast certain fields to. This is set up as an array in the format:
	 *
	 * <code>
	 * public static $casting = array (
	 *     'FieldName' => 'ClassToCastTo(Arguments)'
	 * );
	 * </code>
	 *
	 * @var array
	 * @config
	 */
	private static $casting = array(
		'CSSClasses' => 'Varchar'
	);
	
	/**
	 * The default object to cast scalar fields to if casting information is not specified, and casting to an object
	 * is required.
	 *
	 * @var string
	 * @config
	 */
	private static $default_cast = 'Text';
	
	/**
	 * @var array
	 */
	private static $casting_cache = array();
	
	// -----------------------------------------------------------------------------------------------------------------

	/**
	 * A failover object to attempt to get data from if it is not present on this object.
	 *
	 * @var ViewableData
	 */
	protected $failover;
	
	/**
	 * @var ViewableData
	 */
	protected $customisedObject;
	
	/**
	 * @var array
	 */
	private $objCache = array();
	
	// -----------------------------------------------------------------------------------------------------------------
	
	/**
	 * Converts a field spec into an object creator. For example: "Int" becomes "new Int($fieldName);" and "Varchar(50)"
	 * becomes "new Varchar($fieldName, 50);".
	 *
	 * @param string $fieldSchema The field spec
	 * @return string
	 */
	public static function castingObjectCreator($fieldSchema) {
		Deprecation::notice('2.5', 'Use Object::create_from_string() instead');
	}
	
	/**
	 * Convert a field schema (e.g. "Varchar(50)") into a casting object creator array that contains both a className
	 * and castingHelper constructor code. See {@link castingObjectCreator} for more information about the constructor.
	 *
	 * @param string $fieldSchema
	 * @return array
	 */
	public static function castingObjectCreatorPair($fieldSchema) {
		Deprecation::notice('2.5', 'Use Object::create_from_string() instead');
	}
	
	// FIELD GETTERS & SETTERS -----------------------------------------------------------------------------------------
	
	/**
	 * Check if a field exists on this object or its failover.
	 *
	 * @param string $property
	 * @return bool
	 */
	public function __isset($property) {
		return $this->hasField($property) || ($this->failover && $this->failover->hasField($property));
	}
	
	/**
	 * Get the value of a property/field on this object. This will check if a method called get{$property} exists, then
	 * check if a field is available using {@link ViewableData::getField()}, then fall back on a failover object.
	 *
	 * @param string $property
	 * @return mixed
	 */
	public function __get($property) {
		if($this->hasMethod($method = "get$property")) {
			return $this->$method();
		} elseif($this->hasField($property)) {
			return $this->getField($property);
		} elseif($this->failover) {
			return $this->failover->$property;
		}
	}
	
	/**
	 * Set a property/field on this object. This will check for the existence of a method called set{$property}, then
	 * use the {@link ViewableData::setField()} method.
	 *
	 * @param string $property
	 * @param mixed $value
	 */
	public function __set($property, $value) {
		if($this->hasMethod($method = "set$property")) {
			$this->$method($value);
		} else {
			$this->setField($property, $value);
		}
	}
	
	/**
	 * Check if a field exists on this object. This should be overloaded in child classes.
	 *
	 * @param string $field
	 * @return bool
	 */
	public function hasField($field) {
		return property_exists($this, $field);
	}
	
	/**
	 * Get the value of a field on this object. This should be overloaded in child classes.
	 *
	 * @param string $field
	 * @return mixed
	 */
	public function getField($field) {
		return $this->$field;
	}
	
	/**
	 * Set a field on this object. This should be overloaded in child classes.
	 *
	 * @param string $field
	 * @param mixed $value
	 */
	public function setField($field, $value) {
		$this->$field = $value;
	}
	
	// -----------------------------------------------------------------------------------------------------------------
	
	/**
	 * Add methods from the {@link ViewableData::$failover} object, as well as wrapping any methods prefixed with an
	 * underscore into a {@link ViewableData::cachedCall()}.
	 */
	public function defineMethods() {
		if($this->failover) {
			if(is_object($this->failover)) $this->addMethodsFrom('failover');
			else user_error("ViewableData::\$failover set to a non-object", E_USER_WARNING);
			
			if(isset($_REQUEST['debugfailover'])) {
				Debug::message("$this->class created with a failover class of {$this->failover->class}");
			}
		}
		
		foreach($this->allMethodNames() as $method) {
			if($method[0] == '_' && $method[1] != '_') {
				$this->createMethod(
					substr($method, 1),
					"return \$obj->deprecatedCachedCall('$method', \$args, '" . substr($method, 1) . "');"
				);
			}
		}
		
		parent::defineMethods();
	}

	/**
	 * Method to facilitate deprecation of underscore-prefixed methods automatically being cached.
	 * 
	 * @param string $field
	 * @param array $arguments
	 * @param string $identifier an optional custom cache identifier
	 * @return unknown
	 */
	public function deprecatedCachedCall($method, $args = null, $identifier = null) {
		Deprecation::notice(
			'4.0',
			'You are calling an underscore-prefixed method (e.g. _mymethod()) without the underscore. This behaviour,
				and the caching logic behind it, has been deprecated.',
			Deprecation::SCOPE_GLOBAL
		);
		return $this->cachedCall($method, $args, $identifier);
	}
	
	/**
	 * Merge some arbitrary data in with this object. This method returns a {@link ViewableData_Customised} instance
	 * with references to both this and the new custom data.
	 *
	 * Note that any fields you specify will take precedence over the fields on this object.
	 *
	 * @param array|ViewableData $data
	 * @return ViewableData_Customised
	 */
	public function customise($data) {
		if(is_array($data) && (empty($data) || ArrayLib::is_associative($data))) {
			$data = new ArrayData($data);
		}
		
		if($data instanceof ViewableData) {
			return new ViewableData_Customised($this, $data);
		}
		
		throw new InvalidArgumentException (
			'ViewableData->customise(): $data must be an associative array or a ViewableData instance'
		);
	}
	
	/**
	 * @return ViewableData
	 */
	public function getCustomisedObj() {
		return $this->customisedObject;
	}

	/**
	 * @param ViewableData $object
	 */
	public function setCustomisedObj(ViewableData $object) {
		$this->customisedObject = $object;
	}
	
	// CASTING ---------------------------------------------------------------------------------------------------------
	
	/**
	 * Get the class a field on this object would be casted to, as well as the casting helper for casting a field to
	 * an object (see {@link ViewableData::castingHelper()} for information on casting helpers).
	 *
	 * The returned array contains two keys:
	 *  - className: the class the field would be casted to (e.g. "Varchar")
	 *  - castingHelper: the casting helper for casting the field (e.g. "return new Varchar($fieldName)")
	 *
	 * @param string $field
	 * @return array
	 */
	public function castingHelperPair($field) {
		Deprecation::notice('2.5', 'use castingHelper() instead');
		return $this->castingHelper($field);
	}

	/**
	 * Return the "casting helper" (a piece of PHP code that when evaluated creates a casted value object) for a field
	 * on this object.
	 *
	 * @param string $field
	 * @return string
	 */
	public function castingHelper($field) {
		if($this->hasMethod('db') && $fieldSpec = $this->db($field)) {
			return $fieldSpec;
		}

		$specs = Config::inst()->get(get_class($this), 'casting');
		if(isset($specs[$field])) return $specs[$field];

		if($this->failover) return $this->failover->castingHelper($field);
	}
	
	/**
	 * Get the class name a field on this object will be casted to
	 *
	 * @param string $field
	 * @return string
	 */
	public function castingClass($field) {
		$spec = $this->castingHelper($field);
		if(!$spec) return null;
		
		$bPos = strpos($spec,'(');
		if($bPos === false) return $spec;
		else return substr($spec, 0, $bPos);
	}
	
	/**
	 * Return the string-format type for the given field.
	 *
	 * @param string $field
	 * @return string 'xml'|'raw'
	 */
	public function escapeTypeForField($field) {
		$class = $this->castingClass($field) ?: $this->config()->default_cast;

		return Config::inst()->get($class, 'escape_type', Config::FIRST_SET);
	}

	/**
	 * Save the casting cache for this object (including data from any failovers) into a variable
	 *
	 * @param reference $cache
	 */
	public function buildCastingCache(&$cache) {
		$ancestry = array_reverse(ClassInfo::ancestry($this->class));
		$merge    = true;
		
		foreach($ancestry as $class) {
			if(!isset(self::$casting_cache[$class]) && $merge) {
				$mergeFields = is_subclass_of($class, 'DataObject') ? array('db', 'casting') : array('casting');
				
				if($mergeFields) foreach($mergeFields as $field) {
					$casting = Config::inst()->get($class, $field, Config::UNINHERITED);
					if($casting) foreach($casting as $field => $cast) {
						if(!isset($cache[$field])) $cache[$field] = self::castingObjectCreatorPair($cast);
					}
				}
				
				if($class == 'ViewableData') $merge = false;
			} elseif($merge) {
				$cache = ($cache) ? array_merge(self::$casting_cache[$class], $cache) : self::$casting_cache[$class];
			}
			
			if($class == 'ViewableData') break;
		}
	}
	
	// TEMPLATE ACCESS LAYER -------------------------------------------------------------------------------------------
	
	/**
	 * Render this object into the template, and get the result as a string. You can pass one of the following as the
	 * $template parameter:
	 *  - a template name (e.g. Page)
	 *  - an array of possible template names - the first valid one will be used
	 *  - an SSViewer instance
	 *
	 * @param string|array|SSViewer $template the template to render into
	 * @param array $customFields fields to customise() the object with before rendering
	 * @return HTMLText
	 */
	public function renderWith($template, $customFields = null) {
		if(!is_object($template)) {
			$template = new SSViewer($template);
		}
		
		$data = ($this->customisedObject) ? $this->customisedObject : $this;
		
		if($customFields instanceof ViewableData) {
			$data = $data->customise($customFields);
		}
		if($template instanceof SSViewer) {
			return $template->process($data, is_array($customFields) ? $customFields : null);
		}
		
		throw new UnexpectedValueException (
			"ViewableData::renderWith(): unexpected $template->class object, expected an SSViewer instance"
		);
	}

	/**
	 * Generate the cache name for a field
	 *
	 * @param string $fieldName Name of field
	 * @param array $arguments List of optional arguments given
	 */
	protected function objCacheName($fieldName, $arguments) {
		return $arguments
			? $fieldName . ":" . implode(',', $arguments)
			: $fieldName;
	}

	/**
	 * Get a cached value from the field cache
	 *
	 * @param string $key Cache key
	 * @return mixed
	 */
	protected function objCacheGet($key) {
		if(isset($this->objCache[$key])) return $this->objCache[$key];
	}

	/**
	 * Store a value in the field cache
	 *
	 * @param string $key Cache key
	 * @param mixed $value
	 */
	protected function objCacheSet($key, $value) {
		$this->objCache[$key] = $value;
	}
	
	/**
	 * Get the value of a field on this object, automatically inserting the value into any available casting objects
	 * that have been specified.
	 *
	 * @param string $fieldName
	 * @param array $arguments
	 * @param bool $forceReturnedObject if TRUE, the value will ALWAYS be casted to an object before being returned,
	 *        even if there is no explicit casting information
	 * @param bool $cache Cache this object
	 * @param string $cacheName a custom cache name
	 */
	public function obj($fieldName, $arguments = null, $forceReturnedObject = true, $cache = false, $cacheName = null) {
		if(!$cacheName && $cache) $cacheName = $this->objCacheName($fieldName, $arguments);

		$value = $cache ? $this->objCacheGet($cacheName) : null;
		if(!isset($value)) {
			// HACK: Don't call the deprecated FormField::Name() method
			$methodIsAllowed = true;
			if($this instanceof FormField && $fieldName == 'Name') $methodIsAllowed = false;
			
			if($methodIsAllowed && $this->hasMethod($fieldName)) {
				$value = $arguments ? call_user_func_array(array($this, $fieldName), $arguments) : $this->$fieldName();
			} else {
				$value = $this->$fieldName;
			}
			
			if(!is_object($value) && ($this->castingClass($fieldName) || $forceReturnedObject)) {
				if(!$castConstructor = $this->castingHelper($fieldName)) {
					$castConstructor = $this->config()->default_cast;
				}
				
				$valueObject = Object::create_from_string($castConstructor, $fieldName);
				$valueObject->setValue($value, $this);
				
				$value = $valueObject;
			}
			
			if($cache) $this->objCacheSet($cacheName, $value);
		}
		
		if(!is_object($value) && $forceReturnedObject) {
			$default = $this->config()->default_cast;
			$castedValue = new $default($fieldName);
			$castedValue->setValue($value);
			$value = $castedValue;
		}
		
		return $value;
	}
	
	/**
	 * A simple wrapper around {@link ViewableData::obj()} that automatically caches the result so it can be used again
	 * without re-running the method.
	 *
	 * @param string $field
	 * @param array $arguments
	 * @param string $identifier an optional custom cache identifier
	 */
	public function cachedCall($field, $arguments = null, $identifier = null) {
		return $this->obj($field, $arguments, false, true, $identifier);
	}
	
	/**
	 * Checks if a given method/field has a valid value. If the result is an object, this will return the result of the
	 * exists method, otherwise will check if the result is not just an empty paragraph tag.
	 *
	 * @param string $field
	 * @param array $arguments
	 * @param bool $cache
	 * @return bool
	 */
	public function hasValue($field, $arguments = null, $cache = true) {
		$result = $cache ? $this->cachedCall($field, $arguments) : $this->obj($field, $arguments, false, false);
		
		if(is_object($result) && $result instanceof Object) {
			return $result->exists();
		} else {
			// Empty paragraph checks are a workaround for TinyMCE
			return ($result && $result !== '<p></p>');
		}
	}
	
	/**#@+
	 * @param string $field
	 * @param array $arguments
	 * @param bool $cache
	 * @return string
	 */
	
	/**
	 * Get the string value of a field on this object that has been suitable escaped to be inserted directly into a
	 * template.
	 */
	public function XML_val($field, $arguments = null, $cache = false) {
		$result = $this->obj($field, $arguments, false, $cache);
		return (is_object($result) && $result instanceof Object) ? $result->forTemplate() : $result;
	}
	
	/**
	 * Return the value of the field without any escaping being applied.
	 */
	public function RAW_val($field, $arguments = null, $cache = true) {
		return Convert::xml2raw($this->XML_val($field, $arguments, $cache));
	}
	
	/**
	 * Return the value of a field in an SQL-safe format.
	 */
	public function SQL_val($field, $arguments = null, $cache = true) {
		return Convert::raw2sql($this->RAW_val($field, $arguments, $cache));
	}
	
	/**
	 * Return the value of a field in a JavaScript-save format.
	 */
	public function JS_val($field, $arguments = null, $cache = true) {
		return Convert::raw2js($this->RAW_val($field, $arguments, $cache));
	}
	
	/**
	 * Return the value of a field escaped suitable to be inserted into an XML node attribute.
	 */
	public function ATT_val($field, $arguments = null, $cache = true) {
		return Convert::raw2att($this->RAW_val($field, $arguments, $cache));
	}
	
	/**#@-*/
	
	/**
	 * Get an array of XML-escaped values by field name
	 *
	 * @param array $elements an array of field names
	 * @return array
	 */
	public function getXMLValues($fields) {
		$result = array();
		
		foreach($fields as $field) {
			$result[$field] = $this->XML_val($field);
		}
		
		return $result;
	}
	
	// ITERATOR SUPPORT ------------------------------------------------------------------------------------------------
	
	/**
	 * Return a single-item iterator so you can iterate over the fields of a single record.
	 *
	 * This is useful so you can use a single record inside a <% control %> block in a template - and then use
	 * to access individual fields on this object.
	 *
	 * @return ArrayIterator
	 */
	public function getIterator() {
		return new ArrayIterator(array($this));
	}
	
	// UTILITY METHODS -------------------------------------------------------------------------------------------------
	
	/**
	 * When rendering some objects it is necessary to iterate over the object being rendered, to do this, you need
	 * access to itself.
	 *
	 * @return ViewableData
	 */
	public function Me() {
		return $this;
	}
	
	/**
	 * Return the directory if the current active theme (relative to the site root).
	 *
	 * This method is useful for things such as accessing theme images from your template without hardcoding the theme
	 * page - e.g. <img src="$ThemeDir/images/something.gif">.
	 *
	 * This method should only be used when a theme is currently active. However, it will fall over to the current
	 * project directory.
	 *
	 * @param string $subtheme the subtheme path to get
	 * @return string
	 */
	public function ThemeDir($subtheme = false) {
		if(
			Config::inst()->get('SSViewer', 'theme_enabled') 
			&& $theme = Config::inst()->get('SSViewer', 'theme')
		) {
			return THEMES_DIR . "/$theme" . ($subtheme ? "_$subtheme" : null);
		}
		
		return project();
	}
	
	/**
	 * Get part of the current classes ancestry to be used as a CSS class.
	 *
	 * This method returns an escaped string of CSS classes representing the current classes ancestry until it hits a
	 * stop point - e.g. "Page DataObject ViewableData".
	 *
	 * @param string $stopAtClass the class to stop at (default: ViewableData)
	 * @return string
	 * @uses ClassInfo
	 */
	public function CSSClasses($stopAtClass = 'ViewableData') {
		$classes       = array();
		$classAncestry = array_reverse(ClassInfo::ancestry($this->class));
		$stopClasses   = ClassInfo::ancestry($stopAtClass);
		
		foreach($classAncestry as $class) {
			if(in_array($class, $stopClasses)) break;
			$classes[] = $class;
		}
		
		// optionally add template identifier
		if(isset($this->template) && !in_array($this->template, $classes)) {
			$classes[] = $this->template;
		}
		
		return Convert::raw2att(implode(' ', $classes));
	}

	/**
	 * Return debug information about this object that can be rendered into a template
	 *
	 * @return ViewableData_Debugger
	 */
	public function Debug() {
		return new ViewableData_Debugger($this);
	}
	
}

/**
 * @package framework
 * @subpackage view
 */
class ViewableData_Customised extends ViewableData {
	
	/**
	 * @var ViewableData
	 */
	protected $original, $customised;
	
	/**
	 * Instantiate a new customised ViewableData object
	 *
	 * @param ViewableData $originalObject
	 * @param ViewableData $customisedObject
	 */
	public function __construct(ViewableData $originalObject, ViewableData $customisedObject) {
		$this->original   = $originalObject;
		$this->customised = $customisedObject;
		
		$this->original->setCustomisedObj($this);
		
		parent::__construct();
	}
	
	public function __call($method, $arguments) {
		if($this->customised->hasMethod($method)) {
			return call_user_func_array(array($this->customised, $method), $arguments);
		}
		
		return call_user_func_array(array($this->original, $method), $arguments);
	}
	
	public function __get($property) {
		if(isset($this->customised->$property)) {
			return $this->customised->$property;
		}
		
		return $this->original->$property;
	}
	
	public function __set($property, $value) {
		$this->customised->$property = $this->original->$property = $value;
	}
	
	public function hasMethod($method) {
		return $this->customised->hasMethod($method) || $this->original->hasMethod($method);
	}
	
	public function cachedCall($field, $arguments = null, $identifier = null) {
		if($this->customised->hasMethod($field) || $this->customised->hasField($field)) {
			$result = $this->customised->cachedCall($field, $arguments, $identifier);
		} else {
			$result = $this->original->cachedCall($field, $arguments, $identifier);
		}
		
		return $result;
	}
	
	public function obj($fieldName, $arguments = null, $forceReturnedObject = true, $cache = false, $cacheName = null) {
		if($this->customised->hasField($fieldName) || $this->customised->hasMethod($fieldName)) {
			return $this->customised->obj($fieldName, $arguments, $forceReturnedObject, $cache, $cacheName);
		}
		
		return $this->original->obj($fieldName, $arguments, $forceReturnedObject, $cache, $cacheName);
	}
	
}

/**
 * Allows you to render debug information about a {@link ViewableData} object into a template.
 *
 * @package framework
 * @subpackage view
 */
class ViewableData_Debugger extends ViewableData {
	
	/**
	 * @var ViewableData
	 */
	protected $object;
	
	/**
	 * @param ViewableData $object
	 */
	public function __construct(ViewableData $object) {
		$this->object = $object;
		parent::__construct();
	}

	/**
	 * @return string The rendered debugger
	 */
	public function __toString() {
		return $this->forTemplate();
	}

	/**
	 * Return debugging information, as XHTML. If a field name is passed, it will show debugging information on that
	 * field, otherwise it will show information on all methods and fields.
	 *
	 * @param string $field the field name
	 * @return string
	 */
	public function forTemplate($field = null) {
		// debugging info for a specific field
		if($field) return "<b>Debugging Information for {$this->class}->{$field}</b><br/>" .
			($this->object->hasMethod($field)? "Has method '$field'<br/>" : null)             .
			($this->object->hasField($field) ? "Has field '$field'<br/>"  : null)             ;
		
		// debugging information for the entire class
		$reflector = new ReflectionObject($this->object);
		$debug     = "<b>Debugging Information: all methods available in '{$this->object->class}'</b><br/><ul>";
		
		foreach($this->object->allMethodNames() as $method) {
			// check that the method is public
			if($method[0] === strtoupper($method[0]) && $method[0] != '_') {
				if($reflector->hasMethod($method) && $method = $reflector->getMethod($method)) {
					if($method->isPublic()) {
						$debug .= "<li>\${$method->getName()}";
						
						if(count($method->getParameters())) {
							$debug .= ' <small>(' . implode(', ', $method->getParameters()) . ')</small>';
						}
						
						$debug .= '</li>';
					}
				} else {
					$debug .= "<li>\$$method</li>";
				}
			}
		}
		
		$debug .= '</ul>';
		
		if($this->object->hasMethod('toMap')) {
			$debug .= "<b>Debugging Information: all fields available in '{$this->object->class}'</b><br/><ul>";
			
			foreach($this->object->toMap() as $field => $value) {
				$debug .= "<li>\$$field</li>";
			}
			
			$debug .= "</ul>";
		}
		
		// check for an extra attached data
		if($this->object->hasMethod('data') && $this->object->data() != $this->object) {
			$debug .= ViewableData_Debugger::create($this->object->data())->forTemplate();
		}
		
		return $debug;
	}

}