<?php
/**
 * A list designed to hold form field instances.
 *
 * @package    forms
 * @subpackage fields-structural
 */
class FieldList extends ArrayList {
	
	/**
	 * Cached flat representation of all fields in this set,
	 * including fields nested in {@link CompositeFields}.
	 *
	 * @uses self::collateDataFields()
	 * @var array
	 */
	protected $sequentialSet;
	
	/**
	 * @var array
	 */
	protected $sequentialSaveableSet;
	
	/**
	 * @todo Documentation
	 */
	protected $containerField;

	/**
	 * @var array Ordered list of regular expressions,
	 * see {@link setTabPathRewrites()}.
	 */
	protected $tabPathRewrites = array();
	
	public function __construct($items = array()) {
		if (!is_array($items) || func_num_args() > 1) {
			$items = func_get_args();
		}

		parent::__construct($items);

		foreach ($items as $item) {
			if ($item instanceof FormField) $item->setContainerFieldList($this);
		}
	}
	
	/**
	 * Return a sequential set of all fields that have data.  This excludes wrapper composite fields
	 * as well as heading / help text fields.
	 */
	public function dataFields() {
		if(!$this->sequentialSet) $this->collateDataFields($this->sequentialSet);
		return $this->sequentialSet;
	}
	
	public function saveableFields() {
		if(!$this->sequentialSaveableSet) $this->collateDataFields($this->sequentialSaveableSet, true);
		return $this->sequentialSaveableSet;
	}
	
	protected function flushFieldsCache() {
		$this->sequentialSet = null;
		$this->sequentialSaveableSet = null;
	}
	
	protected function collateDataFields(&$list, $saveableOnly = false) {
		foreach($this as $field) {
			if($field->isComposite()) $field->collateDataFields($list, $saveableOnly);

			if($saveableOnly) {
				$isIncluded =  ($field->hasData() && !$field->isReadonly() && !$field->isDisabled());
			} else {
				$isIncluded =  ($field->hasData());
			}
			if($isIncluded) {
				$name = $field->getName();
				if(isset($list[$name])) {
					$errSuffix = "";
					if($this->form) {
						$errSuffix = " in your '{$this->form->class}' form called '" . $this->form->Name() . "'";
					} else {
						$errSuffix = '';
					}
					user_error("collateDataFields() I noticed that a field called '$name' appears twice$errSuffix.",
						E_USER_ERROR);
				}
				$list[$name] = $field;
			}
		}
	}
	
	/**
	 * Add an extra field to a tab within this FieldList.
	 * This is most commonly used when overloading getCMSFields()
	 * 
	 * @param string $tabName The name of the tab or tabset.  Subtabs can be referred to as TabSet.Tab
	 *                        or TabSet.Tab.Subtab. This function will create any missing tabs.
	 * @param FormField $field The {@link FormField} object to add to the end of that tab.
	 * @param string $insertBefore The name of the field to insert before.  Optional.
	 */
	public function addFieldToTab($tabName, $field, $insertBefore = null) {
		// This is a cache that must be flushed
		$this->flushFieldsCache();

		// Find the tab
		$tab = $this->findOrMakeTab($tabName);

		// Add the field to the end of this set
		if($insertBefore) $tab->insertBefore($field, $insertBefore);
		else $tab->push($field);
	}
	
	/**
	 * Add a number of extra fields to a tab within this FieldList.
	 * This is most commonly used when overloading getCMSFields()
	 * 
	 * @param string $tabName The name of the tab or tabset.  Subtabs can be referred to as TabSet.Tab
	 *                        or TabSet.Tab.Subtab.
	 * This function will create any missing tabs.
	 * @param array $fields An array of {@link FormField} objects.
	 */
	public function addFieldsToTab($tabName, $fields, $insertBefore = null) {
		$this->flushFieldsCache();

		// Find the tab
		$tab = $this->findOrMakeTab($tabName);

		// Add the fields to the end of this set
		foreach($fields as $field) {
			// Check if a field by the same name exists in this tab
			if($insertBefore) {
				$tab->insertBefore($field, $insertBefore);
			} elseif(($name = $field->getName()) && $tab->fieldByName($name)) {
				// It exists, so we need to replace the old one
				$this->replaceField($field->getName(), $field);
			} else {
				$tab->push($field);
			}
		}
	}

