<?php namespace ProcessWire;

/**
 * ProcessWire Page Table Fieldtype
 *
 * Concept by Antti Peisa
 * Code by Ryan Cramer
 * Sponsored by Avoine
 *
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 *
 */

class FieldtypePageTable extends FieldtypeMulti implements Module {

	public static function getModuleInfo() {
		return array(
			'title' => 'ProFields: Page Table',
			'version' => 8,
			'summary' => 'A fieldtype containing a group of editable pages.',
			'installs' => 'InputfieldPageTable',
			'autoload' => true, 
		);
	}

	/**
	 * Initialize the PageTable hooks
	 * 
	 */
	public function init() {
		$pages = $this->wire()->pages; 
		$pages->addHookAfter('delete', $this, 'hookPagesDelete');
		$pages->addHookAfter('deleteReady', $this, 'hookPagesDeleteReady');
		$pages->addHookAfter('trashed', $this, 'hookPagesTrashed');
		$pages->addHookAfter('unpublished', $this, 'hookPagesUnpublished');
		$pages->addHookAfter('published', $this, 'hookPagesPublished'); 
		$pages->addHookAfter('cloned', $this, 'hookPagesCloned'); 
		parent::init();
	}

	/**
	 * Get class name to use Field objects of this type (must be class that extends Field class)
	 *
	 * @param array $a Field data from DB (if needed)
	 * @return string Return class name or blank to use default Field class
	 *
	 */
	public function getFieldClass(array $a = array()) {
		return 'PageTableField';
	}

	/**
	 * Hook called when a page is deleted
	 * 
	 * Used to delete references to the page in any PageTable tables
	 * 
	 * @param HookEvent $event
	 *
	 */
	public function hookPagesDelete(HookEvent $event) {
		$database = $this->wire()->database;
		$page = $event->arguments(0); /** @var Page $page */
		foreach($this->wire()->fields as $field) {
			if(!$field->type instanceof FieldtypePageTable) continue; 
			$table = $database->escapeTable($field->table); 
			$sql = "DELETE FROM `$table` WHERE pages_id=:pages_id OR data=:data";
			$query = $database->prepare($sql); 
			$query->bindValue(':pages_id', (int) $page->id); 
			$query->bindValue(':data', (int) $page->id); 
			$query->execute();
		}
	}

	/**
	 * Hook called when a page is about to be deleted
	 * 
	 * This automatically trashes the PageTable pages that a deleted page owns, if the unpubOnDelete option is true. 
	 * This is really only applicable when PageTable pages are stored somewhere other than as children of the 
	 * deleted page. 
	 * 
	 * @param HookEvent $event
	 *
	 */
	public function hookPagesDeleteReady(HookEvent $event) {
		
		$pages = $this->wire()->pages;
		$page = $event->arguments(0); /** @var Page $page */
		
		foreach($page->template->fieldgroup as $field) {
			/** @var PageTableField $field */
			if(!$field->type instanceof FieldtypePageTable) continue; 
			if(is_null($field->trashOnDelete) && !is_null($field->autoTrash)) $field->trashOnDelete = $field->autoTrash;
			if(!$field->parent_id || !$field->trashOnDelete) continue; 
			$value = $page->getUnformatted($field->name); 
			if(!wireCount($value)) continue; 
			foreach($value as $item) {	
				/** @var Page $item */
				$deleted = false; 
				if($field->trashOnDelete == 2) {
					$pages->message("Auto Delete PageTable Item: $item->url", Notice::debug); 
					try {
						$pages->delete($item); 
						$deleted = true; 
					} catch(\Exception $e) {
						$pages->error($e->getMessage(), Notice::debug); 
					}
				}
				if(!$deleted) {
					if($item->isTrash()) continue; 
					$pages->message("Auto Trash PageTable Item: $item->url", Notice::debug);
					$pages->trash($item);
				}
			}
		}
	}

