<?php namespace ProcessWire;

/**
 * ProcessWire Field
 *
 * The Field class corresponds to a record in the fields database table 
 * and is managed by the 'Fields' class.
 * 
 * #pw-summary Field represents a custom field that is used on a Page.
 * #pw-var $field
 * #pw-instantiate $field = $fields->get('field_name');
 * #pw-body Field objects are managed by the `$fields` API variable. 
 * #pw-use-constants
 * 
 * ProcessWire 3.x, Copyright 2022 by Ryan Cramer
 * https://processwire.com
 *
 * @property int $id Numeric ID of field in the database #pw-group-properties
 * @property string $name Name of field  #pw-group-properties
 * @property string $table Database table used by the field #pw-group-properties
 * @property string $prevTable Previously database table (if field was renamed) #pw-group-properties
 * @property string $prevName Previously used name (if field was renamed), 3.0.164+ #pw-group-properties
 * @property Fieldtype|null $type Fieldtype module that represents the type of this field #pw-group-properties
 * @property Fieldtype|null $prevFieldtype Previous Fieldtype, if type was changed #pw-group-properties
 * @property int $flags Bitmask of flags used by this field #pw-group-properties
 * @property-read string $flagsStr Names of flags used by this field (readonly) #pw-group-properties
 * @property string $label Text string representing the label of the field #pw-group-properties
 * @property string $description Longer description text for the field #pw-group-properties
 * @property string $notes Additional notes text about the field #pw-group-properties
 * @property string $icon Icon name used by the field, if applicable #pw-group-properties
 * @property string $tags Tags that represent this field, if applicable (space separated string). #pw-group-properties
 * @property-read array $tagList Same as $tags property, but as an array. #pw-group-properties
 * @property bool $useRoles Whether or not access control is enabled #pw-group-access
 * @property array $editRoles Role IDs with edit access, applicable only if access control is enabled. #pw-group-access
 * @property array $viewRoles Role IDs with view access, applicable only if access control is enabled. #pw-group-access
 * @property array|null $orderByCols Columns that WireArray values are sorted by (default=null), Example: "sort" or "-created". #pw-internal
 * @property int|null $paginationLimit Used by paginated WireArray values to indicate limit to use during load. #pw-internal
 * @property array $allowContexts Names of settings that are custom configured to be allowed for context. #pw-group-properties
 * @property bool|int|null $flagUnique Non-empty value indicates request for, or presence of, Field::flagUnique flag. #pw-internal
 * @property Fieldgroup|null $_contextFieldgroup Fieldgroup field is in context for or null if not in context. #pw-internal
 *
 * Common Inputfield properties that Field objects store:  
 * @property int|bool|null $required Whether or not this field is required during input #pw-group-properties
 * @property string|null $requiredIf A selector-style string that defines the conditions under which input is required #pw-group-properties
 * @property string|null $showIf A selector-style string that defines the conditions under which the Inputfield is shown #pw-group-properties
 * @property int|null $columnWidth The Inputfield column width (percent) 10-100. #pw-group-properties
 * @property int|null $collapsed The Inputfield 'collapsed' value (see Inputfield collapsed constants). #pw-group-properties
 * @property int|null $textFormat The Inputfield 'textFormat' value (see Inputfield textFormat constants). #pw-group-properties
 * 
 * @method bool viewable(Page $page = null, User $user = null) Is the field viewable on the given $page by the given $user? #pw-group-access
 * @method bool editable(Page $page = null, User $user = null) Is the field editable on the given $page by the given $user? #pw-group-access
 * @method Inputfield getInputfield(Page $page, $contextStr = '') Get instance of the Inputfield module that collects input for this field. 
 * @method InputfieldWrapper getConfigInputfields() Get Inputfields needed to configure this field in the admin. 
 * 
 * @todo add modified date property
 *
 */
class Field extends WireData implements Saveable, Exportable {

	/**
	 * Field should be automatically joined to the page at page load time
	 * 
	 * #pw-group-flags
	 *
	 */
	const flagAutojoin = 1;

	/**
	 * Field used by all fieldgroups - all fieldgroups required to contain this field
	 * 
	 * #pw-group-flags
	 *
	 */
	const flagGlobal = 4;

	/**
	 * Field is a system field and may not be deleted, have it's name changed, or be converted to non-system
	 * 
	 * #pw-group-flags
	 *
	 */
	const flagSystem = 8;

	/**
	 * Field is permanent in any fieldgroups/templates where it exists - it may not be removed from them
	 * 
	 * #pw-group-flags
	 *
	 */
	const flagPermanent = 16;

	/**
	 * Field is access controlled
	 * 
	 * #pw-group-flags
	 *
	 */
	const flagAccess = 32;

	/**
	 * If field is access controlled, this flag says that values are still front-end API accessible
	 * 
	 * Without this flag, non-viewable values are made blank when output formatting is ON.
	 * 
	 * #pw-group-flags
	 * 
	 */
	const flagAccessAPI = 64;

	/**
	 * If field is access controlled and user has no edit access, they can still view in the editor (if they have view permission)
	 * 
	 * Without this flag, non-editable values are simply not shown in the editor at all.
	 * 
	 * #pw-group-flags
	 * 
	 */
	const flagAccessEditor = 128;

	/**
	 * Field requires that the same value is not repeated more than once in its table 'data' column (when supported by Fieldtype)
	 * 
	 * When this flag is set and there is a non-empty $flagUnique property on the field, then it indicates a unique index 
	 * is currently present. When only this flag is present (no property), it indicates a request to remove the index and flag. 
	 * When only the property is present (no flag), it indicates a pending request to add unique index and flag. 
	 * 
	 * #pw-group-flags
	 * @since 3.0.150
	 * 
	 */
	const flagUnique = 256;

	/**
	 * Field has been placed in a runtime state where it is contextual to a specific fieldgroup and is no longer saveable
	 * 
	 * #pw-group-flags
	 *
	 */
	const flagFieldgroupContext = 2048;

	/**
	 * Set this flag to override system/permanent flags if necessary - once set, system/permanent flags can be removed, but not in the same set().
	 * 
	 * #pw-group-flags
	 *
	 */
	const flagSystemOverride = 32768;

