<?php namespace ProcessWire;

/**
 * ProcessWire Page Traversal
 *
 * Provides implementation for Page traversal functions.
 * Based upon the jQuery traversal functions. 
 *
 * ProcessWire 3.x, Copyright 2022 by Ryan Cramer
 * https://processwire.com
 *
 */

class PageTraversal {
	

	/**
	 * Return number of children, optionally with conditions
	 *
	 * Use this over $page->numChildren property when you want to specify a selector or when you want the result to
	 * include only visible children. See the options for the $selector argument. 
	 *
	 * @param Page $page
	 * @param bool|string|int|array $selector 
	 *	When not specified, result includes all children without conditions, same as $page->numChildren property.
	 *	When a string or array, a selector is assumed and quantity will be counted based on selector. 
	 * 	When boolean true, number includes only visible children (excludes unpublished, hidden, no-access, etc.)
	 *	When boolean false, number includes all children without conditions, including unpublished, hidden, no-access, etc.
	 * 	When integer 1 number includes viewable children (as opposed to visible, viewable includes hidden pages + it also includes unpublished pages if user has page-edit permission).
	 * @param array $options
	 *  - `descendants` (bool): Use descendants rather than direct children
	 * @return int Number of children
	 *
	 */
	public function numChildren(Page $page, $selector = null, array $options = array()) {
	
		$descendants = empty($options['descendants']) ? false : true;
		$parentType = $descendants ? 'has_parent' : 'parent_id';
		
		if(is_bool($selector)) {
			// onlyVisible takes the place of selector
			$onlyVisible = $selector; 
			$numChildren = $page->get('numChildren');
			if(!$numChildren) {
				return 0;
			} else if($onlyVisible) {
				return $page->_pages('count', "$parentType=$page->id"); 
			} else if($descendants) {
				return $this->numDescendants($page);
			} else {
				return $numChildren;
			}
			
		} else if($selector === 1) { 
			// viewable pages only
			$numChildren = $page->get('numChildren');
			if(!$numChildren) return 0;
			$user = $page->wire()->user;
			if($user->isSuperuser()) {
				if($descendants) return $this->numDescendants($page);
				return $numChildren;
			} else if($user->hasPermission('page-edit')) {
				return $page->_pages('count', "$parentType=$page->id, include=unpublished");
			} else {
				return $page->_pages('count', "$parentType=$page->id, include=hidden");
			}

		} else if(empty($selector) || (!is_string($selector) && !is_array($selector))) {
			// no selector provided
			if($descendants) return $this->numDescendants($page);
			return $page->get('numChildren');

		} else {
			// selector string or array provided
			if(is_string($selector)) {
				$selector = "$parentType=$page->id, $selector";
			} else if(is_array($selector)) {
				$selector[$parentType] = $page->id;
			}
			return $page->_pages('count', $selector);
		}
	}

	/**
	 * Return number of descendants, optionally with conditions
	 *
	 * Use this over $page->numDescendants property when you want to specify a selector or when you want the result to
	 * include only visible descendants. See the options for the $selector argument.
	 *
	 * @param Page $page
	 * @param bool|string|int|array $selector
	 *	When not specified, result includes all descendants without conditions, same as $page->numDescendants property.
	 *	When a string or array, a selector is assumed and quantity will be counted based on selector.
	 * 	When boolean true, number includes only visible descendants (excludes unpublished, hidden, no-access, etc.)
	 *	When boolean false, number includes all descendants without conditions, including unpublished, hidden, no-access, etc.
	 * 	When integer 1 number includes viewable descendants (as opposed to visible, viewable includes hidden pages + it also includes unpublished pages if user has page-edit permission).
	 * @return int Number of descendants
	 *
	 */
	public function numDescendants(Page $page, $selector = null) {
		if($selector === null) {
			return $page->_pages('count', "has_parent=$page->id, include=all");
		} else {
			return $this->numChildren($page, $selector, array('descendants' => true));
		}
	}
	
	/**
	 * Return this page's children pages, optionally filtered by a selector
	 *
	 * @param Page $page
	 * @param string|array $selector Selector to use, or blank to return all children
	 * @param array $options
	 * @return PageArray
	 *
	 */
	public function children(Page $page, $selector = '', $options = array()) {
		if(!$page->numChildren) return $page->_pages()->newPageArray();
		$defaults = array('caller' => 'page.children'); 
		$options = array_merge($defaults, $options); 
		$sortfield = $page->sortfield();
		if(is_array($selector)) {
			// selector is array
			$selector["parent_id"] = $page->id;
			if(isset($selector["sort"])) $sortfield = '';
			if($sortfield) $selector[] = array("sort", $sortfield);
		} else {
			// selector is string
			$selector = trim("parent_id=$page->id, $selector", ", ");
			if(strpos($selector, 'sort=') === false) $selector .= ", sort=$sortfield";
		}
		return $page->_pages('find', $selector, $options); 
	}

	/**
	 * Return the page's first single child that matches the given selector. 
	 *
	 * Same as children() but returns a Page object or NullPage (with id=0) rather than a PageArray
	 *
	 * @param Page $page
	 * @param string|array $selector Selector to use, or blank to return the first child. 
	 * @param array $options
	 * @return Page|NullPage
	 *
	 */
	public function child(Page $page, $selector = '', $options = array()) {
		if(!$page->numChildren) return $page->_pages()->newNullPage();
		$defaults = array('getTotal' => false, 'caller' => 'page.child'); 
		$options = array_merge($defaults, $options); 
		if(is_array($selector)) {
			$selector["limit"] = 1;
			$selector[] = array("start", "0");
		} else {
			$selector .= ($selector ? ', ' : '') . "limit=1";
			if(strpos($selector, 'start=') === false) $selector .= ", start=0"; // prevent pagination
		}
		$children = $this->children($page, $selector, $options); 
		return count($children) ? $children->first() : $page->_pages()->newNullPage();
	}

	/**
	 * Return this page's parent pages, or the parent pages matching the given selector.
	 *
	 * @param Page $page
	 * @param string|array|bool $selector Optional selector string to filter parents by or boolean true for reverse order
	 * @return PageArray
	 *
	 */
	public function parents(Page $page, $selector = '') {
		$parents = $page->wire()->pages->newPageArray();
		$parent = $page->parent();
		$method = $selector === true ? 'add' : 'prepend';
		while($parent && $parent->id) {
			$parents->$method($parent); 	
			$parent = $parent->parent();
		}
		return !is_bool($selector) && strlen($selector) ? $parents->filter($selector) : $parents; 
	}