	/**
	 * Hook called when a page has been trashed
	 * 
	 * @param HookEvent $event
	 *
	 */
	public function hookPagesTrashed(HookEvent $event) {
		
		$pages = $this->wire()->pages;
		$page = $event->arguments(0); /** @var Page $page */
		
		foreach($page->template->fieldgroup as $field) {
			if(!$field->type instanceof FieldtypePageTable) continue;
			/** @var PageTableField $field */
			if(!$field->parent_id || !$field->unpubOnTrash) continue;
			$value = $page->getUnformatted($field->name);
			if(!wireCount($value)) continue;
			foreach($value as $item) {
				/** @var Page $item */
				$pages->message("Auto Unpublish PageTable Item: $item->url", Notice::debug);
				$of = $item->of();
				$item->of(false);
				$item->addStatus(Page::statusUnpublished); 
				$item->save();
				$item->of($of);
			}
		}
	}
	
	/**
	 * Hook called when a page has been unpublished
	 * 
	 * @param HookEvent $event
	 *
	 */
	public function hookPagesUnpublished(HookEvent $event) {
		
		$pages = $this->wire()->pages;
		$page = $event->arguments(0); /** @var Page $page */
		
		if($pages->cloning) return;
		
		foreach($page->template->fieldgroup as $field) {
			if(!$field->type instanceof FieldtypePageTable) continue;
			/** @var PageTableField $field */
			if(!$field->parent_id || !$field->unpubOnUnpub) continue;
			$value = $page->getUnformatted($field->name);
			if(!wireCount($value)) continue;
			foreach($value as $item) {
				/** @var Page $item */
				$of = $item->of();
				$item->of(false);
				if($field->unpubOnUnpub == 2) {
					$pages->message("Auto Hide PageTable Item: $item->url", Notice::debug);
					$item->addStatus(Page::statusHidden);
				} else {
					$pages->message("Auto Unpublish PageTable Item: $item->url", Notice::debug);
					$item->addStatus(Page::statusUnpublished);
				}
				$item->save();
				$item->of($of);
			}
		}
	}
	
	/**
	 * Hook called when a page has been published
	 * 
	 * @param HookEvent $event
	 *
	 */
	public function hookPagesPublished(HookEvent $event) {
		
		$pages = $this->wire()->pages;
		$page = $event->arguments(0);
		
		foreach($page->template->fieldgroup as $field) {
			if(!$field->type instanceof FieldtypePageTable) continue;
			/** @var PageTableField $field */
			if(!$field->parent_id || $field->unpubOnUnpub != 2) continue;
			$value = $page->getUnformatted($field->name);
			if(!wireCount($value)) continue;
			foreach($value as $item) {
				/** @var Page $item */
				if(!$item->hasStatus(Page::statusHidden)) continue; 
				$of = $item->of();
				$item->of(false);
				$pages->message("Auto Un-hide PageTable Item: $item->url", Notice::debug);
				$item->removeStatus(Page::statusHidden);
				$item->save();
				$item->of($of);
			}
		}
	}

	/**
	 * Hook called when a page is cloned
	 * 
	 * We use this to clone and save any PageTable fields owned by the cloned page. 
	 * This ensures we don't get two pages referring to the same PageTable fields. 
	 * 
	 * @param HookEvent $event
	 * 
	 */
	public function hookPagesCloned(HookEvent $event) {

		static $clonedIDs = array();

		$pages = $this->wire()->pages;
		$page = $event->arguments(0); /** @var Page $page */
		$copy = $event->arguments(1); /** @var Page $copy */
		
		if($page) {} // ignore

		if(in_array($copy->id, $clonedIDs)) return;
		$clonedIDs[] = $copy->id; 
	
		foreach($copy->template->fieldgroup as $field) {
			if(!$field->type instanceof FieldtypePageTable) continue; 
			/** @var PageTableField $field */
			//if(!$field->parent_id) continue; // let that be handled manually since recursive clones are already an option
			$parent = $field->parent_id ? $pages->get($field->parent_id) : $copy; 
			$value = $copy->getUnformatted($field->name); 
			if(!wireCount($value)) continue; 
			$newValue = $pages->newPageArray();
			foreach($value as $item) {
				try { 
					$newItem = null;
					if(!$field->parent_id && $copy->numChildren) {
						// value was already cloned by API with recursive option?
						$newItem = $pages->get("parent=$copy, name=$item->name, include=all"); 
						if(!$newItem->id) $newItem = null; 
					}
					if(!$newItem) $newItem = $pages->clone($item, $parent); 
					if($newItem->id) {
						$newValue->add($newItem); 
						$pages->message("Cloned item $item->path", Notice::debug); 
					}
				} catch(\Exception $e) {
					$pages->error("Error cloning $item->path"); 
					$pages->error($e->getMessage(), Notice::debug); 
				}
			}
			$copy->set($field->name, $newValue); 
			$copy->save($field->name); 
		}
	}

