<?php namespace ProcessWire;

/**
 * ProcessWire Page Fieldtype
 *
 * Field that stories references to one or more ProcessWire pages. 
 *
 * For documentation about the fields used in this class, please see:  
 * /wire/core/Fieldtype.php
 * /wire/core/FieldtypeMulti.php
 * 
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 *
 */

class FieldtypePage extends FieldtypeMulti implements Module, ConfigurableModule {

	public static function getModuleInfo() {
		return array(
			'title' => 'Page Reference',
			'version' => 107,
			'summary' => 'Field that stores one or more references to ProcessWire pages',
			'permanent' => true, 
		);
	}

	const derefAsPageArray = 0; 
	const derefAsPageOrFalse = 1; 
	const derefAsPageOrNullPage = 2;

	/**
	 * Subfield names that will match to the 'pages' table, rather than custom fields
	 *
	 */
	protected $nativeNames = array(
		'name', 
		'status',
		'template',
		'templates_id',
		'parent',
		'parent_id',
		'created',
		'modified',
		'published',
	);

	/**
	 * Runtime/temp caches indexed by function name
	 * 
	 * @var array 
	 * 
	 */
	protected $caches = array();

	/**	
	 * Setup a hook to Pages::delete so that we can remove references when pages are deleted
	 *
	 */
	public function init() {
		$pages = $this->wire()->pages; 
		if($pages) {
			$pages->addHookAfter('delete', $this, 'hookPagesDelete');
		} else {
			$this->addHookAfter('Pages::delete', $this, 'hookPagesDelete');
		}
	}

	/**
	 * FieldtypePage instances are only compatible with other FieldtypePage derived classes. 
	 * 
	 * @param Field $field
	 * @return Fieldtypes
	 *
	 */
	public function ___getCompatibleFieldtypes(Field $field) {
		$fieldtypes = parent::___getCompatibleFieldtypes($field); 
		foreach($fieldtypes as $type) if(!$type instanceof FieldtypePage) $fieldtypes->remove($type); 
		return $fieldtypes; 
	}

	/**
	 * Delete any records that are referencing the page that was deleted
	 * 
	 * @param HookEvent $event
	 *
	 */
	public function hookPagesDelete($event) {
		if(!$event->return) return; // if delete failed, then don't continue
		$page_id = $event->arguments[0]->id; 
		$database = $this->wire()->database;
		foreach($this->wire()->fields as $field) {
			if(!$field->type instanceof FieldtypePage) continue; 
			$table = $database->escapeTable($field->table);
			// delete references to this page
			$query = $database->prepare("DELETE FROM `$table` WHERE data=:page_id");
			$query->bindValue(":page_id", $page_id, \PDO::PARAM_INT);
			$query->execute();
			// delete references this page is keeping to other pages
			$query = $database->prepare("DELETE FROM `$table` WHERE pages_id=:page_id");
			$query->bindValue(":page_id", $page_id, \PDO::PARAM_INT);
			$query->execute();
		}	
	}

	/**
	 * We want FieldtypePage to autoload so that it can monitor page deletes
	 * 
	 * @return bool
	 *
	 */
	public function isAutoload() {
		return true; 
	}
	
	/**
	 * Get class name to use Field objects of this type (must be class that extends Field class)
	 *
	 * @param array $a Field data from DB (if needed)
	 * @return string Return class name or blank to use default Field class
	 *
	 */
	public function getFieldClass(array $a = array()) {
		if($a) {} // ignore
		return 'PageField';
	}

	/**
	 * Return an InputfieldPage of the type configured
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @return InputfieldPage
	 *
	 */
	public function getInputfield(Page $page, Field $field) {
		/** @var InputfieldPage $inputfield */
		$inputfield = $this->wire()->modules->get("InputfieldPage"); 
		$inputfield->class = $this->className();
		return $inputfield; 
	}

	/**
	 * Get setup options and setup functions for new fields
	 *
	 * @return array
	 * @since 3.0.213
	 *
	 */
	public function ___getFieldSetups() {
		$setups = parent::___getFieldSetups();
		/** @var InputfieldPage $f */
		$f = $this->wire()->modules->getModule('InputfieldPage', array('noInit' => true)); 
		return array_merge($setups, $f->getFieldSetups());
	}

	/**
	 * Per the Fieldtype interface, Save the given Field from the given Page to the database
	 *
	 * @param Page $page
	 * @param Field $field
	 * @return bool
	 * @throws \PDOException|WireException|WireDatabaseQueryException on failure
	 *
	 */
	public function ___savePageField(Page $page, Field $field) {
		$value = $page->get($field->name);
		if($value instanceof PageArray) {
			if($value->data('filters')) throw new WireException('Filtered value is not saveable'); 
			if($value->getLimit()) throw new WireException('Paginated value is not saveable');
		}
		return parent::___savePageField($page, $field);
	}

	/**
	 * Given a raw value (value as stored in DB), return the value as it would appear in a Page object
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param string|int|array $value
	 * @return PageArray
	 *
	 */
	public function ___wakeupValue(Page $page, Field $field, $value) {
		
		if($field->hasContext($page)) $field = $field->getContext($page);

		$template = null;
		$templateIds = self::getTemplateIDs($field);
		$allowUnpub = $field->get('allowUnpub');
		$pagination = array('limit' => null, 'start' => null, 'total' => null);
		$filters = null; /** @var Selectors|null $filters */

		if(is_array($value)) {
			// see if pagination and filter data are populated from FieldtypeMulti
			if(isset($value['_pagination_limit'])) {
				foreach(array_keys($pagination) as $key) {
					$valueKey = "_pagination_$key";
					$pagination[$key] = isset($value[$valueKey]) ? $value[$valueKey] : 0;
					unset($value[$valueKey]);
				}
			}
			if(isset($value['_filters'])) {
				$filters = count($value['_filters']) ? $value['_filters'] : null;
				unset($value['_filters']);
			}
		}
		
		if(count($templateIds) == 1) {
			// we only use $template optimization if only one template selected
			$template = $this->wire()->templates->get(reset($templateIds));
		}

		if($field->get('derefAsPage') > 0) {
			// configured to return single Page, NullPage or false
			return $this->wakeupValueSingle($page, $field, $template, $value);
		}

		if($value instanceof PageArray) {
			$pageArray = $value;
		} else {
			if(!is_array($value)) $value = $this->wakeupValueToArray($value);
			if(count($value)) {
				$pageArray = $this->wire()->pages->getById($value, $template); 
			} else {
				$pageArray = $this->getBlankValue($page, $field); 
			}
		}
		
		if(!$allowUnpub) {
			// remove any pages that have an unpublished status
			foreach($pageArray as $p) {
				// note: these removals can affect pagination accuracy
				if($p->status >= Page::statusUnpublished) $pageArray->remove($p);
			}
		}
		
		if($pagination['limit'] !== null) {
			$pageArray->setLimit($pagination['limit']);
			$pageArray->setStart($pagination['start']);
			$pageArray->setTotal($pagination['total']);
		}
		
		if($filters !== null) $pageArray->data('filters', $filters);
	
		$pageArray->resetTrackChanges(); 
		
		return $pageArray; 
	}

	/**
	 * Wakeup given string or int value to an array
	 * 
	 * @param string|int $value
	 * @return array
	 * @since 3.0.173
	 * 
	 */
	protected function wakeupValueToArray($value) {
		$value = (string) $value;
		if(strpos($value, '|') !== false) {
			$value = explode('|', $value);
		} else if(strpos($value, ',') !== false) {
			$value = explode(',', $value);
		} else if(ctype_digit($value)) {
			$value = ((int) $value) > 0 ? array((int) $value) : array();
		} else {
			$value = array();
		}
		foreach($value as $k => $v) {
			if(ctype_digit("$v")) {
				$value[$k] = (int) $v;
			} else {
				unset($value[$k]); 
			}
		}
		return $value;
	}

