<?php namespace ProcessWire;

/**
 * ProcessWire Page Type Process
 *
 * Manage, edit add pages of a specific type in ProcessWire
 * 
 * For more details about how Process modules work, please see: 
 * /wire/core/Process.php 
 * 
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 * 
 * @property array $showFields Names of fields to show in the main list table  (default=['name'])
 * @property string $addLabel Translated "Add New" label
 * @property string $jsonListLabel What to use for 'label' property in JSON nav data (default='name')
 * 
 * @method string executeList()
 *
 */

class ProcessPageType extends Process implements ConfigurableModule, WirePageEditor {

	static public function getModuleInfo() {
		return array(
			'title' => __('Page Type', __FILE__), // getModuleInfo title
			'version' => 101, 
			'summary' => __('List, Edit and Add pages of a specific type', __FILE__), // getModuleInfo summary
			'permanent' => true, 
			'useNavJSON' => true, 
			'addFlag' => Modules::flagsNoUserConfig
		); 
	}

	/**
	 * @var PagesType
	 * 
	 */
	protected $pages;

	/**
	 * Predefined template for page type represented by this Process
	 * 
	 * @var null|Template
	 * 
	 */
	protected $template = null;

	/**
	 * ProcessPageEdit or ProcessPageAdd
	 * 
	 * @var WirePageEditor|ProcessPageEdit|null
	 * 
	 */
	protected $editor = null;

	/**
	 * Instance of ProcessPageLister
	 * 
	 * @var null|ProcessPageLister or ProcessPageListerPro
	 * 
	 */
	protected $lister = null;

	/**
	 * Requested Lister bookmark ID (when applicable)
	 * 
	 * @var string|null|bool
	 * 
	 */
	protected $listerBookmarkID = '';

	/**
	 * Construct
	 * 
	 */
	public function __construct() {
		$this->set('showFields', array('name')); 
		$this->set('addLabel', $this->_('Add New')); 
		$this->set('jsonListLabel', 'name'); // what to use for 'label' property in JSON nav data
		parent::__construct();
	}

	/**
	 * Init
	 * 
	 */
	public function init() {
	
		$config = $this->wire()->config;
		
		$config->scripts->add($config->urls('ProcessPageType') . 'ProcessPageType.js'); 
		$config->styles->add($config->urls('ProcessPageType') . 'ProcessPageType.css');

		$this->pages = $this->wire($this->page->name); 
		
		if(!$this->pages instanceof PagesType) {
			$this->pages = $this->wire()->pages; 
		}
	
		if($this->pages instanceof PagesType) {
			$this->template = $this->pages->getTemplate();
		}
		
		$this->initLister();

		parent::init();
	}

	/**
	 * Return true or false as to whether use of Lister should be attempted (template method)
	 * 
	 * @return bool
	 * 
	 */
	protected function useLister() {
		return false;
	}


	/**
	 * Initialize the $this->lister variable, if Lister is in use
	 * 
	 */
	protected function initLister() {
		
		if(!$this->useLister() || $this->lister) return;
		
		// init lister, but only if executing an action that will use it
		$modules = $this->wire()->modules;
		$segment = $this->wire()->input->urlSegment1;
		$user = $this->wire()->user;
		
		$listerSegments = array(
			'list',
			'config',
			'viewport',
			'reset',
			'actions',
			'save',
			'edit-bookmark',
		);
		
		if(strpos($segment, 'bm') === 0 && preg_match('/^bm[0-9O]+$/', $segment)) {
			// bookmark ID, i.e. users/bm42O1604139292
			$bookmarkID = $segment;
		} else {
			$bookmarkID = '';
		}
		
		if(empty($segment) || $bookmarkID || in_array($segment, $listerSegments)) { 
			if(!$user->hasPermission('page-lister')) return;
			if($modules->isInstalled('ProcessPageListerPro')) {
				$this->lister = $modules->get('ProcessPageListerPro');
				if($this->lister && method_exists($this->lister, 'isValid') && !$this->lister->isValid()) {
					$this->lister = null;
				}
			}
			if(!$this->lister && $modules->isInstalled('ProcessPageLister')) {
				// for regular Lister we init() in the getLister() method instead
				$this->lister = $modules->getModule('ProcessPageLister', array('noInit' => true));
			}
		}
		
		if($this->lister && $bookmarkID) {
			if($this->lister->className() === 'ProcessPageLister') {
				$this->lister->set('_' . $this->className(), true)->init();
			}
			$bookmarks = $this->lister->getBookmarksInstance();
			$bookmarkID = $bookmarks->_bookmarkID(ltrim($bookmarkID, 'bm'));
			$this->listerBookmarkID = $this->lister->checkBookmark($bookmarkID);
		}
	}
	
