<?php namespace ProcessWire;

require_once(dirname(__FILE__) . '/FieldtypeRepeater.module');

/**
 * ProcessWire Fieldset (Page)
 *
 * Maintains a collection of fields as a fieldset via an independent Page.
 *
 * ProcessWire 3.x, Copyright 2024 by Ryan Cramer
 * https://processwire.com
 *
 * @property int $repeatersRootPageID
 *
 */

class FieldtypeFieldsetPage extends FieldtypeRepeater implements ConfigurableModule, FieldtypeDoesVersions {

	public static function getModuleInfo() {
		return array(
			'title' => __('Fieldset (Page)', __FILE__), // Module Title
			'summary' => __('Fieldset with fields isolated to separate namespace (page), enabling re-use of fields.', __FILE__), // Module Summary
			'version' => 1,
			'autoload' => true,
			'requires' => 'FieldtypeRepeater'
		);
	}

	/**
	 * Construct 
	 * 
	 */
	public function __construct() {
		parent::__construct();
		require_once(dirname(__FILE__) . '/FieldsetPage.php');
	}

	/**
	 * Get the FieldsetPage object for the given $page and $field, or NullPage if field is not on page
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param bool $createIfNotExists
	 * @return FieldsetPage|NullPage
	 *
	 */
	public function getFieldsetPage(Page $page, Field $field, $createIfNotExists = true) {
		
		/** @var FieldsetPage|NullPage $readyPage */
		
		if(!$page->hasField($field)) return new NullPage();
		
		$parent = $this->getRepeaterPageParent($page, $field);

		if($page->id) {
			$name = self::repeaterPageNamePrefix . $page->id;
			$readyPage = $parent->child("name=$name, include=all");
		} else {
			$name = '';
			$readyPage = new NullPage();
		}

		if(!$readyPage->id) {
			$class = $this->getCustomPageClass($field);
			$readyPage = $this->wire(new $class());
			$readyPage->template = $this->getRepeaterTemplate($field);
			if($parent->id) $readyPage->parent = $parent;
			$readyPage->addStatus(Page::statusOn);
			if($name) $readyPage->name = $name;
			if($name && $createIfNotExists) {
				$readyPage->save();
				$readyPage->setQuietly('_repeater_new', 1);
				$this->readyPageSaved($readyPage, $page, $field);
			} else {
				$readyPage->setQuietly('_repeater_new', 1);
			}
		}

		if($readyPage instanceof FieldsetPage) {
			$readyPage->setForPage($page);
			$readyPage->setForField($field);
			$readyPage->setTrackChanges(true);
		}

		return $readyPage; 
	}

	/**
	 * Convert a repeater Page to a PageArray for use with methods/classes expecting RepeaterPageArray
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param RepeaterPage|FieldsetPage $value Optionally add this item to it 
	 * @return RepeaterPageArray|PageArray
	 * 
	 */
	public function getRepeaterPageArray(Page $page, Field $field, $value = null) {
		if($value instanceof PageArray) return $value; 
		$pageArray = parent::getBlankValue($page, $field); 
		if($value instanceof Page) $pageArray->add($value); 
		$pageArray->resetTrackChanges();
		return $pageArray;
	}
	
	/**
	 * Get the class used for repeater Page objects
	 *
	 * @return string
	 *
	 */
	public function getPageClass() {
		return __NAMESPACE__ . "\\FieldsetPage";
	}

	/**
	 * Get a blank value of this type, i.e. return a blank PageArray
	 *
	 * @param Page $page
	 * @param Field $field
	 * @return FieldsetPage
	 *
	 */
	public function getBlankValue(Page $page, Field $field) {
		return $this->getFieldsetPage($page, $field, false); 
	}

	/**
	 * Return an InputfieldRepeater, ready to be used
	 *
	 * @param Page $page Page being edited
	 * @param Field $field Field that needs an Inputfield
	 * @return Inputfield
	 *
	 */
	public function getInputfield(Page $page, Field $field) {

		$hasField = $page->hasField($field->name);
		
		/** @var InputfieldRepeater $inputfield */
		$inputfield = $this->wire()->modules->get($this->getInputfieldClass());
		$inputfield->set('page', $page);
		$inputfield->set('field', $field);
		$inputfield->set('repeaterDepth', 0); 
		$inputfield->set('repeaterReadyItems', 0);  // ready items deprecated
		
		if($hasField) {
			$inputfield->set('repeaterMaxItems', 1);
			$inputfield->set('repeaterMinItems', 1); 
			$inputfield->set('singleMode', true); 
		}

		if($page->id && $hasField) {
			$item = $page->get($field->name); 
			if(!$item->id) $item = null;
		} else {
			$item = null;
		}

		$value = $this->getRepeaterPageArray($page, $field, $item);
		$inputfield->val($value); 
		
		return $inputfield;
	}

