<?php namespace ProcessWire;

/**
 * ProcessPageSearch: Live Search (for PW admin)
 * 
 * @method renderList(array $items, $prefix = 'pw-search', $class = 'list')
 * @method renderItem(array $item, $prefix = 'pw-search', $class = 'list')
 * @method string|array execute($getJSON = true)
 * 
 * @todo support searching repeaters
 * 
 */

class ProcessPageSearchLive extends Wire {

	/**
	 * Reference to ProcessPageSearch, if available
	 * 
	 * @var Process|ProcessPageSearch
	 * 
	 */
	protected $process;

	/**
	 * Properties to skip in selectors
	 * 
	 * @var array
	 * 
	 */
	protected $skipProperties = array(
		'include',
		'check_access',
		'checkaccess',
	);

	/**
	 * Template for live search settings
	 * 
	 * @var array
	 * 
	 */
	protected $liveSearchDefaults = array(
		'type' => '', // type of search, if not pages, i.e. "templates", "fields", "modules", "comments", etc. 
		'property' => '', // property to search for within type, or blank if no specific property
		'operator' => '%=',
		'q' => '', // query text to find
		'selectors' => array(),
		'template' => null, 
		'multilang' => true,
		'language' => '', // language name
		'edit' => true,
		'start' => 0, 
		'limit' => 15,
		'verbose' => false, 
		'debug' => false,
		'help' => false,
	);

	/**
	 * Template for individual live search result items
	 * 
	 * @var array
	 * 
	 */
	protected $itemTemplate = array(
		'id' => 0,
		'url' => '', // required
		'name' => '',
		'title' => '', // required
		'subtitle' => '',
		'summary' => '',
		'icon' => '',
		'group' => '',
		'status' => 0, 
		'modified' => 0, 
	);

	/**
	 * Allowed operators
	 * 
	 * @var array
	 * 
	 */
	protected $allowOperators = array(
		'=', '==', '!=', '*=', '~=', '%=', '^=', '$=', '<=', '>=', '<', '>'
	);

	/**
	 * Operator to use for single-word matches (if not overridden)
	 * 
	 * @var string
	 * 
	 */
	protected $singleWordOperator = '%=';

	/**
	 * Operator to use for multi-word matches (if not overridden)
	 * 
	 * @var string
	 * 
	 */
	protected $multiWordOperator = '~=';

	/**
	 * Default fields to search for pages
	 * 
	 * @var array
	 * 
	 */
	protected $defaultPageSearchFields = array('title');

	/**
	 * Are we currently in “view all” mode?
	 * 
	 * @var bool
	 * 
	 */
	protected $isViewAll = false;

	/**
	 * Order to render results in, by search type
	 * 
	 * @var array
	 * 
	 */
	protected $searchTypesOrder = array();

	/**
	 * Search types that are specifically excluded
	 * 
	 * @var array
	 * 
	 */
	protected $noSearchTypes = array();

	/**
	 * PaginatedArray to use for pagination, when applicable for “view all” mode
	 * 
	 * @var null|PaginatedArray
	 * 
	 */
	protected $pagination = null;

	/**
	 * Shared translation labels, defined in constructor
	 * 
	 * @var array
	 * 
	 */
	protected $labels = array();

	/**
	 * Construct
	 *
	 * @param Process|ProcessPageSearch $process
	 * @param array $liveSearch
	 * 
	 */
	public function __construct(Process $process = null, array $liveSearch = array()) {
		
		if($process) {
			$process->wire($this);
			if($process instanceof ProcessPageSearch) $this->process = $process;
			$searchFields = $this->wire()->config->ajax ? $process->searchFields2 : $process->searchFields;
			$a = explode(' ', $searchFields);
			if(count($a)) $this->defaultPageSearchFields = $a;
		}
		
		if(!empty($liveSearch)) {
			$this->liveSearchDefaults = array_merge($this->liveSearchDefaults, $liveSearch);
		}
		
		$findOperators = Selectors::getOperators(array(
			'compareType' => Selector::compareTypeFind, 
			'getIndexType' => 'none',
			'getValueType' => 'operator',
		));
		
		$this->allowOperators = array_unique(array_merge($this->allowOperators, $findOperators)); 
		
		$this->labels = array(
			'missing-query' => $this->_('No search specified'),
			'pages' => $this->_('Pages'),
			'trash' => $this->_('Trash'),
			'modules' => $this->_('Modules'), 
			'view-all' => $this->_('View All'),
			'search-results' => $this->_('Search Results'),
		);
		
		parent::__construct();
	}

	/**
	 * Set order of search types
	 * 
	 * @param array $types Names of types, in order
	 * 
	 */
	public function setSearchTypesOrder(array $types) {
		$this->searchTypesOrder = $types; 
	}