	/**
	 * Remove the given field from the given tab in the field.
	 * 
	 * @param string $tabName The name of the tab
	 * @param string $fieldName The name of the field
	 */
	public function removeFieldFromTab($tabName, $fieldName) {
		$this->flushFieldsCache();

		// Find the tab
		$tab = $this->findOrMakeTab($tabName);
		$tab->removeByName($fieldName);
	}
	
	/**
	 * Removes a number of fields from a Tab/TabSet within this FieldList.
	 *
	 * @param string $tabName The name of the Tab or TabSet field
	 * @param array $fields A list of fields, e.g. array('Name', 'Email')
	 */
	public function removeFieldsFromTab($tabName, $fields) {
		$this->flushFieldsCache();

		// Find the tab
		$tab = $this->findOrMakeTab($tabName);
		
		// Add the fields to the end of this set
		foreach($fields as $field) $tab->removeByName($field);
	}
	
	/**
	 * Remove a field or fields from this FieldList by Name.
	 * The field could also be inside a CompositeField.
	 * 
	 * @param string|array $fieldName The name of, or an array with the field(s) or tab(s)
	 * @param boolean $dataFieldOnly If this is true, then a field will only
	 * be removed if it's a data field.  Dataless fields, such as tabs, will
	 * be left as-is.
	 */
	public function removeByName($fieldName, $dataFieldOnly = false) {
		if(!$fieldName) {
			user_error('FieldList::removeByName() was called with a blank field name.', E_USER_WARNING);
		}

		// Handle array syntax
		if(is_array($fieldName)) {
			foreach($fieldName as $field){
				$this->removeByName($field, $dataFieldOnly);
			}
			return;
		}

		$this->flushFieldsCache();
		foreach($this->items as $i => $child) {
			if(is_object($child)){
				$childName = $child->getName();
				if(!$childName) $childName = $child->Title();
				
				if(($childName == $fieldName) && (!$dataFieldOnly || $child->hasData())) {
					array_splice( $this->items, $i, 1 );
					break;
				} else if($child->isComposite()) {
					$child->removeByName($fieldName, $dataFieldOnly);
				}
			}
		}
	}
	
	/**
	 * Replace a single field with another.  Ignores dataless fields such as Tabs and TabSets
	 *
	 * @param string $fieldName The name of the field to replace
	 * @param FormField $newField The field object to replace with
	 * @return boolean TRUE field was successfully replaced
	 * 					 FALSE field wasn't found, nothing changed
	 */
	public function replaceField($fieldName, $newField) {
		$this->flushFieldsCache();
		foreach($this->items as $i => $field) {
			if(is_object($field)) {
				if($field->getName() == $fieldName && $field->hasData()) {
					$this->items[$i] = $newField;
					return true;
				
				} else if($field->isComposite()) {
					if($field->replaceField($fieldName, $newField)) return true;
				}
			}
		}
		return false;
	}
	
	/**
	 * Rename the title of a particular field name in this set.
	 *
	 * @param string $fieldName Name of field to rename title of
	 * @param string $newFieldTitle New title of field
	 * @return boolean
	 */
	public function renameField($fieldName, $newFieldTitle) {
		$field = $this->dataFieldByName($fieldName);
		if(!$field) return false;
		
		$field->setTitle($newFieldTitle);
		
		return $field->Title() == $newFieldTitle;
	}
	
	/**
	 * @return boolean
	 */
	public function hasTabSet() {
		foreach($this->items as $i => $field) {
			if(is_object($field) && $field instanceof TabSet) {
				return true;
			}
		}
		
		return false;
	}
	