	/**
	 * Returns a page ready for use as a repeater
	 *
	 * @param Field $field
	 * @param Page $page The page that the repeater field lives on
	 * @return Page
	 *
	 */
	public function getBlankRepeaterPage(Page $page, Field $field) {
		return $this->getFieldsetPage($page, $field, true); 	
	}
	
	/**
	 * Get next page ready to be used as a new repeater item, creating it if it doesn't already exist
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param PageArray|Page $value
	 * @param array $notIDs Optional Page IDs that should be excluded from the next ready page
	 * @return Page
	 *
	 */
	public function getNextReadyPage(Page $page, Field $field, $value = null, array $notIDs = array()) {
		return $this->getFieldsetPage($page, $field, true); 
	}

	/**
	 * Given a raw value (value as stored in DB), return the value as it would appear in a Page object
	 *
	 * Something to note is that this wakeup function is different than most in that the $value it is given
	 * is just an array like array('data' => 123, 'parent_id' => 456) -- it doesn't actually contain any of the
	 * repeater page data other than saying how many there are and the parent where they are stored. So this
	 * wakeup function can technically do its job without even having the $value, unlike most other fieldtypes.
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param array|Page $value
	 * @return FieldsetPage|Page $value
	 *
	 */
	public function ___wakeupValue(Page $page, Field $field, $value) {
		if($value instanceof Page) return $value; 
		return $this->getFieldsetPage($page, $field, true); 
	}
	
	/**
	 * Return the database schema in predefined format
	 *
	 * @param Field $field
	 * @return array
	 *
	 */
	public function getDatabaseSchema(Field $field) {

		$schema = parent::getDatabaseSchema($field);
		
		unset(
			$schema['parent_id'],
			$schema['count'], 
			$schema['keys']['parent_id'], 
			$schema['keys']['count'],
			$schema['keys']['data_exact']
		); 
		
		$schema['data'] = 'int UNSIGNED NOT NULL';
		$schema['keys']['data'] = 'KEY `data` (`data`)';

		return $schema;
	}

	/**
	 * Update a DatabaseQuerySelect object to match a Page
	 *
	 * @param DatabaseQuerySelect $query
	 * @param string $table
	 * @param string $subfield
	 * @param string $operator
	 * @param string $value
	 * @return DatabaseQuerySelect
	 * @throws WireException
	 *
	 */
	public function getMatchQuery($query, $table, $subfield, $operator, $value) {
		$field = $query->field;

		if(in_array($subfield, array('count', 'parent', 'parent_id'))) {
			throw new WireException("The '$subfield' subfield option is not available for field '$field'");
		}

		if($subfield == 'data' || $subfield == 'id' || !$subfield) {

			if(!in_array($operator, array('=', '!=', '<', '>', '<=', '>='))) {
				throw new WireException("Operator $operator not supported for $field." . ($subfield ? $subfield : 'data'));
			}
			$value = (int) "$value";
			$query->where("($table.data{$operator}$value)");

		} else {

			$f = $this->wire()->fields->get($subfield);
			if(!$f) return $query; // unknown subfield

			// match fields from the repeater template, perform a separate find() operation for the subfield
			$templateID = $field->get('template_id');
			$value = $this->wire()->sanitizer->selectorValue($value);
			$ids = $this->wire()->pages->findIDs("templates_id=$templateID, include=all, $f->name$operator$value");

			// use the IDs found from the separate find() as our getMatchQuery
			if(count($ids)) {
				$query->where("$table.data IN(" . implode(',', $ids) . ")");
			} else {
				$query->where("1>2");  // force a non-match
			}
		}

		return $query;
	}

