<?php namespace ProcessWire;

/**
 * ProcessWire Select Options Fieldtype
 *
 * ProcessWire 3.x, Copyright 2020 by Ryan Cramer
 * https://processwire.com
 * 
 * @property SelectableOptionManager $manager
 *
 */

class FieldtypeOptions extends FieldtypeMulti implements Module {

	public static function getModuleInfo() {
		return array(
			'title' => __('Select Options', __FILE__),
			'summary' => __('Field that stores single and multi select options.', __FILE__),
			'version' => 1,
		);
	}

	/**
	 * @var SelectableOptionManager
	 * 
	 */
	protected $manager;

	/**
	 * Construct
	 * 
	 */
	public function __construct() {
		$path = dirname(__FILE__) . '/'; 
		// TBA for planned LanguagesValueInterface support
		// require_once($this->wire('config')->paths->modules . 'LanguageSupport/LanguagesValueInterface.php');
		require_once($path . 'SelectableOption.php');
		require_once($path . 'SelectableOptionArray.php');
		require_once($path . 'SelectableOptionManager.php');	
		parent::__construct();
	}
	
	public function wired() {
		$this->manager = $this->wire(new SelectableOptionManager());
		parent::wired();
	}

	/**
	 * Get a property from the Fieldtype
	 * 
	 * @param string $key
	 * @return mixed|SelectableOptionManager
	 * 
	 */
	public function get($key) {
		if($key == 'manager') return $this->manager;
		return parent::get($key); 
	}

	/**
	 * Get a blank SelectableOptionArray
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @return SelectableOptionArray
	 * 
	 */
	public function getBlankValue(Page $page, Field $field) {
		$a = $this->wire(new SelectableOptionArray());
		$a->setPage($page);
		$a->setField($field);
		return $a; 
	}

	/**
	 * Get schema for the Fieldtype's database table
	 * 
	 * @param Field $field
	 * @return array
	 * 
	 */
	public function getDatabaseSchema(Field $field) {
		$schema = parent::getDatabaseSchema($field);
		$schema['data'] = 'int unsigned NOT NULL';
		$schema['sort'] = 'int unsigned NOT NULL';
		$schema['keys']['primary'] = 'PRIMARY KEY (pages_id, sort)';
		return $schema;
	}

	/**
	 * Get the Inputfield that provides input for this Fieldtype
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @return Inputfield
	 * 
	 */
	public function getInputfield(Page $page, Field $field) {
		
		$inputfieldClass = $field->get('inputfieldClass'); 
		if(!$inputfieldClass) $inputfieldClass = 'InputfieldSelect';
		$inputfield = $this->wire('modules')->get($inputfieldClass);
		if(!$inputfield) $inputfield = $this->wire('modules')->get('InputfieldSelect'); 
		
		foreach($this->manager->getOptions($field) as $option) {
			$inputfield->addOption((int) $option->id, $option->getTitle()); 
		}
	
		if($field->get('initValue')) {
			$value = $page->getUnformatted($field->name); 
			if($field->required && !$field->requiredIf) {
				if(empty($value) || !wireCount($value)) {
					$page->set($field->name, $field->get('initValue'));
				}
			} else if($this->wire('process') != 'ProcessField' && !wireCount($value)) {
				$this->warning(
					$field->getLabel() . " ($field->name): " . 
					$this->_('Configured pre-selection not populated since value is not always required. Please correct this field configuration.') 
				);
			}
		}
	
		return $inputfield;
	}

	/**
	 * Get Fieldtypes that are known compatible with this one 
	 * 
	 * @param Field $field
	 * @return Fieldtypes
	 * @throws WireException
	 * 
	 */
	public function ___getCompatibleFieldtypes(Field $field) {
		$fieldtypes = $this->wire(new Fieldtypes());
		foreach($this->wire('fieldtypes') as $fieldtype) {
			if($fieldtype instanceof FieldtypeOptions) $fieldtypes->add($fieldtype);
		}
		return $fieldtypes;
	}