	/**
	 * Return number of parents (depth relative to homepage) that this page has, optionally filtered by a selector
	 * 
	 * For example, homepage has 0 parents and root level pages have 1 parent (which is the homepage), and the
	 * number increases the deeper the page is in the pages structure. 
	 * 
	 * @param Page $page
	 * @param string $selector Optional selector to filter by (default='')
	 * @return int Number of parents
	 * 
	 */
	public function numParents(Page $page, $selector = '') {
		$num = 0;
		$parent = $page->parent();
		while($parent && $parent->id) {
			if($selector !== '' && !$parent->matches($selector)) continue;
			$num++;
			$parent = $parent->parent();
		}
		return $num;
	}

	/**
	 * Return all parent from current till the one matched by $selector
	 *
	 * @param Page $page
	 * @param string|Page|array $selector May either be a selector or Page to stop at. Results will not include this. 
	 * @param string|array $filter Optional selector to filter matched pages by
	 * @return PageArray
	 *
	 */
	public function parentsUntil(Page $page, $selector = '', $filter = '') {

		$parents = $this->parents($page); 
		$matches = $page->wire()->pages->newPageArray();
		$stop = false;

		foreach($parents->reverse() as $parent) {
			/** @var Page $parent */

			if(is_string($selector) && strlen($selector)) {
				if(ctype_digit("$selector") && $parent->id == $selector) {
					$stop = true;
				} else if($parent->matches($selector)) {
					$stop = true;
				}
				
			} else if(is_array($selector) && !empty($selector)) {
				if($parent->matches($selector)) $stop = true;

			} else if(is_int($selector)) {
				if($parent->id == $selector) $stop = true; 

			} else if($selector instanceof Page && $parent->id == $selector->id) {
				$stop = true; 
			}

			if($stop) break;
			$matches->prepend($parent);
		}

		if(!empty($filter)) $matches->filter($filter); 
		
		return $matches;
	}


	/**
	 * Get the lowest-level, non-homepage parent of this page
	 *
	 * rootParents typically comprise the first level of navigation on a site. 
	 *
	 * @param Page $page
	 * @return Page 
	 *
	 */
	public function rootParent(Page $page) {
		$parent = $page->parent;
		if(!$parent || !$parent->id || $parent->id === 1) return $page; 
		$parents = $this->parents($page);
		$parents->shift(); // shift off homepage
		return $parents->first();
	}

	/**
	 * Return this Page's sibling pages, optionally filtered by a selector. 
	 *
	 * Note that the siblings include the current page. To exclude the current page, specify "id!=$page". 
	 *
	 * @param Page $page
	 * @param string $selector Optional selector to filter siblings by.
	 * @return PageArray
	 *
	 */
	public function siblings(Page $page, $selector = '') {
		$parent = $page->parent();
		$sort = $parent->sortfield(); 
		if(is_array($selector)) {
			$selector["parent_id"] = $page->parent_id;	
			$selector[] = array('sort', $sort);
		} else {
			$selector = "parent_id=$page->parent_id, $selector";
			if(strpos($selector, 'sort=') === false) $selector .= ", sort=$sort";
			$selector = trim($selector, ", ");
		}
		$options = array('caller' => 'page.siblings'); 
		return $page->_pages('find', $selector, $options); 
	}

	/**
	 * Get include mode specified in selector or blank if none
	 * 
	 * @param string|array|Selectors $selector
	 * @return string
	 * 
	 */
	protected function _getIncludeMode($selector) {
		if(is_string($selector) && strpos($selector, 'include=') === false) return '';
		if(is_array($selector)) return isset($selector['include']) ? $selector['include'] : '';
		$selector = $selector instanceof Selectors ? $selector : new Selectors($selector);
		$include = $selector->getSelectorByField('include');
		return $include ? $include->value() : '';
	}

	/**
	 * Builds the PageFinder options for the _next() method
	 * 
	 * @param Page $page
	 * @param string|array|Selectors $selector
	 * @param array $options
	 * @return array
	 * 
	 */
	protected function _nextFinderOptions(Page $page, $selector, $options) {
		
		$fo = array(
			'findOne' => $options['all'] ? false : true,
			'startAfterID' => $options['prev'] ? 0 : $page->id,
			'stopBeforeID' => $options['prev'] ? $page->id : 0,
			'returnVerbose' => $options['all'] ? false : true,
			'alwaysAllowIDs' => array(),
		);
		
		if($page->isUnpublished() || $page->isHidden()) {
			// allow next() to still move forward even though it is hidden or unpublished
			$includeMode = $this->_getIncludeMode($selector);
			if(!$includeMode || ($includeMode === 'hidden' && $page->isUnpublished())) {
				$fo['alwaysAllowIDs'][] = $page->id;
			}
		}

		if(!$options['until']) return $fo;
	
		/***************************************************************
		 * All code below this specific to the 'until' option
		 * 
		 */ 
		
		$until = $options['until'];
		/** @var string $until */
		if(is_array($until)) $until = (string) (new Selectors($until));
		
		if(ctype_digit("$until")) {
			// id or Page object
			$stopPage = new WireData();
			$stopPage->set('id', (int) $until);
			
		} else if(strpos($until, '/') === 0) {
			// page path
			$stopPage = $page->_pages('get', $until);
			
		} else if(is_array($selector) || is_array($options['until'])) {
			// either selector or until is an array
			$s = new Selectors($options['until']);
			foreach(new Selectors($selector) as $item) $s->add($item);
			$s->add(new SelectorEqual('limit', 1));
			$stopPage = $page->_pages('find', $s)->first();
			
		} else {
			// selector string
			$findOptions = $options['prev'] ? array() : array('startAfterID' => $page->id);
			$stopPage = $page->_pages('find', "$selector, limit=1, $until", $findOptions)->first();
		}
		
		if($stopPage && $stopPage->id) {
			if($options['prev']) {
				$fo['startAfterID'] = $stopPage->id;
				$fo['stopBeforeID'] = $page->id;
			} else {
				$fo['startAfterID'] = $page->id;
				$fo['stopBeforeID'] = $stopPage->id;
			}
		}
		
		return $fo;
	}