	/**
	 * Wakeup single page field value (when derefAsPage>0)
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param Template|null $template
	 * @param array|int|PageArray|Page|null|bool $value
	 * @return bool|Page|PageArray
	 * @since 3.0.173
	 * 
	 */
	protected function wakeupValueSingle(Page $page, Field $field, $template, $value) {
		
		$pageValue = false;
		
		// value will ultimately be a single Page
		if(empty($value)) {
			// empty value 

		} else if($value instanceof Page) {
			// already a Page
			$pageValue = $value;

		} else if($value instanceof PageArray) {
			// it's a PageArray and should be a Page
			if($value->count()) {
				// if there's a Page in there, get just the first one
				$pageValue = $value->first();
			}
			
		} else {
			// array or other
			if(!is_array($value)) {
				$value = ctype_digit("$value") ? array((int) $value) : array();
			}

			if(count($value)) {
				// get the first value in a PageArray, using $template and parent for optimization
				$pageArray = $this->wire()->pages->getById(array((int) reset($value)), $template);
				if($pageArray->count()) $pageValue = $pageArray->first();
			}
		}

		if($pageValue && $pageValue->status >= Page::statusUnpublished && !$field->get('allowUnpub')) {
			$pageValue = false;
		}
		
		if(!$pageValue || !$pageValue->id) {
			$pageValue = $this->getBlankValue($page, $field);
		}

		return $pageValue;
	}

	/**
	 * Pre-validate the given page IDs
	 * 
	 * @param Page $page
	 * @param array $ids
	 * @return array
	protected function validatePageIDs(Page $page, array $ids) {
		foreach($ids as $key => $id) {
			// ensure no circular reference
			if($id == $page->id) unset($ids[$key]); 
		}
		return $ids; 
	}
	 */

	/**
	 * Given an 'awake' value, as set by wakeupValue, convert the value back to a basic type for storage in DB. 
	 *              
	 * @param Page $page
	 * @param Field $field
	 * @param string|int|array|object $value
	 * @return array
	 *
	 */
	public function ___sleepValue(Page $page, Field $field, $value) {
		
		if($field->hasContext($page)) $field = $field->getContext($page);
		
		$sleepValue = array();
		
		if($field->get('derefAsPage') > 0) {
			// if the $value isn't specifically a Page, make it a blank array for storage
			if(!$value instanceof Page || !$value->id) return $sleepValue; 
			// if $value is a Page (not a NullPage) then place its ID in an array for storage
			if($this->isValidPage($value, $field, $page)) {
				$sleepValue[] = $value->id;
			}
		} else {
			// if $value isn't a PageArray then we'll store a blank array
			if(!$value instanceof PageArray) return $sleepValue; 
			// iterate through the array and place each Page ID
			foreach($value as $pg) {
				if($pg->id && $this->isValidPage($pg, $field, $page)) {
					$sleepValue[] = $pg->id;
				}
			}
		}
		
		return $sleepValue;
	}

	/**
	 * Export value
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param array|int|object|string $value
	 * @param array $options
	 * @return array|string
	 * 
	 */
	public function ___exportValue(Page $page, Field $field, $value, array $options = array()) {
		if($value instanceof Page) return $this->exportValuePage($page, $field, $value, $options); 	
		if(!$value instanceof PageArray) return array();
		$a = array();
		foreach($value as $v) {
			$a[] = $this->exportValuePage($page, $field, $v, $options); 	
		}
		// in human mode just return the titles separated by a carriage return
		if(!empty($options['human'])) return implode("\n", $a); 
		return $a; 
	}
	
	protected function exportValuePage(Page $page, Field $field, Page $value, array $options = array()) {
		if(!$value->id) return array();
		// in human mode, just return the title or name
		if(!empty($options['human'])) {
			return (string) $value->get('title|name');
		}
		// otherwise return an array of info
		$a = array(); 
		if(!empty($options['system'])) {
			$a = $value->path;	
		} else {
			if($value->template && $value->template->fieldgroup->has('title')) {
				$a['title'] = (string) $value->getUnformatted('title');
			}
			$a['id'] = $value->id;
			$a['name'] = $value->name;
			$a['path'] = $value->path;
			$a['template'] = (string) $value->template;
			$a['parent_id'] = $value->parent_id;
		}
		return $a; 
	}
	
	/**
	 * Import value
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param array|string $value
	 * @param array $options
	 * @return PageArray
	 *
	 */
	public function ___importValue(Page $page, Field $field, $value, array $options = array()) {
		$pages = $this->wire()->pages;
		$pageArray = $pages->newPageArray();
		if(empty($value)) return $pageArray;
		if(is_string($value)) $value = array($value);
		foreach($value as $item) {
			if(is_array($item)) {
				$path = $item['path'];
			} else if(is_string($item)) {
				// system option
				$path = $item;
			} else {
				continue; 
			}
			$p = $pages->get($path);
			if(!$p->id) {
				$pageArray->error("Unable to find page '$path' to add to field '$field->name'");
			} else if(!$this->isValidPage($p, $field, $page)) {
				$reason = $page->get('_isValidPage');
				$warning = "Page '$p->path' is not allowed by field '$field->name' " . ($reason ? "($reason)" : "");
				$pageArray->error($warning);
			} else {
				$pageArray->add($p); 
			}
		}
		return $pageArray;
	}

	/**
	 * Get associative array of options (name => default) that Fieldtype supports for importValue
	 * 
	 * #pw-internal
	 *
	 * @param Field $field
	 * @return array
	 *
	 */
	public function getImportValueOptions(Field $field) {
		$options = parent::getImportValueOptions($field);
		$options['test'] = true;
		return $options;
	}

	/**
	 * Format the given value for output. 
	 *
	 * In this case, we remove non-listable (unpublished) pages when necessary.
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param string|int|WireArray|object $value
	 * @return string
	 *
	 */
	public function ___formatValue(Page $page, Field $field, $value) {

		// remove unpublished pages for front-end formatted output
		if($value instanceof Page) {
			if($value->hasStatus(Page::statusUnpublished)) {
				$value = $this->getBlankValue($page, $field);
			}
		} else if($value instanceof PageArray && $field->get('allowUnpub')) {
			// unpublished pages are allowed, so if any are present, create new
			// formatted value that excludes the unpublished pages 
			$hasUnpublished = false;
			foreach($value as $item) {
				$hasUnpublished = $item->hasStatus(Page::statusUnpublished);
				if($hasUnpublished) break;
			}
			if($hasUnpublished) {
				$items = $this->getBlankValue($page, $field); 
				$items->setTrackChanges(false);
				foreach($value as $item) {
					if(!$item->hasStatus(Page::statusUnpublished)) $items->add($item); 	
				}
				$items->resetTrackChanges(true);
				$value = $items;
			}
		}

		return $value;
	}

	/**
	 * Render markup value for field
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param null $value
	 * @param string $property
	 * @return MarkupFieldtype|string
	 * 
	 */
	public function ___markupValue(Page $page, Field $field, $value = null, $property = '') {
		$labelFieldName = $field->get('labelFieldName');
		$labelFieldFormat = $field->get('labelFieldFormat');
		if(is_null($value)) $value = $page->getFormatted($field->name);
		if($property === '.') $property = '';
		if(empty($property) && $labelFieldName) {
			if($labelFieldName === '.') {
				if(strlen($labelFieldFormat)) $property = $labelFieldFormat;
					else $property = 'name';
			} else {
				$property = $labelFieldName;
			}
		} 
		return parent::___markupValue($page, $field, $value, $property);
	}


	/**
	 * Return either a blank Page or a blank PageArray or boolean false (depending on derefAsPage setting)
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @return Page|PageArray|bool
	 *
	 */
	public function getBlankValue(Page $page, Field $field) {
		
		$derefAsPage = $field->get('derefAsPage');

		if($derefAsPage == FieldtypePage::derefAsPageOrFalse) {
			// single page possible blank values
			return false;

		} else if($derefAsPage == FieldtypePage::derefAsPageOrNullPage) {
			// single page possible blank values
			return $this->wire()->pages->newNullPage(); 
			
		} else {
			// multi page blank value (FieldtypePage::derefAsPageArray)
			$pageArray = $this->wire()->pages->newPageArray();
			$pageArray->setTrackChanges(true);
			return $pageArray;
		}
	}
	
