<?php namespace ProcessWire;

/**
 * ProcessWire Page Table Inputfield
 *
 * Concept by Antti Peisa
 * Code by Ryan Cramer
 * Sponsored by Avoine
 *
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 * 
 * @todo add renderValue support (perhaps delegating to Fieldtype::markupValue), likewise for repeaters. 
 * 
 * @property int $parent_id
 * @property int $template_id
 * @property string $columns
 * @property string $nameFormat
 * @property int|bool $noclose
 * @property string $blankLabel
 *
 * @method string renderTable(array $columns)
 * 
 */

class InputfieldPageTable extends Inputfield {

	public static function getModuleInfo() {
		return array(
			'title' => __('ProFields: Page Table', __FILE__), // Module Title
			'summary' => __('Inputfield to accompany FieldtypePageTable', __FILE__), // Module Summary
			'version' => 14,
			'requires' => 'FieldtypePageTable'
		);
	}

	/**
	 * Labels for native fields, indexed by native field name
	 * 
	 * @var array
	 * 
	 */
	protected $nativeLabels = array();

	/**
	 * Array of Template objects used for each row
	 *
	 * @var array
	 *
	 */
	protected $rowTemplates = array();

	/**
	 * Possible orphan pages that may be added (set by the Fieldtype)
	 * 
	 * @var PageArray|null
	 * 
	 */
	protected $orphans = null;

	/**
	 * Whether or not a separate "edit" column is needed
	 * 
	 * @var bool
	 * 
	 */
	protected $needsEditColumn = false;

	/**
	 * True when in renderValue mode
	 * 
	 * @var bool
	 * 
	 */
	protected $renderValueMode = false;

	/**
	 * Initialize and establish default values
	 * 
	 */
	public function init() {

		// fieldtype and inputfield config settings
		$this->set('parent_id', 0); 
		$this->set('template_id', 0); // placeholder only 
		$this->set('columns', ''); 
		$this->set('nameFormat', '');
		$this->set('noclose', 0);
		$this->set('blankLabel', $this->_('[blank]'));

		// local settings
		$this->nativeLabels = array(
			'id' => $this->_x('ID', 'th'), 
			'name' => $this->_x('Name', 'th'), 
			'created' => $this->_x('Created', 'th'), 
			'modified' => $this->_x('Modified', 'th'),
			'published' => $this->_x('Published', 'th'), 
			'modifiedUser' => $this->_x('Modified By', 'th'), 
			'createdUser' => $this->_x('Created By', 'th'), 
			'url' => $this->_x('URL', 'th'), 
			'path' => $this->_x('Path', 'th'), 
			'template' => $this->_x('Template', 'th'), 
			'parent' => $this->_x('Parent', 'th'), 
			'numChildren' => $this->_x('Children', 'th'), 
			'status' => $this->_x('Status', 'th'), 
		);

		parent::init();
	}
	
	public function renderReady(?Inputfield $parent = null, $renderValueMode = false) {
		$this->addClass('InputfieldNoFocus', 'wrapClass');
		$jQueryUI = $this->wire()->modules->get('JqueryUI'); /** @var JqueryUI $jQueryUI */
		$jQueryUI->use('modal'); 
		return parent::renderReady($parent, $renderValueMode);
	}