	/**
	 * Sanitize value for storage in a page
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param mixed $value
	 * @return SelectableOptionArray
	 *
	 */
	public function sanitizeValue(Page $page, Field $field, $value) {
		
		if(is_string($value)) {
			// convert delimited string to array
			if(strpos($value, '|') !== false) {
				$value = explode("|", $value); 
			} else if(strpos($value, "\n") !== false) {
				$value = explode("\n", $value); 
			}
			if(is_array($value)) {
				foreach($value as $k => $v) {
					$value[$k] = trim($v); 
				}
			}
		}
		
		if(empty($value)) return $this->getBlankValue($page, $field); 
	
		if($value instanceof SelectableOptionArray) {
			// fantastic, this is our target
			
		} else if($value instanceof SelectableOption) {
			// one option: convert to SelectableOptionArray
			$a = $this->getBlankValue($page, $field); 
			$a->add($value); 
			$value = $a; 
			
		} else if(is_int($value) || (is_string($value) && ctype_digit("$value"))) {
			// assumed to be an option ID
			$value = $this->manager->getOptionsByID($field, array((int) $value)); 
			
		} else if(is_string($value)) {
			// may be option 'title' (first) or option 'value' (second)
			$_value = $value; // save for second check
			$value = $this->manager->getOptions($field, array('title' => $value)); 
			if(!$value->count()) $value = $this->manager->getOptions($field, array('value' => $_value));
			unset($_value);
			
		} else if(is_array($value) && count($value)) {
			// populated array of id, title or value
			if(ctype_digit(implode('0', $value))) {
				// array of IDs
				$value = $this->manager->getOptions($field, array('id' => $value));
			} else {
				// array of titles or values
				$_value = $value; // save for second check
				$value = $this->manager->getOptions($field, array('title' => $value)); 
				if(!$value->count()) $value = $this->manager->getOptions($field, array('value' => $_value)); 
				unset($_value);
			}
			
		} else if(is_array($value)) {
			// blank array
			$value = $this->getBlankValue($page, $field); 
		}
		
		return $value;
	}

	/**
	 * Render a markup string of the value
	 *
	 * @param Page $page Page that $value comes from
	 * @param Field $field Field that $value comes from
	 * @param mixed $value Optionally specify the $page->field value. If null or not specified, it will be retrieved.
	 * @param string $property Optionally specify the property or index to render. If omitted, entire value is rendered.
	 * @return string
	 *
	 */
	public function ___markupValue(Page $page, Field $field, $value = null, $property = '') {
		if(empty($property)) $property = 'title';
		return parent::___markupValue($page, $field, $value, $property); 
	}

	/**
	 * Prep value for DB storage
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param SelectableOptionArray $value
	 * @return array
	 * @throws WireException if given invalid value
	 * 
	 */
	public function ___sleepValue(Page $page, Field $field, $value) {
		$sleepValue = array();
		if(empty($value) || !count($value)) {
			// value is empty
			if($field->required && $field->get('initValue')) {
				// value is required, and an initial value is supplied
				// so populate the initial value
				$initValue = $field->get('initValue');	
				if(!is_array($initValue)) $initValue = array($initValue);
				foreach($initValue as $v) {
					$sleepValue[] = (int) $v;
				}
			}
			return $sleepValue;
		}
		if(!$value instanceof SelectableOptionArray) throw new WireException("sleepValue requires SelectableOptionArray"); 
		
		foreach($value as $option) {
			$sleepValue[] = (int) $option->id;
		}
		return $sleepValue; 
	}

	/**
	 * Prep value from DB for storage in Page
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param array $value
	 * @return SelectableOptionArray
	 * 
	 */
	public function ___wakeupValue(Page $page, Field $field, $value) {
		if($value) {
			$wakeupValue = $this->manager->getOptions($field, array('id' => $value));
		} else {
			$wakeupValue = $this->getBlankValue($page, $field); 
		}
		return $wakeupValue; 
	}

	/**
	 * Prep a value for front-end output
	 * 
	 * This returns a cloned copy of $value with output formatting enabled. 
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param SelectableOptionArray $value
	 * @return SelectableOptionArray
	 * 
	 */
	public function ___formatValue(Page $page, Field $field, $value) {
		$_value = $this->getBlankValue($page, $field); 
		foreach($value as $option) {
			$_option = clone $option; 
			$_value->add($_option); 
		}
		$_value->of(true);
		return $_value; 
	}