	/**
	 * Set types that should be excluded unless specifically asked for
	 * 
	 * @param array $types Names of types to exclude
	 * 
	 */
	public function setNoSearchTypes(array $types) {
		$this->noSearchTypes = $types;
	}

	/**
	 * Set default operators to use for searches (if query does not specify operator)
	 * 
	 * @param string $singleWordOperator
	 * @param string $multiWordOperator
	 * 
	 */
	public function setDefaultOperators($singleWordOperator, $multiWordOperator = '') {
		$this->singleWordOperator = $singleWordOperator;
		$this->multiWordOperator = empty($multiWordOperator) ? $singleWordOperator : $multiWordOperator;
	}
	
	/**
	 * Initialize live search
	 *
	 * @param array $presets Additional info to populate in liveSearchInfo
	 * @return array Current liveSearchInfo
	 *
	 */
	protected function init(array $presets = array()) {

		/** @var WireInput $input */
		$input = $this->wire('input');
		/** @var Sanitizer $sanitizer */
		$sanitizer = $this->wire('sanitizer');
		/** @var Fields $fields */
		$fields = $this->wire('fields');
		/** @var Templates $templates */
		$templates = $this->wire('templates');
		/** @var User $user */
		$user = $this->wire('user');
		/** @var Languages $languages */
		$languages = $this->wire('languages');
		/** @var AdminTheme|AdminThemeFramework $adminTheme */
		$adminTheme = $this->wire()->adminTheme; 

		$type = isset($presets['type']) ? $presets['type'] : '';
		$language = isset($presets['language']) ? $presets['language'] : '';
		$property = isset($presets['property']) ? $presets['property'] : '';
		$operator = isset($presets['operator']) ? $presets['operator'] : '';
		$template = isset($presets['template']) ? $presets['template'] : '';
		$limit = isset($presets['limit']) ? (int) $presets['limit'] : $this->liveSearchDefaults['limit'];
		$start = isset($presets['start']) ? (int) $presets['start'] : ($input->pageNum() - 1) * $limit;
		$selectors = array();
		$replaceOperator = '';
		$opHolders = array('<=' => '~@LT=', '>=' => '~@GT=', '<' => '~@LT', '>' => '~@GT');  // operator placeholders
		
		$q = empty($presets['q']) ? $input->get('q') : $presets['q'];
		if(empty($q)) $q = $input->get('admin_search'); // legacy name
		if(strpos($q, '~@') !== false) $q = str_replace('~@', '', $q); // disallow placeholder prefix
		if(empty($operator)) $q = str_replace(array_keys($opHolders), array_values($opHolders), $q);
		$q = $sanitizer->text($q, array('reduceSpace' => true));
		
		if($user->isSuperuser() && strpos($q, 'DEBUG') !== false) {
			$q = str_replace('DEBUG', '', $q);
			$presets['debug'] = true;
		}
		
		if(empty($q)) {
			// if no query, we've got nothing to do
			return $this->liveSearchDefaults;
		}

		if(empty($operator)) {
			// operator may be bundled into query: $q
			if(strpos($q, '~@') !== false) {
				foreach($opHolders as $op => $placeholder) { // <=, >=, <, >
					if(strpos($q, $placeholder) === false) continue;
					$replaceOperator = $placeholder;
					$operator = $op;
					break;
				}
				
			} else if(strpos($q, '==') !== false) {
				// forced equals operator
				$replaceOperator = '==';
				$operator = '=';

			} else if(strpos($q, '=') !== false) {
				// regular equals or other w/equals
				$replaceOperator = '=';
				$opChars = Selectors::getOperatorChars();
				if(preg_match('/([' . preg_quote(implode('', $opChars)) . ']{1,3}=)/', $q, $matches)) {
					if(in_array($matches[1], $this->allowOperators)) {
						$operator = $matches[1];
						$replaceOperator = $operator;
					} else {
						$q = str_replace($opChars, ' ', $q);
					}
				} else {
					// regular equals, use default operator	
				}
			}
			
			if($replaceOperator) {
				$q = str_replace($replaceOperator, ':', $q);
			}
		}
		
		if(empty($operator) || !in_array($operator, $this->allowOperators)) {
			$operator = strpos($q, ' ') ? $this->multiWordOperator : $this->singleWordOperator;
		}

		// check if type and property may be part of query: $q
		if(empty($type) && empty($property) && strpos($q, ':')) {
			// Search specifies a specific type "type:text", i.e. "users:ryan"
			list($type, $q) = explode(':', $q, 2);
			// live search type: pages, users, modules, fields, templates, comments, etc.
			$type = $sanitizer->name($type);
			if(strpos($type, '.') !== false) {
				// live search type includes a property, i.e. "pages.body", "users.first_name", etc. 
				list($type, $property) = explode('.', $type, 2);
			}
			if($type === 'pages') {
				// ok
			} else if($type) {
				// check if type refers to a template name or language
				$template = true;
				$language = true;
			} else {
				// search all types
			}
		} else if($type) {
			$template = true;
			$language = true;
		}
		
		if($language === true) {
			// check if type refers to a language
			$language = $languages ? $languages->get($type) : null;
			if($language && $language->id) {
				$language = $language->name;
				$template = null;
				$type = '';
			} else {
				$language = '';
			}
		}
		
		if($template === true) {
			// check if type refers to template name or language
			$template = $templates->get($type);
		}
		
		if($template && $template instanceof Template) {
			// does search type match the name of a template?
			$selectors[] = "template=$template->name";
			$type = '';
			// $type = 'pages';
		}
		
		$type = $sanitizer->name($type);
		$property = $sanitizer->fieldName($property);
		$q = trim($q);
		$value = $sanitizer->selectorValue($q);
		$lp = strtolower($property);

		if($property && ($fields->isNative($property) || $fields->get($property)) && !in_array($lp, $this->skipProperties)) {
			// we recognize this property as searchable, so add it to the selector
			if($lp == 'status' && !$user->isSuperuser() && $value > Page::statusHidden) $value = Page::statusHidden;
			$selectors[] = $property . $operator . $value;
		} else {
			// we did not recognize the property, so use field(s) defined in module instead
			$selectors[] = implode('|', $this->defaultPageSearchFields) . $operator . $value;
		}

		$help = strtolower($q) === 'help';
		if(!$help && $adminTheme && $q === $adminTheme->getLabel('search-help')) $help = true;
		
		$liveSearch = array_merge($this->liveSearchDefaults, $presets, array(
			'type' => $type,
			'property' => $property,
			'operator' => $operator,
			'q' => $q,
			'selectors' => $selectors,
			'template' => $template,
			'multilang' => $this->wire('languages') ? true : false,
			'language' => $language, 
			'start' => $start, 
			'limit' => $limit,
			'help' => $help,
		));
		
		if($this->isViewAll) {
			// variables for pagination
			$input->whitelist('type', $type);
			$input->whitelist('property', $property);
			$input->whitelist('operator', $operator);
			$input->whitelist('q', $q);
			if(!empty($liveSearch['language'])) $input->whitelist('language', $liveSearch['language']); 
		}

		return $liveSearch;
	}