	/**
	 * Provides the core logic for next, prev, nextAll, prevAll, nextUntil, prevUntil
	 *
	 * @param Page $page
	 * @param string|array|Selectors $selector Optional selector. When specified, will find nearest sibling(s) that match.
	 * @param array $options Options to modify behavior
	 *  - `prev` (bool): When true, previous siblings will be returned rather than next siblings.
	 *  - `all` (bool): If true, returns all nextAll or prevAll rather than just single sibling (default=false).
	 *  - `until` (string): If specified, returns siblings until another is found matching the given selector (default=false).
	 *  - `qty` (bool): If true, makes it return just the quantity that would match (default=false). 
	 * @return Page|NullPage|PageArray|int Returns one of the following: 
	 *  - `PageArray` if the "all" or "until" option is specified. 
	 *  - `Page|NullPage` in other cases. 
	 *
	 */ 
	protected function _next(Page $page, $selector = '', array $options = array()) {
		
		$defaults = array(
			'prev' => false, // get previous rather than next
			'all' => false, // get multiple/all
			'until' => '', // until selector string ('all' option assumed)
			'qty' => false, // when true, returns just the quantity that would match ('all' option assumed)
		);

		$options = array_merge($defaults, $options);
		$pages = $page->wire()->pages;
		$parent = $page->parent();
		
		if($options['until'] || $options['qty']) $options['all'] = true;
		if(!$parent || !$parent->id) {
			if($options['qty']) return 0;
			return $options['all'] ? $pages->newPageArray() : $pages->newNullPage();
		}
		
		if(is_array($selector)) {
			$selector['parent_id'] = $parent->id; 
		} else if(is_string($selector)) {
			$selector = trim("parent_id=$parent->id, $selector", ", ");
		} else if($selector instanceof Selectors) {
			$selector->add(new SelectorEqual('parent_id', $parent->id));
		} else {
			throw new WireException('Selector must be string, array or Selectors object');
		}
		
		$pageFinder = $pages->getPageFinder();
		$pageFinderOptions = $this->_nextFinderOptions($page, $selector, $options);
		$rows = $pageFinder->find($selector, $pageFinderOptions);
		
		if($options['qty']) {
			$result = count($rows);

		} else if(!count($rows)) {
			$result = $options['all'] ? $pages->newPageArray() : $pages->newNullPage();

		} else if($options['all']) {
			$result = $pages->getById($rows, array(
				'parent_id' => $parent->id,
				'cache' => $page->loaderCache
			));
			if($options['prev']) $result = $result->reverse();
			
		} else {
			$row = reset($rows);
			if($row && !empty($row['id'])) {
				$result = $pages->getById(array($row['id']), array(
					'template' => $page->wire()->templates->get($row['templates_id']),
					'parent_id' => $row['parent_id'],
					'getOne' => true,
					'cache' => $page->loaderCache
				));
			} else {
				$result = $pages->newNullPage();
			}
		}
		
		return $result;
	}

	/**
	 * Return the index/position of the given page relative to its siblings
	 * 
	 * If given a hidden or unpublished page, that page would not usually be part of the group of siblings. 
	 * As a result, such pages will return what the value would be if they were visible (as of 3.0.121). This
	 * may overlap with the index of other pages, since indexes are relative to visible pages, unless you
	 * specify an include mode (see next paragraph). 
	 * 
	 * If you want this method to include hidden/unpublished pages as part of the index numbers, then 
	 * specify boolean true for the $selector argument (which implies "include=all") OR specify a 
	 * selector of "include=hidden", "include=unpublished" or "include=all". 
	 * 
	 * @param Page $page
	 * @param string|array|bool|Selectors $selector Selector to apply or boolean true for "include=all" (since 3.0.121).
	 *  - Boolean true to include hidden and unpublished pages as part of the index numbers (same as "include=all").
	 *  - An "include=hidden", "include=unpublished" or "include=all" selector to include them in the index numbers.
	 *  - A string selector or selector array to filter the criteria for the returned index number.
	 * @return int Returns index number (zero-based)
	 * 
	 */
	public function index(Page $page, $selector = '') {
		if($selector === true) $selector = "include=all";
		$index = $this->_next($page, $selector, array('prev' => true, 'all' => true, 'qty' => 'index'));
		return $index;
	}
	
	/**
	 * Return the next sibling page
	 *
	 * @param Page $page
	 * @param string|array|Selectors $selector Optional selector. When specified, will find nearest next sibling that matches.
	 * @return Page|NullPage Returns the next sibling page, or a NullPage if none found.
	 *
	 */
	public function next(Page $page, $selector = '') {
		return $this->_next($page, $selector);
	}

	/**
	 * Return the previous sibling page
	 *
	 * @param Page $page
	 * @param string|array|Selectors $selector Optional selector. When specified, will find nearest previous sibling that matches.
	 * @return Page|NullPage Returns the previous sibling page, or a NullPage if none found.
	 *
	 */
	public function prev(Page $page, $selector = '') {
		return $this->_next($page, $selector, array('prev' => true));
	}


	/**
	 * Return all sibling pages after this one, optionally matching a selector
	 *
	 * @param Page $page
	 * @param string|array|Selectors $selector Optional selector. When specified, will filter the found siblings.
	 * @param array $options Options to pass to the _next() method
	 * @return PageArray Returns all matching pages after this one.
	 *
	 */
	public function nextAll(Page $page, $selector = '', array $options = array()) {
		$defaults = array('all' => true);
		$options = array_merge($options, $defaults);
		return $this->_next($page, $selector, $options);
	}
	
	/**
	 * Return all sibling pages prior to this one, optionally matching a selector
	 *
	 * @param Page $page
	 * @param string|array|Selectors $selector Optional selector. When specified, will filter the found siblings.
	 * @param array $options Options to pass to the _next() method
	 * @return PageArray Returns all matching pages after this one.
	 *
	 */
	public function prevAll(Page $page, $selector = '', array $options = array()) {
		$defaults = array(
			'prev' => true, 
			'all' => true
		);
		$options = array_merge($options, $defaults);
		return $this->_next($page, $selector, $options);
	}
	
	/**
	 * Return all sibling pages after this one until matching the one specified
	 *
	 * @param Page $page
	 * @param string|Page|array|Selectors $selector May either be a selector or Page to stop at. Results will not include this.
	 * @param string|array $filter Optional selector to filter matched pages by
	 * @param array $options Options to pass to the _next() method
	 * @return PageArray
	 *
	 */
	public function nextUntil(Page $page, $selector = '', $filter = '', array $options = array()) {
		$defaults = array(
			'all' => true, 
			'until' => $selector
		);
		$options = array_merge($options, $defaults);
		return $this->_next($page, $filter, $options);
	}
	
	/**
	 * Return all sibling pages prior to this one until matching the one specified
	 *
	 * @param Page $page
	 * @param string|Page|array $selector May either be a selector or Page to stop at. Results will not include this.
	 * @param string|array $filter Optional selector to filter matched pages by
	 * @param array $options Options to pass to the _next() method
	 * @return PageArray
	 *
	 */
	public function prevUntil(Page $page, $selector = '', $filter = '', array $options = array()) {
		$defaults = array(
			'prev' => true, 
			'all' => true, 
			'until' => $selector
		);
		$options = array_merge($options, $defaults);
		return $this->_next($page, $filter, $options);
	}
	