	/**
	 * Update a database query for finding values from this Fieldtype
	 * 
	 * @param DatabaseQuerySelect $query
	 * @param string $table
	 * @param string $subfield
	 * @param string $operator
	 * @param mixed $value
	 * @return DatabaseQuery
	 * @throws WireException
	 * 
	 */
	public function getMatchQuery($query, $table, $subfield, $operator, $value) {
	
		if($subfield == 'count') return parent::getMatchQuery($query, $table, $subfield, $operator, $value); 
		if($subfield == 'data' && (ctype_digit("$value") || empty($value))) {
			// this is fine (presumed to be an option_id)
		} else {
			// some other subfield, which needs to be mapped to either value or title
			$options = array();
			
			if($subfield === 'data' && ($operator === '=' || $operator === '!=')) {
				// subfield not specified: matching some string value, is it a value or a title? (allow for either)
				$options = $this->manager->getOptions($query->field, array(
					'value' => $value, 
					'title' => $value, 
					'or' => true
				));
			}
		
			if(!count($options)) {
				if(!$subfield || !SelectableOption::isProperty($subfield)) {
					// if empty subfield or not a subfield we recognize, just assume title
					$subfield = 'title';
				}
				$options = $this->manager->findOptionsByProperty($query->field, $subfield, $operator, $value);
			}
			
			$option = $options->first();
			
			if($operator != '=' && $operator != '!=') {
				// for fulltext operations...
				// since we are now just matching IDs of already found options
				$operator = '='; 
			}
			
			$subfield = 'data';
			$value = $option ? $option->id : null;
		}
		
		if($operator == '!=') {
			// force a != for one value to prevent matching, even if more than one value
			$database = $this->wire('database'); 
			$t = $database->escapeTable($query->field->getTable());
			$s = $database->escapeCol($subfield); 
			
			$bindKey = $query->bindValueGetKey($value);
			$query->where("(SELECT COUNT(*) FROM $t WHERE $t.pages_id=pages.id AND $t.$s=$bindKey)=0");
			
			$bindKey = $query->parentQuery->bindValueGetKey($value);
			$query->parentQuery->where("($table.data IS NULL OR $table.$s!=$bindKey)");
			
			return $query;
		}
		
		return parent::getMatchQuery($query, $table, $subfield, $operator, $value); 
	}

	/**
	 * Get information used for InputfieldSelector interactive selector builder
	 * 
	 * @param Field $field
	 * @param array $data
	 * @return array
	 * 
	 */
	public function ___getSelectorInfo(Field $field, array $data = array()) {
		$info = parent::___getSelectorInfo($field, $data);
		$info['input'] = 'select';
		$info['options'] = array();
		$info['operators'] = array('=', '!=', '@=', '@!=', '=""', '!=""'); 
		foreach($this->manager->getOptions($field) as $option) {
			$info['options'][$option->id] = $option->title;
		}
		$subfields = array(
			'title' => array(
				'name' => 'title', 
				'input' => 'text',
				'operators' => array('%=', '=', '!=', '^=', '$=', '*=', '~='), 
			),
			'value' => array(
				'name' => 'value',
				'input' => 'text',
				'operators' => array('%=', '=', '!=', '^=', '$=', '*=', '~='),
			),
			'id' => array(
				'name' => 'id',
				'input' => 'integer',
				'operators' => array('=', '!=', '>', '>=' ,'<', '<='), 
			), 
		);
		
		$info['subfields'] = array_merge($info['subfields'], $subfields); 
		
		return $info; 
	}
	
	/**
	 * Export configuration values for external consumption
	 *
	 * Use this method to externalize any config values when necessary.
	 * For example, internal IDs should be converted to GUIDs where possible.
	 * 
	 * @param Field $field
	 * @param array $data
	 * @return array
	 *
	 */
	public function ___exportConfigData(Field $field, array $data) {
		$data = parent::___exportConfigData($field, $data); 
		if(isset($data['_options'])) {
			$data['export_options'] = array('default' => $data['_options']); 
			unset($data['_options']); 
		}
		if($this->manager->useLanguages()) foreach($this->wire('languages') as $language) {
			if($language->isDefault()) continue; 
			$key = "_options__$language";
			if(isset($data[$key])) {
				// use language name rather than id for more portability
				$data['export_options'][$language->name] = $data[$key]; 
				unset($data[$key]); 
			}
		}
		return $data;
	}