	/**
	 * Execute live search and return JSON result
	 * 
	 * @param bool $getJSON Get results as JSON string? Specify false to get array instead.
	 * @return string|array
	 * 
	 */
	public function ___execute($getJSON = true) {

		/** @var WireInput $input */
		$input = $this->wire('input');

		$liveSearch = $this->init();

		if((int) $input->get('version') > 1) {
			// version 2+ keep results in native format, for future use
			$items = $this->find($liveSearch);
		} else {
			// version 1 is currently used by PW admin themes
			$items = $this->convertItemsFormat($this->find($liveSearch));
		}
		
		$result = array(
			'matches' => &$items
		);

		return $getJSON ? json_encode($result) : $items;
	}

	/**
	 * Render output for landing page to view all items of a particular type
	 * 
	 * Expects these GET vars to be present: 
	 *  - type
	 *  - operator
	 *  - property
	 *  - q
	 * 
	 * @return string
	 * @throws WireException
	 * 
	 */
	public function executeViewAll() {
	
		/** @var WireInput $input */
		$input = $this->wire('input');
		$this->isViewAll = true;
		
		$type = $input->get->pageName('type');
		$operator = $input->get('operator');
		$property = $input->get->fieldName('property');
		$language = $input->get->pageName('language');
		$q = $input->get->text('q');
		$this->pagination = new PaginatedArray();
		$this->wire($this->pagination);
		
		if(empty($q)) {
			$this->error($this->labels['missing-query']);
			return '';
		}
		
		if(false && ($type == 'pages' || $type == 'trash')) {
			// let Lister handle it
			$results = array();
		} else {
			$liveSearch = $this->init(array(
				'type' => $type,
				'property' => $property,
				'operator' => $operator,
				'q' => $q,
				'limit' => $this->liveSearchDefaults['limit'],
				'verbose' => true,
				'language' => $language, 
			));
			$results = $this->find($liveSearch);
		}
		
		if($this->process) {
			if($type) {
				$this->process->headline($this->pagination->getPaginationString(array(
					'label' => $this->labels['search-results'] . " - " . ucfirst($type),
					'count' => count($results)
				)));
			} else {
				$this->process->headline($this->labels['search-results']); 
			}
		}
	
		$out = $this->renderList($results); 
		
		return $out; 
	}
	