	// Lister-specific methods, all mapped directly to Lister or ListerPro
	public function ___executeConfig() { return $this->getLister()->executeConfig(); }
	public function ___executeViewport() { return $this->getLister()->executeViewport(); }
	public function ___executeReset() { return $this->getLister()->executeReset(); }
	public function ___executeActions() { return $this->getLister()->executeActions(); }
	public function ___executeSave() { return $this->getLister()->executeSave(); }
	public function ___executeEditBookmark() { return $this->getLister()->executeEditBookmark(); }

	/**
	 * Catch-all for bookmarks
	 *
	 * @return string
	 * @throws Wire404Exception
	 * @throws WireException
	 *
	 */
	public function ___executeUnknown() {
		if($this->useLister()) {
			$this->initLister();
			$lister = $this->getLister();
			if($lister && $this->listerBookmarkID) return $lister->executeUnknown();
		}
		throw new Wire404Exception("Unknown action", Wire404Exception::codeNonexist);
	}

	/**
	 * Main execution method, delegated to listing items in this page type
	 * 
	 * @return string
	 * 
	 */
	public function ___execute() {
		return $this->executeList();
	}

	/**
	 * List items in this page type
	 * 
	 * @return string
	 * 
	 */
	public function ___executeList() {
		$templateID = (int) $this->wire()->input->get('templates_id');
		if(!$templateID) $templateID = (int) $this->wire()->session->get($this->className() . 'TemplatesID');
		$selector = $templateID ? "templates_id=$templateID, " : "";
		return $this->renderList($selector . "limit=100, status<" . Page::statusMax);
	}

	/**
	 * Output JSON list of navigation items for this (intended to for ajax use)
	 * 
	 * @param array $options 
	 * @return string|array
	 *
	 */
	public function ___executeNavJSON(array $options = array()) {
	
		if(!isset($options['items'])) {
			$input = $this->wire()->input;
			$limit = (int) $input->get('limit');
			if(!$limit || $limit > 100) $limit = 100;
			$start = (int) $input->get('start');
			$pages = $this->pages->find("start=$start, limit=$limit");
			foreach($pages as $page) {
				if(!$page->editable()) $pages->remove($page);
				$status = ucwords($page->statusStr);
				if($status) $page->setQuietly('_labelClass', trim(str_replace(' ', ' PageListStatus', " $status")));
				$icon = $page->getIcon();
				if($icon) $page->setQuietly('_labelIcon', $icon);
			}
			$options['items'] = $pages;
			$parent = $this->pages instanceof PagesType ? $this->pages->getParent() : null;
			if($parent && $parent->id && $parent->addable()) {
				$options['add'] = 'add/';
			} else {
				$options['add'] = false;
			}
			$options['edit'] = "edit/?id={id}"; 
		}
		
		if(!isset($options['itemLabel'])) $options['itemLabel'] = $this->jsonListLabel;
		if(!isset($options['iconKey'])) $options['iconKey'] = '_labelIcon';
		
		
		return parent::___executeNavJSON($options); 
	}