	/**
	 * Returns the specified tab object, creating it if necessary.
	 * 
	 * @todo Support recursive creation of TabSets
	 * 
	 * @param string $tabName The tab to return, in the form "Tab.Subtab.Subsubtab".
	 *   Caution: Does not recursively create TabSet instances, you need to make sure everything
	 *   up until the last tab in the chain exists.
	 * @param string $title Natural language title of the tab. If {@link $tabName} is passed in dot notation,
	 *   the title parameter will only apply to the innermost referenced tab.
	 *   The title is only changed if the tab doesn't exist already.
	 * @return Tab The found or newly created Tab instance
	 */
	public function findOrMakeTab($tabName, $title = null) {
		// Backwards compatibility measure: Allow rewriting of outdated tab paths
		$tabName = $this->rewriteTabPath($tabName);

		$parts = explode('.',$tabName);
		$last_idx = count($parts) - 1;
		// We could have made this recursive, but I've chosen to keep all the logic code within FieldList rather than
		// add it to TabSet and Tab too.
		$currentPointer = $this;
		foreach($parts as $k => $part) {
			$parentPointer = $currentPointer;
			$currentPointer = $currentPointer->fieldByName($part);
			// Create any missing tabs
			if(!$currentPointer) {
				if(is_a($parentPointer, 'TabSet')) {
					// use $title on the innermost tab only
					if ($k == $last_idx) {
						$currentPointer = isset($title) ? new Tab($part, $title) : new Tab($part);
					}
					else {
						$currentPointer = new TabSet($part);
					}
					$parentPointer->push($currentPointer);
				}
				else {
					$withName = ($parentPointer->hasMethod('Name')) ? " named '{$parentPointer->getName()}'" : null;
					user_error("FieldList::addFieldToTab() Tried to add a tab to object"
						. " '{$parentPointer->class}'{$withName} - '$part' didn't exist.", E_USER_ERROR);
				}
			}
		}
		
		return $currentPointer;
	}

	/**
	 * Returns a named field.
	 * You can use dot syntax to get fields from child composite fields
	 * 
	 * @todo Implement similiarly to dataFieldByName() to support nested sets - or merge with dataFields()
	 */
	public function fieldByName($name) {
		$name = $this->rewriteTabPath($name);
		if(strpos($name,'.') !== false)	list($name, $remainder) = explode('.',$name,2);
		else $remainder = null;
		
		foreach($this->items as $child) {
			if(trim($name) == trim($child->getName()) || $name == $child->id) {
				if($remainder) {
					if($child->isComposite()) {
						return $child->fieldByName($remainder);
					} else {
						user_error("Trying to get field '$remainder' from non-composite field $child->class.$name",
							E_USER_WARNING);
						return null;
					}
				} else {
					return $child;
				}
			}
		}
	}
	
	/**
	 * Returns a named field in a sequential set.
	 * Use this if you're using nested FormFields.
	 * 
	 * @param string $name The name of the field to return
	 * @return FormField instance
	 */
	public function dataFieldByName($name) {
		if($dataFields = $this->dataFields()) {
			foreach($dataFields as $child) {
				if(trim($name) == trim($child->getName()) || $name == $child->id) return $child;
			}
		}
	}

	/**
	 * Inserts a field before a particular field in a FieldList.
	 *
	 * @param FormField $item The form field to insert
	 * @param string $name Name of the field to insert before
	 */
	public function insertBefore($item, $name) {
		$this->onBeforeInsert($item);
		$item->setContainerFieldList($this);
		
		$i = 0;
		foreach($this->items as $child) {
			if($name == $child->getName() || $name == $child->id) {
				array_splice($this->items, $i, 0, array($item));
				return $item;
			} elseif($child->isComposite()) {
				$ret = $child->insertBefore($item, $name);
				if($ret) return $ret;
			}
			$i++;
		}
		
		return false;
	}