	/**
	 * Perform find of types, pages, modules
	 * 
	 * Result format that this find method expects from modules it calls the search() method from:
	 *
	 * $result = array(
	 *   'title' => 'Title of these items, used as the group label except where overridden item "group" property',
	 *   'url' => 'URL to view all items', // if omitted, one will be provided automatically
	 *   'total' => 999, // non-paginated total quantity (can be omitted if pagination not supported)
	 *   'items' => [
	 *     [
	 *      // required properties
	 *     'title' => 'Title of item',
	 *     'url' => 'URL to view or edit the item',
	 *      // optional properties:
	 *     'id' => 0, 
	 *     'name' => 'Name of item', 
	 *     'icon' => 'Optional icon name to represent the item, i.e. "gear" or "fa-gear"', 
	 *     'group' => 'Optionally group with other items having this group name, overrides $result[title]', 
	 *     'status' => int, // if item is a Page, status of page using Page::status* constants 
	 *     'summary' => 'Summary or description of item or excerpt of text that matched', // (recommended)
	 *     'subtitle' => 'Secondary title of item', // (recommended)
	 *     'modified' => int, // last modified date of item 
	 *     ], 
	 *     [ ... ], [ ... ], etc. 
	 *   )
	 * );
	 *
	 * @param array $liveSearch
	 * @return array Array of matches
	 *
	 */
	protected function find(array &$liveSearch) {

		$items = array();
		$user = $this->wire('user');
		$userLanguage = null;
		$q = $liveSearch['q'];
		$type = $liveSearch['type'];
		$foundTypes = array();
		$modulesInfo = array();
		$help = $liveSearch['help'];
		
		/** @var Modules $modules */
		$modules = $this->wire('modules');
		
		/** @var Languages $languages */
		$languages = $this->wire('languages');
		
		if($languages && $liveSearch['language']) {
			// change current user to have requested language, temporarily
			$language = $languages->get($liveSearch['language']);
			if($language && $language->id) {
				$userLanguage = $user->language;	
				$user->language = $language;
			}
		}
		
		if($type != 'pages' && $type != 'trash') {
			$modulesInfo = $modules->getModuleInfo('*', array('verbose' => true));
		}
		
		foreach($modulesInfo as $info) {

			if(empty($info['searchable'])) continue;
			$name = $info['name'];
			$thisType = $info['searchable'];
	
			if(!$this->useType($thisType, $type)) continue;
			if($type && $this->isViewAll && $type != $thisType) continue;
			if($type && stripos($thisType, $type) === false) continue;
			if(!empty($liveSearch['template']) && !empty($liveSearch['property'])) continue;
			if(!$user->isSuperuser() && !$modules->hasPermission($name, $user)) continue;
		
			$foundTypes[] = $thisType;
			$module = null;
			$result = array();
			$timer = null;
			
			try {
				/** @var SearchableModule $module */
				$module = $modules->getModule($name, array('noInit' => true));
				if(!$module) continue;
				$result = $module->search($q, $liveSearch); // see method phpdoc for $result format
				
			} catch(\Exception $e) {
				// ok
			}
			
			if(!$module || (empty($result['items']) && empty($liveSearch['help']))) continue;
			if(empty($result['total'])) $result['total'] = count($result['items']);
		
			if(!in_array($thisType, $this->searchTypesOrder)) $this->searchTypesOrder[] = $thisType;
			$order = array_search($thisType, $this->searchTypesOrder);
			$order = $order * 100;
			
			$title = empty($result['title']) ? "$info[title]" : "$result[title]";
			$n = $liveSearch['start'];
			$item = null;
			
			if($help) {
				foreach($result['items'] as $key => $item) {
					if($item['name'] != 'help') unset($result['items'][$key]); 
				}
				$result['items'] = array_merge($this->makeHelpItems($result, $thisType), $result['items']); 
			}
			
			foreach($result['items'] as $item) {
				$n++;
				$item = array_merge($this->itemTemplate, $item);
				$item['group'] = empty($item['group']) ? "$title" : "$item[group]";
				if(empty($item['group'])) $item['group'] = $title;
				$item['n'] = "$n/$result[total]";
				$items[$order] = $item;
				$order++;
			}
			
			//if($n && $n < $result['total'] && !$this->isViewAll && !$help) {
			if($n && $n < $result['total'] && !$help) {
				$url = isset($result['url']) ? $result['url'] : '';
				$items[$order] = $this->makeViewAllItem($liveSearch, $thisType, $item['group'], $result['total'], $url); 
			}
			
			if($this->isViewAll && $this->pagination && $type && !$help) {
				$this->pagination->setTotal($result['total']); 
				$this->pagination->setLimit($liveSearch['limit']);
				$this->pagination->setStart($liveSearch['start']); 
			}
		}
		
		if($type && !$help && !count($foundTypes) && !in_array($type, array('pages', 'trash', 'modules'))) {
			if(empty($liveSearch['template']) && !count($foundTypes)) {
				// if no types matched, and it’s going to skip pages, assume type is a property, and do a pages search
				$liveSearch = $this->init(array(
					'q' => $liveSearch['q'], 
					'type' => 'pages', 
					'property' => $type, 
					'operator' => $liveSearch['operator']
				));
				$type = 'pages';
			}
		}
		
		if(empty($type) || $type === 'pages' || $type === 'trash' || $liveSearch['template']) {
			// include pages in the search results
			if(!in_array('pages', $this->searchTypesOrder)) $this->searchTypesOrder[] = 'pages';
			$order = array_search('pages', $this->searchTypesOrder) * 100;
			foreach($this->findPages($liveSearch) as $item) {
				$items[$order++] = $item;
			}
		}

		// use built-in modules search when appropriate
		if($this->useType('modules', $type) && $this->wire('user')->isSuperuser()) {
			if(!in_array('modules', $this->searchTypesOrder)) $this->searchTypesOrder[] = 'modules';
			$order = array_search('modules', $this->searchTypesOrder) * 100;
			foreach($this->findModules($liveSearch, $modulesInfo) as $item) {
				$items[$order++] = $item;
			}
		}
		
		// add a debug item if requested to
		if(!empty($liveSearch['debug'])) {
			array_unshift($items, $this->makeDebugItem($liveSearch));
		}
		
		if($userLanguage) {
			// restore original language to user
			$user->language = $userLanguage;
		}
		
		ksort($items);
		
		if($help) $items = array_merge($this->makeHelpItems(array(), 'help'), $items); 

		return $items;
	}