	/**
	 * Install our ajax lister at ready() time, if the conditions are right
	 * 
	 * Note that additional conditions are required and checked for by InputfieldPageTableAjax class. 
	 * 
	 */
	public function ready() {
		$config = $this->wire()->config;
		if(	$config->ajax && 
			$this->wire()->input->get('InputfieldPageTableField') && 
			$this->wire()->user->isLoggedin() && 
			$this->wire()->page->template->name === 'admin') {
			// handle ajax request to render table
			require_once($config->paths('InputfieldPageTable') . 'InputfieldPageTableAjax.php'); 
			new InputfieldPageTableAjax();
		}
	}

	/**
	 * Return the database schema used by this Fieldtype
	 * 
	 * @param Field $field
	 * @return array
	 * 
	 */
	public function getDatabaseSchema(Field $field) {
		$schema = parent::getDatabaseSchema($field);
		$schema['data'] = 'int NOT NULL';
		$schema['keys']['data'] = 'KEY data (data, pages_id, sort)';
		$schema['xtra']['all'] = false; // indicate that this schema doesn't hold all data managed by this fieldtype
		return $schema;
	}

	/**
	 * Get the match query for page selection, delegated to FieldtypePage
	 * 
	 * @param DatabaseQuerySelect|PageFinderDatabaseQuerySelect $query
	 * @param string $table
	 * @param string $subfield
	 * @param string $operator
	 * @param mixed $value
	 * @return DatabaseQuery
	 * 
	 */
	public function getMatchQuery($query, $table, $subfield, $operator, $value) {
		return $this->wire()->fieldtypes->FieldtypePage->getMatchQuery($query, $table, $subfield, $operator, $value); 	
	}

	/**
	 * Get the Inputfield used for input by PageTable
	 * 
	 * @param Page $page
	 * @param Field|PageTableField $field
	 * @return Inputfield
	 * 
	 */
	public function getInputfield(Page $page, Field $field) {
		/** @var InputfieldPageTable $inputfield */
		$inputfield = $this->modules->get('InputfieldPageTable');
		$value = $page->getUnformatted($field->name); 
		$inputfield->attr('value', $value);
		$templateID = $field->get('template_id');
		
		if(!$field->get('parent_id') && !empty($templateID) && $page->numChildren > wireCount($value)) {
			$orphans = $this->findOrphans($page, $field); 
			if(wireCount($orphans)) $inputfield->setOrphans($orphans); 
		}
		
		return $inputfield; 
	}

	/**
	 * Sanitize a PageTable value
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param int|object|string|WireArray $value
	 * @return PageArray
	 * 
	 */
	public function sanitizeValue(Page $page, Field $field, $value) {
		if(is_array($value) && wireCount($value)) {
			$value = $this->wakeupValue($page, $field, $value);
		}
		if(!$value instanceof PageArray) {
			return $this->wire()->pages->newPageArray();
		}
		foreach($value as $item) {
			if($this->isValidItem($page, $field, $item)) continue; 
			$value->remove($item); 
		}
		return $value; 
	}