	/**
	 * Prefix for database tables
	 * 
	 * #pw-internal
	 * 
	 */
	const tablePrefix = 'field_';

	/**
	 * Permanent/native settings to an individual Field
	 *
	 * id: Numeric ID corresponding with id in the fields table.
	 * type: Fieldtype object or NULL if no Fieldtype assigned.
	 * label: String text label corresponding to the <label> field during input.
	 * flags:
	 * - autojoin: True if the field is automatically joined with the page, or False if it's value is loaded separately.
	 * - global: Is this field required by all Fieldgroups?
	 *
	 */
	protected $settings = array(
		'id'    => 0,
		'name'  => '',
		'label' => '',
		'flags' => 0,
		'type'  => null,
	);

	/**
	 * If the field name changed, this is the name of the previous table so that it can be renamed at save time
	 *
	 */
	protected $prevTable;

	/**
	 * If the field name changed, this is the previous name
	 * 
	 * @var string
	 * 
	 */
	protected $prevName = '';

	/**
	 * If the field type changed, this is the previous fieldtype so that it can be changed at save time
	 *
	 */
	protected $prevFieldtype;
	
	/**
	 * A specifically set table name by setTable() for override purposes
	 *
	 * @var string
	 *
	 */
	protected $setTable = '';

	/**
	 * Accessed properties, becomes array when set to true, null when set to false
	 *
	 * Used for keeping track of which properties are accessed during a request, to help determine which
	 * $data properties might no longer be in use.
	 *
	 * @var null|array
	 *
	 */
	protected $trackGets = null;

	/**
	 * Array of Role IDs referring to roles that are allowed to view contents of this field (on pages)
	 *
	 * Applicable only if the flagAccess flag is set
	 *
	 * @var array
	 *
	 */
	protected $viewRoles = array();

	/**
	 * Array of Role IDs referring to roles that are allowed to edit contents of this field (on pages)
	 *
	 * Applicable only if the flagAccess flag is set
	 *
	 * @var array
	 *
	 */
	protected $editRoles = array();

	/**
	 * Optional key=value runtime settings to provide to Inputfield (see: inputfieldSetting method)
	 * 
	 * This are runtime only and not stored in the DB.
	 * 
	 * @var array
	 * 
	 */
	protected $inputfieldSettings = array();

	/**
	 * Tags assigned to this field, keys are lowercase version of tag, values can possibly contain mixed case
	 * 
	 * @var null|array
	 * 
	 */
	protected $tagList = null;

	/**
	 * True if lowercase tables should be enforce, false if not (null = unset). Cached from $config
	 *
	 */
	static protected $lowercaseTables = null;

	/**
	 * Set a native setting or a dynamic data property for this Field
	 * 
	 * This can also be used directly via `$field->name = 'company';`
	 * 
	 * #pw-group-manipulation
	 *
	 * @param string $key Property name to set
	 * @param mixed $value
	 * @return Field|WireData
	 *
	 */
	public function set($key, $value) {
		
		switch($key) {
			case 'id': $this->settings['id'] = (int) $value; return $this;
			case 'name': return $this->setName($value);
			case 'data': return empty($value) ? $this : parent::set($key, $value);
			case 'type': return ($value ? $this->setFieldtype($value) : $this);
			case 'label': $this->settings['label'] = $value; return $this;
			case 'prevTable': $this->prevTable = $value; return $this;
			case 'prevName': $this->prevName = $value; return $this;
			case 'prevFieldtype': $this->prevFieldtype = $value; return $this;
			case 'flags': $this->setFlags($value); return $this;
			case 'flagsAdd': return $this->addFlag($value);
			case 'flagsDel': return $this->removeFlag($value);
			case 'icon': $this->setIcon($value); return $this;
			case 'editRoles': $this->setRoles('edit', $value); return $this; 
			case 'viewRoles': $this->setRoles('view', $value); return $this;
		}
		
		if(isset($this->settings[$key])) {
			$this->settings[$key] = $value;
		} else if($key === 'useRoles') {
			$flags = $this->flags;
			if($value) {
				$flags = $flags | self::flagAccess; // add flag
			} else {
				$flags = $flags & ~self::flagAccess; // remove flag
			}
			$this->setFlags($flags);
		} else {
			return parent::set($key, $value);
		}

		return $this;
	}

	/**
	 * Set raw setting or other value with no validation/processing
	 * 
	 * This is for use when a field is loading and needs no validation.
	 * 
	 * #pw-internal
	 * 
	 * @param string $key
	 * @param mixed $value
	 * @since 3.0.194
	 * 
	 */
	public function setRawSetting($key, $value) {
		if($key === 'data') {
			if(!empty($value)) parent::set($key, $value);
		} else {
			$this->settings[$key] = $value;
		}
	}

	/**
	 * Set the bitmask of flags for the field
	 * 
	 * @param int $value
	 *
	 */
	protected function setFlags($value) {
		// ensure that the system flag stays set
		$value = (int) $value;
		$override = $this->settings['flags'] & Field::flagSystemOverride;
		if(!$override) {
			if($this->settings['flags'] & Field::flagSystem) $value = $value | Field::flagSystem;
			if($this->settings['flags'] & Field::flagPermanent) $value = $value | Field::flagPermanent;
		}
		$this->settings['flags'] = $value;
	}

	/**
	 * Add the given bitmask flag
	 * 
	 * #pw-group-flags
	 * 
	 * @param int $flag
	 * @return $this
	 * 
	 */
	public function addFlag($flag) {
		$flag = (int) $flag;
		$this->setFlags($this->settings['flags'] | $flag);
		return $this;
	}

	/**
	 * Remove the given bitmask flag
	 * 
	 * #pw-group-flags
	 * 
	 * @param int $flag
	 * @return $this
	 * 
	 */
	public function removeFlag($flag) {
		$flag = (int) $flag;
		$this->setFlags($this->settings['flags'] & ~$flag);
		return $this;
	}

	/**
	 * Does this field have the given bitmask flag?
	 * 
	 * #pw-group-flags
	 * 
	 * @param int $flag
	 * @return bool
	 * 
	 */
	public function hasFlag($flag) {
		$flag = (int) $flag;
		return ($this->settings['flags'] & $flag) ? true : false;
	}