	/**
	 * Given a string value return either a Page or PageArray
	 *	
	 * @param Page $page
	 * @param Field $field
	 * @param string $value
	 * @return Page|PageArray
	 *
	 */
	protected function sanitizeValueString(Page $page, Field $field, $value) {

		$pages = $this->wire()->pages;
		$sanitizer = $this->wire()->sanitizer;
		$result = false;
		$parent_id = $field->get('parent_id');

		if(Selectors::stringHasOperator($value)) {
			// selector string
			$selector = $value; 
			/** @var InputfieldPage $inputfield */
			$inputfield = $field->getInputfield($page); 
			/** @var PageArray $selectablePages */
			$selectablePages = $inputfield->getSelectablePages($page); 
			$result = $selectablePages->filter($selector); 

		} else if(ctype_digit("$value")) {
			// page ID
			$result = $pages->get("id=" . $value); 
			
		} else if(strpos($value, '-') === 0 && ctype_digit(ltrim($value, '-'))) {
			// page ID to remove from value
			$result = $pages->get("id=" . ltrim($value, '-')); 
			$result->set('_FieldtypePage_remove', $result->id); 
			
		} else if(strpos($value, '|') !== false && ctype_digit(str_replace('|', '', $value))) {
			// CSV string separated by '|' characters
			$result = $pages->getById(explode('|', $value)); 	

		} else if(strlen($value) && $value[0] == '/') {
			// path to page
			$result = $pages->get($value);

		} else if(strpos($value, "\n") !== false || strpos($value, '|') !== false) {
			// multiple references in a newline or pipe separated string
			$values = str_replace(array("\r\n", "\r", "|"), "\n", $value);
			// string has been normalized to newline separated only
			$values = explode("\n", $values); 
			foreach($values as $str) {
				$v = $this->sanitizeValueString($page, $field, trim($str)); // recursive
				if($v && $v->id) {
					if(!$result) $result = $pages->newPageArray();
					$result->add($v); 
				}
			}
			
		} else if($parent_id) {
			// set by title
			$value = trim($value); 
			$parentIDs = is_array($parent_id) ? implode('|', $parent_id) : $parent_id;
			// find by title
			$pageTitle = $sanitizer->selectorValue($value);
			$result = $pages->get("parent_id=$parentIDs, title=$pageTitle"); 
			// if cannot find by title, find by name
			if(!$result->id) {
				$pageName = $sanitizer->selectorValue($sanitizer->pageNameUTF8($value));
				$result = $pages->get("parent_id=$parentIDs, name=$pageName"); 
			}
			if(!$result->id && $field->get('_sanitizeValueString') === 'create' && $field->get('template_id')) {
				// option to create page if it does not already exist (useful for imports)
				// to use this, you must $field->set('_sanitizeValueString', 'create'); ahead of time
				$template = $this->wire()->templates->get((int) $field->get('template_id'));
				$parent = $pages->get((int) $parent_id); 
				if($template && $parent->id) {
					$result = $pages->newPage($template);
					$result->parent = $parent; 
					$result->title = $value; 
					$result->name = $sanitizer->pageNameUTF8($value); 
					$result->save(array('adjustName' => true));
				}
			}
		// here	
		} else {
			$template_ids = self::getTemplateIDs($field, true);
			if(!empty($template_ids)) {
				// set by title 
				$result = $pages->get("templates_id=$template_ids, title=" . $sanitizer->selectorValue($value));
				// set by name
				if(!$result->id) $result = $pages->get("templates_id=$template_ids, name=" .
					$sanitizer->selectorValue($sanitizer->pageNameUTF8($value)));
			}
		}
		
		if(!$result && $this->wire()->config->debug) {
			$this->warning("Unable to locate page match for: $value"); 
		}

		return $result; 
	}

	/**
	 * Given a value of unknown type, return a Page or PageArray (depending on $field->derefAsPage setting)
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param Page|PageArray|string|int $value
	 * @return Page|PageArray|bool Returns false if value can't be converted to the proper object type.
	 *
	 */
	public function sanitizeValue(Page $page, Field $field, $value) {
		if($field->get('derefAsPage') > 0) {
			// Page
			$value = $this->sanitizeValuePage($page, $field, $value); 
		} else {
			// PageArray
			$value = $this->sanitizeValuePageArray($page, $field, $value); 
		}
		return $value; 
	}
	
	/**
	 * Handles the sanitization of values when target is a single Page
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param Page|PageArray
	 * @return Page|bool|null
	 *
	 */
	protected function sanitizeValuePage(Page $page, Field $field, $value) {

		if(!$value) return $this->getBlankValue($page, $field); 
		
		if($value instanceof Page) {
			// ok
		} else if($value instanceof PageArray) {
			$value = $value->first();
		} else if(is_string($value) || is_int($value)) {
			$value = $this->sanitizeValueString($page, $field, $value); 
			if($value instanceof PageArray) $value = $value->first();
			/** @var Page $value */
			if(is_object($value) && $value->get('_FieldtypePage_remove') === $value->id) {
				$value->__unset('_FieldtypePage_remove'); 
				$value = null; // remove item 
			}
		}
		
		$value = (($value instanceof Page) && $value->id) ? $value : $this->getBlankValue($page, $field); 
		
		if($value && $value->id && $page->id == $value->id) {
			$value = $this->getBlankValue($page, $field); // prevent circular references
		}
		
		return $value; 
	}

	/**
	 * Validate that that $value is a valid Page for this field
	 * 
	 * @param Page $value The value to validate
	 * @param Field $field The field the value is for
	 * @param Page $forPage The page the value will exist on 
	 * @param bool $throwException Whether to throw an exception when not valid (default=false)
	 * @throws WireException
	 * @return bool
	 *
	 */
	public function isValidPage(Page $value, Field $field, Page $forPage, $throwException = false) {
	
		if(InputfieldPage::isValidPage($value, $field, $forPage)) {
			$valid = true;
		} else {
			$n = 0;
			while(wireInstanceOf($forPage, 'RepeaterPage') && ++$n < 10) {
				/** @var RepeaterPage $forPage */
				$forPage = $forPage->getForPage();
			}
			if($n && InputfieldPage::isValidPage($value, $field, $forPage)) {
				$valid = true; 
			} else {
				$valid = false;
				$reason = $forPage->get("_isValidPage");
				if($throwException) throw new WireException("Page $value is not valid for $field->name ($reason)");
			}
		}
		
		return $valid;
	}

	/**
	 * Handles the sanitization of values when target is a PageArray and $value is undetermined
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param Page|PageArray $value
	 * @return PageArray
	 *
	 */
	protected function sanitizeValuePageArray(Page $page, Field $field, $value) {

		// if they are setting it to a PageArray, then we'll take it
		if($value instanceof PageArray) {
			// double check there aren't circular references
			foreach($value as $v) if($v->id == $page->id) $value->remove($v); 
			return $value;
		} 
		
		// otherwise, lets get the current value so we can add to it or return it
		$pageArray = $page->get($field->name); 

		// if no value was provided, then return the existing value already in the page
		if(!$value) return $pageArray; 

		// if it's a string, see if we can convert it to a Page or PageArray
		if(is_string($value)) $value = $this->sanitizeValueString($page, $field, $value); 

		// if it's a Page, and not  NullPage, add it to the existing PageArray
		if($value instanceof Page) {
			if($value->get('_FieldtypePage_remove') === $value->id) {
				$value->__unset('_FieldtypePage_remove'); 
				return $pageArray->remove($value); // remove
			}
			if($value->id && $value->id != $page->id) $pageArray->add($value); 
			return $pageArray;
		}

		// if it's a new PageArray, combine it with the existing PageArray
		if($value instanceof PageArray) {
			foreach($value as $pg) {
				if(!$pg->id || $pg->id == $page->id) continue; 
				$pageArray->add($pg); 
			}
			return $pageArray; 
		}
		
		$pages = $this->wire()->pages;
		$inputfield = null;

		// value is an int or array of int|string|Page, load to pages, add to $pageArray
		if(!is_array($value)) $value = array($value); 
		foreach($value as $v) {
			if($v instanceof Page) {
				// Page object
				if($v->id == $page->id) continue;
				$pg = $v; 
			} else if(is_int($v) || ctype_digit("$v")) {
				// integer page ID
				if($v == $page->id) continue;
				$pg = $pages->get($v);
			} else if(is_string($v)) {
				// path or selector string
				if(strpos($v, '/') === 0 && $this->wire()->sanitizer->pagePathName($v) === $v) {
					$pg = $pages->get($v);
				} else if(Selectors::stringHasSelector($v)) {
					$pg = $pages->get($v);
				} else {
					$pg = null;
				}
				if(!$pg || !$pg->id) {
					if(!$inputfield) $inputfield = $this->getInputfield($page, $field); 
					$pgs = $inputfield->getSelectablePages($page, 'name|title=' . $this->wire()->sanitizer->selectorValue($v));
					if(count($pgs)) $pg = $pgs->first();
				}
			} else {
				// unrecognized type: can't make a page object from it
				continue; 
			}
			if($pg->id && $pg->id != $page->id) $pageArray->add($pg);
		}

		return $pageArray; 
	}
	