	/**
	 * Inserts a field after a particular field in a FieldList.
	 *
	 * @param FormField $item The form field to insert
	 * @param string $name Name of the field to insert after
	 */
	public function insertAfter($item, $name) {
		$this->onBeforeInsert($item);
		$item->setContainerFieldList($this);
		
		$i = 0;
		foreach($this->items as $child) {
			if($name == $child->getName() || $name == $child->id) {
				array_splice($this->items, $i+1, 0, array($item));
				return $item;
			} elseif($child->isComposite()) {
				$ret = $child->insertAfter($item, $name);
				if($ret) return $ret;
			}
			$i++;
		}
		
		return false;
	}
	
	/**
	 * Push a single field into this FieldList instance.
	 *
	 * @param FormField $item The FormField to add
	 * @param string $key An option array key (field name)
	 */
	public function push($item, $key = null) {
		$this->onBeforeInsert($item);
		$item->setContainerFieldList($this);

		return parent::push($item, $key);
	}

	/**
	 * Handler method called before the FieldList is going to be manipulated.
	 */
	protected function onBeforeInsert($item) {
		$this->flushFieldsCache();

		if($item->getName()) {
			$this->rootFieldList()->removeByName($item->getName(), true);
		}
	}
		
	
	/**
	 * Set the Form instance for this FieldList.
	 *
	 * @param Form $form The form to set this FieldList to
	 */
	public function setForm($form) {
		foreach($this as $field) {
			$field->setForm($form);
		}
		
		return $this;
	}
	
	/**
	 * Load the given data into this form.
	 * 
	 * @param data An map of data to load into the FieldList
	 */
	public function setValues($data) {
		foreach($this->dataFields() as $field) {
			$fieldName = $field->getName();
			if(isset($data[$fieldName])) $field->setValue($data[$fieldName]);
		}
		return $this;
	}
	
	/**
	 * Return all <input type="hidden"> fields
	 * in a form - including fields nested in {@link CompositeFields}.
	 * Useful when doing custom field layouts.
	 * 
	 * @return FieldList
	 */
	public function HiddenFields() {
		$hiddenFields = new FieldList();
		$dataFields = $this->dataFields();
		
		if($dataFields) foreach($dataFields as $field) {
			if($field instanceof HiddenField) $hiddenFields->push($field);
		}
		
		return $hiddenFields;
	}
	
	/**
	 * Return all fields except for the hidden fields.
	 * Useful when making your own simplified form layouts.
	 */
	public function VisibleFields() {
		$visibleFields = new FieldList();
		
		foreach($this as $field) {
			if(!($field instanceof HiddenField)) $visibleFields->push($field);
		}
		
		return $visibleFields;
	}

	/**
	 * Transform this FieldList with a given tranform method,
	 * e.g. $this->transform(new ReadonlyTransformation())
	 * 
	 * @return FieldList
	 */
	public function transform($trans) {
		$this->flushFieldsCache();
		$newFields = new FieldList();
		foreach($this as $field) {
			$newFields->push($field->transform($trans));
		}
		return $newFields;
	}

	/**
	 * Returns the root field set that this belongs to
	 */
	public function rootFieldList() {
		if($this->containerField) return $this->containerField->rootFieldList();
		else return $this;
	}
	
	public function setContainerField($field) {
		$this->containerField = $field;
		return $this;
	}
	
	/**
	 * Transforms this FieldList instance to readonly.
	 *
	 * @return FieldList
	 */
	public function makeReadonly() {
		return $this->transform(new ReadonlyTransformation());
	}

	/**
	 * Transform the named field into a readonly feld.
	 * 
	 * @param string|FormField
	 */
	public function makeFieldReadonly($field) {
		$fieldName = ($field instanceof FormField) ? $field->getName() : $field;
		$srcField = $this->dataFieldByName($fieldName);
		$this->replaceField($fieldName, $srcField->performReadonlyTransformation());
	}
	