	/**
	 * Render the PageTable Inputfield
	 * 
	 * @return string
	 * 
	 */
	public function ___render() {
		
		$sanitizer = $this->wire()->sanitizer;
		$modules = $this->wire()->modules;
		$process = $this->wire()->process;
		$config = $this->wire()->config;
		$input = $this->wire()->input;

		// make sure we've got enough info to generate a table
		$errors = array();
		if(!count($this->rowTemplates)) $errors[] = $this->_('Please configure this field with a template selection before using it.');
		if(!$this->columns) $errors[] = $this->_('Please enter one or more columns in your field settings before using this field.'); 
		if(count($errors)) return "<p class='ui-state-error'>" . implode('<br />', $errors) . "</p>";

		// determine what columns we'll show in the table
		$columnsString = $this->columns ? $this->columns : $this->getConfigDefaultColumns();
		$columns = array();	
		
		foreach(explode("\n", $columnsString) as $column) {
			$width = 0; 
			if(strpos($column, '=') !== false) list($column, $width) = explode('=', $column); 
			$column = trim($column); 
			$width = (int) $width; 
			$columns[$column] = $width; 
		}

		// render the table
		$out = $this->renderTable($columns);
		if($this->renderValueMode) return $out;

		$editID = (int) $input->get('id');
		if(!$editID && $process instanceof WirePageEditor) $editID = $process->getPage()->id;
		$parentID = $this->parent_id ? $this->parent_id : $editID;
		$version = $this->version();

		// render the 'Add New' buttons for each template
		$btn = '';
		if($version) {
			$out .= "<span class='detail'>" . $this->_('Items can be added or deleted when editing the live version only.') . "</span>";
		} else {
			foreach($this->rowTemplates as $template) {
				/** @var Template $template */
				/** @var InputfieldButton $button */
				$button = $modules->get('InputfieldButton');
				$button->icon = 'plus-circle';
				$button->value = count($this->rowTemplates) == 1 ? $this->_x('Add New', 'button') : $template->getLabel();

				$url = $config->urls->admin . "page/add/" .
					"?modal=1&template_id=$template->id&parent_id=$parentID" .
					"&context=PageTable&version=$version";
				if($this->nameFormat) {
					$url .= "&name_format=" . $sanitizer->entities($this->nameFormat);
				}
				$btn .= "<span class='InputfieldPageTableAdd' data-url='$url'>" . $button->render() . "</span>";
			}
		}
		

		if(count($this->rowTemplates) > 1) $btn = "<small>$btn</small>";
		$out .= "<div class='InputfieldPageTableButtons ui-helper-clearfix'>$btn</div>";
		

		if(!$input->get('InputfieldPageTableField')) {
			$url = "./?id=$editID&InputfieldPageTableField=$this->name&version=$version"; 
			$out = "<div class='InputfieldPageTableContainer' data-url='$url' data-noclose='$this->noclose'>$out</div>";
			// input for sorting purposes
			$value = $sanitizer->entities($this->attr('value')); 
			$name = $sanitizer->entities($this->attr('name')); 
			$out .= "<input type='hidden' name='$name' class='InputfieldPageTableSort' value='$value' />";
			$out .= "<input type='hidden' name='{$name}__delete' class='InputfieldPageTableDelete' value='' />";
			
			if($this->orphans && count($this->orphans) && !$version) {
				$out .= "<p class='InputfieldPageTableOrphans'>";
				$out .= "<span>" . $this->_('Children were found that may be added to this table. Check the box next to any you would like to add.') . "</span> ";
				if(count($this->orphans) > 1) {
					$out .= "<br /><a class='InputfieldPageTableOrphansAll' href='#'>" . $this->_('Select all') . "</a> ";
				}
				foreach($this->orphans as $item) {
					$label = $item->title;
					if(!strlen($label)) $label = $item->name; 
					$out .= 
						"&nbsp; <label>" . 
						"<input type='checkbox' class='uk-checkbox' name='{$this->name}__add_orphan[]' value='$item->id' /> " . 
						"<span class='detail'>$label</span>" . 
						"</label> &nbsp; ";
				}
				$out .= "</p>";
			}
		}
		
		return $out; 
	}

	/**
	 * Render non-editable value
	 * 
	 * @return string
	 * 
	 */
	public function ___renderValue() {
		$this->renderValueMode = true;
		$out = $this->render();
		$this->renderValueMode = false;
		return $out;
	}