	/**
	 * Get a Field setting or dynamic data property
	 * 
	 * This can also be accessed directly, i.e. `$fieldName = $field->name;`. 
	 * 
	 * #pw-group-retrieval
	 *
	 * @param string $key
	 * @return mixed
	 *
	 */
	public function get($key) {
	
		if($key === 'type') { 
			if(!empty($this->settings['type'])) {
				$value = $this->settings['type'];
				if($value) $value->setLastAccessField($this);
				return $value;
			}
			return null;
		} 
	
		switch($key) {
			case 'id':
			case 'name':	
			case 'type':	
			case 'flags':	
			case 'label': return $this->settings[$key];
			case 'table': return $this->getTable();
			case 'flagsStr': return $this->wire()->fields->getFlagNames($this->settings['flags'], true);
			case 'viewRoles': 
			case 'editRoles': return $this->$key;
			case 'useRoles': return ($this->settings['flags'] & self::flagAccess) ? true : false;
			case 'prevTable':	
			case 'prevName':	
			case 'prevFieldtype': return $this->$key;
			case 'icon': return $this->getIcon(true);
			case 'tags': return $this->getTags(true);
			case 'tagList':	return $this->getTags();
		}

		if(isset($this->settings[$key])) return $this->settings[$key];
		$value = parent::get($key);
		
		if($key === 'allowContexts' && !is_array($value)) $value = array();
		if($this->trackGets && is_array($this->trackGets)) $this->trackGets($key);
		
		return $value;
	}

	/**
	 * Turn on tracking of accessed properties
	 * 
	 * #pw-internal
	 *
	 * @param bool|string $key
	 *    Omit to retrieve current trackGets value.
	 *    Specify true to enable Get tracking.
	 *    Specify false to disable (and reset) Get tracking.
	 *    Specify string key to track.
	 *
	 * @return bool|array Returns current state of trackGets when no arguments provided.
	 *    Otherwise it just returns true.
	 *
	 */
	public function trackGets($key = null) {
		if(is_null($key)) {
			// return current value
			return array_keys($this->trackGets);
		} else if($key === true) {
			// enable tracking
			if(!is_array($this->trackGets)) $this->trackGets = array();
		} else if($key === false) {
			// disable tracking
			$this->trackGets = null;
		} else if(!is_int($key) && is_array($this->trackGets)) {
			// track a key
			$this->trackGets[$key] = 1;
		}
		return true;
	}


	/**
	 * Return a key=value array of the data associated with the database table per Saveable interface
	 * 
	 * #pw-internal
	 *
	 * @return array
	 *
	 */
	public function getTableData() {
		$a = $this->settings;
		$a['data'] = $this->data;
		foreach($a['data'] as $key => $value) {
			// remove runtime data (properties beginning with underscore)
			if(strpos($key, '_') === 0) unset($a['data'][$key]);
		}
		if($this->settings['flags'] & self::flagAccess) {
			$a['data']['editRoles'] = $this->editRoles;
			$a['data']['viewRoles'] = $this->viewRoles;
		} else {
			unset($a['data']['editRoles'], $a['data']['viewRoles']); // just in case
		}
		return $a;
	}

	/**
	 * Per Saveable interface: return data for external storage
	 * 
	 * #pw-internal
	 *
	 */
	public function getExportData() {

		if($this->type) {
			$data = $this->getTableData();
			$data['type'] = $this->type->className();
		} else {
			$data['type'] = '';
		}

		if(isset($data['data'])) $data = array_merge($data, $data['data']); // flatten
		unset($data['data']);

		if($this->type) {
			$typeData = $this->type->exportConfigData($this, $data);
			$data = array_merge($data, $typeData);
		}

		// remove named flags from data since the 'flags' property already covers them
		$flagOptions = array('autojoin', 'global', 'system', 'permanent');
		foreach($flagOptions as $name) unset($data[$name]);

		$data['flags'] = $this->flags;

		foreach($data as $key => $value) {
			// exclude properties beginning with underscore as they are assumed to be for runtime use only
			if(strpos($key, '_') === 0) unset($data[$key]);
		}

		// convert access roles from IDs to names
		if($this->useRoles) {
			$roles = $this->wire()->roles;
			foreach(array('viewRoles', 'editRoles') as $roleType) {
				if(!is_array($data[$roleType])) $data[$roleType] = array();
				$roleNames = array();
				foreach($data[$roleType] as $key => $roleID) {
					$role = $roles->get($roleID);
					if(!$role || !$role->id) continue;
					$roleNames[] = $role->name;
				}
				$data[$roleType] = $roleNames;
			}
		}

		return $data;
	}

	/**
	 * Given an export data array, import it back to the class and return what happened
	 * 
	 * #pw-internal
	 *
	 * @param array $data
	 * @return array Returns array(
	 *    [property_name] => array(
	 *
	 *        // old value (in string comparison format)
	 *        'old' => 'old value',
	 *
	 *        // new value (in string comparison format)
	 *        'new' => 'new value',
	 *
	 *        // error message (string) or messages (array)
	 *        'error' => 'error message or blank if no error' ,
	 *    )
	 *
	 */
	public function setImportData(array $data) {

		$changes = array();
		$data['errors'] = array();
		$_data = $this->getExportData();

		// compare old data to new data to determine what's changed
		foreach($data as $key => $value) {
			if($key == 'errors') continue;
			$data['errors'][$key] = '';
			$old = isset($_data[$key]) ? $_data[$key] : '';
			if(is_array($old)) $old = wireEncodeJSON($old, true);
			$new = is_array($value) ? wireEncodeJSON($value, true) : $value;
			if($old === $new || (empty($old) && empty($new)) || (((string) $old) === ((string) $new))) continue;
			$changes[$key] = array(
				'old'   => $old,
				'new'   => $new,
				'error' => '', // to be populated by Fieldtype::importConfigData when applicable
			);
		}

		// prep data for actual import
		if(!empty($data['type']) && ((string) $this->type) != $data['type']) {
			$this->type = $this->wire()->fieldtypes->get($data['type']);
		}

		if(!$this->type) {
			if(!empty($data['type'])) $this->error("Unable to locate field type: $data[type]"); 
			$this->type = $this->wire()->fieldtypes->get('FieldtypeText');
		}

		$data = $this->type->importConfigData($this, $data);

		// populate import data
		foreach($changes as $key => $change) {
			$this->errors('clear all');
			if(isset($data[$key])) $this->set($key, $data[$key]);
			if(!empty($data['errors'][$key])) {
				$error = $data['errors'][$key];
				// just in case they switched it to an array of multiple errors, convert back to string
				if(is_array($error)) $error = implode(" \n", $error);
			} else {
				$error = $this->errors('last');
			}
			$changes[$key]['error'] = $error ? $error : '';
		}
		$this->errors('clear all');

		return $changes;
	}