	/**
	 * Change the order of fields in this FieldList by specifying an ordered list of field names.
	 * This works well in conjunction with SilverStripe's scaffolding functions: take the scaffold, and
	 * shuffle the fields around to the order that you want.
	 * 
	 * Please note that any tabs or other dataless fields will be clobbered by this operation.
	 *
	 * @param array $fieldNames Field names can be given as an array, or just as a list of arguments.
	 */
	public function changeFieldOrder($fieldNames) {
		// Field names can be given as an array, or just as a list of arguments.
		if(!is_array($fieldNames)) $fieldNames = func_get_args();
		
		// Build a map of fields indexed by their name.  This will make the 2nd step much easier.
		$fieldMap = array();
		foreach($this->dataFields() as $field) $fieldMap[$field->getName()] = $field;
		
		// Iterate through the ordered list	of names, building a new array to be put into $this->items.
		// While we're doing this, empty out $fieldMap so that we can keep track of leftovers.
		// Unrecognised field names are okay; just ignore them
		$fields = array();
		foreach($fieldNames as $fieldName) {
			if(isset($fieldMap[$fieldName])) {
				$fields[] = $fieldMap[$fieldName];
				unset($fieldMap[$fieldName]);
			}
		}
		
		// Add the leftover fields to the end of the list.
		$fields = $fields + array_values($fieldMap);
		
		// Update our internal $this->items parameter.
		$this->items = $fields;
		
		$this->flushFieldsCache();
	}
	
	/**
	 * Find the numerical position of a field within
	 * the children collection. Doesn't work recursively.
	 * 
	 * @param string|FormField
	 * @return int Position in children collection (first position starts with 0). Returns FALSE if the field can't
	 *             be found.
	 */
	public function fieldPosition($field) {
		if(is_object($field)) $field = $field->getName();
		
		$i = 0;
		foreach($this->dataFields() as $child) {
			if($child->getName() == $field) return $i;
			$i++;
		}
		
		return false;
	}

	/**
	 * Ordered list of regular expressions
	 * matching a tab path, to their rewrite rule (through preg_replace()).
	 * Mainly used for backwards compatibility.
	 * Ensure that more specific rules are placed earlier in the list,
	 * and that tabs with children are accounted for in the rule sets.
	 * 
	 * Example: 
	 * $fields->setTabPathRewriting(array(
	 * 	// Rewrite specific innermost tab
	 * 	'/^Root\.Content\.Main$/' => 'Root.Content',
	 * 	// Rewrite all innermost tabs
	 * 	'/^Root\.Content\.([^.]+)$/' => 'Root.\\1',
	 * ));
	 * 
	 * @param array $rewrites 
	 */
	public function setTabPathRewrites($rewrites) {
		$this->tabPathRewrites = $rewrites;
	}

	/**
	 * @return array
	 */
	public function getTabPathRewrites() {
		return $this->tabPathRewrites;
	}

	/**
	 * Support function for backwards compatibility purposes.
	 * Caution: Volatile API, might be removed in 3.1 or later.
	 * 
	 * @param  String $tabname Path to a tab, e.g. "Root.Content.Main"
	 * @return String Rewritten path, based on {@link tabPathRewrites}
	 */
	protected function rewriteTabPath($name) {
		$isRunningTest = (class_exists('SapphireTest', false) && SapphireTest::is_running_test());
		foreach($this->getTabPathRewrites() as $regex => $replace) {
			if(preg_match($regex, $name)) {
				$newName = preg_replace($regex, $replace, $name);
				Deprecation::notice('3.0.0', 
					sprintf(
						'Using outdated tab path "%s", please use the new location "%s" instead',
						$name,
						$newName
					),
					Deprecation::SCOPE_GLOBAL
				);
				return $newName;
			}
		}

		// No match found, return original path
		return $name;
	}

	/**
	 * Default template rendering of a FieldList will concatenate all FieldHolder values.
	 */
	public function forTemplate() {
		$output = "";
		foreach($this as $field) {
			$output .= $field->FieldHolder();
		}
		return $output;
	}
}