	/**
	 * Returns the URL to the page with $options
	 *
	 * You can specify an `$options` argument to this method with any of the following:
	 *
	 * - `pageNum` (int|string|bool): Specify pagination number, "+" for next pagination, "-" for previous pagination, or true for current.
	 * - `urlSegmentStr` (string|bool): Specify a URL segment string to append, or true (3.0.155+) for current. 
	 * - `urlSegments` (array|bool): Specify regular array of URL segments to append (may be used instead of urlSegmentStr). 
	 *    Specify boolean true for current URL segments (3.0.155+). 
	 *    Specify associative array (in 3.0.155+) to make both keys and values part of the URL segment string.  
	 * - `data` (array): Array of key=value variables to form a query string.
	 * - `http` (bool): Specify true to make URL include scheme and hostname (default=false).
	 * - `scheme` (string): Like the http option, makes URL include scheme and hostname, but you specify scheme with this, i.e. 'https' (3.0.178+)
	 * - `host` (string): Hostname to force use of, i.e. 'world.com' or 'hello.world.com'. The 'http' option is implied when host specified. (3.0.178+)
	 * - `language` (Language): Specify Language object to return URL in that Language.
	 *
	 * You can also specify any of the following for `$options` as shortcuts:
	 *
	 * - If you specify an `int` for options it is assumed to be the `pageNum` option.
	 * - If you specify `+` or `-` for options it is assumed to be the `pageNum` “next/previous pagination” option.
	 * - If you specify any other `string` for options it is assumed to be the `urlSegmentStr` option.
	 * - If you specify a `boolean` (true) for options it is assumed to be the `http` option.
	 *
	 * Please also note regarding `$options`:
	 *
	 * - This method honors template slash settings for page, URL segments and page numbers.
	 * - Any passed in URL segments are automatically sanitized with `Sanitizer::pageNameUTF8()`.
	 * - If using the `pageNum` or URL segment options please also make sure these are enabled on the page’s template.
	 * - The query string generated by any `data` variables is entity encoded when output formatting is on.
	 * - The `language` option requires that the `LanguageSupportPageNames` module is installed.
	 * - The prefix for page numbers honors `$config->pageNumUrlPrefix` and multi-language prefixes as well.
	 *
	 * @param Page $page
	 * @param array|int|string|bool|Language $options Optionally specify options to modify default behavior (see method description).
	 * @return string Returns page URL, for example: `/my-site/about/contact/`
	 * @see Page::path(), Page::httpUrl(), Page::editUrl(), Page::localUrl()
	 *
	 */
	public function urlOptions(Page $page, $options = array()) {

		$config = $page->wire()->config;
		$template = $page->template;

		$defaults = array(
			'http' => is_bool($options) ? $options : false,
			'scheme' => '', 
			'host' => '', 
			'pageNum' => is_int($options) || (is_string($options) && in_array($options, array('+', '-'))) ? $options : 1,
			'data' => array(),
			'urlSegmentStr' => is_string($options) ? $options : '',
			'urlSegments' => array(),
			'language' => is_object($options) && wireInstanceOf($options, 'Language') ? $options : null,
		);

		if(empty($options)) {
			$url = rtrim($config->urls->root, '/') . $page->path();
			if($template->slashUrls === 0 && $page->id > 1) $url = rtrim($url, '/');
			return $url;
		}

		$options = is_array($options) ? array_merge($defaults, $options) : $defaults;
		$sanitizer = $page->wire()->sanitizer;
		$input = $page->wire()->input;
		$languages = $page->wire()->languages;
		$language = null;
		$url = null;
		
		if($options['urlSegments'] === true || $options['urlSegmentStr'] === true) {
			$options['urlSegments'] = $input->urlSegments();
		}
		
		if($options['pageNum'] === true) {
			$options['pageNum'] = $input->pageNum();
		}

		if(is_array($options['urlSegments']) && count($options['urlSegments'])) {
			$str = '';
			reset($options['urlSegments']); 
			if(is_string(key($options['urlSegments']))) {
				// associative array converts to key/value style URL segments
				foreach($options['urlSegments'] as $key => $value) {
					$str .= "$key/$value/";
					if(is_int($key)) $str = ''; // abort assoc array option if any int key found
					if($str === '') break;
				}
			}
			if(strlen($str)) {
				$options['urlSegmentStr'] = rtrim($str, '/');
			} else {
				$options['urlSegmentStr'] = implode('/', $options['urlSegments']);
			}
		}

		if($options['language'] && $languages && $languages->hasPageNames()) {
			if(!is_object($options['language'])) {
				$options['language'] = null;
			} else if(!$options['language'] instanceof Page) {
				$options['language'] = null;
			} else if(strpos($options['language']->className(), 'Language') === false) {
				$options['language'] = null;
			}
			if($options['language']) {
				/** @var Language $language */
				$language = $options['language'];
				// localUrl method provided as hook by LanguageSupportPageNames
				$url = $page->localUrl($language);
			}
		}

		if(is_null($url)) {
			$url = rtrim($config->urls->root, '/') . $page->path();
			if($template->slashUrls === 0 && $page->id > 1) $url = rtrim($url, '/');
		}

		if(is_string($options['urlSegmentStr']) && strlen($options['urlSegmentStr'])) {
			$url = rtrim($url, '/') . '/' . $sanitizer->pagePathNameUTF8(trim($options['urlSegmentStr'], '/'));
			if($template->slashUrlSegments > -1) $url .= '/';
		}

		if($options['pageNum']) {
			if($options['pageNum'] === '+') {
				$options['pageNum'] = $input->pageNum + 1;
			} else if($options['pageNum'] === '-' || $options['pageNum'] === -1) {
				$options['pageNum'] = $input->pageNum - 1;
			}
			if((int) $options['pageNum'] > 1) {
				$prefix = '';
				if($language && $languages && $languages->hasPageNames()) {
					$prefix = $languages->pageNames()->get("pageNumUrlPrefix$language");
				}
				if(!strlen($prefix)) $prefix = $config->pageNumUrlPrefix;
				$url = rtrim($url, '/') . '/' . $prefix . ((int) $options['pageNum']);
				if($template->slashPageNum) $url .= '/';
			}
		}

		if(count($options['data'])) {
			$query = http_build_query($options['data']);
			if($page->of()) $query = $sanitizer->entities($query);
			$url .= '?' . $query;
		}

		if($options['scheme']) {
			$scheme = strtolower($options['scheme']);
			if(strpos($scheme, '://') === false) $scheme .= '://';
			if($scheme === 'https://' && $config->noHTTPS) $scheme = 'http' . '://';
			$host = $options['host'] ? $options['host'] : $config->httpHost;
			$url = "$scheme$host$url";
			
		} else if($options['http'] || $options['host']) {
			$mode = $config->noHTTPS ? -1 : $template->https; 
			switch($mode) {
				case -1: $scheme = 'http'; break;
				case 1: $scheme = 'https'; break;
				default: $scheme = $config->https ? 'https' : 'http';
			}
			$host = $options['host'] ? $options['host'] : $config->httpHost;
			$url = "$scheme://$host$url";
		}

		return $url;
	}
	