	/**
	 * Render the outputted PageTable <table> 
	 * 
	 * @param array $columns Array of column name => width percent
	 * @return string
	 * 
	 */
	protected function ___renderTable(array $columns) {
		$fields = $this->wire()->fields;

		$this->needsEditColumn = false;
		/** @var PageArray $value */
		$value = $this->attr('value'); 
		$this->wire()->modules->get('MarkupAdminDataTable'); // for styles
		if(!count($value)) return ''; // if nothing in the value, just return blank
		// $template = $this->template_id ? $this->wire('templates')->get((int) $this->template_id) : null;
		$template = count($this->rowTemplates) > 0 ? reset($this->rowTemplates) : null;
		$fieldsByCol = array();
		$labelsByCol = array();

		// populate $fieldsByCol and $labelsByCol
		foreach($columns as $column => $width) {

			$field = null;
			$fieldName = $column; 
			$label = '';

			// check if field contains field.subfield
			if(strpos($column, '.') !== false) {
				$parentField = null;
				list($parentFieldName, $fieldName) = explode('.', $column); 

				if($template) $parentField = $template->fieldgroup->getFieldContext($parentFieldName); 
				if(!$parentField) $parentField = $fields->get($parentFieldName); 

				if($parentField) {
					$label = $parentField->getLabel(); 

				} else if(isset($this->nativeLabels[$parentFieldName])) {
					$label = $this->nativeLabels[$parentFieldName]; 

				} else {
					$label = $parentFieldName; 
				}

				$label .= " > ";
			}

			if($template) $field = $template->fieldgroup->getFieldContext($fieldName); 
			if(!$field) $field = $fields->get($fieldName); 	

			if($field) {
				$label .= $field->getLabel(); 
				$fieldsByCol[$column] = $field; 

			} else if(isset($this->nativeLabels[$fieldName])) { 
				$label .= $this->nativeLabels[$fieldName]; 

			} else {
				$label .= $column; 
			}

			$labelsByCol[$column] = $label;
		}

		$out = $this->renderTableBody($value, $columns, $fieldsByCol); // render order intentional
		$out = $this->renderTableHead($columns, $labelsByCol) . $out;

		return $out; 		
	}

	/**
	 * Render the table head, from <table> to </thead>
	 * 
	 * @param array $columns
	 * @param array $labels
	 * @return string
	 * 
	 */
	protected function renderTableHead(array $columns, array $labels) {

		/** @var MarkupAdminDataTable $module */
		$module = $this->wire()->modules->get('MarkupAdminDataTable');
		$sanitizer = $this->wire()->sanitizer;
		$classes = array();
		foreach(array('class', 'addClass', 'responsiveClass', 'responsiveAltClass') as $key) {
			$value = $module->settings($key);
			if(!empty($value)) $classes[] = $value;
		}
		$tableClass = implode(' ', $classes);
		
		$out = "<table class='$tableClass'><thead><tr>";

		if($this->needsEditColumn) $out .= "<th>&nbsp;</th>";

		foreach($columns as $column => $width) {
			$attr = $width ? " style='width: $width%'" : '';
			$label = $labels[$column];
			$out .= "<th$attr>" . $sanitizer->entities($label) . "</th>";
		}

		if(!$this->renderValueMode) $out .= "<th>&nbsp;</th>";
		$out .= "</tr></thead>";

		return $out; 
	}

	/**
	 * Render the table body, from <tbody> to </table>
	 * 
	 * @param PageArray $items
	 * @param array $columns
	 * @param array $fields
	 * @return string
	 * 
	 */
	protected function renderTableBody(PageArray $items, array $columns, array $fields) {
		$rows = array();

		foreach($items as $key => $item) {
			/** @var Page $item */
			$of = $item->of();
			$item->of(true); 
			$rows[$key] = $this->renderTableRow($item, $columns, $fields);
			$item->of($of); 
		}

		if($this->needsEditColumn) {
			foreach($rows as $key => $row) {
				list($s1, $s2) = explode('>', $row, 2);
				$item = $items[$key];
				$rows[$key] = "$s1><td>" . $this->renderItemLink($item, wireIconMarkup('edit')) . "</td>$s2";
			}
		}
		
		return "<tbody>" . implode("", $rows) . "</tbody></table>";
	}