	/**
	 * Convert an array of exported data to a format that will be understood internally (opposite of exportConfigData)
	 *
	 * @param Field $field
	 * @param array $data
	 * @return array Data as given and modified as needed. Also included is $data[errors], an associative array
	 *	indexed by property name containing errors that occurred during import of config data.
	 *
	 */
	public function ___importConfigData(Field $field, array $data) {
		$data = parent::___importConfigData($field, $data); 
		$data['errors']['export_options'] = $this->_('Import of options is not yet implemented. Though they can easily be imported from one site to another by copy/paste directly from the field edit screen.'); 
		return $data;
	}

	/**
	 * Return a cloned copy of $field
	 *
	 * @param Field $field
	 * @return Field cloned copy
	 *
	 */
	public function ___cloneField(Field $field) {
		$this->wire('fields')->addHookAfter('cloned', $this, 'hookCloned');
		return parent::___cloneField($field);
	}

	/**
	 * Hook called when field is cloned, to clone the selectable options from old field to new field
	 * 
	 * #pw-internal
	 * 
	 * @param HookEvent $event
	 * 
	 */
	public function hookCloned(HookEvent $event) {
		/** @var Field $oldField */
		$oldField = $event->arguments(0);
		/** @var Field $newField */
		$newField = $event->arguments(1);
		/** @var FieldtypeOptions $fieldtype */
		$fieldtype = $oldField->type; 
		if(!$fieldtype instanceof FieldtypeOptions) return;
		$options = $fieldtype->getOptions($oldField);
		$options->setField($newField);
		$fieldtype->addOptions($newField, $options);
		$event->removeHook(null);
	}
	
	/**
	 * Get Inputfields needed to configure this Fieldtype
	 * 
	 * @param Field $field
	 * @return InputfieldWrapper
	 * 
	 */
	public function ___getConfigInputfields(Field $field) {
		$inputfields = parent::___getConfigInputfields($field); 
		include_once(dirname(__FILE__) . '/SelectableOptionConfig.php'); 
		$cfg = $this->wire(new SelectableOptionConfig($field, $inputfields)); 
		$inputfields = $cfg->getConfigInputfields();
		return $inputfields; 
	}
	
	/**
	 * Given a FieldtypeOptions field id, name or object, return the object
	 * @param $field
	 * @return Field
	 * @throws WireException
	 *
	 */
	protected function _getField($field) {
		if(!$field instanceof Field) {
			$_field = $field;
			$field = $this->wire('fields')->get($field);
			if(!$field) throw new WireException("Unknown field: $_field");
		}
		if(!$field->type instanceof FieldtypeOptions) {
			throw new WireException("Field $field->name is not of type FieldtypeOptions");
		}
		return $field; 
	}

	public function ___install() {
		$this->manager->install();
	}

	public function ___uninstall() {
		$this->manager->uninstall();
	}

	/**************************************************************************************
	 * Public API methods
	 * 
	 * These provide a simpler interface to the internally-used SelectableOptionManager.
	 * 
	 * Accessible from $field->type->[get|add|delete|update]Options($field, $options); 
	 * 
	 */

	/**
	 * Get all options available for the given field
	 * 
	 * @param int|string|Field $field Field name, id or object
	 * @return SelectableOptionArray
	 * @throws WireException
	 * 
	 */
	public function getOptions($field) {
		return $this->manager->getOptions($field = $this->_getField($field)); 
	}

	/**
	 * Update, add, delete as needed to match the given $options
	 *
	 * @param int|string|Field $field Field name, id or object
	 * @param SelectableOptionArray $options Options to save
	 * @return array Summary of what occurred
	 * @throws WireException
	 *
	 */
	public function setOptions($field, SelectableOptionArray $options) {
		return $this->manager->setOptions($field = $this->_getField($field), $options, true); 
	}

	/**
	 * Add the given new options
	 * 
	 * @param $field
	 * @param SelectableOptionArray $options
	 * @return int Number of options added
	 * @throws WireException
	 * 
	 */
	public function addOptions($field, SelectableOptionArray $options) {
		return $this->manager->addOptions($this->_getField($field), $options); 
	}

	/**
	 * Delete the given options
	 *
	 * @param $field
	 * @param SelectableOptionArray $options
	 * @return int Number of options added
	 * @throws WireException
	 *
	 */
	public function deleteOptions($field, SelectableOptionArray $options) {
		return $this->manager->deleteOptions($this->_getField($field), $options);
	}

}