	/**
	 * Find pages for live search
	 * 
	 * @param array $liveSearch
	 * @return array
	 * 
	 */
	protected function findPages(array &$liveSearch) {

		$user = $this->wire('user');
		$superuser = $user->isSuperuser();
		$pages = $this->wire('pages');
		$config = $this->wire('config');
		
		if(!empty($liveSearch['help'])) {
			$result = array('title' => 'pages', 'items' => array(), 'properties' => array('name', 'title'));
			if($this->wire('fields')->get('body')) $result['properties'][] = 'body';
			$result['properties'][] = $this->_('or any field name');
			return $this->makeHelpItems($result, 'pages');	
		}
		
		// a $pages->find() search will be included in the live search
		$selectors = &$liveSearch['selectors'];
		$selectors[] = "start=$liveSearch[start], limit=$liveSearch[limit]";

		if($this->process) {
			$repeaterID = $this->process->getRepeatersPageID();
			if($repeaterID) $selectors[] = "has_parent!=$repeaterID";
		}

		if($superuser) {
			// superuser only
			$selectors[] = "include=all";
		} else if($user->hasPermission('page-edit')) {
			// admin search mode and user has some kind of page-edit permission
			$selectors[] = "include=unpublished";
			// $selectors[] = "template=$editableTemplates";
			// $selectors[] = "status<" . Page::statusTrash;
		} else {
			// only show regular, non-hidden, non-unpublished pages
		}

		$selector = implode(', ', $selectors);
		if($this->process) $selector = $this->process->findReady($selector);
		
		$titles = array();
		$items = array();
		$matches = array('pages' => array(), 'trash' => array());
	
		try {
			if($this->useType('pages', $liveSearch['type'])) {
				$selector .= ', templates_id!=' . implode('|', $config->userTemplateIDs); // users are searched separately
				$items['pages'] = $pages->find("$selector, status<" . Page::statusTrash);
			}
		} catch(\Exception $e) {
		}
		try {
			if($superuser && $this->useType('trash', $liveSearch['type'])) {
				$items['trash'] = $pages->find("$selector, status>=" . Page::statusTrash);
			}
		} catch(\Exception $e) {
		}

		foreach($items as $type => $pageItems) {
			
			$n = $liveSearch['start'];
			$total = $pageItems->getTotal();
			$item = array();
			
			foreach($pageItems as $page) {
				/** @var Page $page */
				if(!$superuser && $page->isUnpublished() && !$page->editable()) continue;
				$isAdmin = $page->template == 'admin';
				$title = (string) $page->get('title|name');
				
				$item = array(
					'id' => $page->id,
					'name' => $page->name,
					'title' => $title,
					'subtitle' => $page->template->name,
					'summary' => $page->path,
					'url' => !$isAdmin && $page->editable() ? $page->editUrl(array('language' => true)) : $page->url(),
					'icon' => $page->getIcon(),
					'group' => '',
					'n' => (++$n) . '/' . $total,
					'modified' => $page->modified,
					'status' => $page->status,
				);
				
				if(!isset($titles[$title])) $titles[$title] = 0;
				$titles[$title]++;
				$item['group'] = $this->labels[$type];
				$matches[$type][] = $item;
			}
			
			if(!empty($item) && $total > count($matches[$type])) {
				$matches[$type][] = $this->makeViewAllItem($liveSearch, $type, $item['group'], $total, '');
			}
		}

		// merge all the matches together
		if(empty($matches['trash'])) {
			$matches = $matches['pages'];
		} else {
			$matches = array_merge($matches['pages'], $matches['trash']);
		}

		// if there any colliding titles, add modified date to the subtitle
		foreach($titles as $title => $qty) {
			if($qty < 2) continue;
			foreach($matches as $key => $item) {
				if($item['title'] !== $title) continue;
				$matches[$key]['subtitle'] .= " (" . wireRelativeTimeStr($item['modified'], true) . ")";
			}
		}

		return $matches;
	}