	/**
	 * Render an individual table row <tr> for a given PageTable item
	 * 
	 * @param Page $item
	 * @param array $columns
	 * @param array $fields
	 * @return string
	 * 
	 */
	protected function renderTableRow(Page $item, array $columns, array $fields) {

		$out = '';
		$n = 0;

		foreach($columns as $column => $width) {
			$linkURL = ($n++ && !$this->renderValueMode ? '' : $this->getItemEditURL($item));
			$out .= $this->renderTableCol($item, $fields, $column, $width, $linkURL);
		}

		// append a delete column/link
		if(!$this->renderValueMode) {
			$a = "<a class='InputfieldPageTableDelete' href='#'>" . wireIconMarkup('trash-o') . "</a>";
			if(!$item->deletable() || $this->version()) $a = "&nbsp;";
			$out .= "<td>$a</td>";
		}

		// wrap the row in a <tr>
		$class = '';
		if($item->hasStatus(Page::statusUnpublished)) $class .= 'PageListStatusUnpublished ';
		if($item->hasStatus(Page::statusHidden)) $class .= 'PageListStatusHidden ';
		if($item->isTrash()) $class .= 'PageListStatusTrash';
		if($class) $class = " class='" . trim($class) . "'";

		return "<tr data-id='$item->id'$class>$out</tr>";	
	}

	/**
	 * Render an individual <td> for a table row 
	 * 
	 * @param Page $item
	 * @param array $fields
	 * @param $column
	 * @param $width
	 * @param string $linkURL
	 * @return string
	 * 
	 */
	protected function renderTableCol(Page $item, array $fields, $column, $width, $linkURL = '') {
		$out = $this->getItemValue($item, $fields, $column); 
		if($linkURL && !$this->renderValueMode) {
			if(stripos($out, '<a ') !== false || stripos($out, '<li') !== false || !strlen($out)) {
				// table will need a separate edit column since this item doesn't work as a link
				$this->needsEditColumn = true;
				if(!strlen($out)) $out =  "<span class='detail'>$this->blankLabel</span>";
			} else {
				$out = $this->renderItemLink($item, $out, $linkURL);
			}
		}
		$attr = $width ? " style='width: $width%'" : '';
		return "<td$attr>$out</td>";
	}

	/**
	 * Get the value for the given Page field identified by $column
	 * 
	 * @param Page $item
	 * @param array $fields
	 * @param $column
	 * @return mixed|object|string
	 * 
	 */
	protected function getItemValue(Page $item, array $fields, $column) {

		$fieldName = $column;
		$subfieldName = '';
		
		if(strpos($column, '.') !== false) {
			list($fieldName, $subfieldName) = explode('.', $column);
		}

		if(isset($fields[$column])) {
			// custom
			$field = $fields[$column]; /** @var Field $field */
			$v = $item->getFormatted($fieldName);
			$value = (string) $field->type->markupValue($item, $field, $v, $subfieldName);

		} else {
			// native
			$value = $item->get($fieldName); 
			if(is_object($value) && $subfieldName) {
				$value = $this->objectToString($value, $subfieldName); 
				$fieldName = $subfieldName; 
			}
			if($fieldName == 'modified' || $fieldName == 'created' || $fieldName == 'published') {
				$value = wireDate($this->_('Y-m-d H:i'), (int) $value); // Date format for created/modified/published
			}
		}
		
		return $value; 
	}

	/**
	 * Render an item edit link surrounded by the given output
	 * 
	 * @param Page $item
	 * @param string $out
	 * @param string $url Optional
	 * @return string
	 * 
	 */
	protected function renderItemLink(Page $item, $out, $url = '') {
		if(!$url) $url = $this->getItemEditURL($item);
		return "<a class='InputfieldPageTableEdit' data-url='$url' href='$url'>$out</a>";
	}

	/**
	 * Get an item edit URL
	 * 
	 * @param Page $item
	 * @return string
	 * 
	 */
	protected function getItemEditURL(Page $item) {
		return 
			$this->wire()->config->urls->admin . "page/edit/?id=$item->id" . 
			"&modal=1&context=PageTable&version=" . $this->version();
	}

	/**
	 * Convert an object to a string for rendering in a table
	 * 
	 * @param $object
	 * @param string $property Property to display from the object (default=title|name)
	 * @return string
	 * 
	 */
	protected function objectToString($object, $property = '') {

		if($object instanceof WireArray) {
			if(!$property) $property = 'title|name';
			if($property == 'count') {
				$value = $object->count();
			} else {
				$value = $object->implode("\n", $property);
			}

		} else if($property) {
			$value = $object->$property; 
			if(is_object($value)) $value = $this->objectToString($value); 
		} else {
			$value = (string) $object;
		}

		$value = $this->wire()->sanitizer->entities(strip_tags("$value")); 
		$value = nl2br($value); 
		
		return $value; 
	}

