<?php
/**
 * A blueprint on how to create instances of a certain {@link DataObject} subclass.
 *
 * Relies on a {@link FixtureFactory} to manage database relationships between instances,
 * and manage the mappings between fixture identifiers and their database IDs.
 *
 * @package framework
 * @subpackage core
 */
class FixtureBlueprint {

	/**
	 * @var array Map of field names to values. Supersedes {@link DataObject::$defaults}.
	 */
	protected $defaults = array();

	/**
	 * @var String Arbitrary name by which this fixture type can be referenced.
	 */
	protected $name;

	/**
	 * @var String Subclass of {@link DataObject}
	 */
	protected $class;

	/**
	 * @var array
	 */
	protected $callbacks = array(
		'beforeCreate' => array(),
		'afterCreate' => array(),
	);

	/** @config */
	private static $dependencies = array(
		'factory' => '%$FixtureFactory'
	);

	/**
	 * @param String $name
	 * @param String $class Defaults to $name
	 * @param array $defaults
	 */
	public function __construct($name, $class = null, $defaults = array()) {
		if(!$class) $class = $name;

		if(!is_subclass_of($class, 'DataObject')) {
			throw new InvalidArgumentException(sprintf(
				'Class "%s" is not a valid subclass of DataObject',
				$class
			));
		}

		$this->name = $name;
		$this->class = $class;
		$this->defaults = $defaults;
	}

	/**
	 * @param String $identifier Unique identifier for this fixture type
	 * @param Array $data Map of property names to their values.
	 * @param Array $fixtures Map of fixture names to an associative array of their in-memory
	 *                        identifiers mapped to their database IDs. Used to look up
	 *                        existing fixtures which might be referenced in the $data attribute
	 *                        via the => notation.
	 * @return DataObject
	 */
	public function createObject($identifier, $data = null, $fixtures = null) {
		// We have to disable validation while we import the fixtures, as the order in
		// which they are imported doesnt guarantee valid relations until after the import is complete.
		$validationenabled = Config::inst()->get('DataObject', 'validation_enabled');
		Config::inst()->update('DataObject', 'validation_enabled', false);

		$this->invokeCallbacks('beforeCreate', array($identifier, &$data, &$fixtures));

		try {
			$class = $this->class;
			$obj = DataModel::inst()->$class->newObject();

			// If an ID is explicitly passed, then we'll sort out the initial write straight away
			// This is just in case field setters triggered by the population code in the next block
			// Call $this->write().  (For example, in FileTest)
			if(isset($data['ID'])) {
				$obj->ID = $data['ID'];

				// The database needs to allow inserting values into the foreign key column (ID in our case)
				$conn = DB::get_conn();
				if(method_exists($conn, 'allowPrimaryKeyEditing')) {
					$conn->allowPrimaryKeyEditing(ClassInfo::baseDataClass($class), true);
				}
				$obj->write(false, true);
				if(method_exists($conn, 'allowPrimaryKeyEditing')) {
					$conn->allowPrimaryKeyEditing(ClassInfo::baseDataClass($class), false);
				}
			}

			// Populate defaults
			if($this->defaults) foreach($this->defaults as $fieldName => $fieldVal) {
				if(isset($data[$fieldName]) && $data[$fieldName] !== false) continue;

				if(is_callable($fieldVal)) {
					$obj->$fieldName = $fieldVal($obj, $data, $fixtures);
				} else {
					$obj->$fieldName = $fieldVal;
				}
			}

			// Populate overrides
			if($data) foreach($data as $fieldName => $fieldVal) {
				// Defer relationship processing
				if(
					$obj->manyManyComponent($fieldName)
					|| $obj->hasManyComponent($fieldName)
					|| $obj->hasOneComponent($fieldName)
				) {
					continue;
				}

				$this->setValue($obj, $fieldName, $fieldVal, $fixtures);
			}

			$obj->write();

			// Save to fixture before relationship processing in case of reflexive relationships
			if(!isset($fixtures[$class])) {
				$fixtures[$class] = array();
			}
			$fixtures[$class][$identifier] = $obj->ID;

			// Populate all relations
			if($data) foreach($data as $fieldName => $fieldVal) {
				$isManyMany = $obj->manyManyComponent($fieldName);
				$isHasMany = $obj->hasManyComponent($fieldName);
				if ($isManyMany && $isHasMany) {
					throw new InvalidArgumentException("$fieldName is both many_many and has_many");
				}
				if($isManyMany || $isHasMany) {
					$obj->write();

					// Many many components need a little extra work to extract extrafields
					if(is_array($fieldVal) && $isManyMany) {
						// handle lists of many_many relations. Each item can
						// specify the many_many_extraFields against each
						// related item.
						foreach($fieldVal as $relVal) {
							// Check for many_many_extrafields
							$extrafields = array();
							if (is_array($relVal)) {
								// Item is either first row, or key in yet another nested array
								$item = key($relVal);
								if (is_array($relVal[$item]) && count($relVal) === 1) {
									// Extra fields from nested array
									$extrafields = $relVal[$item];
								} else {
									// Extra fields from subsequent items
									array_shift($relVal);
									$extrafields = $relVal;
								}
							} else {
								$item = $relVal;
							}
							$id = $this->parseValue($item, $fixtures);

							$obj->getManyManyComponents($fieldName)->add(
								$id, $extrafields
							);
						}
					} else {
						$items = is_array($fieldVal)
							? $fieldVal
							: preg_split('/ *, */',trim($fieldVal));

						$parsedItems = array();
						foreach($items as $item) {
							// Check for correct format: =><relationname>.<identifier>.
							// Ignore if the item has already been replaced with a numeric DB identifier
							if(!is_numeric($item) && !preg_match('/^=>[^\.]+\.[^\.]+/', $item)) {
								throw new InvalidArgumentException(sprintf(
									'Invalid format for relation "%s" on class "%s" ("%s")',
									$fieldName,
									$class,
									$item
								));
							}

							$parsedItems[] = $this->parseValue($item, $fixtures);
						}

						if($isHasMany) {
							$obj->getComponents($fieldName)->setByIDList($parsedItems);
						} elseif($isManyMany) {
							$obj->getManyManyComponents($fieldName)->setByIDList($parsedItems);
						}
					}
				} else {
					$hasOneField = preg_replace('/ID$/', '', $fieldName);
					if($className = $obj->hasOneComponent($hasOneField)) {
						$obj->{$hasOneField.'ID'} = $this->parseValue($fieldVal, $fixtures, $fieldClass);
						// Inject class for polymorphic relation
						if($className === 'DataObject') {
							$obj->{$hasOneField.'Class'} = $fieldClass;
						}
					}
				}
			}
			$obj->write();

			// If LastEdited was set in the fixture, set it here
			if($data && array_key_exists('LastEdited', $data)) {
				$this->overrideField($obj, 'LastEdited', $data['LastEdited'], $fixtures);
			}

			// Ensure Folder objects exist physically, as otherwise future File fixtures can't detect them
			if($obj instanceof Folder) {
				Filesystem::makeFolder($obj->getFullPath());
			}
		} catch(Exception $e) {
			Config::inst()->update('DataObject', 'validation_enabled', $validationenabled);
			throw $e;
		}

		Config::inst()->update('DataObject', 'validation_enabled', $validationenabled);

		$this->invokeCallbacks('afterCreate', array($obj, $identifier, &$data, &$fixtures));

		return $obj;
	}