	/**
	 * Allow this search type?
	 * 
	 * @param string $type Type to check
	 * @param string $requestType Type specifically requested by user
	 * @return bool
	 * 
	 */
	protected function useType($type, $requestType = '') {
		if($requestType) return $type === $requestType; 
		return !in_array($type, $this->noSearchTypes); 
	}


	/**
	 * Find modules matching query
	 * 
	 * @param array $liveSearch
	 * @param array $modulesInfo
	 * @return array
	 * 
	 */
	protected function findModules(array &$liveSearch, array &$modulesInfo) {
		
		$q = $liveSearch['q'];
		$groupLabel = $this->labels['modules'];
		$items = array();
		$forceMatch = false;
		
		if(!empty($liveSearch['help'])) {
			$info = $this->wire('modules')->getModuleInfoVerbose('ProcessPageSearch');
			$properties = array();
			foreach(array_keys($info) as $property) {
				$value = $info[$property];
				if(!is_array($value)) $properties[$property] = $property;
			}
			$exclude = array('id', 'file', 'versionStr', 'core');
			foreach($exclude as $key) unset($properties[$key]); 
			$result = array(
				'title' => 'Modules',
				'items' => array(),
				'properties' => $properties
			);
			$items = $this->makeHelpItems($result, 'modules');
			return $items;
		}

		if($liveSearch['type'] === 'modules' && !empty($liveSearch['property'])) {
			// searching for custom module property
			$forceMatch = true;
			$infos = $this->wire('modules')->findByInfo(
				$liveSearch['property'] . $liveSearch['operator'] . 
				$this->wire('sanitizer')->selectorValue($q), 2
			);
		} else {
			// text-matching for all modules
			$infos = &$modulesInfo;
		}
			
		foreach($infos as $info) {
			$id = isset($info['id']) ? $info['id'] : 0;
			$name = $info['name'];
			$title = $info['title'];
			$summary = isset($info['summary']) ? $info['summary'] : '';
			if(!$forceMatch) {
				$searchText = "$name $title $summary";
				if(stripos($searchText, $q) === false) continue;
			}
			$item = array(
				'id' => $id,
				'name' => $name,
				'title' => $title,
				'subtitle' => $name,
				'summary' => $summary, 
				'url' => $this->wire('config')->urls->admin . "module/edit?name=$name",
				'group' => $groupLabel,
			);
			$item = array_merge($this->itemTemplate, $item);
			$items[] = $item;
		}
		
		$total = count($items); 
		$n = 0;
		foreach($items as $key => $item) {
			$n++;
			$items[$key]['n'] = "$n/$total";
		}
		
		return $items;
	}
	
	/**
	 * Convert items from native live search format (v2) to v1 format
	 * 
	 * v1 format is used by ProcessWire admin themes. 
	 * 
	 * @param array $items
	 * @return array
	 * 
	 */
	protected function convertItemsFormat(array $items) {
		
		$converted = array();
		$sanitizer = $this->wire('sanitizer');
		
		foreach($items as $item) {
			$a = array(
				'id' => $item['id'],
				'name' => (string) $item['name'],
				'title' => (string) $item['title'],
				'template_label' => (string) $item['subtitle'],
				'tip' => (string) $item['summary'],
				'editUrl' => (string) $item['url'],
				'type' => (string) $sanitizer->entities($item['group']),
				'icon' => isset($item['icon']) ? $item['icon'] : '',
			);
			
			if(!empty($item['status'])) {
				if($item['status'] & Page::statusUnpublished) $a['unpublished'] = true;	
				if($item['status'] & Page::statusHidden) $a['hidden'] = true;
				if($item['status'] & Page::statusLocked) $a['locked'] = true;
			}
			
			$converted[] = $a;
		}
	
		return $converted;
	}