	/**
	 * Process input submitted to a PageTable Inputfield
	 * 
	 * @param WireInputData $input
	 * @return $this
	 * 
	 */
	public function ___processInput(WireInputData $input) {
		
		$pages = $this->wire()->pages;

		$name = $this->attr('name'); 
		$deleteName = $name . '__delete';
		$deleteIDs = explode('|', $input->$deleteName); 
		$ids = explode('|', $input->$name);
		$value = $this->attr('value'); /** @var PageArray $value */
		$sorted = $pages->newPageArray();	
		$changed = false; 

		// trash items that have been deleted
		foreach($deleteIDs as $id) {
			foreach($value as $item) {
				/** @var Page $item */
				if($id != $item->id) continue; 
				if(!$item->deleteable()) continue;
				$value->remove($item); 
				$pages->trash($item); 	
				$changed = true; 
			}
		}

		foreach($ids as $id) {
			if(in_array($id, $deleteIDs)) continue; 
			foreach($value as $item) {
				if($id == $item->id) $sorted->add($item); 
			}
		}

		// add in new items that may have been added after a sort
		foreach($value as $item) { 
			if(!in_array($item->id, $ids)) $sorted->add($item); 
		}
		
		// check for orphans that may have been added
		$orphanInputName = $name . '__add_orphan';
		$orphanIDs = $input->$orphanInputName;
		if(is_array($orphanIDs) && count($orphanIDs) && $this->orphans) {
			$numOrphansAdded = 0;
			foreach($orphanIDs as $orphanID) {
				foreach($this->orphans as $orphan) {
					if($orphan->id == $orphanID) {
						$sorted->add($orphan); 
						$numOrphansAdded++;
					}
				}
			}
			if($numOrphansAdded) {
				$this->message(sprintf($this->_('Added %d existing page(s) to table'), $numOrphansAdded) . " ($this->name)"); 
			}
		}
		
		if("$value" != "$sorted") $changed = true; 
		
		if($changed) {
			$this->setAttribute('value', $sorted); 
			$this->trackChange('value'); 
		}

		// check if we need to setup a name format for any pages
		foreach($value as $n => $item) {
			$name = $pages->setupPageName($item, array('format' => $this->nameFormat)); 
			if($name) {
				$this->message("Auto assigned name '$name' to item #" . ($n+1), Notice::debug); 
				$item->save();
			}
		}

		return $this; 
	}

	/**
	 * Set a property to this Inputfield
	 * 
	 * @param string $key
	 * @param mixed $value
	 * @return $this
	 *
	 */
	public function set($key, $value) {
		if($key === 'template_id' && $value) {
			// convert template_id to $this->rowTemplates array
			$templates = $this->wire()->templates;
			if(!is_array($value)) $value = array($value);	
			foreach($value as $id) {
				$template = $templates->get($id); 
				if($template) $this->rowTemplates[$id] = $template;
			}
			return $this; 
		} else {
			return parent::set($key, $value);
		}
	}

	/**
	 * Set an attribute to the Inputfield
	 *
	 * In this case we capture set to the 'value' attribute to make sure it can only be a PageArray
	 * 
	 * @param array|string $key
	 * @param int|string $value
	 * @return $this
	 * @throws WireException
	 * 
	 */
	public function setAttribute($key, $value) {
		if($key == 'value') {
			if($value === null) $value = $this->wire()->pages->newPageArray();
			if(!$value instanceof PageArray) {
				throw new WireException('This Inputfield only accepts a PageArray for its value attribute.');
			}
		}
		return parent::setAttribute($key, $value); 
	}
	
	public function setOrphans(PageArray $orphans) {
		$this->orphans = $orphans;
	}

	/**
	 * @return int
	 *
	 */
	public function version() {
		if($this->version !== null) return $this->version;
		$page = $this->hasPage;
		$this->version = $page ? (int) ((string) $page->get('_version')) : 0;
		return $this->version;
	}
	