	/**
	 * Return all URLs that this page can be accessed from (excluding URL segments and pagination)
	 *
	 * This includes the current page URL, any other language URLs (for which page is active), and
	 * any past (historical) URLs the page was previously available at (which will redirect to it).
	 *
	 * - Returned URLs do not include additional URL segments or pagination numbers.
	 * - Returned URLs are indexed by language name, i.e. “default”, “fr”, “es”, etc.
	 * - If multi-language URLs not installed, then index is just “default”.
	 * - Past URLs are indexed by language; then ISO-8601 date, i.e. “default;2016-08-11T07:44:43-04:00”,
	 *   where the date represents the last date that URL was considered current.
	 * - If PagePathHistory core module is not installed then past/historical URLs are excluded.
	 * - You can disable past/historical or multi-language URLs by using the $options argument.
	 *
	 * @param Page $page
	 * @param array $options Options to modify default behavior:
	 *  - `http` (bool): Make URLs include current scheme and hostname (default=false).
	 *  - `past` (bool): Include past/historical URLs? (default=true)
	 *  - `languages` (bool): Include other language URLs when supported/available? (default=true).
	 *  - `language` (Language|int|string): Include only URLs for this language (default=null). 
	 *     Note: the `languages` option must be true if using the `language` option. 
	 * @return array
	 *
	 */
	public function urls(Page $page, $options = array()) {

		$defaults = array(
			'http' => false,
			'past' => true,
			'languages' => true,
			'language' => null, 
		);

		$modules = $page->wire()->modules;
		$options = array_merge($defaults, $options);
		$languages = $options['languages'] ? $page->wire()->languages : null;
		$slashUrls = $page->template->slashUrls;
		$httpHostUrl = $options['http'] ? $page->wire()->input->httpHostUrl() : '';
		$urls = array();
		
		if($options['language'] && $languages) {
			if(!$options['language'] instanceof Page) {
				$options['language'] = $languages->get($options['language']);
			}
			if($options['language'] && $options['language']->id) {
				$languages = array($options['language']);
			}
		}

		// include other language URLs
		if($languages && $languages->hasPageNames()) {
			foreach($languages as $language) {
				/** @var Language $language */
				if(!$language->isDefault() && !$page->get("status$language")) continue;
				$urls[$language->name] = $page->localUrl($language);
			}
		} else {
			$urls = array('default' => $page->url());
		}

		// add in historical URLs
		if($options['past'] && $modules->isInstalled('PagePathHistory')) {
			/** @var PagePathHistory $history */
			$history = $modules->get('PagePathHistory');
			$rootUrl = $page->wire()->config->urls->root;
			$pastPaths = $history->getPathHistory($page, array(
				'language' => $options['language'],
				'verbose' => true	
			)); 
			foreach($pastPaths as $pathInfo) {
				$key = '';
				if(!empty($pathInfo['language'])) {
					/** @var Language $language */
					$language = $pathInfo['language'];
					if($options['languages']) {
						$key .= $language->name . ';';
					} else {
						// they asked to have multi-language excluded
						if(!$language->isDefault()) continue;
					}
				} 
				$key .= wireDate('c', $pathInfo['date']);
				$urls[$key] = $rootUrl . ltrim($pathInfo['path'], '/');
			}
		}

		// update URLs for current expected slash and http settings
		foreach($urls as $key => $url) {
			if($url !== '/') $url = $slashUrls ? rtrim($url, '/') . '/' : rtrim($url, '/');
			if($options['http']) $url = $httpHostUrl . $url;	
			$urls[$key] = $url;
		}
		
		return $urls;
	}
	
	/**
	 * Return the URL necessary to edit page
	 *
	 * - We recommend checking that the page is editable before outputting the editUrl().
	 * - If user opens URL in their browser and is not logged in, they must login to account with edit permission.
	 * - This method can also be accessed by property at `$page->editUrl` (without parenthesis).
	 *
	 * ~~~~~~
	 * if($page->editable()) {
	 *   echo "<a href='$page->editUrl'>Edit this page</a>";
	 * }
	 * ~~~~~~
	 *
	 * @param Page $page
	 * @param array|bool|string $options Specify true for http option, specify name of field to find (3.0.151+), or use $options array:
	 *  - `http` (bool): True to force scheme and hostname in URL (default=auto detect).
	 *  - `language` (Language|bool): Optionally specify Language to start editor in, or boolean true to force current user language.
	 *  - `find` (string): Name of field to find in the editor (3.0.151+)
	 * @return string URL for editing this page
	 *
	 */
	public function editUrl(Page $page, $options = array()) {

		$config = $page->wire()->config;
		$adminTemplate = $page->wire()->templates->get('admin'); /** @var Template $adminTemplate */
		$https = $adminTemplate && ($adminTemplate->https > 0) && !$config->noHTTPS;
		$url = ($https && !$config->https) ? 'https://' . $config->httpHost : '';
		$url .= $config->urls->admin . "page/edit/?id=$page->id";

		if($options === true || (is_array($options) && !empty($options['http']))) {
			if(strpos($url, '://') === false) {
				$url = ($https || $config->https ? 'https' : 'http' ) . '://' . $config->httpHost . $url;
			}
		}

		$languages = $page->wire()->languages;
		if($languages) {
			$language = $page->wire()->user->language;
			if(empty($options['language'])) {
				if($page->wire()->page->template->id == $adminTemplate->id) $language = null;
			} else if($options['language'] instanceof Page) {
				$language = $options['language'];
			} else if($options['language'] !== true) {
				$language = $languages->get($options['language']);
			}
			if($language && $language->id) $url .= "&language=$language->id";
		}

		$append = $page->wire()->session->getFor($page, 'appendEditUrl');

		if($append) $url .= $append;

		if($options) {
			if(is_string($options)) {
				$find = $options;
			} else if(is_array($options) && !empty($options['find'])) {
				$find = $options['find'];
			} else $find = '';
			if($find && strpos($url, '#') === false) {
				$url .= '#find-' . $page->wire()->sanitizer->fieldName($find);
			}
		}

		return $url;
	}
	
	/**
	 * Returns the URL to the page, including scheme and hostname
	 *
	 * - This method is just like the `$page->url()` method except that it also includes scheme and hostname.
	 *
	 * - This method can also be accessed at the property `$page->httpUrl` (without parenthesis).
	 *
	 * - It is desirable to use this method when some page templates require https while others don't.
	 *   This ensures local links will always point to pages with the proper scheme. For other cases, it may
	 *   be preferable to use `$page->url()` since it produces shorter output.
	 *
	 * ~~~~~
	 * // Generating a link to this page using httpUrl
	 * echo "<a href='$page->httpUrl'>$page->title</a>";
	 * ~~~~~
	 *
	 * @param Page $page
	 * @param array $options For details on usage see `Page::url()` options argument.
	 * @return string Returns full URL to page, for example: `https://processwire.com/about/`
	 * @see Page::url(), Page::localHttpUrl()
	 *
	 */
	public function httpUrl(Page $page, $options = array()) {
		
		$template = $page->template();
		if(!$template) return '';
		
		if(is_array($options)) unset($options['http']);
		if($options === true || $options === false) $options = array();
		
		$url = $page->url($options);
		if(strpos($url, '://')) return $url;
		
		$config = $page->wire()->config;
		$mode = $template->https;
		
		if($mode > 0 && $config->noHTTPS) $mode = 0;
		
		switch($mode) {
			case -1: $scheme = 'http'; break;
			case 1: $scheme = 'https'; break;
			default: $scheme = $config->https ? 'https' : 'http';
		}
		
		$url = "$scheme://$config->httpHost$url";
		
		return $url;
	}