	/**
	 * Given an 'awake' value, as set by wakeupValue, convert the value back to a basic type for storage in DB.
	 *
	 * In this case, the sleepValue doesn't represent the actual value as they are stored in pages.
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param string|int|array|object $value
	 * @return int
	 *
	 */
	public function ___sleepValue(Page $page, Field $field, $value) {
		if($value instanceof PageArray) $value = $value->first();
		$sleepValue = $value instanceof Page ? (int) $value->id : 0;
		/*
		$sleepValue = array(
			'data' => "$id", 
			'count' => $id > 0 ? 1 : 0,
			'parent_id' => $id
		);
		*/
		return $sleepValue;
	}

	/**
	 * Export repeater value
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param RepeaterPageArray$value
	 * @param array $options
	 * @return array
	 *
	 */
	public function ___exportValue(Page $page, Field $field, $value, array $options = array()) {
		$value = $this->getRepeaterPageArray($page, $field, $value); // export as PageArray
		return parent::___exportValue($page, $field, $value, $options); 
	}

	/**
	 * Return the parent used by the repeater pages for the given Page and Field
	 * 
	 * Unlike regular repeaters, all page items for a given field share the same parent, regardless of owning page. 
	 *
	 * i.e. /processwire/repeaters/for-field-123/
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param bool $create
	 * @return Page
	 *
	 */
	public function getRepeaterPageParent(Page $page, Field $field, $create = true) {
		return $this->getRepeaterParent($field);
	}

	/**
	 * Handles the sanitization and convertion to RepeaterPage value
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param mixed $value
	 * @return FieldsetPage|Page
	 *
	 */
	public function sanitizeValue(Page $page, Field $field, $value) {
		
		if(is_string($value) || is_array($value)) {
			$value = parent::sanitizeValue($page, $field, $value);
		}
		
		if($value instanceof PageArray) {
			if($value->count()) {
				$value = $value->first();
			} else {
				$value = $this->getFieldsetPage($page, $field); 
			}
		}

		if(!$value instanceof Page) {
			$value = $this->getFieldsetPage($page, $field);
		}
		
		return $value;
	}

	/**
	 * Perform output formatting on the value delivered to the API
	 *
	 * This method is only used when $page->outputFormatting is true.
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param Page|PageArray $value
	 * @return Page
	 *
	 */
	public function ___formatValue(Page $page, Field $field, $value) {
		return $value; 
	}
	
	/**
	 * Load the given page field from the database table and return the value.
	 *
	 * - Return NULL if the value is not available.
	 * - Return the value as it exists in the database, without further processing.
	 * - This is intended only to be called by Page objects on an as-needed basis.
	 * - Typically this is only called for fields that don't have 'autojoin' turned on.
	 * - Any actual conversion of the value should be handled by the `Fieldtype::wakeupValue()` method.
	 *
	 * #pw-group-loading
	 *
	 * @param Page $page Page object to save.
	 * @param Field $field Field to retrieve from the page.
	 * @return FieldsetPage
	 *
	 */
	public function ___loadPageField(Page $page, Field $field) {
		return $this->getFieldsetPage($page, $field, true); 
	}

	/**
	 * Save the given field from page
	 *
	 * @param Page $page Page object to save.
	 * @param Field $field Field to retrieve from the page.
	 * @return bool True on success, false on DB save failure.
	 *
	 */
	public function ___savePageField(Page $page, Field $field) {
		
		$pages = $this->wire()->pages;
		
		if(!$page->id || !$field->id) return false;
		
		if($page->get('_cloning') instanceof Page) {
			// $page is being cloned, so lets also clone the FieldsetPage
			$source = $page->get('_cloning')->get($field->name);
			if(!$source->id) return false;
			$target = $pages->clone($source, null, false, array(
				'uncacheAll' => false,
				'set' => array('name' => self::repeaterPageNamePrefix . $page->id)
			));
			if(!$target->id) return false;
			$page->set($field->name, $target); 
		}

		$value = $page->get($field->name);
		if(!$value instanceof Page) return false;
		
		// make the FieldsetPage mirror unpublished/hidden state from its owner
		if($page->isUnpublished()) {
			if(!$value->isUnpublished()) $value->addStatus(Page::statusUnpublished); 
		} else {
			if($value->isUnpublished()) $value->removeStatus(Page::statusUnpublished); 
		}
		
		if($page->isHidden()) {
			if(!$value->isHidden()) $value->addStatus(Page::statusHidden); 
		} else {
			if($value->isHidden()) $value->removeStatus(Page::statusHidden); 
		}
		
		$pages->save($value, array('uncacheAll' => false));

		$database = $this->wire()->database;
		$table = $database->escapeTable($field->table);
		
		$sql = 
			"INSERT INTO `$table` (pages_id, data) VALUES(:pages_id, :data) " . 
			"ON DUPLICATE KEY UPDATE `data`=VALUES(`data`)";
		
		$query = $database->prepare($sql);
		$query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT);
		$query->bindValue(':data', $value->id, \PDO::PARAM_INT);
		$result = $query->execute();