	/**
	 * Set the field’s name
	 * 
	 * This method will throw a WireException when field name is a reserved word, is already in use, 
	 * is a system field, or is in some format not accepted for a field name.
	 * 
	 * #pw-group-manipulation
	 *
	 * @param string $name
	 * @return Field $this
	 * @throws WireException 
	 *
	 */
	public function setName($name) {

		$fields = $this->wire()->fields;
		
		if($fields) {
			if(!ctype_alnum("$name")) {
				$name = $this->wire()->sanitizer->fieldName($name);
			}
			if($fields->isNative($name)) {
				throw new WireException("Field may not be named '$name' because it is a reserved word");
			}
			if(($f = $fields->get($name)) && $f->id != $this->id) {
				throw new WireException("Field may not be named '$name' because it is already used by another field ($f->id: $f->name)");
			}
			if(strpos($name, '__') !== false) {
				throw new WireException("Field name '$name' may not have double underscores because this usage is reserved by the core");
			}
		}
		
		if(!empty($this->settings['name']) && $this->settings['name'] != $name) {
			if($this->settings['name'] && ($this->settings['flags'] & Field::flagSystem)) {
				throw new WireException("You may not change the name of field '{$this->settings['name']}' because it is a system field.");
			}
			$this->trackChange('name');
			if($this->settings['name']) {
				$this->prevName = $this->settings['name'];
				$this->prevTable = $this->getTable(); // so that Fields can perform a table rename
			}
		}

		$this->settings['name'] = $name;
		
		return $this;
	}

	/**
	 * Set what type of field this is (Fieldtype). 
	 * 
	 * #pw-group-manipulation
	 *
	 * @param string|Fieldtype $type Type should be either a Fieldtype object or the string name of a Fieldtype object.
	 * @return Field $this
	 * @throws WireException
	 *
	 */
	public function setFieldtype($type) {

		if(is_object($type) && $type instanceof Fieldtype) {
			// good for you

		} else if(is_string($type)) {
			$typeStr = $type;
			$type = $this->wire()->fieldtypes->get($type);
			if(!$type) {
				$this->error("Fieldtype '$typeStr' does not exist");
				return $this;
			}
		} else {
			throw new WireException("Invalid field type in call to Field::setFieldType");
		}

		$thisType = $this->settings['type'];
			
		if($thisType && "$thisType" != "$type") {
			if($this->trackChanges) $this->trackChange("type:$type");
			$this->prevFieldtype = $thisType;
		}
		
		$this->settings['type'] = $type;

		return $this;
	}

	/**
	 * Return the Fieldtype module representing this field’s type.
	 * 
	 * Can also be accessed directly via `$field->type`. 
	 * 
	 * #pw-group-retrieval
	 * 
	 * @return Fieldtype|null|string
	 * @since 3.0.16 Added for consistency, but all versions can still use $field->type. 
	 * 
	 */
	public function getFieldtype() {
		return $this->type; 
	}

	/**
	 * Get this field in context of a Page/Template
	 * 
	 * #pw-group-retrieval
	 * 
	 * @param Page|Template|Fieldgroup|string $for Specify Page, Template, or template name string
	 * @param string $namespace Optional namespace (internal use)
	 * @param bool $has Return boolean rather than Field to check if context exists? (default=false)
	 * @return Field|bool
	 * @since 3.0.162
	 * @see Fieldgroup::getFieldContext(), Field::hasContext()
	 * 
	 */
	public function getContext($for, $namespace = '', $has = false) {
		/** @var Fieldgroup|null $fieldgroup */
		$fieldgroup = null;
		if(is_string($for)) {
			$for = $this->wire()->templates->get($for);
		}
		if($for instanceof Page) {
			/** @var Page $context */
			$template = $for instanceof NullPage ? null : $for->template;
			if(!$template) throw new WireException('Page must have template to get context');
			$fieldgroup = $template->fieldgroup;
		} else if($for instanceof Template) {
			/** @var Template $context */
			$fieldgroup = $for->fieldgroup;
		} else if($for instanceof Fieldgroup) {
			$fieldgroup = $for;
		}
		if(!$fieldgroup) throw new WireException('Cannot get Fieldgroup for field context'); 
		
		if($has) return $fieldgroup->hasFieldContext($this->id, $namespace);

		return $fieldgroup->getFieldContext($this->id, $namespace);
	}

	/**
	 * Does this field have context settings for given Page/Template?
	 *
	 * #pw-group-retrieval
	 *
	 * @param Page|Template|Fieldgroup|string $for Specify Page, Template, or template name string
	 * @param string $namespace Optional namespace (internal use)
	 * @return Field|bool
	 * @since 3.0.163
	 * @see Field::getContext()
	 *
	 */
	public function hasContext($for, $namespace = '') {
		return $this->getContext($for, $namespace, true);
	}

	/**
	 * Get all contexts this field is used in
	 * 
	 * @return array Array of 'fieldgroup-name' => [ contexts ]
	 * @since 3.0.182
	 * 
	 */
	public function getContexts() {
		$contexts = array();
		foreach($this->wire()->fieldgroups as $fieldgroup) {
			/** @var Fieldgroup $fieldgroup */
			$context = $fieldgroup->getFieldContextArray($this->id);
			if(empty($context)) continue;
			$contexts[$fieldgroup->name] = $context;
		}
		return $contexts;	
	}