	/**
	 * Return true or false as to whether the item is valid for this PageTable
	 * 
	 * @param Page $page
	 * @param Field|PageTableField $field
	 * @param Page $item
	 * @return bool
	 * 
	 */
	protected function isValidItem(Page $page, Field $field, Page $item) {
		$template_id = $field->get('template_id'); 
		if(is_array($template_id)) {
			if(in_array($item->template->id, $template_id)) return true; 
		} else {
			// old style for backwards compatibility
			if($template_id == $item->template->id) return true; 
		}
		return false; 
	}

	/**
	 * Return a blank value used by a PageTable
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @return PageArray
	 * 
	 */
	public function getBlankValue(Page $page, Field $field) {
		return $this->wire()->pages->newPageArray();
	}

	/**
	 * Return a formatted PageTable value, which is essentially a new PageArray with hidden items removed
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param PageArray $value
	 * @return PageArray
	 *
	 */
	public function ___formatValue(Page $page, Field $field, $value) {
		$formatted = $this->wire()->pages->newPageArray();
		if(!$value instanceof PageArray) return $formatted; 
		foreach($value as $item) {
			if($item->status >= Page::statusHidden) continue; 
			$formatted->add($item); 
		}
		$formatted->data('notSaveable', true); 
		return $formatted; 
	}

	/**
	 * Prep a value for storage
	 * 
	 * @param Page $page
	 * @param Field|PageTableField $field
	 * @param PageArray $value
	 * @throws WireException
	 * @return array
	 * 
	 */
	public function ___sleepValue(Page $page, Field $field, $value) {
		$sleepValue = array();
		if(!$value instanceof PageArray) {
			return $sleepValue;
		}
		if($field->get('sortfields')) {
			$value->sort($field->get('sortfields'));
		}
		if($value->data('notSaveable')) {
			throw new WireException("Field '$field->name' from page $page->id is not saveable because it is a formatted value.");
		}
		foreach($value as $item) {
			if(!$item->id) continue; 
			if(!$this->isValidItem($page, $field, $item)) continue; 
			$sleepValue[] = $item->id; 
		}
		return $sleepValue; 
	}

	/**
	 * Wake up a stored value
	 *
	 * @param Page $page
	 * @param Field|PageTableField $field
	 * @param array $value
	 * @return PageArray
	 *
	 */
	public function ___wakeupValue(Page $page, Field $field, $value) {
		
		if(!is_array($value) || !wireCount($value) || empty($field->template_id)) {
			return $this->getBlankValue($page, $field);
		}
		
		$template_id = $field->get('template_id'); 
		
		if(!is_array($template_id)) { 
			$template_id = $template_id ? array($template_id) : array();
		}
		
		if(wireCount($template_id) == 1) {
			$template = $this->wire()->templates->get(reset($template_id));
		} else {
			$template = null;
		}
		
		$loadOptions = array('cache' => false);
		if($template) $loadOptions['template'] = $template;
		
		$items = $this->wire()->pages->getById($value, $loadOptions);
		$sanitizer = $this->wire()->sanitizer;
		
		$sortfields = $field->get('sortfields');
		if($sortfields) {
			$sorts = array();
			foreach(explode(',', $sortfields) as $sortfield) {
				$sorts[] = $sanitizer->name(trim($sortfield));
			}
			if(wireCount($sorts)) $items->sort($sorts);
		}
		
		foreach($items as $item) {
			$item->setQuietly('_pageTableField', $field->id);
			$item->setQuietly('_pageTableParent', $page->id); 
		}
		
		return $items; 
	}

	/**
	 * Get information used by selectors for querying this field
	 * 
	 * @param Field $field
	 * @param array $data
	 * @return array
	 * 
	 */
	public function ___getSelectorInfo(Field $field, array $data = array()) {
		$info = $this->wire()->fieldtypes->FieldtypePage->getSelectorInfo($field, $data); 
		$info['operators'] = array(); // force it to be non selectable, subfields only
		return $info; 
	}