	protected $version = null;

	/**
	 * Determine a default set of columns for the PageTable based on the fields defined in the defined template
	 * 
	 * @return string of newline separated field names
	 * 
	 */
	protected function getConfigDefaultColumns() {
		$out = '';
		if(!count($this->rowTemplates)) return $out;
	
		$fieldCounts = array();
		foreach($this->rowTemplates as $template) {
			foreach($template->fieldgroup as $field) {
				if(!isset($fieldCounts[$field->name])) {
					$fieldCounts[$field->name] = 1;
				} else {
					$fieldCounts[$field->name]++;
				}
			}
		}
	
		// sort most used to least used
		if(count($this->rowTemplates) > 1) arsort($fieldCounts); 
		
		$n = 0;
		foreach(array_keys($fieldCounts) as $fieldName) {
			$out .= $fieldName . "\n";	
			if(++$n >= 5) break;
		}
		return trim($out); 
	}
	
	/**
	 * Get field configuration for input tab
	 * 
	 * @return InputfieldWrapper
	 * 
	 */
	public function ___getConfigInputfields() {
		$inputfields = parent::___getConfigInputfields(); 

		$f = $inputfields->InputfieldTextarea;
		$f->attr('name', 'columns'); 
		$f->label = $this->_('Table fields to display in admin'); 
		$f->description = 
			$this->_('Enter the names of the fields (1 per line) that you want to display as columns in the table.') . ' ' . 
			$this->_('To specify a column width for the field, specify "field_name=30" where "30" is the width (in percent) of the column.') . ' ' . 
			$this->_('When specifying widths, make the total of all columns add up to 100.');
		$f->notes = 
			$this->_('You may specify any native or custom field.') . ' ' . 
			$this->_('You may also use subfields (field.subfield) with fields that contain multiple properties, like page references.') . ' ';
		$columns = $this->columns ? $this->columns : $this->getConfigDefaultColumns();
		$f->attr('value', $columns); 

		if(count($this->rowTemplates)) {
			$options = array();
			foreach($this->rowTemplates as $template) { 
				foreach($template->fieldgroup as $item) $options[$item->name] = $item->name; 
			}
			$f->notes .= $this->_('Custom fields assigned to your selected templates include the following:') . ' **';
			$f->notes .= implode(', ', $options) . '**';
		} else {
			$f->notes .= $this->_('To see a list of possible custom fields here, select a template on the Details tab, Save, and come back here.'); 
		}

		$inputfields->add($f); 

		$f = $inputfields->InputfieldText;
		$f->attr('name', 'nameFormat');
		$f->attr('value', $this->nameFormat);
		$f->label = $this->_('Automatic Page Name Format');
		$f->description = 
			$this->_('When populated, pages will be created automatically using this name format whenever a user clicks the "Add New" button.') . ' ' . // page name format description 1
			$this->_('If left blank, the user will be asked to enter a name for the page before it is created.'); // page name format description 2
		$f->notes = 
			sprintf(
				$this->_('If the name format contains any non-alphanumeric characters, it is considered to be a [PHP date](%s) format.'),
				'https://www.php.net/manual/en/datetime.format.php'
			). ' ' . 
			$this->_('If it contains only alphanumeric characters then it will be used directly, with a number appended to the end (when necessary) to ensure uniqueness.'); // page name format notes
		$f->notes .= ' ' . $this->_('Example: **Ymd:His** is a good name format for date/time based page names.'); 
		$f->collapsed = Inputfield::collapsedBlank;
		$inputfields->add($f);
	
		$f = $inputfields->InputfieldRadios;
		$f->attr('name', 'noclose'); 
		$f->label = $this->_('Modal edit window behavior'); 
		$f->addOption(0, $this->_('Automatically close on save (default)')); 
		$f->addOption(1, $this->_('Keep window open, close manually'));
		$f->attr('value', (int) $this->noclose); 
		if(!$this->noclose) $f->collapsed = Inputfield::collapsedYes; 
		$inputfields->add($f); 

		return $inputfields; 
	}
}