	/**
	 * Return pages that are referencing the given one by way of Page references
	 * 
	 * @param Page $page
	 * @param string|bool $selector Optional selector to filter results by or boolean true as shortcut for `include=all`. 
	 * @param Field|string $field Limit to follower pages using this field, 
	 *   - or specify boolean TRUE to make it return array of PageArrays indexed by field name. 
	 * @param bool $getCount Specify true to return counts rather than PageArray(s)
	 * @return PageArray|array|int
	 * @throws WireException Highly unlikely
	 * 
	 */
	public function references(Page $page, $selector = '', $field = '', $getCount = false) {
		/** @var FieldtypePage $fieldtype */
		$fieldtype = $page->wire()->fieldtypes->get('FieldtypePage');	
		if(!$fieldtype) throw new WireException('Unable to find FieldtypePage');
		if($selector === true) $selector = "include=all";
		return $fieldtype->findReferences($page, $selector, $field, $getCount); 
	}
	
	/**
	 * Return number of VISIBLE pages that are following (referencing) the given one by way of Page references
	 * 
	 * Note that this excludes hidden, unpublished and otherwise non-accessible pages (access control). 
	 * If you do not want to exclude these, use the numFollowers() function instead, OR specify "include=all" for
	 * the $selector argument. 
	 *
	 * @param Page $page
	 * @param string $selector Filter count by this selector
	 * @param string|Field|bool $field Limit count to given Field or specify boolean true to return array of counts.
	 * @return int|array Returns count, or array of counts (if $field==true)
	 *
	 */
	public function hasReferences(Page $page, $selector = '', $field = '') {
		return $this->references($page, $selector, $field, true);
	}

	/**
	 * Return number of ANY pages that are following (referencing) the given one by way of Page references
	 * 
	 * @param Page $page
	 * @param string $selector Filter count by this selector
	 * @param string|Field|bool $field Limit count to given Field or specify boolean true to return array of counts. 
	 * @return int|array Returns count, or array of counts (if $field==true)
	 * 
	 */
	public function numReferences(Page $page, $selector = '', $field = '') {
		if(stripos($selector, "include=") === false) $selector = rtrim("include=all, $selector", ', ');
		return $this->hasReferences($page, $selector, $field); 
	}

	/**
	 * Return pages that this page is referencing by way of Page reference fields
	 * 
	 * @param Page $page
	 * @param bool|Field|string|int $field Limit results to requested field, or specify boolean true to return array indexed by field names.
	 * @param bool $getCount Specify true to return count(s) rather than pages. 
	 * @return PageArray|int|array
	 * 
	 */
	public function referencing(Page $page, $field = false, $getCount = false) {
		$fieldName = '';
		$byField = null;
		if(is_bool($field) || is_null($field)) {
			$byField = $field ? true : false;
		} else if(is_string($field)) {
			$fieldName = $page->wire()->sanitizer->fieldName($field);
		} else if(is_int($field)) {
			$field = $page->wire()->fields->get($field);
			if($field) $fieldName = $field->name;
		} else if($field instanceof Field) {
			$fieldName = $field->name;
		}

		// results
		$fieldCounts = array(); // counts indexed by field name (if count mode)
		$pages = $page->wire()->pages;
		$items = $pages->newPageArray();
		$itemsByField = array();
		
		foreach($page->template->fieldgroup as $f) {
			/** @var Field $f */
			if($fieldName && $field->name != $fieldName) continue;
			if(!$f->type instanceof FieldtypePage) continue;
			if($byField) $itemsByField[$f->name] = $pages->newPageArray();
			$value = $page->get($f->name);
			if($value instanceof Page && $value->id) {
				$items->add($value);
				if($byField) $itemsByField[$f->name]->add($value);
				$fieldCounts[$f->name] = 1;
			} else if($value instanceof PageArray && $value->count()) {
				$items->import($value);
				if($byField) $itemsByField[$f->name]->import($value);
				$fieldCounts[$f->name] = $value->count();
			} else {
				unset($itemsByField[$f->name]);
			}
		}
		
		if($getCount) return $byField ? $fieldCounts : $items->count();
		if($byField) return $itemsByField;
		
		return $items;
	}

	/**
	 * Return number of pages this one is following (referencing) by way of Page references
	 * 
	 * @param Page $page
	 * @param bool $field Optionally limit to field, or specify boolean true to return array of counts per field. 
	 * @return int|array
	 * 
	 */
	public function numReferencing(Page $page, $field = false) {
		return $this->referencing($page, $field, true); 
	}

	/**
	 * Find other pages linking to the given one by way contextual links is textarea/html fields
	 * 
	 * @param Page $page
	 * @param string $selector
	 * @param bool|string|Field $field
	 * @param array $options
	 *  - `getIDs` (bool): Return array of page IDs rather than Page instances. (default=false)
	 *  - `getCount` (bool): Return a total count (int) of found pages rather than Page instances. (default=false)
	 *  - `confirm` (bool): Confirm that the links are present by looking at the actual page field data. (default=true)
	 *     You can specify false for this option to make it perform faster, but with a potentially less accurate result.
	 * @return PageArray|array|int
	 * @throws WireException
	 * 
	 */
	public function links(Page $page, $selector = '', $field = false, array $options = array()) {
		/** @var FieldtypeTextarea $fieldtype */
		$fieldtype = $page->wire()->fieldtypes->get('FieldtypeTextarea');
		if(!$fieldtype) throw new WireException('Unable to find FieldtypeTextarea');
		return $fieldtype->findLinks($page, $selector, $field, $options); 
	}

	/**
	 * Return total found number of pages linking to this one with no exclusions
	 * 
	 * @param Page $page
	 * @param bool $field
	 * @return int
	 * 
	 */
	public function numLinks(Page $page, $field = false) {
		return $this->links($page, true, $field, array('getCount' => true));
	}

	/**
	 * Return total number of pages visible to current user linking to this one
	 * 
	 * @param Page $page
	 * @param bool $field
	 * @return array|int|PageArray
	 * 
	 */
	public function hasLinks(Page $page, $field = false) {
		return $this->links($page, '', $field, array('getCount' => true));
	}
	

	/******************************************************************************************************************
	 * LEGACY METHODS
	 * 
	 * Following are legacy methods to support backwards compatibility with previous PW versions that used 
	 * a $siblings argument for next/prev related methods. 
	 * 
	 */