	/*
	public function x___executeNavJSON(array $options = array()) {

		if(!isset($options['items'])) {
			$sanitizer = $this->wire()->sanitizer;
			$input = $this->wire()->input;
			$limit = (int) $input->get('limit');
			if(!$limit || $limit > 100) $limit = 100;
			$start = (int) $input->get('start');
			$options['items'] = $this->pages->find("start=$start, limit=$limit");
			if(empty($options['itemLabel'])) $options['itemLabel'] = $this->jsonListLabel;

			foreach($options['items'] as $page) {
				if(!$page->editable()) $options['items']->remove($page);
				$label = $page->get($options['itemLabel']);
				$a = array();
				foreach(explode(' ', $page->statusStr) as $status) {
					if($status) $a[] = 'PageListStatus' . ucfirst($status);
				}
				$label = $sanitizer->entities1($label);
				if(count($a)) $label = "<span class='" . implode(' ', $a) . "'>$label</span>";
					$page->setQuietly('_itemLabel', $label);
				$options['entityEncode'] = false;
			}

			$parent = $this->pages->getParent();
			if($parent && $parent->id && $parent->addable()) {
				$options['add'] = 'add/';
			} else {
				$options['add'] = false;
			}
			$options['edit'] = "edit/?id={id}";
		}

		$options['itemLabel'] = '_itemLabel';

		return parent::___executeNavJSON($options);
	}
	*/

	/**
	 * Get an instanceof ProcessPageLister or null if not applicable
	 * 
	 * @param string $selector
	 * @return ProcessPageLister|null 
	 * 
	 */
	public function getLister($selector = '') {

		$lister = $this->lister;
		if(!$lister) return null;

		$settings = $this->getListerSettings($lister, $selector);

		foreach($settings as $name => $value) {
			$lister->$name = $value;
		}
		
		if($lister->className() == 'ProcessPageListerPro') {
			// ProcessPageListerPro only
			$data = $this->wire()->modules->getConfig('ProcessPageListerPro');
			if(isset($data['settings'][$this->page->name])) {
				foreach($data['settings'][$this->page->name] as $key => $value) {
					$lister->$key = $value;
				}
			}
		} else if(!$lister->get('_' . $this->className())) {
			// ProcessPageLister only
			$lister->set('_'. $this->className(), true)->init(); // because it used noInit option on get()
		}
		
		return $lister;
	}

	/**
	 * Return an array of Lister settings, ready to be populated to Lister
	 * 
	 * @param ProcessPageLister $lister
	 * @param string $selector
	 * @return array
	 * 
	 */
	protected function getListerSettings(ProcessPageLister $lister, $selector) {
		
		$templates = $this->pages->getTemplates();
		$parents = $this->pages->getParents();
		$_selector = "template=" . implode('|', array_keys($templates)) . ", ";
		if(count($parents)) $_selector .= "parent=$parents, ";
		$_selector .= "include=all, $selector";
		$selector = rtrim($_selector, ", ");
		
		$settings = array(
			'initSelector' => $selector,
			'columns' => $this->showFields,
			'defaultSelector' => "name%=",
			'defaultSort' => 'name',
			'parent' => $this->page,
			'editURL' => './edit/',
			'addURL' => './add/',
			'delimiters' => array(),
			'allowSystem' => true,
			'allowIncludeAll' => true,
			'allowBookmarks' => true, 
			'showIncludeWarnings' => false,
			'toggles' => array('collapseFilters'),
		);
		
		if($lister->className() == 'ProcessPageLister') $settings['editMode'] = ProcessPageLister::windowModeDirect;
		
		if(count($templates) == 1) $settings['template'] = reset($templates);
		
		return $settings;
	}

	/**
	 * Get the page editor
	 * 
	 * @param string $moduleName One of 'ProcessPageEdit' or 'ProcessPageAdd' (or other that extends)
	 * @return ProcessPageEdit|ProcessPageAdd|WirePageEditor
	 * @throws WireException If requested editor moduleName not found
	 * 
	 */
	protected function getEditor($moduleName) {
		if($this->editor && $this->editor->className() == $moduleName) {
			return $this->editor;
		}
		$this->editor = $this->modules->get($moduleName);
		if(!$this->editor) {
			throw new WireException("Unable to load editor: $moduleName"); 				
		}
		if(wireInstanceOf($this->editor, array('ProcessPageEdit', 'ProcessPageAdd'))) {
			$this->editor->setEditor($this); // set us as the parent editor
			if($this->pages instanceof PagesType) {
				$templates = $this->pages->getTemplates();
				$parents = $this->pages->getParentIDs();
				$this->editor->setPredefinedTemplates($templates);
				$this->editor->setPredefinedParents($this->wire()->pages->getById($parents));
			}
		}
		return $this->editor;
	}