	/**
	 * Make a search result item that displays debugging info
	 * 
	 * @param array $liveSearch
	 * @return array
	 * 
	 */
	protected function makeDebugItem($liveSearch) {
		$liveSearch['user_language'] = $this->wire('user')->language->name;
		$summary = print_r($liveSearch, true);
		return array_merge($this->itemTemplate, array(
			'id' => 0,
			'name' => 'debug',
			'title' => implode(', ', $liveSearch['selectors']),
			'subtitle' => $liveSearch['q'],
			'summary' => $summary,
			'url' => '#',
			'group' => 'Debug',
		));
	}
	
	/**
	 * Make a search result item that displays property info
	 *
	 * @param array $result Result array returned by a SearchableModule::search() method
	 * @param string $type
	 * @return array
	 *
	 */
	protected function makeHelpItems(array $result, $type) {
		
		$items = array();
		$helloLabel = $this->_('test');
		$usage1desc = $this->wire('sanitizer')->unentities($this->_('Searches %1$s for: %2$s')); 
		$usage2desc = $this->wire('sanitizer')->unentities($this->_('Searches “%1$s” property of %2$s for: %3$s')); 
		
		if($type === 'help') {
			$operators = ProcessPageSearch::getOperators();
			$summary = 
				$this->_('Examples use the “=” equals operator.') . " \n" . 
				$this->_('In some cases you can also use these:') . "\n";
			foreach($operators as $op => $label) {
				$summary .= "$op " . rtrim($label, '*') . "\n";
			}
			$items[] = array(
				'title' => $this->_('operators:'),
				'subtitle' => implode(', ', array_keys($operators)), 
				'summary' => $summary, 
				'group' => 'help',
				'url' => 'https://processwire.com/api/selectors/#operators'
			);
			if($this->wire('user')->isSuperuser() && $this->process) {
				$items[] = array(
					'title' => $this->_('configure'),
					'subtitle' => $this->_('Click here to configure search settings'),
					'url' => $url = $this->wire('modules')->getModuleEditUrl('ProcessPageSearch'),
					'group' => 'help',
				);
			}
			foreach($items as $key => $item) $items[$key] = array_merge($this->itemTemplate, $item); 
			return $items;
		}
		
		// include any items from result that had the name "help"
		foreach($result['items'] as $item) {
			if($item['name'] == 'help') $items[] = $item;
		}

		$items[] = array(
			'title' => "$type=$helloLabel",
			'subtitle' => sprintf($usage1desc, $type, $helloLabel),
		);
		
		if(!empty($result['properties'])) {
		
			if($type == 'pages' || $type == 'modules') {
				$property = 'title';
			} else if($type == 'fields' || $type == 'templates') {
				$property = 'label';
			} else {
				$property = reset($result['properties']);
			}
			
			$items[] = array(
				'title' => "$type.$property=$helloLabel",
				'subtitle' => sprintf($usage2desc, $property, $type, $helloLabel)
			);
			
			if($type === 'pages') {
				$items[] = array(
					'title' => "$property=$helloLabel", 
					'subtitle' => $this->_('Same as above (shorter syntax if no names collide)')
				);
				$templateName = 'basic-page';
				$items[] = array(
					'title' => "$templateName=$helloLabel",
					'subtitle' => sprintf($this->_('Limit results to template: %s'), $templateName),
				);
				$items[] = array(
					'title' => "$templateName.$property=$helloLabel",
					'subtitle' => sprintf($this->_('Limit results to %s field on template'), $property)
				);
				
			} else if($type === 'templates') {
				$fieldName = 'images';
				$items[] = array(
					'title' => "templates.fields=$fieldName",
					'subtitle' => sprintf($this->_('Find templates that have field: %s'), $fieldName)
				);
			} else if($type === 'fields') {
				$items[] = array(
					'title' => "fields.settings=ckeditor",
					'subtitle' => $this->_('Find fields with “ckeditor” in settings'),
				);
			}
			
			$properties = implode(', ', $result['properties']);

			if(strlen($properties) > 50) {
				$properties = $this->wire('sanitizer')->truncate($properties, 50) . ' ' . $this->_('(hover for more)');
			}

			$summary =
				sprintf($this->_('The examples use the “%s” property.'), $property) . "\n" .
				$this->_('You can also use any of these properties:') . "\n • " .
				implode("\n • ", $result['properties']);

			$items[] = array(
				'title' => $this->_('properties'),
				'subtitle' => $properties,
				'summary' => $summary
			);
		}
		
		$group = sprintf($this->_('%s help'), $type);
		
		foreach($items as $key => $item) {
			$item['name'] = 'help';
			$item['group'] = $group;
			$items[$key] = array_merge($this->itemTemplate, $item); 
		}
	
		return $items;
	}