	/**
	 * Return the next sibling page, within a group of provided siblings (that includes the current page)
	 *
	 * This method is the old version of the next() method and is only used if a $siblings argument is provided
	 * to the Page::next() call.  It is much slower than the next() method.
	 *
	 * If given a PageArray of siblings (containing the current) it will return the next sibling relative to the provided PageArray.
	 *
	 * Be careful with this function when the page has a lot of siblings. It has to load them all, so this function is best
	 * avoided at large scale, unless you provide your own already-reduced siblings list (like from pagination)
	 *
	 * When using a selector, note that this method operates only on visible children. If you want something like "include=all"
	 * or "include=hidden", they will not work in the selector. Instead, you should provide the siblings already retrieved with
	 * one of those modifiers, and provide those siblings as the second argument to this function.
	 *
	 * @param Page $page
	 * @param string|array $selector Optional selector. When specified, will find nearest next sibling that matches.
	 * @param PageArray $siblings Optional siblings to use instead of the default. May also be specified as first argument when no selector needed.
	 * @return Page|NullPage Returns the next sibling page, or a NullPage if none found.
	 *
	 */
	public function nextSibling(Page $page, $selector = '', PageArray $siblings = null) {
		if($selector instanceof PageArray) {
			// backwards compatible to when $siblings was first argument
			$siblings = $selector;
			$selector = '';
		}
		if(is_null($siblings)) {
			$siblings = $page->parent->children();
		} else if(!$siblings->has($page)) {
			$siblings->prepend($page);
		}

		$next = $page;
		do {
			/** @var Page $next */
			$next = $siblings->getNext($next, false);
			if(empty($selector) || !$next || $next->matches($selector)) break;
		} while($next->id);
		if(is_null($next)) $next = $page->wire()->pages->newNullPage();
		return $next;
	}


	/**
	 * Return the previous sibling page within a provided group of siblings that contains the current page
	 * 
	 * This method is the old version of the prev() method and is only used if a $siblings argument is provided
	 * to the Page::prev() call. It is much slower than the prev() method. 
	 *
	 * If given a PageArray of siblings (containing the current) it will return the previous sibling relative to the provided PageArray.
	 *
	 * Be careful with this function when the page has a lot of siblings. It has to load them all, so this function is best
	 * avoided at large scale, unless you provide your own already-reduced siblings list (like from pagination)
	 *
	 * When using a selector, note that this method operates only on visible children. If you want something like "include=all"
	 * or "include=hidden", they will not work in the selector. Instead, you should provide the siblings already retrieved with
	 * one of those modifiers, and provide those siblings as the second argument to this function.
	 *
	 * @param Page $page
	 * @param string|array $selector Optional selector. When specified, will find nearest previous sibling that matches. 
	 * @param PageArray $siblings Optional siblings to use instead of the default. May also be specified as first argument when no selector needed.
	 * @return Page|NullPage Returns the previous sibling page, or a NullPage if none found. 
	 *
	 */
	public function prevSibling(Page $page, $selector = '', PageArray $siblings = null) {
		if($selector instanceof PageArray) {
			// backwards compatible to when $siblings was first argument
			$siblings = $selector;
			$selector = '';
		}
		if(is_null($siblings)) {
			$siblings = $page->parent->children();
		} else if(!$siblings->has($page)) {
			$siblings->add($page);
		}

		$prev = $page;
		do {
			/** @var Page $prev */
			$prev = $siblings->getPrev($prev, false); 
			if(empty($selector) || !$prev || $prev->matches($selector)) break;
		} while($prev->id); 
		if(is_null($prev)) $prev = $page->wire()->pages->newNullPage();
		return $prev;
	}

	/**
	 * Return all sibling pages after this one, optionally matching a selector
	 *
	 * @param Page $page
	 * @param string|array $selector Optional selector. When specified, will filter the found siblings.
	 * @param PageArray $siblings Optional siblings to use instead of the default. 
	 * @return PageArray Returns all matching pages after this one.
	 *
	 */
	public function nextAllSiblings(Page $page, $selector = '', PageArray $siblings = null) {

		if(is_null($siblings)) {
			$siblings = $page->parent()->children();
		} else if(!$siblings->has($page)) {
			$siblings->prepend($page);
		}

		$id = $page->id;
		$all = $page->wire()->pages->newPageArray();
		$rec = false;

		foreach($siblings as $sibling) {
			if($sibling->id == $id) {
				$rec = true;
				continue;
			}
			if($rec) $all->add($sibling);
		}

		if(!empty($selector)) $all->filter($selector); 
		
		return $all;
	}

	/**
	 * Return all sibling pages before this one, optionally matching a selector
	 *
	 * @param Page $page
	 * @param string|array $selector Optional selector. When specified, will filter the found siblings.
	 * @param PageArray $siblings Optional siblings to use instead of the default. 
	 * @return PageArray
	 *
	 */
	public function prevAllSiblings(Page $page, $selector = '', PageArray $siblings = null) {

		if(is_null($siblings)) {
			$siblings = $page->parent()->children();
		} else if(!$siblings->has($page)) {
			$siblings->add($page);
		}

		$id = $page->id;
		$all = $page->wire()->pages->newPageArray();

		foreach($siblings as $sibling) {
			if($sibling->id == $id) break;
			$all->add($sibling);
		}

		if(!empty($selector)) $all->filter($selector); 
		
		return $all;
	}

	/**
	 * Return all sibling pages after this one until matching the one specified 
	 *
	 * @param Page $page
	 * @param string|Page|array $selector May either be a selector or Page to stop at. Results will not include this. 
	 * @param string|array $filter Optional selector to filter matched pages by
	 * @param PageArray|null $siblings Optional PageArray of siblings to use instead of all from the page.
	 * @return PageArray
	 *
	 */
	public function nextUntilSiblings(Page $page, $selector = '', $filter = '', PageArray $siblings = null) {

		if(is_null($siblings)) {
			$siblings = $page->parent()->children();
		} else if(!$siblings->has($page)) {
			$siblings->prepend($page);
		}

		$siblings = $this->nextAllSiblings($page, '', $siblings); 
		$all = $page->wire()->pages->newPageArray();
		$stop = false;

		foreach($siblings as $sibling) {
			/** @var Page $sibling */

			if(is_string($selector) && strlen($selector)) {
				if(ctype_digit("$selector") && $sibling->id == $selector) {
					$stop = true;
				} else if($sibling->matches($selector)) {
					$stop = true;
				}
				
			} else if(is_array($selector) && count($selector)) {
				if($sibling->matches($selector)) $stop = true;

			} else if(is_int($selector)) {
				if($sibling->id == $selector) $stop = true; 

			} else if($selector instanceof Page && $sibling->id == $selector->id) {
				$stop = true; 
			}

			if($stop) break;
			
			$all->add($sibling);
		}

		if(!empty($filter)) $all->filter($filter); 
		
		return $all;
	}
	