	/**
	 * Edit item of this page type
	 * 
	 * @return string
	 * 
	 */
	public function ___executeEdit() {
	
		$pageTitle = $this->page->get('title|name');
		$this->breadcrumb('../', $pageTitle); 
		$editor = $this->getEditor('ProcessPageEdit'); 
		$urlSegment = ucfirst($this->wire()->input->urlSegment2);
		$editPage = $this->getPage(); 
		$this->browserTitle("$pageTitle > " . $editPage->name); 
		
		if($urlSegment && (method_exists($editor, "___execute$urlSegment") || method_exists($editor, "execute$urlSegment"))) {
			// i.e. executeTemplate() and executeSaveTemplate()
			return $editor->{"execute$urlSegment"}(); 
		} else {
			// regular edit
			return $editor->execute();
		}
	}

	/**
	 * Add item of this page type
	 * 
	 * @return string
	 * 
	 */
	public function ___executeAdd() {
		$pageTitle = $this->page->get('title|name');
		$this->breadcrumb('../', $pageTitle); 
		/** @var ProcessPageAdd $editor */
		$editor = $this->getEditor("ProcessPageAdd"); 
		$editor->template = $this->template;
		try {
			$out = $editor->execute();
		} catch(\Exception $e) {
			$out = '';
			$this->error($e->getMessage());
		}
		$this->browserTitle("$pageTitle > $this->addLabel");
		return $out; 
	}

	/**
	 * Execute the "change template" action delegated to ProcessPageEdit
	 * 
	 * @return string
	 * 
	 */
	public function ___executeTemplate() {
		$editor = $this->getEditor('ProcessPageEdit');
		return $editor->executeTemplate();
	}

	/**
	 * Execute saving changes of the "change template" action delegated to ProcessPageEdit
	 * 
	 * @return string
	 * 
	 */
	public function ___executeSaveTemplate() {
		$editor = $this->getEditor('ProcessPageEdit');
		return $editor->executeSaveTemplate();
	}

	/**
	 * Render Lister output of pages
	 * 
	 * This is used as an alternative to the built-in item list when Lister/ListerPro is available.
	 * 
	 * @param string $selector Selector string for pages
	 * @param array $pagerOptions Not currently used by Lister
	 * @return string
	 * @throws WireException
	 * 
	 */
	protected function renderLister($selector = '', $pagerOptions = array()) {
		if($pagerOptions) {} // ignore
		$lister = $this->getLister($selector);
		if(!$lister) throw new WireException("Lister not available");
		return $lister->execute();
	}