	/**
	 * Return the query used for loading all parts of the data from this field.
	 *
	 * #pw-group-loading
	 *
	 * @param Field|PageField $field
	 * @param DatabaseQuerySelect $query
	 * @return DatabaseQuerySelect
	 * @throws WireException
	 *
	 */
	public function getLoadQuery(Field $field, DatabaseQuerySelect $query) {
		
		$query = parent::getLoadQuery($field, $query);
		$filters = $query->data('_filters');  /** @var Selectors $filters */
	
		// this method implementation only applies if filters are in use
		if(!$filters || !count($filters)) return $query;
		
		$hasInclude = false;
		$hasTemplate = false;
		$hasParent = false;
		$hasLimit = false;
		
		foreach($filters as $selector) {
			$f = $selector->field();
			if($f === 'limit') {
				$hasLimit = $selector->value();
				$filters->remove($selector);
			} else if($f === 'start') {
				$filters->remove($selector);
			} else if($f === 'include') {
				$hasInclude = $selector->value();
			} else if($f === 'template' || $f === 'templates_id') {
				$hasTemplate = $selector->value();
			} else if($f === 'parent' || $f === 'parent_id') {
				$hasParent = $selector->value();
			}
		}
		
		if($hasLimit && !$field->allowUnpub) {
			$fieldTable = $field->getTable();
			$pagesTable = 'pages_' . $fieldTable;
			$unpublished = Page::statusUnpublished;
			$query->join("pages AS $pagesTable ON $pagesTable.id=$fieldTable.data AND $pagesTable.status<$unpublished");
			// $query->where("$pagesTable.status<$unpublished"); 
		}
	
		if(!count($filters)) {
			// if removal of start/limit filters resulted in no other filters, exit now
			return $query;
		}
		
		if((!$hasTemplate || !$hasParent) && $field instanceof PageField) {
			// use configured template and parent data to better narrow in on filtered results
			$data = $field->getTemplateAndParentIds();
			if(!$hasTemplate) {
				$templateIds = $data['templateIds'];
				if(count($templateIds)) $filters->add(new SelectorEqual('templates_id', $templateIds));
			}
			if(!$hasParent) {
				$parentIds = $data['parentIds'];
				if(count($parentIds)) $filters->add(new SelectorEqual('parent_id', $parentIds));
			}
		}
	
		if(!$hasInclude) {
			if($field->get('allowUnpub')) {
				$filters->add(new SelectorEqual('include', 'unpublished'));
			} else {
				$filters->add(new SelectorEqual('include', 'hidden'));
			}
		}
	
		$pageIds = $this->wire()->pages->findIDs($filters);
		
		if(count($pageIds)) {
			$table = $this->wire()->database->escapeTable($field->table);
			$query->where("$table.data IN(" . implode(',', $pageIds) . ")"); 
		} else {
			$query->where('1>2'); 
		}
		
		return $query;
	}

	/**
	 * Apply a where condition to a load query (used by getLoadQuery method)
	 *
	 * @param Field $field
	 * @param DatabaseQuerySelect $query
	 * @param string $col The column name
	 * @param string $operator The comparison operator
	 * @param mixed $value The value to find
	 * @return DatabaseQuery $query
	 *
	 */
	protected function getLoadQueryWhere(Field $field, DatabaseQuerySelect $query, $col, $operator, $value) {
		// cancel the default behavior since Page fields filter from getLoadQuery() instead
		return $query;
	}

	/**
	 * Return the query used for Autojoining this field
	 *
	 * @param Field $field
	 * @param DatabaseQuerySelect $query
	 * @return DatabaseQuerySelect|null
	 *
	 */
	public function getLoadQueryAutojoin(Field $field, DatabaseQuerySelect $query) {
		$field->setQuietly('distinctAutojoin', true);
		return parent::getLoadQueryAutojoin($field, $query);
	}

	/**
	 * Update a DatabaseQuerySelect object to match a Page
	 *
	 * @param DatabaseQuerySelect|PageFinderDatabaseQuerySelect $query
	 * @param string $table
	 * @param string $subfield
	 * @param string $operator
	 * @param string $value
	 * @return DatabaseQuery
	 * @throws WireException
	 *
	 */
	public function getMatchQuery($query, $table, $subfield, $operator, $value) {

		$names = array(
			'id',
			'data',
			'pages_id',
			'path',
			'url',
			'sort',
		);

		$database = $this->wire()->database;
		$pages = $this->wire()->pages;
		
		// when $idstr is true, indicates $value is a multi-value CSV ID string (converts to boolean once known)
		$idstr = null; 

		// if subfield is 'data' (meaning no subfield specified) and it's in the format of 'some-string', 
		// then we assume this to be a page name
		if($subfield == 'data' 
			&& !ctype_digit("$value") 
			&& strlen($value) 
			&& strpos($value, '/') === false
			&& strpos($value, 'page.') !== 0) {
			
			$idstr = strpos($value, ',') !== false && ctype_digit(str_replace(',', '', $value));
			if(!$idstr) $subfield = 'name';
		}

		// let the FieldtypeMulti base class handle count queries
		if($subfield == 'count') {
			
			return parent::getMatchQuery($query, $table, $subfield, $operator, $value);

		} else if(in_array($subfield, $names) || ($subfield == 'name' && $operator == '!=')) { 

			if(!$database->isOperator($operator)) {
				throw new WireException("Operator '$operator' is not implemented in {$this->className}");
			}
			
			if($subfield == 'name') {
				$value = $this->wire()->sanitizer->pageName($value);
				$value = implode(',', $pages->findIDs("name=$value, include=all"));
				if(empty($value)) $value = "0";
				$idstr = true;
			}
			
			if(in_array($subfield, array('id', 'path', 'url', 'name'))) $subfield = 'data';
		
			// if a page path rather than page ID was provided, then we translate the path to an ID for API syntax convenience
			if($idstr || !ctype_digit("$value")) {
				if(is_null($idstr) && $subfield == 'data' && strpos($value, ',') !== false) {
					$idstr = ctype_digit(str_replace(',', '', $value));
				}
				if($subfield == 'data' && $idstr) {
					// CSV string of page IDs
					$value = trim($value, ",");
					$idstr = true; 
				} else if(substr(trim($value), 0, 1) == '/') {
					// path from root
					$v = $pages->get($value); 
					if($v && $v->id) $value = $v->id; 
				}
			}

			$subfield = $database->escapeCol($subfield);

			if($operator == '!=') {
				$t = $database->escapeTable($query->field->getTable());
				$where = "SELECT COUNT(*) FROM $t WHERE $t.pages_id=pages.id ";
				if($idstr) {
					$value = explode(',', $value); 
					$value = array_map('intval', $value); 
					$value = implode(',', $value);
					$where .= "AND $t.$subfield IN($value)"; 
				} else {
					$bindKey = $query->bindValueGetKey($value);
					$where .= "AND $t.$subfield=$bindKey";
				}
				$query->where("($where)=0");
			} else if($operator == '=' && $idstr) {
				$value = explode(',', $value);
				$value = array_map('intval', $value);
				$value = implode(',', $value);
				$query->where("($table.{$subfield} IN($value))"); 
			} else {
				$bindKey = $query->bindValueGetKey($value);
				$query->where("($table.{$subfield}{$operator}$bindKey)"); 
			}

		} else if($this->getMatchQueryNative($query, $table, $subfield, $operator, $value)) {
			// great

		} else if($this->getMatchQueryCustom($query, $table, $subfield, $operator, $value)) {
			// wonderful as well

		} else {
			// we were unable to determine what subfield is
			throw new WireException("Unknown subfield: $subfield"); 
		}

		return $query; 
	}