	/**
	 * Set the roles that are allowed to view or edit this field on pages.
	 *
	 * Applicable only if the `Field::flagAccess` is set to this field's flags.
	 * 
	 * #pw-group-manipulation
	 *
	 * @param string $type Must be either "view" or "edit"
	 * @param PageArray|array|null $roles May be a PageArray of Role objects or an array of Role IDs.
	 * @throws WireException if given invalid argument
	 *
	 */
	public function setRoles($type, $roles) {
		if(empty($roles)) $roles = array();
		if(!WireArray::iterable($roles)) {
			throw new WireException("setRoles expects PageArray or array of Role IDs");
		}
		$ids = array();
		foreach($roles as $role) {
			if(is_int($role) || (is_string($role) && ctype_digit("$role"))) {
				$ids[] = (int) $role;
			} else if($role instanceof Role) {
				$ids[] = (int) $role->id;
			} else if(is_string($role) && strlen($role)) {
				$rolePage = $this->wire()->roles->get($role); 
				if($rolePage && $rolePage->id) {
					$ids[] = $rolePage->id;
				} else {
					$this->error("Unknown role '$role'"); 
				}
			} else {
				// invalid
			}
		}
		if($type == 'view') {
			$guestID = $this->wire()->config->guestUserRolePageID;
			// if guest is present, then that's inclusive of all, no need to store others in viewRoles
			if(in_array($guestID, $ids)) $ids = array($guestID); 
			if($this->viewRoles != $ids) {
				$this->viewRoles = $ids;
				$this->trackChange('viewRoles');
			}
		} else if($type == 'edit') {
			if($this->editRoles != $ids) {
				$this->editRoles = $ids;
				$this->trackChange('editRoles');
			}
		} else {
			throw new WireException("setRoles expects either 'view' or 'edit' (arg 0)");
		}
	}

	/**
	 * Is this field viewable?
	 * 
	 * #pw-group-access
	 *
	 * - To maximize efficiency check that `$field->useRoles` is true before calling this.  
	 * - If you have already verified that the page is viewable, omit or specify null for $page argument.
	 * - **Please note:** this does not check that the provided $page itself is viewable. If you want that 
	 *   check, then use `$page->viewable($field)` instead.
	 * 
	 * @param Page|null $page Optionally specify a Page for context (i.e. Is field viewable on $page?)
	 * @param User|null $user Optionally specify a different user for context (default=current user)
	 * @return bool True if viewable, false if not
	 * 
	 */
	public function ___viewable(Page $page = null, User $user = null) {
		return $this->wire()->fields->_hasPermission($this, 'view', $page, $user);
	}

	/**
	 * Is this field editable?
	 * 
	 * - To maximize efficiency check that `$field->useRoles` is true before calling this.
	 * - If you have already verified that the page is editable, omit or specify null for $page argument.
	 * - **Please note:** this does not check that the provided $page itself is editable. If you want that 
	 *   check, then use `$page->editable($field)` instead.
	 * 
	 * #pw-group-access
	 *
	 * @param Page|string|int|null $page Optionally specify a Page for context
	 * @param User|string|int|null $user Optionally specify a different user (default = current user)
	 * @return bool
	 *
	 */
	public function ___editable(Page $page = null, User $user = null) {
		return $this->wire()->fields->_hasPermission($this, 'edit', $page, $user);
	}
	
	/**
	 * Save this field’s settings and data in the database. 
	 *
	 * To hook this save, hook to `Fields::save()` instead.
	 * 
	 * #pw-group-manipulation
	 * 
	 * @return bool
	 *
	 */
	public function save() {
		return $this->wire()->fields->save($this); 
	}

	/**
	 * Return the number of Fieldgroups this field is used in.
	 *
	 * Primarily used to check if the Field is deletable. 
	 * 
	 * #pw-group-retrieval
	 * 
	 * @return int
	 *
	 */ 
	public function numFieldgroups() {
		return $this->getFieldgroups(true); 
	}

	/**
	 * Return the list of Fieldgroups using this field.
	 * 
	 * #pw-group-retrieval
	 *
	 * @param bool $getCount Get count rather than FieldgroupsArray? (default=false) 3.0.182+
	 * @return FieldgroupsArray|int WireArray of Fieldgroup objects or count if requested
	 *
	 */ 
	public function getFieldgroups($getCount = false) {
		return $this->wire()->fields->getFieldgroups($this, $getCount);
	}

	/**
	 * Return the list of of Templates using this field.
	 * 
	 * #pw-group-retrieval
	 *
	 * @param bool $getCount Get count rather than FieldgroupsArray? (default=false) 3.0.182+
	 * @return TemplatesArray|int WireArray of Template objects or count when requested. 
	 *
	 */ 
	public function getTemplates($getCount = false) {
		return $this->wire()->fields->getTemplates($this, $getCount);
	}

	/**
	 * Return the default value for this field (if set), or null otherwise. 
	 * 
	 * #pw-internal
	 * 
	 * @deprecated Use $field->type->getDefaultValue($page, $field) instead. 
	 *
	 */
	public function getDefaultValue() {
		$value = $this->get('default'); 
		if($value) return $value; 
		return null;
	}