	/**
	 * Render page list
	 * 
	 * When Lister/ListerPro is available, this will delegate to the renderLister() method instead. 
	 * When not available, it will render the list itself. 
	 * 
	 * @param string $selector Selector string for pages
	 * @param array $pagerOptions
	 *
	 * @return string
	 * 
	 */
	protected function renderList($selector = '', $pagerOptions = array()) {
		
		if($this->lister && $this->useLister()) {
			// delegate to Lister/ListerPro when available
			return $this->renderLister($selector, $pagerOptions);
		}
		
		$out = '';

		if(!$this->pages instanceof PagesType || count($this->pages->getTemplates()) != 1) {
			$form = $this->getTemplateFilterForm();		
			$out = $form->render();
		}

		/** @var MarkupAdminDataTable $table */
		$table = $this->wire()->modules->get("MarkupAdminDataTable"); 
		$table->setEncodeEntities(false); 
		$fieldNames = $this->showFields; 
		$fieldLabels = $fieldNames; 

		foreach($fieldLabels as $key => $name) {
			if($name == 'name') {
				$fieldLabels[$key] = $this->_('Name'); // Label for 'name' field
				continue; 
			}
			$field = $this->wire()->fields->get($name); 	
			if($field) { 
				$label = $field->getLabel();
				$fieldLabels[$key] = htmlentities($label, ENT_QUOTES, "UTF-8");
			}
		}

		$table->headerRow($fieldLabels); 
		$pages = $this->pages->find($selector); 
		$numRows = 0;

		foreach($pages as $page) {
			if(!$page->editable()) continue;
			$n = 0; 
			$row = array();
			foreach($fieldNames as $name) {
				if(!$n) {
					$value = htmlentities($page->getUnformatted($name), ENT_QUOTES, 'UTF-8') . ' ';
					$status = '';
					if($page->hasStatus(Page::statusUnpublished)) $status .= 'PageListStatusUnpublished ';
					if($page->hasStatus(Page::statusHidden)) $status .= 'PageListStatusHidden ';
					if($status) $value = "<span class='" . trim($status) . "'>$value</span>";
					$row[$value] = "edit/?id={$page->id}";
				} else {
					$row[] = $this->renderListFieldValue($name, $page->getUnformatted($name)); 
				}
				$n++;
			}
			$table->row($row); 
			$numRows++;
		}

		if($this->wire()->page->addable()) $table->action(array($this->addLabel => 'add/')); 

		if($pages->getTotal() > count($pages)) {
			/** @var MarkupPagerNav $pager */
			$pager = $this->modules->get("MarkupPagerNav"); 
			$out .= $pager->render($pages, $pagerOptions);
		}

		if(!$numRows) $out .= $this->renderEmptyList($pages);

		$out .= $table->render();

		return $out; 
	}

	/**
	 * Render an empty page list
	 * 
	 * @param PageArray $pages
	 * @return string
	 * 
	 */
	protected function renderEmptyList(PageArray $pages) {
		$out = "<p>" . $this->_('No items to display yet.') . "</p>";
		return $out; 
	}