	/**
	 * Update a query object to match a Page with a subfield native to pages table
	 *
	 * @param DatabaseQuerySelect $query
	 * @param string $table
	 * @param string $subfield
	 * @param string $operator
	 * @param string $value
	 * @return bool True if used, false if not
	 *
	 */
	protected function getMatchQueryNative($query, $table, $subfield, $operator, $value) {

		$database = $this->wire()->database;

		$key = array_search($subfield, $this->nativeNames);
		if($key === false) return false;
		$subfield = $this->nativeNames[$key];

		// we let the custom field query matcher handle the '!=' scenario
		if(!$database->isOperator($operator)) {
			return $this->getMatchQueryCustom($query, $table, $subfield, $operator, $value);
		}

		if($subfield == 'created' || $subfield == 'modified' || $subfield == 'published') {
			if(!ctype_digit($value)) $value = strtotime($value); 
			$value = (int) $value;
			$value = date('Y-m-d H:i:s', $value); 

		} else if(in_array($subfield, array('template', 'templates_id'))) {
			$template = $this->wire()->templates->get($value); 
			$value = $template ? $template->id : 0; 
			$subfield = 'templates_id';
			
		} else if(in_array($subfield, array('parent', 'parent_id'))) {
			if(!ctype_digit("$value")) $value = $this->wire()->pages->get($value)->id; 
			$subfield = 'parent_id';	
			
		} else if($subfield == 'status') {
			$statuses = Page::getStatuses();
			if(ctype_digit("$value")) {
				$value = (int) $value; 
			} else if(isset($statuses[$value])) {
				$value = $statuses[$value]; 
			} else $value = 0;
		
		} else if($subfield == 'name') {
			$value = $this->wire()->sanitizer->pageName($value, Sanitizer::toAscii); 

		} else {
			$value = (int) $value;
		}

		$values = $query->selector->values();
		
		if(count($values) > 1 && $operator === '=') {
			// we are in an OR match among multiple values
			// make sure they all exist before sending to match query
			// which performs regular joins (not left joins)
			$valids = $this->getMatchQueryNativeValidValues($subfield, $values);
			if(!count($valids)) {
				// none of the values in the OR are possible matches
				// let it pass through to query, as query will fail as it should
			} else if(count($valids) === count($values)) {
				// all values are valid
			} else if(!in_array($value, $valids)) {
				// value not valid and should be ignored since others are valid
				return true;
			}
		}
		
		static $n = 0;
		$table = $database->escapeTable($table);
		$table2 = "_fieldtypepage" . (++$n);
		$subfield = $database->escapeCol($subfield);
		$bindKey = $query->bindValueGetKey($value);
		$query->join("pages AS $table2 ON $table2.$subfield$operator$bindKey");
		$query->where("($table.data=$table2.id)");

		return true; 
	}

	/**
	 * Get values for $subfield that match those of given $values
	 * 
	 * @param string $subfield
	 * @param array $values
	 * @return array
	 * @since 3.0.220
	 * 
	 */
	protected function getMatchQueryNativeValidValues($subfield, array $values) {
		
		$cacheKey = implode('|', $values);
		
		if(isset($this->caches[__FUNCTION__])) {
			// cache is used to prevent back-to-back duplicate queries
			$a = $this->caches[__FUNCTION__];
			if($a[0] === $cacheKey) return $a[1];
		}
		
		$wheres = array();
		$binds = array();
		
		foreach(array_values($values) as $i => $v) {
			$wheres[] = "$subfield=:value$i";
			$binds[":value$i"] = $v;
		}
		
		$where = implode(' OR ', $wheres);
		$sql = "SELECT id, $subfield FROM pages WHERE $where GROUP BY $subfield";
		$query = $this->wire()->database->prepare($sql);
		
		foreach($binds as $bindKey => $bindValue) {
			$query->bindValue($bindKey, $bindValue);
		}
		
		$query->execute();
		$valids = array();
		
		while($row = $query->fetch(\PDO::FETCH_NUM)) {
			$id = (int) $row[0];
			$valids[$id] = $row[1];
		}
		
		$query->closeCursor();
		
		$this->caches[__FUNCTION__] = array($cacheKey, $valids);
		
		return $valids; 
	}

	/**
	 * Update a DatabaseSelectQuery object to match a Page containing a matching custom subfield
	 * 
	 * Note: $field->findPagesCode is not implemented and thus not supported here. 
	 *
	 * @param DatabaseQuerySelect|PageFinderDatabaseQuerySelect $query
	 * @param string $table
	 * @param string $subfield
	 * @param string $operator
	 * @param string $value
	 * @return bool true if used, false if not
	 * @throws WireException if selector not supported
	 *
	 */
	protected function getMatchQueryCustom($query, $table, $subfield, $operator, $value) {
		
		$database = $this->wire()->database;
		$field = $query->field;
		$group = $query->group;
		$table = $database->escapeTable($table);
		$pageFinder = $this->wire(new PageFinder());
		$pageFinderOptions = array('getTotal' => false);
		$value = $this->wire()->sanitizer->selectorValue($value);
		$findPagesSelector = $field->get('findPagesSelector');
		if(empty($findPagesSelector)) $findPagesSelector = $field->get('findPagesSelect');
		$parent_id = $field->get('parent_id');
		$template_ids = self::getTemplateIDs($field, true);
		$selector = ''; 
		
		if($query->pageFinder) {
			$pf = $query->pageFinder;
			$pfo = $pf->getOptions();
			if(empty($pfo['findAll']) === true) {
				$selector = "include=all, $selector";
			} else if($pf->includeMode) {
				$selector = "include=$pf->includeMode, $selector";
			} else {
				$selector = "include=hidden, $selector";
			}
			if($pf->checkAccess === false) {
				$selector = "check_access=0, $selector";
			}
		} else {
			$selector = "include=hidden, $selector";
		}

		if(in_array($subfield, $this->nativeNames)) {
			// fine then, we can handle that here when needed (like !=)
		} else {
			$subfield = $this->wire()->fields->get($subfield);
			if(!$subfield) return false; // not a custom field
			$subfield = $subfield->name; 
		}

		if($findPagesSelector) {
			// remove the existing include=hidden|all|unpublished, only if selector specifies a different include=something
			if(strpos($findPagesSelector, 'include=') !== false) {
				$selector = preg_replace('/include=[a-z]+,/', '', $selector);
			}
			$selector .= $findPagesSelector . ", ";
			if(strpos($selector, 'page.') !== false) {
				// remove any page.property from selector as it won't be applicable during a getMatchQuery
				$selector = preg_replace('/\bpage\.[a-zA-Z0-9_]+\s*[=!]+\s*[^,]*,?/', '', $selector);
				$selector = preg_replace('/[a-zA-Z0-9_.]+\s*[=!]+\s*page\.[a-zA-Z0-9_.]+\s*,?/', '', $selector);
			}
		}
		
		if($parent_id) $selector .= "parent_id=$parent_id, ";
		if($template_ids) $selector .= "templates_id=$template_ids, ";

		if(!is_null($group)) {
			// combine with other selectors sharing the same group so that both must match in our subquery below
			foreach($query->selectors as $item) {
				if($item->group !== $group) continue; 
				if($item === $query->selector) continue; 
				$itemField = $item->field; 
				if(is_array($itemField)) {
					if(count($itemField) > 1) throw new WireException("Selector not supported"); 
					$itemField = reset($itemField); 
				}
				/** @noinspection PhpUnusedLocalVariableInspection */
				list($itemField, $itemSubfield) = explode('.', $itemField); 
				if($itemField != $field->name) continue; // only group the same fields together in one selector query
				if(!preg_match('/^' . $group . '@' . $field->name . '\.(([_a-zA-Z0-9]+).*)$/', (string) $item, $matches)) continue; 
				// extract the field name portion so we just get the subfield and rest of the selector
				$selector .= "$matches[1], ";
			}
		}
		
		if($operator == '!=') {

			$selector .= "$subfield=$value, ";
			$matches = $pageFinder->find(new Selectors(trim($selector, ', ')), $pageFinderOptions);
			if(count($matches)) { 
				$ids = array();
				foreach($matches as $match) $ids[$match['id']] = (int) $match['id'];
				static $xcnt = 0; 
				$fieldTable = $database->escapeTable($field->table); 
				$t = 'x_' . $fieldTable . (++$xcnt);
				$query->leftjoin("$fieldTable AS $t ON $t.pages_id=pages.id AND $t.data IN(" . implode(',', $ids) . ")"); 
				$query->parentQuery->where("$t.data IS NULL"); 
			}

		} else {

			$selector .= "{$subfield}$operator$value, ";
			$selectors = $this->wire(new Selectors(trim($selector, ', ')));
			$matches = $pageFinder->find($selectors, $pageFinderOptions);

			// use the IDs found from the separate find() as our getMatchQuery
			if(count($matches)) {
				$ids = array();
				foreach($matches as $match) {
					$ids[$match['id']] = (int) $match['id'];
				}
				$query->where("$table.data IN(" . implode(',', $ids) . ")");
			} else {
				$query->where("1>2"); // forced non-match
			}

		}

		return true;
	}