	/**
	 * Return all sibling pages before this one until matching the one specified 
	 *
	 * @param Page $page
	 * @param string|Page|array $selector May either be a selector or Page to stop at. Results will not include this. 
	 * @param string|array $filter Optional selector string to filter matched pages by
	 * @param PageArray|null $siblings Optional PageArray of siblings to use instead of all from the page.
	 * @return PageArray
	 *
	 */
	public function prevUntilSiblings(Page $page, $selector = '', $filter = '', PageArray $siblings = null) {

		if(is_null($siblings)) {
			$siblings = $page->parent()->children();
		} else if(!$siblings->has($page)) {
			$siblings->add($page);
		}

		$siblings = $this->prevAllSiblings($page, '', $siblings); 
		$all = $page->wire()->pages->newPageArray();
		$stop = false;

		foreach($siblings->reverse() as $sibling) {
			/** @var Page $sibling */

			if(is_string($selector) && strlen($selector)) {
				if(ctype_digit("$selector") && $sibling->id == $selector) {
					$stop = true;
				} else if($sibling->matches($selector)) {
					$stop = true;
				}
				
			} else if(is_array($selector) && count($selector)) {
				if($sibling->matches($selector)) $stop = true;

			} else if(is_int($selector)) {
				if($sibling->id == $selector) $stop = true; 

			} else if($selector instanceof Page && $sibling->id == $selector->id) {
				$stop = true; 
			}

			if($stop) break;
			
			$all->prepend($sibling);
		}

		if(!empty($filter)) $all->filter($filter); 
		
		return $all;
	}

	/**
	 * Return the next or previous sibling page (new fast version)
	 *
	 * @param Page $page
	 * @param bool $getNext Specify true to return next page, or false to return previous.
	 * @param string|array $selector Optional selector. When specified, will find nearest sibling that matches.
	 * @param array $options Options to modify behavior
	 *   - `all` (bool): If true, returns all nextAll or prevAll rather than just single sibling (default=false).
	 *   - `until` (string): If specified, returns all siblings until another is found matching the given selector.
	 * @return Page|NullPage|PageArray Returns the next/prev sibling page, or a NullPage if none found.
	 *   Returns PageArray if 'all' or 'until' option is specified.
	 *
	 */

	/*
	 * KEEPING THIS AROUND AS ALTERNATIVE METHOD FOR SHORT TERM REFERENCE
	 * This method performs worse than _next() in most cases, but if there are millions of siblings,
	 * this method is likely to perform significantly faster. So we may add this back into the logic
	 * if need dictates. However, it can't accommodate all possible sorting scenarios. 
	 * 
	protected function _nextAlternate(Page $page, $selector = '', array $options = array()) {

		$defaults = array(
			'prev' => false,
			'all' => false,
			'until' => '', // selector string
		);

		$options = array_merge($defaults, $options);
		$getNext = !$options['prev'];

		if($options['until']) {
			if(is_array($options['until'])) {
				$selectors = new Selectors($options['until']);
				$options['until'] = (string) $selectors;
			}
			$options['all'] = true; // the 'all' option is assumed with 'until' 
		}

		if(is_array($selector)) {
			$selectors = new Selectors($selector);
			$selector = (string) $selectors;
		}

		$pages = $page->wire('pages');
		$parent = $page->parent();
		$sanitizer = $page->wire('sanitizer');

		if(!$parent || !$parent->id) {
			// homepage or NullPage, quick exit
			return $options['all'] ? $pages->newPageArray() : $pages->newNullPage();
		}

		$sortfield = $parent->sortfield();
		$descending = strpos($sortfield, '-') === 0;
		if($descending) $sortfield = ltrim($sortfield, '-');
		if($getNext === false) $descending = !$descending;
		$operator = $descending ? "<" : ">";
		$value = $sanitizer->selectorValue($page->getUnformatted($sortfield));
		$sortfield2 = $sortfield == 'sort' ? 'sort.value' : $sortfield;
		$countSelector = rtrim("parent_id=$parent->id, $sortfield2=$value, $selector", ", ");
		$sortSelector = $descending ? "sort=-$sortfield" : "sort=$sortfield";
		$uniqueSorts = array('sort', 'id', 'name'); // sorts where same value never appears twice among siblings
		$useSlower = false;
		$isUniqueSort = in_array($sortfield, $uniqueSorts);
		$next = false;
		$nextAll = $options['all'] ? $pages->newPageArray() : false;

		if(!$isUniqueSort) {
			$field = $page->wire('fields')->get($sortfield);
			if($field->type instanceof FieldtypePage) {
				$sortfield2 .= ".name";
				$sortSelector .= ".name";
			}
		} else {
			$field = null;
		}

		// count how many other children have this same exact sort value
		if(!$isUniqueSort && $pages->count($countSelector) > 1) {
			// multiple siblings have the same sort value
			// we will have to load them all to determine where $page fits in there
			$siblings = $parent->children(rtrim("$sortfield2=$value, $selector", ", "));
			if(!$getNext) $siblings = $siblings->reverse();
			foreach($siblings as $sibling) {
				if($next === true) {
					$next = $sibling;
					if($nextAll) {
						$nextAll->add($next);
					} else {
						break;
					}
				} else if($sibling->id == $page->id) {
					$next = true;
				}
			}
			if(!$nextAll && $next && $next instanceof Page) {
				return $next;
			}
		}

		// page id exclusion will be used, so operator can include pages having sort value
		if($nextAll && $nextAll->count() > 1) $operator .= '=';

		// selector that that only matches pages having a higher/lower sortfield value than $page
		$selector = rtrim("parent_id=$parent->id, id!=$page->id, $sortfield2$operator$value, $sortSelector, $selector", ", ");

		if($options['until']) {
			// multiple next/prev sibling pages until a particular one
			$selector = $nextAll->each('id!={id}, ') . $selector;
			// include matches only up until page matching 'until' selector
			$until = $pages->find("$selector, $options[until], limit=1");
			// setup for fast exclusion method
			if($until->count()) {
				$items = $pages->find($selector, array('untilID' => $until->first()->id));
			} else {
				$items = $pages->find($selector);
				// use slower exclusion method when necessary, excluding pages after loaded
				$exclude = false;
				foreach($items as $item) {
					if($exclude) {
						$items->remove($item);
					} else if($item->matches($options['until'])) {
						$exclude = true;
						$items->remove($item);
					}
				}
			}
			return $items;

		} else if($nextAll) {
			// multiple next/prev sibling pages
			$selector = $nextAll->each('id!={id}, ') . $selector;
			return $pages->find($selector);

		} else {
			// single next/prev sibling page
			return $pages->findOne($selector);
		}
	}
	*/

}