	/**
	 * @param Array $defaults
	 */
	public function setDefaults($defaults) {
		$this->defaults = $defaults;
		return $this;
	}

	/**
	 * @return Array
	 */
	public function getDefaults() {
		return $this->defaults;
	}

	/**
	 * @return String
	 */
	public function getClass() {
		return $this->class;
	}

	/**
	 * See class documentation.
	 *
	 * @param String $type
	 * @param callable $callback
	 */
	public function addCallback($type, $callback) {
		if(!array_key_exists($type, $this->callbacks)) {
			throw new InvalidArgumentException(sprintf('Invalid type "%s"', $type));
		}

		$this->callbacks[$type][] = $callback;
		return $this;
	}

	/**
	 * @param String $type
	 * @param callable $callback
	 */
	public function removeCallback($type, $callback) {
		$pos = array_search($callback, $this->callbacks[$type]);
		if($pos !== false) unset($this->callbacks[$type][$pos]);

		return $this;
	}

	protected function invokeCallbacks($type, $args = array()) {
		foreach($this->callbacks[$type] as $callback) {
			call_user_func_array($callback, $args);
		}
	}

	/**
	 * Parse a value from a fixture file.  If it starts with =>
	 * it will get an ID from the fixture dictionary
	 *
	 * @param string $fieldVal
	 * @param array $fixtures See {@link createObject()}
	 * @param string $class If the value parsed is a class relation, this parameter
	 * will be given the value of that class's name
	 * @return string Fixture database ID, or the original value
	 */
	protected function parseValue($value, $fixtures = null, &$class = null) {
		if(substr($value,0,2) == '=>') {
			// Parse a dictionary reference - used to set foreign keys
			list($class, $identifier) = explode('.', substr($value,2), 2);

			if($fixtures && !isset($fixtures[$class][$identifier])) {
				throw new InvalidArgumentException(sprintf(
					'No fixture definitions found for "%s"',
					$value
				));
			}

			return $fixtures[$class][$identifier];
		} else {
			// Regular field value setting
			return $value;
		}
	}

	protected function setValue($obj, $name, $value, $fixtures = null) {
		$obj->$name = $this->parseValue($value, $fixtures);
	}

	protected function overrideField($obj, $fieldName, $value, $fixtures = null) {
		$table = ClassInfo::table_for_object_field(get_class($obj), $fieldName);
		$value = $this->parseValue($value, $fixtures);

		DB::manipulate(array(
			$table => array(
				"command" => "update", "id" => $obj->ID,
				"fields" => array($fieldName => $value)
			)
		));
		$obj->$fieldName = $value;
	}

}