	/**
	 * Return whether the given value is considered empty or not.
	 *
	 * @param Field $field
	 * @param mixed|Selector $value
	 * @return bool
	 *
	 */
	public function isEmptyValue(Field $field, $value) {
		if($value instanceof Selector && $value->field === $field->name) {
			// PageFinder is asking if we want to handle this field/value match internally
			if($value->operator === '!=') {
				$v = $value->value;
				if(is_array($v)) $v = implode('', $v);
				if(!empty("$v") && !ctype_digit("$v")) {
					// if a page name (rather than id) is specified with a `!=` operator
					// then tell PageFinder that we handle this unique match condition internally
					// since the page name has to be converted to page ID by getMatchQuery()
					return true;
				}
			}
		}
		return parent::isEmptyValue($field, $value);
	}
	
	/**
	 * Return the database schema in predefined format 
	 * 
	 * @param Field $field
	 * @return array
	 *
	 */
	public function getDatabaseSchema(Field $field) {
		$schema = parent::getDatabaseSchema($field); 
		$schema['data'] = 'int NOT NULL';
		$schema['keys']['data'] = 'KEY data (data, pages_id, sort)'; 
		return $schema;
	}

	/**
	 * Return array with information about what properties and operators can be used with this field
	 * 
	 * @param Field $field
	 * @param array $data
	 * @return array
	 *
	 */
	public function ___getSelectorInfo(Field $field, array $data = array()) {
		
		$pages = $this->wire()->pages;
		$templates = $this->wire()->templates;

		$info = parent::___getSelectorInfo($field, $data); 
		if(!isset($data['level'])) $data['level'] = 0;
		$info['input'] = 'page';
		if($data['level'] > 0) return $info;

		$subfields = array();
		$fieldgroups = array();
		$template_ids = self::getTemplateIDs($field);
		$parent_id = $field->get('parent_id');
		$templateOptions = array();

		if($template_ids) {
			// determine fieldgroup(s) from template setting
			// template_id can be int or array of ints
			foreach($template_ids as $tid) {
				$template = $templates->get((int) $tid);
				if(!$template) continue;
				$fieldgroups[] = $template->fieldgroup; 
				$templateOptions[$template->id] = $template->getLabel();
			}

		} else if($parent_id) {
			// determine fieldgroup(s) from family settings
			$parent = $pages->get((int) $parent_id); 
			if($parent->id) {
				foreach($parent->template->childTemplates as $template_id) {
					$template = $templates->get((int) $template_id); 		
					if(!$template) continue; 
					$fieldgroups[$template->fieldgroup->id] = $template->fieldgroup;
					$templateOptions[$template->id] = $template->getLabel();
				}
	
				foreach($templates as $template) {
					if(!in_array($parent->template->id, $template->parentTemplates)) continue; 
					if(!$pages->count("parent=$parent_id, template=$template->id, include=all")) continue; 
					$fieldgroups[$template->fieldgroup->id] = $template->fieldgroup;
					$templateOptions[$template->id] = $template->getLabel();
				}
			}
		}

		if(!count($fieldgroups)) {
			// if no fieldgorups found, then we have no choice but to use all fields
			//$fieldgroups[] = $this->wire('fields'); 
		}
		
		if(!count($templateOptions)) {
			foreach($templates as $template) {
				$templateOptions[$template->id] = $template->getLabel();
			}
		}

		foreach($fieldgroups as $fieldgroup) {
			foreach($fieldgroup as $f) {
				if(!$f->type) continue; 
				if(strpos("$f->type", "FieldtypeFieldset") === 0) continue; 
				if(isset($subfields[$f->name])) continue; 
				$subfields[$f->name] = $f->type->getSelectorInfo($f, array('level' => $data['level']+1)); 
			}
		}
	
		$subfields['count'] = array(
			'name' => 'count', 
			'label' => $this->_('Count'), // Label for 'count' property of a PageArray
			'input' => 'number', 
		);
		
		$subfields['templates_id'] = array(
			'name' => 'templates_id',
			'label' => $this->_('Template'), // Label for 'template' property of a Page
			'input' => 'select',
			'options' => $templateOptions,
		);

		$info['subfields'] = $subfields;

		return $info; 
	}