	/**
	 * Export configuration values for external consumption
	 *
	 * Use this method to externalize any config values when necessary.
	 * For example, internal IDs should be converted to GUIDs where possible.
	 *
	 * @param Field $field
	 * @param array $data
	 * @return array
	 *
	 */
	public function ___exportConfigData(Field $field, array $data) {
		$data = $this->wire()->fieldtypes->FieldtypePage->exportConfigData($field, $data);
		if(isset($data['template_id']) && is_array($data['template_id'])) {
			// convert template IDs to names
			$templates = $this->wire()->templates;
			$names = array();
			foreach($data['template_id'] as $id) {
				$template = $templates->get((int) $id); 
				if($template) $names[] = $template->name;
			}
			$data['template_id'] = $names;
		}
		return $data;
	}
	
	/**
	 * Convert an array of exported data to a format that will be understood internally (opposite of exportConfigData)
	 *
	 * @param Field $field
	 * @param array $data
	 * @return array Data as given and modified as needed. Also included is $data[errors], an associative array
	 *	indexed by property name containing errors that occurred during import of config data.
	 *
	 */
	public function ___importConfigData(Field $field, array $data) {
		
		$templates = $this->wire()->templates;
		$templateIDs = array();
		
		if(!empty($data['template_id'])) {
			if(!is_array($data['template_id'])) $data['template_id'] = array($data['template_id']); 
			$errorTemplates = array();
			foreach($data['template_id'] as $name) {
				$template = $templates->get($name); 	
				if($template) {
					$templateIDs[] = $template->id;
				} else {
					$errorTemplates[] = $name;
				}
			}
			$data['template_id'] = 0;
			if(wireCount($errorTemplates)) {
				$data['errors']['template_id'] = "Unable to find template(s): " . implode(', ', $errorTemplates);
			}
		}
		
		$data = $this->wire()->fieldtypes->FieldtypePage->importConfigData($field, $data);
		$data['template_id'] = $templateIDs; 
		
		return $data;
	}

	/**
	 * Return orphan pages that match the PageTable settings
	 * 
	 * Applicable only to PageTable fields utilizing the editable page's children as PageTable items.
	 * (i.e. no parent_id is set)
	 *
	 * @param Page $page
	 * @param Field $field
	 * @return PageArray Found orphans
	 *
	 */
	public function findOrphans(Page $page, Field $field) { 
		$pages = $this->wire()->pages;
		
		$orphans = $pages->newPageArray();
		if($field->get('parent_id')) return $orphans;
		
		$templateID = $field->get('template_id'); 
		if(!$templateID) return $orphans; // we need at least a template to do this
		
		if(!is_array($templateID)) $templateID = array($templateID); 
		
		$value = $page->getUnformatted($field->name); 
		if(!$value instanceof PageArray) $value = $pages->newPageArray();
		
		if($page->numChildren <= $value->count()) return $orphans; // nothing new
		
		$templateNames = array();
		$templates = $this->wire()->templates;
		
		foreach($templateID as $id) {
			$template = $templates->get($id);
			if($template) $templateNames[] = $template->name; 
		}
		
		$selector = "include=unpublished, template=" . implode('|', $templateNames); 
		if($value->count()) $selector .= ", id!=$value";
		
		foreach($page->children($selector) as $item) {
			$orphans->add($item);
		}
		
		return $orphans;	
	}