	/**
	 * Get the Inputfield module used to collect input for this field.
	 * 
	 * #pw-group-retrieval
	 * 
	 * @param Page $page Page that the Inputfield is for. 
	 * @param string $contextStr Optional context string to append to the Inputfield's name/id (for repeaters and such). 
	 * @return Inputfield|null 
	 *
	 */
	public function ___getInputfield(Page $page, $contextStr = '') {

		if(!$this->type) return null;
		
		// check access control
		$locked = false;
		if($this->useRoles && !$this->editable($page)) {
			// $this->message("not editable: " . $this->name);
			if(($this->flags & self::flagAccessEditor) && $this->viewable($page)) {
				// Inputfield is viewable but not editable
				$locked = true;
			} else {
				// Inputfield is neither editable nor viewable
				$locked = 'hidden';
			}
		}
		
		$inputfield = $this->type->getInputfield($page, $this);
		if(!$inputfield) return null; 

		// predefined field settings
		$inputfield->attr('name', $this->name . $contextStr); 
		$inputfield->set('label', $this->label);

		// just in case an Inputfield needs to know its Fieldtype/Field context, or lack of it
		$inputfield->set('hasFieldtype', $this->type);
		$inputfield->set('hasField', $this);
		$inputfield->set('hasPage', $page); 
		
		// custom field settings
		foreach($this->data as $key => $value) {
			if($inputfield instanceof InputfieldWrapper) {
				$has = $inputfield->hasSetting($key) || $inputfield->hasAttribute($key);
			} else {
				$has = $inputfield->has($key);
			}
			if($has) {
				if(is_array($this->trackGets)) $this->trackGets($key); 
				$inputfield->set($key, $value); 
			}
		}

		if($locked && $locked === 'hidden') {
			// Inputfield should not be shown
			$inputfield->collapsed = Inputfield::collapsedHidden;
		} else if($locked) {
			// Inputfield is locked as a result of access control
			$collapsed = $inputfield->getSetting('collapsed'); 
			$ignoreCollapsed = array(
				Inputfield::collapsedNoLocked, 
				Inputfield::collapsedBlankLocked, 
				Inputfield::collapsedYesLocked, 
				Inputfield::collapsedHidden
			);
			if(!in_array($collapsed, $ignoreCollapsed)) {
				// Inputfield is not already locked or hidden, convert to locked equivalent
				if($collapsed == Inputfield::collapsedYes) {
					$collapsed = Inputfield::collapsedYesLocked;
				} else if($collapsed == Inputfield::collapsedBlank) {
					$collapsed = Inputfield::collapsedBlankLocked;
				} else if($collapsed == Inputfield::collapsedNo) {
					$collapsed = Inputfield::collapsedNoLocked;
				} else {
					$collapsed = Inputfield::collapsedYesLocked;
				}
				$inputfield->collapsed = $collapsed;
			}
		}
	
		if(count($this->inputfieldSettings)) {
			// runtime-only settings to Inputfield (these are not stored in DB)
			foreach($this->inputfieldSettings as $name => $value) {
				$inputfield->set($name, $value);
			}
		}
		
		if($contextStr) {
			// update dependency strings for the context 
			foreach(array('showIf', 'requiredIf') as $depType) {
				$theIf = $inputfield->getSetting($depType);
				if(empty($theIf)) continue;
				$inputfield->set($depType, preg_replace('/([_.|a-zA-Z0-9]+)([=!%*<>]+)/', '$1' . $contextStr . '$2', $theIf));
			}
		}

		return $inputfield; 
	}

	/**
	 * Get or set a runtime-only setting that will be sent to the Inputfield during the getInputfield() call
	 * 
	 * #pw-internal
	 * 
	 * @param string $name Specify setting name to get or set, or '*' to get all.
	 * @param null|mixed $value Specify value, or 'clear' to clear setting(s) described in $name argument.
	 * @return null|array|bool|mixed Returns setting value, null if not found, true if set or clear requested, or array if all settings requested.
	 * 
	 */
	public function inputfieldSetting($name, $value = null) {
		if($name === '*') {
			// get or clear ALL settings
			if($value === 'clear') {
				$this->inputfieldSettings = array();
				return true;
			} else {
				return $this->inputfieldSettings;
			}
		} else if(is_null($value)) {
			// get a setting, or return null if not found
			return isset($this->inputfieldSettings[$name]) ? $this->inputfieldSettings[$name] : null;
		} else if($value === 'clear') {
			// clear a setting
			unset($this->inputfieldSettings[$name]);	
			return true;
		} else {
			// set a named setting
			$this->inputfieldSettings[$name] = $value;
			return true;
		}
	}

	/**
	 * Get any Inputfields needed to configure the field in the admin.
	 * 
	 * #pw-group-retrieval
	 *
	 * @return InputfieldWrapper
	 *
	 */
	public function ___getConfigInputfields() {

		$wrapper = $this->wire(new InputfieldWrapper());
		$fieldgroupContext = $this->flags & Field::flagFieldgroupContext; 
		
		if($fieldgroupContext) {
			$allowContext = $this->type->getConfigAllowContext($this); 
			if(!is_array($allowContext)) $allowContext = array();
			$allowContext = array_merge($allowContext, $this->allowContexts); 
		} else {
			$allowContext = array();
		}

		if(!$fieldgroupContext || count($allowContext)) {
			
			$inputfields = $this->wire(new InputfieldWrapper());
			if(!$fieldgroupContext) $inputfields->head = $this->_('Field type details');
			$inputfields->attr('title', $this->_('Details'));
			$inputfields->attr('id+name', 'fieldtypeConfig');
			$remainingNames = array();
			foreach($allowContext as $name) $remainingNames[$name] = $name;

			try {
				$fieldtypeInputfields = $this->type->getConfigInputfields($this); 
				if(!$fieldtypeInputfields) $fieldtypeInputfields = $this->wire(new InputfieldWrapper());
				$configArray = $this->type->getConfigArray($this); 
				if(count($configArray)) {
					$w = $this->wire(new InputfieldWrapper());
					$w->importArray($configArray);
					$w->populateValues($this);
					$fieldtypeInputfields->import($w);
				}
				foreach($fieldtypeInputfields as $inputfield) {
					if($fieldgroupContext && !in_array($inputfield->name, $allowContext)) continue;
					$inputfields->append($inputfield);
					unset($remainingNames[$inputfield->name]);
				}
				// now capture those that may have been stuck in a fieldset
				if($fieldgroupContext) {
					foreach($remainingNames as $name) {
						if($inputfields->getChildByName($name)) continue;
						$inputfield = $fieldtypeInputfields->getChildByName($name);
						if(!$inputfield) continue;
						$inputfields->append($inputfield);
						unset($remainingNames[$inputfield->name]);
					}
				}
				
			} catch(\Exception $e) {
				$this->trackException($e, false, true); 
			}

			if(count($inputfields)) $wrapper->append($inputfields); 
		}

		$inputfields = $this->wire(new InputfieldWrapper());
		$dummyPage = $this->wire()->pages->get('/'); // only using this to satisfy param requirement 

		$inputfield = $this->getInputfield($dummyPage);
		if($inputfield) {
			if($fieldgroupContext) {
				$allowContext = array('visibility', 'collapsed', 'columnWidth', 'required', 'requiredIf', 'showIf');
				$allowContext = array_merge($allowContext, $this->allowContexts, $inputfield->getConfigAllowContext($this)); 
			} else {
				$allowContext = array();
				$inputfields->head = $this->_('Input field settings');
			}
			$remainingNames = array();
			foreach($allowContext as $name) {
				$remainingNames[$name] = $name;
			}
			$inputfields->attr('title', $this->_('Input')); 
			$inputfields->attr('id+name', 'inputfieldConfig');
			/** @var InputfieldWrapper $inputfieldInputfields */
			$inputfieldInputfields = $inputfield->getConfigInputfields();
			if(!$inputfieldInputfields) $inputfieldInputfields = $this->wire(new InputfieldWrapper());
			$configArray = $inputfield->getConfigArray(); 
			if(count($configArray)) {
				$w = $this->wire(new InputfieldWrapper());
				$w->importArray($configArray);
				$w->populateValues($this);
				$inputfieldInputfields->import($w);
			}
			foreach($inputfieldInputfields as $i) { 
				if($fieldgroupContext && !in_array($i->name, $allowContext)) continue; 
				$inputfields->append($i); 
				unset($remainingNames[$i->name]); 
			}
			if($fieldgroupContext) {
				foreach($remainingNames as $name) {
					if($inputfields->getChildByName($name)) continue;
					$inputfield = $inputfieldInputfields->getChildByName($name);
					if(!$inputfield) continue;
					$inputfields->append($inputfield);
					unset($remainingNames[$inputfield->name]);
				}
			}
		}

		$wrapper->append($inputfields); 

		return $wrapper; 
	}