	/**
	 * Return a value for output in list table
	 * 
	 * Only used if Lister/ListerPro is not available. 
	 * 
	 * @param string $name Name of property
	 * @param mixed $value Value of property
	 * @return string
	 * 
	 */
	protected function renderListFieldValue($name, $value) {
		if($name) {} // ignore
		if(is_string($value) || is_int($value)) return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); 
		if(is_array($value)) return htmlspecialchars(print_r($value, true), ENT_QUOTES, 'UTF-8'); 
		if(is_object($value)) {
			if($value instanceof PageArray) {
				$item = $value->first();
				if($item && $item->title) {
					$out = $value->implode("\n", '{title} (~{name}~)');
					$out = nl2br($this->wire('sanitizer')->entities1($out));
					$out = str_replace(array('(~', '~)'), array('<span class="detail">(', ')</span>'), $out);
				} else {
					$out = $value->implode("\n", 'name'); 
				}
				return $out;
			} else if($value instanceof WireArray) {
				$out = '';	
				foreach($value as $k => $v) {
					$out .= $v->name . ", ";
				}
				return nl2br(rtrim($out, ", ")); 

			} else if($value instanceof Wire) {
				if($value->name) return $value->name; 
				return (string) $value; 
			}
		}
		return '';
	}

	/**
	 * Get the filter-by-template form
	 * 
	 * Only used if Lister/ListerPro is not available. 
	 * 
	 * @return InputfieldForm
	 * 
	 */
	protected function getTemplateFilterForm() {

		/** @var InputfieldForm $form */
		$form = $this->modules->get("InputfieldForm"); 
		$form->attr('id', 'template_filter_form'); 
		$form->attr('method', 'get'); 
		$form->attr('action', './list'); 

		/** @var InputfieldSelect $field */
		$field = $this->modules->get("InputfieldSelect"); 
		$field->attr('id+name', 'templates_id'); 
		$field->label = $this->_('Filter by Template'); 
		$field->addOption('', $this->_('Show All')); 
		$field->icon = 'filter';
		$field->collapsed = Inputfield::collapsedBlank;
		
		$templates = $this->pages instanceof PagesType ? $this->pages->getTemplates() : array();
		if(!count($templates)) $templates = $this->wire('templates');

		foreach($templates as $template) {
			$field->addOption($template->id, $template->name); 
		}

		$filterName = $this->className . 'TemplatesID';
		$templatesId = (int) $this->wire()->input->get('templates_id');
		if($templatesId) {
			$this->wire()->session->set($filterName, (int) $templatesId); 
		}

		$filterValue = (int) $this->wire()->session->get($filterName); 
		if($filterValue) $this->template = $this->wire()->templates->get($filterValue); 

		$field->attr('value', $filterValue); 
		$form->append($field); 

		return $form;
	}

	/**
	 * Module config
	 * 
	 * @param array $data
	 * @return InputfieldWrapper
	 * 
	 */
	public function getModuleConfigInputfields(array $data) {

		$showFields = isset($data['showFields']) ? $data['showFields'] : array();
		$fields = array('name'); 
		foreach($this->wire()->fields as $field) {
			$fields[] = $field->name;
		}

		/** @var InputfieldWrapper $inputfields */
		$inputfields = $this->wire(new InputfieldWrapper());
		/** @var InputfieldAsmSelect $f */
		$f = $this->wire()->modules->get('InputfieldAsmSelect'); 
		$f->label = $this->_("What fields should be displayed in the page listing?");
		$f->attr('id+name', 'showFields'); 
		foreach($fields as $name) $f->addOption($name); 
		$f->attr('value', $showFields); 
		$inputfields->add($f);

		return $inputfields;
	}

	/**
	 * Get page being edited (for WirePageEditor interface)
	 * 
	 * @return NullPage|Page
	 * 
	 */
	public function getPage() {
		if($this->editor) return $this->editor->getPage();
		return $this->wire()->pages->newNullPage();
	}
	
	/**
	 * Search for items containing $text and return an array representation of them
	 *
	 * Implementation for SearchableModule interface
	 *
	 * @param string $text Text to search for
	 * @param array $options Options to modify behavior:
	 *  - `edit` (bool): True if any 'url' returned should be to edit items rather than view them
	 *  - `multilang` (bool): If true, search all languages rather than just current (default=true).
	 *  - `start` (int): Start index (0-based), if pagination active (default=0).
	 *  - `limit` (int): Limit to this many items, if pagination active (default=0, disabled).
	 * @return array
	 *
	 */
	public function search($text, array $options = array()) {

		$page = $this->getProcessPage();
		$this->pages = $this->wire($page->name);
		$templates = $this->pages->getTemplates();
		
		/** @var Languages $languages */
		$page = $this->getProcessPage();

		$result = array(
			'title' => $page->id ? $page->title : $this->className(),
			'items' => array(),
		);
	
		if(!empty($options['help'])) {
			$result['properties'] = array('name');
			foreach($templates as $template) {
				foreach($template->fieldgroup as $field) {
					if($field->type instanceof FieldtypePassword) continue;
					if($field->type instanceof FieldtypeFieldsetOpen) continue;
					$result['properties'][] = $field->name;
				}
			}
			return $result;
		}
		
		$text = $this->wire()->sanitizer->selectorValue($text);
		$property = empty($options['property']) ? 'name' : $options['property'];
		$operator = isset($options['operator']) ? $options['operator'] : '%=';
		$selector = "$property$operator$text, ";
		if(isset($options['start'])) $selector .= "start=$options[start], ";
		if(!empty($options['limit'])) $selector .= "limit=$options[limit], ";
		$items = $this->pages->find(trim($selector, ", ")); 
	
		foreach($items as $item) {
			$result['items'][] = $this->getSearchItemInfo($item, $options); 
		}

		return $result;
	}
	
	protected function getSearchItemInfo(Page $item, array $options) {
		return array(
			'id' => $item->id,
			'name' => $item->name,
			'title' => $item->get('title|name'),
			'subtitle' => $item->template->name,
			'summary' => '',
			'icon' => $item->getIcon(),
			'url' => empty($options['edit']) ? $item->url() : $item->editUrl()
		);
	}
	
}