	/**
	 * Return configuration fields definable for each FieldtypePage
	 * 
	 * @param Field|PageTableField $field
	 * @return InputfieldWrapper
	 *
	 */
	public function ___getConfigInputfields(Field $field) {
		
		if($field->get('autoTrash') !== null) { // autoTrash was renamed to trashOnDelete
			if($field->get('trashOnDelete') === null) {	
				$field->set('trashOnDelete', $field->get('autoTrash')); 
			}
			unset($field->autoTrash); 
		}

		$inputfields = parent::___getConfigInputfields($field);

		$f = $inputfields->InputfieldAsmSelect;
		$f->attr('name', 'template_id');
		$f->label = $this->_('Select one or more templates for items');
		foreach($this->wire()->templates as $template) {
			if($template->flags & Template::flagSystem) continue; 
			$f->addOption($template->id, $template->name); 
		}
		$value = $field->get('template_id'); 
		if(!is_array($value)) $value = $value ? array($value) : array();
		$f->attr('value', $value); 
		$f->required = true; 
		$f->description = $this->_('These are the templates that will be used by pages managed from this field. You may wish to create a new template specific to the needs of this field.'); // Templates selection description
		$f->notes = $this->_('Please hit Save after selecting a template and the remaining configuration on the Input tab will contain more context.'); // Templates selection notes 
		$inputfields->add($f);

		$f = $inputfields->InputfieldPageListSelect; 
		$f->attr('name', 'parent_id'); 
		$f->label = $this->_('Select a parent for items'); 
		$f->description = $this->_('All items created and managed from this field will live under the parent you select here.');
		$f->notes = $this->_('If no parent is selected, then items will be placed as children of the page being edited.'); 
		$f->collapsed = $field->get('parent_id') ? Inputfield::collapsedNo : Inputfield::collapsedYes;
		$f->attr('value', (int) $field->get('parent_id')); 
		$inputfields->add($f);
		
		$fieldset = $inputfields->InputfieldFieldset; 
		$fieldset->label = $this->_('Page behaviors');
		$fieldset->showIf = 'parent_id!=""';
		$inputfields->add($fieldset);
		
		$labels = array(
			'nothing' => $this->_('Nothing'), 
			'trash' => $this->_('Trash them'),
			'delete' => $this->_('Delete them'),
			'unpub' => $this->_('Unpublish them'), 
			'hide' => $this->_('Hide them'), 
		);
	
		$f = $inputfields->InputfieldRadios;
		$f->attr('name', 'trashOnDelete');
		$f->label = $this->_('Delete');
		$f->description = sprintf($this->_('What should happen to "%s" items when the containing page is permanently deleted?'), $field->name); 
		$f->addOption(0, $labels['nothing']); 
		$f->addOption(1, $labels['trash']);
		$f->addOption(2, $labels['delete']); 
		$f->attr('value', (int) $field->get('trashOnDelete')); // aka autoTrash
		$f->columnWidth = 33; 
		$fieldset->add($f); 
		unset($f);

		$f = $inputfields->InputfieldRadios;
		$f->attr('name', 'unpubOnTrash');
		$f->label = $this->_('Trash');
		$f->description = sprintf($this->_('What should happen to "%s" items when the containing page is trashed?'), $field->name); 
		$f->addOption(0, $labels['nothing']);
		$f->addOption(1, $labels['unpub']);
		$f->attr('value', (int) $field->get('unpubOnTrash'));
		$f->columnWidth = 33; 
		$fieldset->add($f); 
		unset($f);
		
		$f = $inputfields->InputfieldRadios;
		$f->attr('name', 'unpubOnUnpub');
		$f->label = $this->_('Unpublish');
		$f->description = sprintf($this->_('What should happen to "%s" items when the containing page is unpublished?'), $field->name); 
		$f->addOption(0, $labels['nothing']);
		$f->addOption(1, $labels['unpub']);
		$f->addOption(2, $labels['hide']);
		$f->attr('value', (int) $field->get('unpubOnUnpub'));
		$f->columnWidth = 33; 
		$fieldset->add($f); 
	
		$f = $inputfields->InputfieldText; 
		$f->attr('name', 'sortfields'); 
		$f->label = $this->_('Sort fields'); 
		$f->description = $this->_('Enter the field name that you want your table to sort by. For a descending sort, precede the field name with a hyphen, i.e. "-date" rather than "date".'); // sort description 1 
		$f->description .= ' ' . $this->_('You may specify multiple sort fields by separating each with a comma, i.e. "last_name, first_name, -birthday".'); // sort description 2
		$f->notes = $this->_('Leave this blank for manual drag-and-drop sorting (default).'); 
		$f->collapsed = Inputfield::collapsedBlank;
		$f->attr('value', $field->get('sortfields')); 
		$inputfields->add($f); 
		
		return $inputfields;
	}
}

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