	/**
	 * Get the database table used by this field.
	 * 
	 * #pw-group-retrieval
	 * 
	 * @return string
	 * @throws WireException
	 * 
	 */
	public function getTable() {
		if(self::$lowercaseTables === null) {
			self::$lowercaseTables = $this->wire()->config->dbLowercaseTables ? true : false;
		}
		if(!empty($this->setTable)) {
			$table = $this->setTable;
		} else {
			$name = $this->settings['name'];
			if(!strlen($name)) throw new WireException("Field 'name' is required");
			$table = self::tablePrefix . $name;
		}
		if(self::$lowercaseTables) $table = strtolower($table); 
		return $table;
	}

	/**
	 * Set an override table name, or omit (or null) to restore default table name
	 * 
	 * #pw-group-advanced
	 * 
	 * @param null|string $table
	 * 
	 */
	public function setTable($table = null) {
		$table = empty($table) ? '' : $this->wire()->sanitizer->fieldName($table);
		$this->setTable = $table;
	}

	/**
	 * The string value of a Field is always it's name
	 *
	 */
	public function __toString() {
		return $this->settings['name']; 
	}

	/**
	 * Isset
	 * 
	 * @param string $key
	 * @return bool
	 * 
	 */
	public function __isset($key) {
		if(parent::__isset($key)) return true; 
		return isset($this->settings[$key]); 
	}
	
	/**
	 * Return field label, description or notes for language
	 *
	 * @param string $property Specify either label, description or notes
	 * @param Page|Language $language Optionally specify a language. If not specified user's current language is used.
	 * @return string
	 *
	 */
	protected function getText($property, $language = null) {
		if(is_null($language)) {
			$language = $this->wire()->languages ? $this->wire()->user->language : null;
		}
		if($language) {
			$value = (string) $this->get("$property$language");
			if(!strlen($value)) $value = (string) $this->$property;
		} else {
			$value = (string) $this->$property;
		}
		if($property === 'label' && !strlen($value)) $value = $this->name;
		return $value;
	}
	
	/**
	 * Set a field label, description or notes for language
	 *
	 * @param string $property Specify either label, description or notes
	 * @param string $value Text to set for property
	 * @param Page|Language $language Optionally specify a language. If not specified default language is used. 
	 *
	 */
	protected function setText($property, $value, $language = null) {
		$languages = $this->wire()->languages;
		if($languages && $language != null) {
			if(is_string($language) || is_int($language)) $language = $languages->get($language);
			if($language && (!$language->id || $language->isDefault())) $language = null;
		} else {
			$language = null;
		}
		if(is_null($language)) $language = '';
		$this->set("$property$language", $value); 
	}

	/**
	 * Get field label for current language, or another specified language.
	 *
	 * This is different from `$field->label` in that it knows about languages (when installed).
	 * 
	 * #pw-group-retrieval
	 *
	 * @param Page|Language $language Optionally specify a language. If not specified user's current language is used.
	 * @return string
	 *
	 */
	public function getLabel($language = null) {
		return $this->getText('label', $language);
	}

	/**
	 * Return field description for current language, or another specified language.
	 *
	 * This is different from `$field->description` in that it knows about languages (when installed).
	 * 
	 * #pw-group-retrieval
	 *
	 * @param Page|Language $language Optionally specify a language. If not specified user's current language is used.
	 * @return string
	 *
	 */
	public function getDescription($language = null) {
		return $this->getText('description', $language);
	}

	/**
	 * Return field notes for current language, or another specified language. 
	 *
	 * This is different from `$field->notes` in that it knows about languages (when installed).
	 * 
	 * #pw-group-retrieval
	 *
	 * @param Page|Language $language Optionally specify a language. If not specified user's current language is used.
	 * @return string
	 *
	 */
	public function getNotes($language = null) {
		return $this->getText('notes', $language);
	}

	/**
	 * Return the icon used by this field, or blank if none.
	 * 
	 * #pw-group-retrieval
	 * 
	 * @param bool $prefix Whether or not you want the icon prefix included (i.e. "fa-")
	 * @return mixed|string
	 * 
	 */
	public function getIcon($prefix = false) {
		$icon = parent::get('icon'); 
		if(empty($icon)) return '';
		if(strpos($icon, 'fa-') === 0) $icon = str_replace('fa-', '', $icon);
		if(strpos($icon, 'icon-') === 0) $icon = str_replace('icon-', '', $icon); 
		return $prefix ? "fa-$icon" : $icon;
	}
	
	/**
	 * Set label, optionally for a specific language
	 *
	 * #pw-group-manipulation
	 *
	 * @param string $text Text to set
	 * @param Language|string|int|null $language Language to use
	 * @since 3.0.16 Added for consistency, all versions can still set property directly. 
	 *
	 */
	public function setLabel($text, $language = null) {
		$this->setText('label', $text, $language);
	}