	/**
	 * Return configuration fields definable for each FieldtypePage
	 * 
	 * @param Field $field
	 * @return InputfieldWrapper
	 *
	 */
	public function ___getConfigInputfields(Field $field) {

		$modules = $this->wire()->modules;
		$sanitizer = $this->wire()->sanitizer;
		$inputfields = parent::___getConfigInputfields($field);
		
		$labels = array(
			'PageArray' => $this->_('Multiple pages (PageArray)'),
			'PageOrFalse' => $this->_('Single page (Page) or boolean false when none selected'),
			'PageOrNullPage' => $this->_('Single page (Page) or empty page (NullPage) when none selected'),
		);
		
		$_labels = $labels;
		$url = "https://processwire.com/api/ref";
		$aPageArray = "[PageArray]($url/page-array/)";
		$aPage = "[Page]($url/page/)";
		$aNullPage = "[NullPage]($url/null-page/)";

		foreach($labels as $key => $value) {
			$labels[$key] = str_replace(
				array('(PageArray)', '(Page)', '(NullPage)'), 
				array("$aPageArray", "$aPage", "$aNullPage"), 
				$value
			);
		}
		
		/** @var InputfieldRadios $select */
		$select = $modules->get("InputfieldRadios"); 
		$select->attr('name', 'derefAsPage'); 
		$select->label = $this->_('Page field value type');
		$select->description = $this->_('If your field will contain multiple pages, then you should select the first option (PageArray). If your field only needs to contain a single page, then select one of the single Page options (if you are not sure which, select the last option).'); // Long description for: dereference in API
		$select->addOption(FieldtypePage::derefAsPageArray, $labels['PageArray']); 
		$select->addOption(FieldtypePage::derefAsPageOrFalse, $labels['PageOrFalse']); 
		$select->addOption(FieldtypePage::derefAsPageOrNullPage, $labels['PageOrNullPage']); 
		$select->attr('value', (int) $field->get('derefAsPage'));  
		$select->icon = 'tasks';
		$inputfields->append($select);

		/** @var InputfieldFieldset $exampleFieldset */
		$exampleFieldset = $modules->get('InputfieldFieldset');
		$exampleFieldset->attr('name', '_examplesFieldset');
		$exampleFieldset->label = $this->_('API usage examples');
		$exampleFieldset->icon = 'code';
		$inputfields->add($exampleFieldset);

		/** @var InputfieldMarkup $f */
		$f = $modules->get('InputfieldMarkup');
		$f->attr('name', '_examplePageArray');
		$f->label = $_labels['PageArray'];
		$f->showIf = 'derefAsPage=' . FieldtypePage::derefAsPageArray;
		$f->icon = 'scissors';
		$f->value =
			"<pre class='language-php'><code>" . $sanitizer->entities(
			"foreach(\$page->{$field->name} as \$item) {" .
			"\n  echo \"<li><a href='\$item->url'>\$item->title</a></li>\";".
			"\n}" 
			) . "</code></pre>";
		$exampleFieldset->add($f);
		
		$alternateLabel = $this->_('Same as above with alternate syntax');
	
		/** @var InputfieldMarkup $f */
		$f = $modules->get('InputfieldMarkup');
		$f->attr('name', '_examplePageArray2');
		$f->label = $alternateLabel;
		$f->showIf = 'derefAsPage=' . FieldtypePage::derefAsPageArray;
		$f->icon = 'scissors';
		$f->value =
			"<pre class='language-php'><code>" . $sanitizer->entities(
			"echo \$page->{$field->name}->each(\n  \"<li><a href='{url}'>{title}</a></li>\"\n);"	
			) . "</code></pre>";
		$f->notes = sprintf($this->_('More about the %s method.'), "[each()]($url/page-array/each/)");
		$exampleFieldset->add($f);
		
		/** @var InputfieldMarkup $f */
		$f = $modules->get('InputfieldMarkup');
		$f->attr('name', '_examplePageOrFalse');
		$f->label = $_labels['PageOrFalse'];
		$f->showIf = 'derefAsPage=' . FieldtypePage::derefAsPageOrFalse;
		$f->icon = 'scissors';
		$f->value =
			"<pre class='language-php'><code>" . $sanitizer->entities(
			"if(\$page->{$field->name}) {" .
			"\n  echo \"<a href='{\$page->{$field->name}->url}'>{\$page->{$field->name}->title}</a>\";" .
			"\n}" 
			) . "</code></pre>";
		$exampleFieldset->add($f);
		
		/** @var InputfieldMarkup $f */
		$f = $modules->get('InputfieldMarkup');
		$f->attr('name', '_examplePageOrFalse2');
		$f->label = $alternateLabel;
		$f->showIf = 'derefAsPage=' . FieldtypePage::derefAsPageOrFalse;
		$f->icon = 'scissors';
		$f->value =
			"<pre class='language-php'><code>" . $sanitizer->entities(
			"if(\$page->{$field->name}) {" .
			"\n  echo \$page->{$field->name}(\"<a href='{url}'>{title}</a>\");" .
			"\n}"
			) . "</code></pre>";
		$exampleFieldset->add($f);
		
		/** @var InputfieldMarkup $f */
		$f = $modules->get('InputfieldMarkup');
		$f->attr('name', '_examplePageOrNullPage');
		$f->label = $_labels['PageOrNullPage'];
		$f->showIf = 'derefAsPage=' . FieldtypePage::derefAsPageOrNullPage;
		$f->icon = 'scissors';
		$f->value =
			"<pre class='language-php'><code>" . $sanitizer->entities(
			"if(\$page->{$field->name}->id) {" .
			"\n  echo \"<a href='{\$page->{$field->name}->url}'>{\$page->{$field->name}->title}</a>\";" .
			"\n}" 
			) . "</code></pre>";
		$exampleFieldset->add($f);
		
		/** @var InputfieldMarkup $f */
		$f = $modules->get('InputfieldMarkup');
		$f->attr('name', '_examplePageOrNullPage2');
		$f->label = $alternateLabel;
		$f->showIf = 'derefAsPage=' . FieldtypePage::derefAsPageOrNullPage;
		$f->icon = 'scissors';
		$f->value =
			"<pre class='language-php'><code>" . $sanitizer->entities(
				"if(\$page->{$field->name}->id) {" .
				"\n  echo \$page->{$field->name}(\"<a href='{url}'>{title}</a>\");" .
				"\n}"
			) . "</code></pre>";
		$exampleFieldset->add($f);
		
		/** @var InputfieldCheckbox $checkbox */
		$value = (int) $field->get('allowUnpub');
		$checkbox = $modules->get('InputfieldCheckbox');
		$checkbox->attr('name', 'allowUnpub');
		$checkbox->label = $this->_('Allow unpublished pages?');
		$checkbox->description = $this->_('When checked, unpublished pages will be selectable for input and allowed in the *unformatted* field value. They will still be excluded from the *formatted* field value either way.'); // Description for allowUnpub option
		$checkbox->attr('value', 1);
		$checkbox->attr('checked', $value ? 'checked' : '');
		$checkbox->icon = 'eye-slash';
		if(!$value) $checkbox->collapsed = Inputfield::collapsedYes;
		$inputfields->append($checkbox);

		return $inputfields; 
	}

	/**
	 * 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.
	 * 
	 * FieldtypePage: Note the exported config values here are actually from 
	 * InputfieldPage, but we're handling them in here rather than 
	 * InputfieldPage::exportConfigData() to increase the reusability of these
	 * conversions (parent_id and template_id) which are common conversions
	 * used by other Fieldtypes. 
	 *
	 * @param Field $field
	 * @param array $data
	 * @return array
	 *
	 */
	public function ___exportConfigData(Field $field, array $data) {
		$data = parent::___exportConfigData($field, $data); 
		if(!empty($data['parent_id']) && ctype_digit("$data[parent_id]")) {
			// convert parent ID to parent path
			$data['parent_id'] = $this->wire()->pages->get((int) $data['parent_id'])->path;
		}
		$templates = $this->wire()->templates;
		foreach(array('template_id', 'template_ids') as $key) {
			if(empty($data[$key])) continue;
			if(is_array($data[$key])) {
				// convert array of template ids to template names
				foreach($data[$key] as $k => $id) {
					if(ctype_digit("$id")) continue;
					$template = $templates->get((int) $id);
					if($template) $data[$key][$k] = $template->name;
				}
			} else if(ctype_digit((string) $data[$key])) {
				// convert template id to template name
				$template = $templates->get((int) $data[$key]);
				if($template) $data[$key] = $template->name;
			}
		}
		return $data;
	}

	/**
	 * Convert an array of exported data to a format that will be understood internally
	 * 
	 * FieldtypePage: Note the mported config values here are actually from
	 * InputfieldPage, but we're handling them in here rather than
	 * InputfieldPage::importConfigData() to increase the reusability of these
	 * conversions (parent_id and template_id) which are common conversions
	 * used by other Fieldtypes.
	 *
	 *
	 * @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) {
		$sanitizer = $this->wire()->sanitizer;
		$templates = $this->wire()->templates;
		$pages = $this->wire()->pages;
		$data = parent::___importConfigData($field, $data);
		// parent page
		if(!empty($data['parent_id']) && !ctype_digit("$data[parent_id]")) {
			// we have a page apth rather than id
			$id = $pages->get("path=" . $sanitizer->selectorValue($data['parent_id']))->id;
			if(!$id) $data['errors']['parent_id'] = $this->_('Unable to find page') . " - $data[parent_id].";
			$data['parent_id'] = $id;
		}
		// template
		foreach(array('template_id', 'template_ids') as $property) {
			if(empty($data[$property])) continue;
			// template_id can be an id or array of IDs, but we will be importing a template name or array of them
			$errors = array();
			$isArray = is_array($data[$property]);
			if(!$isArray) $data[$property] = array($data[$property]);
			foreach($data[$property] as $key => $name) {
				if(ctype_digit("$name")) continue;
				// we have a template name rather than id
				$template = $templates->get($sanitizer->name($name));
				if($template) {
					$data[$property][$key] = $template->id;
				} else {
					$errors[] = $this->_('Unable to find template') . " - $name.";
				}
			}
			if(!$isArray) $data[$property] = reset($data[$property]);
			if(count($errors)) $data['errors'][$property] = implode(" \n", $errors);
		}
		return $data;
	}

	/**
	 * Find and clean orphaned references in each of FieldtypePage's tables
	 *
	 * Previous versions of PW had an issue where a reference to a deleted page could still exist in some instances.
	 * This could cause "reference.count>0" type selectors to produce inaccurate results. This cleans up for that.
	 * This may also be handy if a Page reference table has become corrupted by some other means. 
 	 *	
	 *
	 */
	public function cleanOrphanedReferences() {

		$database = $this->wire()->database; 
		$totalCleaned = 0; 

		foreach($this->wire()->fields as $field) {

			if(!$field->type instanceof FieldtypePage) continue; 
			$table = $database->escapeTable($field->getTable()); 

			foreach(array('data', 'pages_id') as $key) {

				$sql = "SELECT $table.pages_id, $table.data FROM $table LEFT JOIN pages ON $table.$key=pages.id WHERE pages.id IS NULL";
				$query = $database->prepare($sql); 
				$query->execute();
				$numCleaned = 0;

				/** @noinspection PhpAssignmentInConditionInspection */
				while($row = $query->fetch(\PDO::FETCH_NUM)) {
					list($pages_id, $data) = $row; 
					$q = $database->prepare("DELETE FROM $table WHERE pages_id=:pages_id AND data=:data"); 
					$q->bindValue(':pages_id', $pages_id, \PDO::PARAM_INT); 
					$q->bindValue(':data', $data, \PDO::PARAM_INT); 
					$q->execute();
					$numCleaned++;
				}

				$totalCleaned += $numCleaned; 

				if($numCleaned) $this->message("Fixed $numCleaned orphaned '$key' references for field '$field->name'"); 
			}
		}

		if(!$totalCleaned) $this->message("No problems found"); 
			else $this->message("Found and fixed a total of $totalCleaned issues."); 
	}
	