	/**
	 * Make a search result item that displays a “view all” link
	 * 
	 * @param array $liveSearch
	 * @param string $type
	 * @param string $group
	 * @param int $total
	 * @param string $url If module provides its own view-all URL
	 * @return array
	 * 
	 */
	protected function makeViewAllItem(&$liveSearch, $type, $group, $total, $url = '') {
		
		if(!empty($url)) {
			// use provided url
		} else if($type == 'pages' || $type == 'trash' || !empty($liveSearch['template'])) {
			$url = $this->wire('page')->url();
			$url .= "?q=" . urlencode($liveSearch['q']) . "&live=1";
			if($type == 'trash') $url .= "&trash=1";
			if(!empty($liveSearch['template'])) {
				$url .= "&template=" . $liveSearch['template']->name;
			}
			if(!empty($liveSearch['property'])) {
				$url .= "&field=" . urlencode($liveSearch['property']);
			}
			if(!empty($liveSearch['operator'])) {
				$url .= "&operator=" . urlencode($liveSearch['operator']);
			}
		} else {
			$url = $this->wire('page')->url() . 'live/' . 
				'?q=' . urlencode($liveSearch['q']) .
				'&type=' . urlencode($type) .
				'&property=' . urlencode($liveSearch['property']) .
				'&operator=' . urlencode($liveSearch['operator']);
		}
		
		return array_merge($this->itemTemplate, array(
			'id' => 0,
			'name' => 'view-all',
			'title' => $this->labels['view-all'],
			'subtitle' => sprintf($this->_('%d items'), $total),
			'summary' => '',
			'url' => $url,
			'group' => $group,
		));
	}

	/**
	 * Render “view all” list
	 * 
	 * @param array $items
	 * @param string $prefix For CSS classes, default is "pw-search"
	 * @param string $class Class name for list, default is "list" which translates to "pw-search-list"
	 * @return string HTML markup
	 * 
	 */
	protected function ___renderList(array $items, $prefix = 'pw-search', $class = 'list') {

		$pagination = $this->pagination->renderPager();
		$group = '';
		$groups = array();
		$totals = array();
		$counts = array();
		$btn = $this->modules->get('InputfieldButton'); /** @var InputfieldButton $btn */
		$btn->aclass = "$prefix-view-all";
	
		foreach($items as $item) {
			if($item['group'] != $group) {
				$group = $item['group'];
				$groups[$group] = ''; 
			}
			$counts[$group] = isset($counts[$group]) ? $counts[$group] + 1 : 1;
			if(empty($totals[$group]) && isset($item['n'])) {
				list(, $total) = explode('/', $item['n']);
				$totals[$group] = (int) $total;
			}
			if($item['name'] === 'view-all') {
				if($pagination) continue;
				$btn->href = $item['url'];
				$btn->value = "$item[title] > $group (" . $totals[$group] . ")";
				$groups[$group] .= $btn->render();
			} else {
				$groups[$group] .= $this->renderItem($item, $prefix) . '<hr />';
			}
		}
		
		$totalGroups = array();
		foreach($groups as $group => $content) {
			$total = empty($totals[$group]) ? $counts[$group] : (int) $totals[$group];
			$totalGroups["$group ($total)"] = $content;
			unset($groups[$group]); 
		}
		
		$wireTabs = $this->wire('modules')->get('JqueryWireTabs');
		
		return
			"<div class='pw-search-$class'>" . 
				$pagination . 
				$wireTabs->render($totalGroups) . 
				$pagination . 
			"</div>";
	} 
	/**
	 * Render an item for the “view all” list
	 * 
	 * @param array $item
	 * @param string $prefix For CSS classes, default is "pw-search"
	 * @param string $class Class name for item, default is "item" which translates to "pw-search-item"
	 * @return string HTML markup
	 * 
	 */
	protected function ___renderItem(array $item, $prefix = 'pw-search', $class = 'item') {
	
		/** @var Sanitizer $sanitizer */
		$sanitizer = $this->wire('sanitizer');
		
		foreach(array('title', 'subtitle', 'summary', 'url') as $key) {
			if(isset($item[$key])) {
				$item[$key] = $sanitizer->entities($item[$key]);
			} else {
				$item[$key] = '';
			}
		}
		
		$title = "<strong class='$prefix-title'>$item[title]</strong>";
		$subtitle = empty($item['subtitle']) ? '' : "<br /><em class='$prefix-subtitle'>$item[subtitle]</em> ";
		$summary = empty($item['summary']) ? '' : "<br /><span class='$prefix-summary'>$item[summary]</span> ";
		
		return "\n\t<div class='$prefix-$class'><p><a href='$item[url]'>$title</a> $subtitle $summary</p></div>";
	}

}