		return $result;
	}

	/**
	 * Return configuration fields definable for each FieldtypePage
	 *
	 * @param Field|RepeaterField $field
	 * @return InputfieldWrapper
	 *
	 */
	public function ___getConfigInputfields(Field $field) {
		
		$inputfields = parent::___getConfigInputfields($field);
	
		/** @var InputfieldAsmSelect $f */
		$f = $inputfields->getChildByName('repeaterFields');
		$f->label = $this->_('Fields in fieldset'); 
		$f->description = $this->_('Define the fields that are used by this fieldset. You may also drag and drop fields to the desired order.');
		$f->notes = '';
		
		$field->repeaterLoading = FieldtypeRepeater::loadingOff;
		$field->repeaterMaxItems = 1;
		$field->repeaterMinItems = 1;
		
		include_once(dirname(__FILE__) . '/FieldsetPageInstructions.php'); 
		$inputfields->add(FieldsetPageInstructions($field));
		
		return $inputfields;
	}

	/**
	 * Populate the settings for a newly created repeater template
	 *
	 * @param Template $template
	 *
	 */
	protected function populateRepeaterTemplateSettings(Template $template) {
		parent::populateRepeaterTemplateSettings($template); 
		$template->pageLabelField = 'for_page_path';
	}

	/*************************************************************************
	 * VERSION SUPPORT
	 * 
	 */

	/**
	 * @var PagesVersions|null 
	 * 
	 */
	protected $pagesVersions = null;

	/**
	 * Get PagesVersions instance
	 * 
	 * #pw-internal
	 * 
	 * @return PagesVersions
	 * @throws WireException
	 * 
	 */
	public function pagesVersions() {
		if($this->pagesVersions === null) $this->pagesVersions = $this->wire('pagesVersions');
		if(!$this->pagesVersions) throw new WireException("PagesVersions module is not installed");
		return $this->pagesVersions;
	}

	/**
	 * Get the value for given page, field and version
	 *
	 * #pw-internal for FieldtypeDoesVersions interface
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param int $version
	 * @return FieldsetPage|null
	 * @since 3.0.232
	 *
	 */
	public function getPageFieldVersion(Page $page, Field $field, $version) {
		$value = $page->get($field->name);
		if(!$value instanceof FieldsetPage) return null;
		$item = $this->pagesVersions()->getPageVersion($value, $version);
		if(!$item) $item = $value;
		return $item;
	}

	/**
	 * Save version of given page field
	 *
	 * #pw-internal for FieldtypeDoesVersions interface
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param int $version
	 * @return bool
	 * @since 3.0.232
	 *
	 */
	public function savePageFieldVersion(Page $page, Field $field, $version) {
		$value = $page->get($field->name);
		if(!$value instanceof FieldsetPage) return false;
		return (bool) $this->pagesVersions()->savePageVersion($value, $version);
	}

	/**
	 * Restore version of given page field
	 *
	 * #pw-internal for FieldtypeDoesVersions interface
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param int $version
	 * @return bool
	 * @since 3.0.232
	 *
	 */
	public function restorePageFieldVersion(Page $page, Field $field, $version) {
		$value = $page->get($field->name);
		if(!$value instanceof FieldsetPage) return false;
		return $this->pagesVersions()->restorePageVersion($value, $version);
	}

	/**
	 * Delete version
	 *
	 * #pw-internal for FieldtypeDoesVersions interface
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param int $version
	 * @return bool
	 * @since 3.0.232
	 *
	 */
	public function deletePageFieldVersion(Page $page, Field $field, $version) {
		$value = $page->get($field->name);
		if(!$value instanceof FieldsetPage) return false;
		return $this->pagesVersions()->deletePageVersion($value, $version);
	}

}