	/**
	 * Return pages referencing the given $page, optionally indexed by field name
	 * 
	 * The default behavior when no arguments are provided (except $page) is to simply return a PageArray of all pages
	 * referencing the given one (excluding hidden, unpublished, trash, no-access, etc.). Specify "include=all"
	 * as your $selector if you want to include all pages without filtering. If the quantity may be large,
	 * you should also include a "limit=100" (replacing 100 with your limit). This method supports pagination
	 * when specifying a limit.
	 *
	 * @param Page $page Page to get references for
	 * @param string|bool $selector Optionally filter/modify returned result, i.e. "limit=10, include=all", etc.
	 *  Or boolean TRUE as shortcut for "include=all". 
	 * @param bool|Field|null $field Optionally specify Field to limit results to (default includes all fields of this type),
	 * 	Or boolean TRUE to return array indexed by field name.
	 * @param bool $getCount Specify true to get a count (int) rather than a PageArray (default=false)
	 * @return PageArray|array|int Returns one of the following, according to the provided arguments:
	 * 	- returns PageArray as default behavior, including when given a $selector string and/or Field object.
	 * 	- returns array of PageArray objects if $field argument is TRUE ($selector may be populated string or blank string).
	 *  - returns int if the count option (boolean true) specified for $selector.
	 *
	 */
	public function findReferences(Page $page, $selector = '', $field = false, $getCount = false) {

		$pages = $this->wire()->pages;
		$fields = $this->wire()->fields;
		$database = $this->wire()->database;
		
		// modifier option defaults
		$byField = false;
		$includeAll = $selector === true || $selector === "include=all";
		$findLimit = 200;
		$fieldName = '';

		// determine whether to use include=all
		if($selector === true) {
			$selector = "include=all";
		} else if(strlen($selector) && !$includeAll && strpos($selector, "include=all") !== false) {
			foreach(new Selectors($selector) as $s) {
				if($s->field() === 'include' && $s->value() === 'all') {
					$includeAll = true;
					break;
				}
			}
		}

		if(is_bool($field) || is_null($field)) {
			$byField = $field ? true : false;
		} else if(is_string($field)) {
			$fieldName = $this->wire()->sanitizer->fieldName($field);
		} else if(is_int($field)) {
			$field = $fields->get($field);
			if($field) $fieldName = $field->name;
		} else if($field instanceof Field) {
			$fieldName = $field->name;
		}

		// results
		$fieldNames = array(); // field names that point to $page, array of [ field_id => field_name ]
		$fieldCounts = array(); // counts indexed by field name (if count mode)
		$total = 0;
	
		// first determine which fields have references to $page
		foreach($fields as $field) {

			if($fieldName && $field->name != $fieldName) continue;
			if(!$field->type instanceof FieldtypePage) continue;

			$table = $field->getTable();
			$sql = "SELECT COUNT(*) FROM `$table` WHERE data=:id";
			$query = $database->prepare($sql);
			$query->bindValue(':id', $page->id, \PDO::PARAM_INT);
			$query->execute();

			$cnt = (int) $query->fetchColumn();
			if($cnt > 0) {
				$fieldNames[$field->id] = $field->name;
				$fieldCounts[$field->name] = $cnt;
				$total += $cnt;
			}

			$query->closeCursor();
		}

		// if they just asked for the count, then we have all that we need to finish now
		if($getCount && $includeAll) {
			// return count or array of counts
			return $byField ? $fieldCounts : $total;
		}

		// if there was nothing found, finish early
		if(!$total) {
			// no references found 
			if($getCount) return $total;
			return $byField ? array() : $pages->newPageArray();
		}
	
		// perform another find() to filter results, and return requested result type
		if($byField) {
			// return array of PageArrays indexed by fieldName
			$result = array();
			foreach($fieldNames as $fieldName) {
				$s = rtrim("$fieldName=$page->id, $selector", ', ');
				if($getCount) {
					$cnt = $pages->count($s);
					if($cnt) $result[$fieldName] = $cnt;
				} else {
					if($total > $findLimit) {
						$items = $pages->findMany($s);
					} else {
						$items = $pages->find($s);
					}
					if($items->count()) $result[$fieldName] = $items;
				}
			}
		} else {
			// return PageArray of all references
			$selector = rtrim(implode('|', $fieldNames) . "=$page->id, $selector", ', ');
			if($getCount) {
				$result = $pages->count($selector);
			} else if($total > $findLimit) {
				$result = $pages->findMany($selector);
			} else {
				$result = $pages->find($selector);
			}
		}

		return $result;
	}

	/**
	 * Return array or string of configured template IDs
	 * 
	 * Accounts for both template_id and template_ids settings, making sure both are included.
	 * 
	 * #pw-internal
	 *
	 * @param Field|array $field Field object or array with all possible template IDs
	 * @param bool $getString Specify true to return a 1|2|3 style string rather than an array
	 * @return array|string
	 *
	 */
	static public function getTemplateIDs($field, $getString = false) {
		
		$ids = array();
		$values = array();
		
		if($field instanceof Field) {
			$values = array($field->get('template_id'), $field->get('template_ids'));
		} else if(is_array($field)) {
			$values = $field;
		}
		
		foreach($values as $value) {
			if(empty($value)) continue;
			if(!is_array($value)) $value = array($value);
			foreach($value as $id) {
				$id = (int) $id;
				if($id) $ids[$id] = $id;
			}
		}

		return $getString ? implode('|', $ids) : array_values($ids);
	}

	/**
	 * Module configuration screen
	 * 
	 * @param array $data
	 * @return InputfieldWrapper
	 *
	 */
	public function getModuleConfigInputfields(array $data) {
		if($data) {}

		$inputfields = $this->wire(new InputfieldWrapper());
		/** @var InputfieldCheckbox $inputfield */
		$inputfield = $this->wire()->modules->get('InputfieldCheckbox'); 
		$inputfield->attr('name', '_clean'); 
		$inputfield->attr('value', 1); 
		$inputfield->label = $this->_('Find and clean orphaned page references'); 
		$inputfield->description = $this->_('This cleans up for an issue in older versions of ProcessWire that could leave orphaned page references for deleted pages. If you are getting inaccurate results from page finding operations (especially with selectors using pageref.count), then you may want to run this.'); // Find and clean description
		$inputfield->icon = 'eraser';
		$inputfield->collapsed = Inputfield::collapsedYes; 
		$inputfield->notes = $this->_('Warning: To be safe you should back-up your database before running this.'); // Find and clean notes
		$inputfields->add($inputfield); 

		if($this->wire()->input->post('_clean')) {
			$this->message($this->_('Finding and cleaning...')); 
			$this->cleanOrphanedReferences();
		}

		return $inputfields;
	}

}

require_once(__DIR__ . '/PageField.php');