	/**
	 * Set description, optionally for a specific language
	 *
	 * #pw-group-manipulation
	 *
	 * @param string $text Text to set
	 * @param Language|string|int|null $language Language to use
	 * @since 3.0.16 Added for consistency, all versions can still set property directly.
	 *
	 */
	public function setDescription($text, $language = null) {
		$this->setText('description', $text, $language);
	}
	
	/**
	 * Set notes, optionally for a specific language
	 *
	 * #pw-group-manipulation
	 *
	 * @param string $text Text to set
	 * @param Language|string|int|null $language Language to use
	 * @since 3.0.16 Added for consistency, all versions can still set property directly.
	 *
	 */
	public function setNotes($text, $language = null) {
		$this->setText('notes', $text, $language);
	}

	/**
	 * Set the icon for this field
	 * 
	 * #pw-group-manipulation
	 * 
	 * @param string $icon Icon name
	 * @return $this
	 * 
	 */
	public function setIcon($icon) {
		// store the non-prefixed version
		if(strlen("$icon")) {
			if(strpos($icon, 'icon-') === 0) $icon = str_replace('icon-', '', $icon);
			if(strpos($icon, 'fa-') === 0) $icon = str_replace('fa-', '', $icon);
			$icon = $this->wire()->sanitizer->pageName($icon);
		}
		parent::set('icon', "$icon"); 
		return $this; 
	}

	/**
	 * Get tags
	 * 
	 * @param bool|string $getString Optionally specify true for space-separated string, or delimiter string (default=false)
	 * @return array|string Returns array of tags unless $getString option is requested
	 * @since 3.0.106
	 * 
	 */
	public function getTags($getString = false) {
		if($this->tagList === null) {
			$tagList = $this->setTags(parent::get('tags'));
		} else {
			$tagList = $this->tagList;
		}
		if($getString !== false) {
			$delimiter = $getString === true ? ' ' : $getString;
			return implode($delimiter, $tagList);
		}
		return $tagList;
	}

	/**
	 * Set all tags
	 * 
	 * #pw-internal
	 * 
	 * @param array|string $tagList Array of tags to add (or space-separated string)
	 * @param bool $reindex Set to false to set given $tagsList exactly as-is (assumes it's already in correct format)
	 * @return array Array of tags that were set
	 * @since 3.0.106
	 * 
	 */
	public function setTags($tagList, $reindex = true) {
		$textTools = $this->wire()->sanitizer->getTextTools();
		if($tagList === null || $tagList === '') {
			$tagList = array();
		} else if(!is_array($tagList)) {
			$tagList = explode(' ', $tagList);
		}
		if($reindex && count($tagList)) {
			$tags = array();
			foreach($tagList as $tag) {
				$tag = trim($tag);
				if(strlen($tag)) $tags[$textTools->strtolower($tag)] = $tag;
			}
			$tagList = $tags;
		}
		if($this->tagList !== $tagList) {
			$this->tagList = $tagList;
			parent::set('tags', implode(' ', $tagList)); 
			$this->wire()->fields->getTags('reset');
		}
		return $tagList;
	}

	/**
	 * Add one or more tags
	 * 
	 * @param string $tag
	 * @return array Returns current tag list
	 * @since 3.0.106
	 * 
	 */
	public function addTag($tag) {
		$textTools = $this->wire()->sanitizer->getTextTools();
		$tagList = $this->getTags();
		$tagList[$textTools->strtolower($tag)] = $tag;
		$this->setTags($tagList, false);
		return $tagList;
	}

	/**
	 * Return true if this field has the given tag or false if not
	 * 
	 * @param string $tag
	 * @return bool
	 * @since 3.0.106
	 * 
	 */
	public function hasTag($tag) {
		$textTools = $this->wire()->sanitizer->getTextTools();
		$tagList = $this->getTags();
		return isset($tagList[$textTools->strtolower(trim(ltrim($tag, '-')))]);
	}

	/**
	 * Remove a tag
	 * 
	 * @param string $tag
	 * @return array Returns current tag list
	 * @since 3.0.106
	 * 
	 */
	public function removeTag($tag) {
		$textTools = $this->wire()->sanitizer->getTextTools();
		$tagList = $this->getTags();
		$tag = $textTools->strtolower($tag);
		if(!isset($tagList[$tag])) return $tagList;
		unset($tagList[$tag]); 
		return $this->setTags($tagList, false);
	}

	/**
	 * Get URL to edit field in the admin
	 * 
	 * @param array|bool|string $options Specify array of options, string for find option, or bool for http option.
	 *  - `find` (string): Name of field to find in editor form 
	 *  - `http` (bool): True to force inclusion of scheme and hostname
	 * @return string
	 * @since 3.0.151
	 * 
	 */
	public function editUrl($options = array()) {
		if(is_string($options)) $options = array('find' => $options);
		if(is_bool($options)) $options = array('http' => $options);
		if(!is_array($options)) $options = array();
		$url = $this->wire()->config->urls(empty($options['http']) ? 'admin' : 'httpAdmin');
		$url .= "setup/field/edit?id=$this->id";
		if(!empty($options['find'])) $url .= '#find-' . $this->wire()->sanitizer->fieldName($options['find']);
		return $url;
	}

	/**
	 * debugInfo PHP 5.6+ magic method
	 *
	 * This is used when you print_r() an object instance.
	 *
	 * @return array
	 *
	 */
	public function __debugInfo() {
		$info = $this->settings;
		$info['flags'] = $info['flags'] ? "$this->flagsStr ($info[flags])" : "";
		$info = array_merge($info, parent::__debugInfo());
		if($this->prevTable) $info['prevTable'] = $this->prevTable;
		if($this->prevName) $info['prevName'] = $this->prevName;
		if($this->prevFieldtype) $info['prevFieldtype'] = (string) $this->prevFieldtype;
		if(!empty($this->trackGets)) $info['trackGets'] = $this->trackGets;
		if($this->useRoles) {
			$info['viewRoles'] = $this->viewRoles;
			$info['editRoles'] = $this->editRoles; 
		}
		return $info; 
	}
	
	public function debugInfoSmall() {
		return array(
			'id' => $this->id, 
			'name' => $this->name,
			'label' => $this->getLabel(), 
			'type' => $this->type ? wireClassName($this->type) : '',
		);
	}
	
}

