<?php namespace ProcessWire;

/**
 * ProcessWire Repeater Inputfield
 *
 * Maintains a collection of fields that are repeated for any number of times.
 *
 * ProcessWire 3.x, Copyright 2022 by Ryan Cramer
 * https://processwire.com
 * 
 * @property int $repeaterMaxItems
 * @property int $repeaterMinItems
 * @property int $repeaterDepth
 * @property bool|int $familyFriendly
 * @property bool|int $accordionMode
 * @property bool|int $singleMode
 * @property bool|int $loudControls Always show controls regardless of hover?
 * 
 * @method string renderRepeaterLabel($label, $cnt, Page $page)
 * 
 *
 */

class InputfieldRepeater extends Inputfield implements InputfieldItemList {

	public static function getModuleInfo() {
		return array(
			'title' => __('Repeater', __FILE__), // Module Title
			'summary' => __('Repeats fields from another template. Provides the input for FieldtypeRepeater.', __FILE__), // Module Summary
			'version' => 111,
			'requires' => 'FieldtypeRepeater',
		);
	}

	/**
	 * Array of InputfieldWrapper objects indexed by repeater page ID
	 *
	 */
	protected $wrappers = array();

	/**
	 * Array of text labels indexed by repeater page ID
	 *
	 */
	protected $labels = array();

	/**
	 * The page that the repeaters field lives on, set by FieldtypeRepeater::getInputfield
	 * 
	 * @var Page
	 *
	 */
	protected $page;

	/**
	 * The field this InputfieldRepeater is serving, set by FieldtypeRepeater::getInputfield
	 * 
	 * @var Field
	 *
	 */
	protected $field;

	/**
	 * Cached form containing the repeaters
	 * 
	 * @var InputfieldWrapper
	 *
	 */
	protected $form;

	/**
	 * All Inputfield classes (strings) used by the Inputfield
	 * 
	 * @var array
	 * 
	 */
	protected $inputfieldClasses = array();

	/**
	 * Are we currently in render value mode? (i.e. render values, but not inputs)
	 * 
	 * @var bool
	 * 
	 */
	protected $renderValueMode = false;

	/**
	 * Number of required empty Inputfields after processing
	 * 
	 * @var int 
	 * 
	 */
	protected $numRequiredEmpty = 0;

	/**
	 * Set config defaults
	 *
	 */
	public function __construct() {
		parent::__construct();
		// these are part of the Fieldtype's config, and automatically set from it
		$this->set('repeaterMaxItems', 0);
		$this->set('repeaterMinItems', 0); 
		$this->set('repeaterDepth', 0);
		$this->set('familyFriendly', 0); 
		$this->set('accordionMode', false);
		$this->set('singleMode', false); 
		$this->set('loudControls', false);
	}

	/**
	 * Initialize the repeaters inputfield
	 *
	 */
	public function init() {
		parent::init();
		$pages = $this->wire()->pages;
		if(is_null($this->page)) $this->page = $pages->newNullPage();
		$this->attr('value', $pages->newPageArray()); 
	}

	/**
	 * Render the repeater label
	 * 
	 * @param string $label Default label
	 * @param int $cnt Item index (1-based)
	 * @param Page $page Repeater item
	 * @return string
	 *
	 */
	public function ___renderRepeaterLabel($label, $cnt, Page $page) {

		// situations where we skip the render of repeater label because it is not needed
		$repeaterEditID = (int) $this->wire()->input->get('repeater_edit');
		if($repeaterEditID && $repeaterEditID === $page->id) {
			// edit of item requested in URL that matches given $page
			return $label;
		} else if(count($_POST) && !$this->wire()->config->ajax) {
			// POST request that is not ajax
			return $label;
		}
		
		$out = '';
		$repeaterTitle = $this->field ? $this->field->get('repeaterTitle') : '';
		$colorPrefix = "\tC/O/L/O/R:"; 
		$hasColorPrefix = false;

		if($page->id && $repeaterTitle) {
			// custom repeater titles specified
			$hasCnt = stripos($repeaterTitle, '#n') !== false;
			
			if(strpos($repeaterTitle, '#') !== false) {
				$repeaterTitle = preg_replace('/#([a-f\d]{3,})/i', "$colorPrefix$1", $repeaterTitle);
				if(strpos($repeaterTitle, $colorPrefix) !== false) $hasColorPrefix = true;
			}
			
			// update index numbers?
			if($hasCnt) {
				// replace "#n" with index number of repeater item
				$repeaterTitle = str_replace("#n", "#$cnt", $repeaterTitle);
			}

			if(strpos($repeaterTitle, '{') !== false) {
				// formatted {label}
				$out = $page->getText($repeaterTitle, true);

			} else if(!$hasCnt && $this->wire()->sanitizer->fieldName($repeaterTitle) === $repeaterTitle) {
				// just a single field name
				$value = $page->getFormatted($repeaterTitle);
				if(is_object($value)) {
					if($value instanceof Page) {
						$out = $value->get('title|name');
					} else if($value instanceof PageArray) {
						$out = $value->implode(', ');
					} else {
						$out = (string) $value;
					}
				}
			} else {
				// label, but with no page variables
				$out = $repeaterTitle;
			}
			
			$out = strip_tags(trim($out));
		}
		
		if(!strlen($out)) {
			// fallback to default
			if(!strlen($label)) $label = $this->field->getLabel();
			$out = "$label #" . $cnt;
		}
	
		// note {brackets} surround text that will be visually muted from the JS side
		if(!$page->id) {
			// non-editable new item
			$out .= ' {• ' . $this->_('This item will become editable after you save.') . '}';
		} else if($page->isUnpublished() && $page->hasStatus(Page::statusOn)) {
			// editable new item
			$out .= ' {• ' . $this->_('New') . '}';
		}
	
		$maxlen = 100;
		if(strlen($out) > $maxlen) {
			$out = substr($out, 0, $maxlen);
			$pos = strrpos($out, ' '); 
			if($pos > ($maxlen / 2)) $out = substr($out, 0, $pos);
		}

		if(strpos($out, '#') !== false) {
			$sp = html_entity_decode('&#8203;', 0, 'UTF-8');
			$out = str_replace('#', "#$sp", $out);
		}
	
		if($hasColorPrefix && strpos($out, $colorPrefix) === strrpos($out, $colorPrefix)) {
			$out = str_replace($colorPrefix, '#', $out);
		}
		
		return $out;
	}

	/**
	 * Get the repeater item type (if used)
	 * 
	 * @param Page $page
	 * @return int
	 * 
	 */
	protected function getRepeaterItemType(Page $page) {
		return 1;
	}
	

	/**
	 * Get the name of the repeater item type (if used)
	 * 
	 * @param Page|int $type
	 * @return string
	 *
	 */
	protected function getRepeaterItemTypeName($type) {
		if($type) {} // ignore
		return '';
	}
	
	/**
	 * Preload all assets used by Inputfields of this type
	 * 
	 * This ensures all required JS/CSS files are loaded in the original/non-ajax request.
	 * This should be called only when needed, like if there are 0 items in the repeater 
	 * when ajax-add support enabled.
	 * 
	 * @param array $fieldIDs Optionally specify the IDs of the Field objects you want to limit preload to.
	 * 
	 */
	protected function preloadInputfieldAssets($fieldIDs = array()) {
		
		if(empty($fieldIDs) && $this->field) $fieldIDs = $this->field->get('repeaterFields');
		if(!is_array($fieldIDs)) return;
		
		$fields = $this->wire()->fields;
		$items = $this->attr('value');
		$item = count($items) ? $items->first() : null;
		
		$templateId = $this->field ? (int) $this->field->get('template_id') : 0;
		$template = $templateId ? $this->wire()->templates->get($templateId) : null;
		$fieldgroup = $template ? $template->fieldgroup : null;
		
		foreach($fieldIDs as $fieldID) {
			
			$field = $fields->get((int) $fieldID); 
			if(!$field) continue;
			
			$fieldContext = $fieldgroup && $fieldgroup->hasFieldContext($field) ? $fieldgroup->getFieldContext($field) : $field;
			$fieldtype = $field->type;
			
			if(!$item && $fieldtype instanceof FieldtypeFile) {
				// repeater has no items yet and this is a file or image field
				if($fieldtype->getFieldsTemplate($field)) {
					// if it has custom fields, it needs a real example rather than $this->page substitute
					// so we generate the first repeater item as a ready page. it is okay that it replaces
					// the null $item for remaining iterations, as having a live item is always preferable
					$item = $this->getNextReadyPage(array());
				}
			}
			
			try {
				// the following forces assets to be loaded
				$inputfield = $fieldContext->getInputfield($item ? $item : $this->page);
				if($inputfield) $this->renderReadyInputfield($inputfield);
			} catch(\Exception $e) {
				$this->warning("Repeater '$this->name' preload '$field': " . $e->getMessage(), Notice::debug);
			}
		}
	}

	/**
	 * Render ready for an Inputfield within a repeater item
	 * 
	 * @param Inputfield $f
	 * @param Inputfield|InputfieldWrapper|null $parent
	 * @param bool $renderValueMode
	 * @since 3.0.184
	 * 
	 */
	protected function renderReadyInputfield(Inputfield $f, $parent = null, $renderValueMode = false) {
		if($f instanceof InputfieldTextarea && wireInstanceOf($f, 'InputfieldCKEditor')) {
			/** @var InputfieldCKEditor $f Keeps config in JS var so use custom to allow for context settings $f */
			$field = $f->hasField;
			if($f->configName) {
				// may have already been set by descending class like matrix, so leave as-is
			} else if($field && !($field->flags & Field::flagFieldgroupContext)) {
				// does not have context-specific settings, so leave as-is
			} else {
				// use context-specific configuration settings name for this CKEditor
				$f->configName = $f->className() . "_{$f->name}_in_{$this->name}";
			}
		}
		$f->renderReady($parent, $renderValueMode);
	}

	/**
	 * Get Inputfields for the given repeater item
	 * 
	 * @param Page $page
	 * @return InputfieldWrapper
	 * 
	 */
	protected function getRepeaterItemInputfields(Page $page) {
		return $page->template->fieldgroup->getPageInputfields($page, "_repeater{$page->id}");
	}
	

	/**
	 * Build the form containing the repeaters
	 *
	 * @param int $itemID Build form for only this item (optional)
	 * @param array|null $loadInputsForIDs If array specified, load inputs for the custom page fields into the form for only these page IDs
	 * @return InputfieldWrapper
	 * @throws WireException if $this->page or $this->field are set incorrectly
	 *
	 */
	protected function buildForm($itemID = 0, $loadInputsForIDs = null) {
		
		$input = $this->wire()->input;
		$session = $this->wire()->session;
		$modules = $this->wire()->modules;
		$typeStyles = array();

		// if it's already been built, then return the cached version
		if(!is_null($this->form)) return $this->form; 
	
		// if required fields don't exist then exit
		if(!$this->field || !$this->field->type instanceof FieldtypeRepeater) {
			throw new WireException("You must set a 'field' (type Field) property to {$this->className} of FieldtypeRepeater");
		}
		if(!$this->page || !$this->page->id) {
			throw new WireException("You must set a 'page' (type Page) property to {$this->className} with repeater field '$this->name'");
		}

		/** @var InputfieldWrapper $form */
		$form = $this->wire(new InputfieldWrapper());
		$form->name = 'repeater_form_' . $this->name . ($itemID ? "_$itemID" : "");
		
		/** @var PageArray $value */
		$value = $this->attr('value'); 
	
		// get field label in user's language if available
		$label = $this->field->getLabel();
		if(!$label) $label = ucfirst($this->field->name); 
		
		if((int) $this->repeaterDepth > 0 && (int) $this->familyFriendly) {
			$this->addClass('InputfieldRepeaterFamilyFriendly', 'wrapClass'); 
		}
		
		// remember which repeater items are open (as stored in cookie), when enabled
		$openIDs = array();
		if((int) $this->field->get('rememberOpen')) {
			$this->addClass('InputfieldRepeaterRememberOpen', 'wrapClass');
			$openIDs = $input->cookie('repeaters_open'); 
			if($openIDs) $openIDs = explode('|', trim($openIDs, '|'));
			if(!is_array($openIDs)) $openIDs = array();
		}
		// merge with any open IDs in session
		$_openIDs = $session->getFor($this, 'openIDs');
		if(is_array($_openIDs) && !empty($_openIDs)) {
			$openIDs = array_merge($openIDs, array_values($_openIDs));
		}
		
		$minItems = $this->repeaterMinItems;
	
		// if there are a minimum required number of items, set them up now
		if(!$itemID && $minItems > 0) {
			$notIDs = $value->explode('id');
			while($value->count() < $minItems) {
				$item = $this->getNextReadyPage($notIDs);
				$value->add($item);
				$notIDs[] = $item->id;
			}
		}
		
		$repeaterCollapse = (int) $this->field->get('repeaterCollapse');
		$cnt = 0;
		$numVisible = 0;
		$numOpen = 0;
		$isPost = $input->requestMethod('POST');
		$isSingle = $this->singleMode;
		
		// create field for each repeater iteration	
		foreach($value as /* $key => */ $page) {
			if($itemID && $page->id != $itemID) continue;
			
			/** @var RepeaterPage $page */
			$isUnpublished = $page->isUnpublished();
			$isHidden = $page->isHidden();
			$isOn = $page->hasStatus(Page::statusOn);
			$isReadyItem = $isHidden && $isUnpublished;
			$isClone = $page->get('_repeater_clone'); 
			$isOpen = in_array($page->id, $openIDs) || $isClone || $isSingle;
			$isMinItem = $isReadyItem && $minItems && $cnt < $minItems;
			
			if($isOpen && $numOpen > 0 && $this->accordionMode) $isOpen = false;
			
			// get the inputfields for the repeater page
			if(is_null($loadInputsForIDs) || in_array($page->id, $loadInputsForIDs) || $isOpen) {
				$inputfields = $this->getRepeaterItemInputfields($page);
				$isLoaded = true;
			} else {
				$inputfields = $this->wire(new InputfieldWrapper()); // non loaded
				$isLoaded = false;
			}
			$inputfields->set('useDependencies', false);
			$this->wrappers[$page->id] = $inputfields;
		
			if($isSingle) {
				$delete = null;
				$sort = null;
				$depth = null;
			} else {
				// also add a delete checkbox to the repeater page fields
				/** @var InputfieldCheckbox $delete */
				$delete = $modules->get('InputfieldCheckbox');
				$delete->attr('id+name', "delete_repeater{$page->id}");
				$delete->addClass('InputfieldRepeaterDelete', 'wrapClass');
				$delete->label = $this->_('Delete');
				$delete->attr('value', $page->id);

				/** @var InputfieldHidden $sort */
				$sort = $modules->get('InputfieldHidden');
				$sort->attr('id+name', "sort_repeater{$page->id}");
				$sort->class = 'InputfieldRepeaterSort';
				$sort->addClass('InputfieldRepeaterItemSort', 'wrapClass');
				$sort->label = $this->_('Sort');
				$sort->attr('value', $cnt);

				if($this->repeaterDepth > 0) {
					/** @var InputfieldHidden $depth */
					$depth = $modules->get('InputfieldHidden');
					$depth->attr('id+name', "depth_repeater{$page->id}");
					$depth->addClass('InputfieldRepeaterItemDepth', 'wrapClass');
					$depth->label = $this->_('Depth');
					$depthValue = $page->getDepth();
					$depth->attr('value', $depthValue);
					$depth->set('renderValueAsInput', true);
				} else {
					$depth = null;
				}
			}
		
			/** @var InputfieldHidden $loaded */
			$loaded = $modules->get('InputfieldHidden');
			$loaded->attr('id+name', "loaded_repeater{$page->id}");
			$loaded->attr('value', $isLoaded ? 1 : 0);
			$loaded->set('renderValueAsInput', true);
			$loaded->class = 'InputfieldRepeaterLoaded';

			/** @var InputfieldFieldset $wrap */
			$wrap = $modules->get('InputfieldFieldset');
			$wrapIcon = '';
			$wrapClasses = array_unique(array('InputfieldRepeaterItem', $this->className() . 'Item', 'InputfieldNoFocus'));
			$wrap->addClass(implode(' ', $wrapClasses));
			
			$itemType = $this->getRepeaterItemType($page);
			$itemTypeName = $this->getRepeaterItemTypeName($itemType);
			
			if(!$isPost) {
				$wrap->entityEncodeLabel = false;
				$wrapLabel =
					"<span class='InputfieldRepeaterItemLabel'>" .
					$this->entityEncode($this->renderRepeaterLabel($label, ++$cnt, $page)) .
					"</span>";
				if(strpos($wrapLabel, 'icon-') !== false && preg_match('/\bicon[-]([a-z][-a-z0-9]+)\b\s*/', $wrapLabel, $matches)) {
					$wrapLabel = str_replace($matches[0], '', $wrapLabel);
					$wrapIcon = $matches[1];
				}
				if(strpos($wrapLabel, '#') !== false && preg_match('/(?<!&)#([0-9a-fA-F]{3,})/', $wrapLabel, $matches)) {
					// background-color definition
					if(strlen($matches[1]) === 3 || strlen($matches[1]) === 6) {
						$wrapLabel = str_replace($matches[0], '', $wrapLabel);
						$typeStyles[$itemTypeName] = 
							".Inputfield_$this->name .InputfieldContent " . 
							".InputfieldRepeaterItem[data-typeName=\"$itemTypeName\"] > .InputfieldHeader " . 
							"{ background-color: #$matches[1]; outline-color: #$matches[1] }";
					}
				}

				$wrap->label = $wrapLabel;
			} else {
				$wrap->label = "$label " . (++$cnt);
			}
			$wrap->name = "repeater_item_{$page->id}";
			$wrap->wrapAttr('data-page', $page->id);
			$wrap->wrapAttr('data-type', $itemType);
			$wrap->wrapAttr('data-typeName', $itemTypeName);
			$wrap->wrapAttr('data-fnsx', "_repeater$page->id");  // fnsx=field name suffix
			$wrap->wrapAttr('data-depth', $depth ? $depth->val() : '0'); 
			if($wrapIcon) $wrap->wrapAttr('data-icon', $wrapIcon);
			//$wrap->wrapAttr('data-editorPage', $this->page->id);
			//$wrap->wrapAttr('data-parentPage', $page->parent->id);
			$wrap->wrapAttr('data-editUrl', $page->editUrl()); // if needed by any Inputfields within like InputfieldFile/InputfieldImage
			$wrap->set('useDependencies', false);
			
			if($isClone) $wrap->addClass('InputfieldRepeaterItemClone');
			if($itemID) $wrap->addClass('InputfieldRepeaterItemRequested');
			
			if($delete && $page->get('_repeater_delete')) {
				// something indicates it should already show delete state in editor
				$delete->attr('checked', 'checked');
				$wrap->addClass('InputfieldRepeaterDeletePending');
				$wrap->addClass('ui-state-error', 'headerClass');
			}
			
			if($isOpen) {
				$wrap->collapsed = Inputfield::collapsedNo;
				$numOpen++;
			} else if($repeaterCollapse == FieldtypeRepeater::collapseExisting && !$page->get('_repeater_new') && !$isHidden) {
				$wrap->collapsed = Inputfield::collapsedYes;
			} else if($repeaterCollapse == FieldtypeRepeater::collapseExisting && $isMinItem) {
				$wrap->collapsed = Inputfield::collapsedYes;
			} else if($repeaterCollapse == FieldtypeRepeater::collapseAll) {
				$wrap->collapsed = Inputfield::collapsedYes;
			}
			
			$hasErrors = count($inputfields->getErrors()) > 0; 
			if($hasErrors) $wrap->icon = 'warning';

			if(!$isSingle) {
				// add a hidden field that will be populated with a positive value for all visible repeater items
				// this is so that processInput can see this item should be a published item
				/** @var InputfieldHidden $f */
				$f = $modules->get('InputfieldHidden');
				$f->attr('name', "publish_repeater{$page->id}");
				$f->attr('class', 'InputfieldRepeaterPublish');

				if($isReadyItem) {
					// ready item
					$f->attr('value', 0);
				} else if($isUnpublished && !$isOn) {
					// unpublished item
					$f->attr('value', -1);
				} else {
					// published item
					$f->attr('value', 1);
				}

				$wrap->add($f);

				if($isUnpublished) {
					$wrap->addClass('InputfieldRepeaterUnpublished');
					if(!$isOn) $wrap->addClass('InputfieldRepeaterOff');
				}

				$wrap->add($inputfields);
				$wrap->prepend($delete);
				$wrap->prepend($sort);
				if($depth) $wrap->prepend($depth);
				$wrap->prepend($loaded);
			} else {
				$wrap->add($inputfields);
				$wrap->prepend($loaded);
			}
				
			if($isMinItem) {
				// allow this ready item to be added so that minimum is met
				$wrap->addClass('InputfieldRepeaterMinItem');
				$isReadyItem = false;
			}
			
			if(!$isReadyItem) {
				$form->add($wrap);
				$numVisible++;
			}
			
			$this->labels[$page->id] = $wrap->getSetting('label');
			if($itemID) break;
		}	

		if($itemID) {
			// only rendering a single item, ajax mode
			foreach($form->getAll() as $inputfield) {
				/** @var Inputfield $inputfield */
				$idAttr = $inputfield->attr('id');
				$this->renderReadyInputfield($inputfield, $form, $this->renderValueMode);
				$jsValue = $this->wire()->config->js($idAttr);
				if(!empty($jsValue)) {
					$inputfield->appendMarkup .= "<script>ProcessWire.config['$idAttr'] = " . json_encode($jsValue) . ';</script>';
				}
			}
		} else if(!$isSingle) {
			// create a new/blank item to be used as a template for any new items added
			/** @var InputfieldWrapper $wrap */
			$wrap = $modules->get('InputfieldFieldset');
			$wrap->entityEncodeLabel = false;
			$label = $this->entityEncode($this->renderRepeaterLabel($label, ++$cnt, new NullPage()));
			$wrap->label = "<span class='InputfieldRepeaterItemLabel'>$label</span>";
			$wrap->class = 'InputfieldRepeaterItem InputfieldRepeaterNewItem';
			$wrap->attr('data-depth', 0);
			$wrap->collapsed = Inputfield::collapsedNo;
			$form->add($wrap);
		}

		// max items warning
		if($this->repeaterMaxItems && $numVisible > $this->repeaterMaxItems) {
			$this->prependMarkup .=
			"<p class='ui-state-error-text'>" .
				sprintf($this->_('Warning: only the first %d item(s) will be used.'), $this->repeaterMaxItems) . 
			"</p>";
		}
		
		// cache
		$this->form = $form;
		
		if(!$isPost && count($typeStyles)) {
			$styles = "<style type='text/css'>" . implode("\n", $typeStyles) . "</style>";
			if($this->wire()->config->ajax && ($this->page instanceof RepeaterPage || $this->hasPage instanceof RepeaterPage)) {
				// ajax-rendered nested repeater page
				$this->prependMarkup .= $styles;
			} else {
				$this->wire()->adminTheme->addExtraMarkup('head', $styles);
			}
		}	

		return $form; 
	}

	/**
	 * Get next page ready to be used as new item
	 * 
	 * @param array $notIDs Page IDs that we won't allow for the new item (because already rendered)
	 * @return Page
	 * 
	 */
	protected function getNextReadyPage(array $notIDs) {
		/** @var FieldtypeRepeater $fieldtype */
		$fieldtype = $this->field->type;
		/** @var PageArray $value */
		$value = $this->attr('value');
		$readyPage = $fieldtype->getNextReadyPage($this->page, $this->field, $value, $notIDs);
		return $readyPage;
	}

	/**
	 * Render a new item for ajax after 'add new' link clicked
	 * 
	 * @param int $cloneItemID
	 * @param int $cloneToParentID
	 * @return string
	 * 
	 */
	public function renderAjaxNewItem($cloneItemID = 0, $cloneToParentID = 0) {

		/** @var PageArray $value */
		$value = $this->attr('value');
		$clonePage = null;
		$cloneToParent = null;
		$readyPage = null;
	
		if($cloneItemID) {
			foreach($value as $item) {
				if($item->id == $cloneItemID) {
					$clonePage = $item;
					break;
				}
			}
			if($cloneToParentID && $cloneToParentID != $this->page->id) {
				$cloneToParent = $this->wire()->pages->get((int) $cloneToParentID);
				if($cloneToParent->id && $cloneToParent->hasField($this->field) && $cloneToParent->editable($this->field)) {
					// ok
					$fieldtype = $this->field->type; /** @var FieldtypeRepeater $fieldtype */
					// convert from /path/to/page having repeater to /processwire/repeaters/for-field-123/for-page-456/
					$cloneToParent = $fieldtype->getRepeaterPageParent($cloneToParent, $this->field); 
				} else {
					$cloneToParent = null;
				}
			}
		}
		
		if($clonePage && $clonePage->id) {
			/** @var FieldtypeRepeater $fieldtype */
			$fieldtype = $this->field->type;
			$readyPage = $this->wire()->pages->clone($clonePage, $cloneToParent, true,
				array('set' => array(
					'name' => $fieldtype->getUniqueRepeaterPageName() . 'c', // trailing "c" indicates clone
					'sort' => count($value)+1,
					'status' => $clonePage->status | Page::statusUnpublished
					)
				)
			);
			$readyPage->set('_repeater_clone', $clonePage->id); 
		} else if(!$cloneItemID) {
			$notIDs = $this->wire()->sanitizer->intArray(explode(',', trim((string) $this->wire()->input->get('repeater_not'), ',')));
			$readyPage = $this->getNextReadyPage($notIDs);
			$readyPage->removeStatus(Page::statusHidden);
		} 
	
		if($readyPage) {
			// ensure editing page doesn't get saved (just in case) since we're removing all items
			$this->page->addStatus(Page::statusCorrupted);
			$value->add($readyPage);
			return $this->buildForm($readyPage->id)->render();
		} else {
			return '';
		}
	}
	
	/**
	 * Render the "add new" repeater label
	 * 
	 * @return mixed|string
	 * @throws WireException
	 * 
	 */
	protected function renderAddLabel() {
		$addLabel = $this->field->get('repeaterAddLabel');
		if($this->wire()->languages)  {
			$language = $this->wire()->user->language;
			if(!$language->isDefault()) {
				$addLabel = $this->field->get("repeaterAddLabel$language");
			}
		}
		if(!strlen("$addLabel")) $addLabel = $this->_('Add New');
		return $addLabel;
	}
	
	protected function renderPasteLabel() {
		return $this->_('Paste');
	}

	protected function renderPasteLink() {
		$icon = wireIconMarkup('paste', 'fw');
		$label = $this->renderPasteLabel();
		$out = "<a class='InputfieldRepeaterPaste' href='#'>$icon $label</a>";
		return $out;
	}

	/**
	 * Called before render() or renderValue() method by InputfieldWrapper, before Inputfield-specific CSS/JS files added
	 *
	 * @param Inputfield|InputfieldWrapper|null The parent Inputfield/wrapper that is rendering it or null if no parent.
	 * @param bool $renderValueMode Whether renderValueMode will be used.
	 * @return bool
	 *
	 */
	public function renderReady(Inputfield $parent = null, $renderValueMode = false) {
	
		$user = $this->wire()->user;
		$modules = $this->wire()->modules;
	
		/** @var JqueryCore $jQueryCore */
		$jQueryCore = $modules->get('JqueryCore');
		$jQueryCore->use('cookie');
	
		/** @var JqueryUI $jQueryUI */
		$jQueryUI = $modules->get('JqueryUI');
		$jQueryUI->use('vex');
		
		$this->preloadInputfieldAssets();
		
		$min = (int) $this->repeaterMinItems;
		$max = (int) $this->repeaterMaxItems;
		
		if($this->field->get('repeaterLoading') == FieldtypeRepeater::loadingOff) {
			$this->addClass('InputfieldRepeaterNoAjaxAdd', 'wrapClass');
		}
		if($max > 0) {
			$this->addClass('InputfieldRepeaterMax', 'wrapClass');
		}
		if($min > 0) {
			$this->addClass('InputfieldRepeaterMin', 'wrapClass');
		}
		if($this->singleMode) {
			$this->addClass('InputfieldRepeaterSingle', 'wrapClass');
			
		} else if($this->repeaterDepth > 0) {
			$this->addClass('InputfieldRepeaterDepth', 'wrapClass');
		}
		if($this->accordionMode) {
			$this->addClass('InputfieldRepeaterAccordion', 'wrapClass');
		}
		if($this->loudControls || $this->wire()->session->get('touch')) {
			$this->addClass('InputfieldRepeaterLoudControls', 'wrapClass');
		}
		if(!empty($_COOKIE[$this->copyPasteCookieName()])) {
			$this->addClass('InputfieldRepeaterCanPaste', 'wrapClass'); 
		}
		
		$this->wrapAttr('data-name', $this->field->name);
		$this->wrapAttr('data-page', $this->page->id);
		$this->wrapAttr('data-max', (int) $this->repeaterMaxItems);
		$this->wrapAttr('data-min', (int) $this->repeaterMinItems);
		$this->wrapAttr('data-depth', (int) $this->repeaterDepth);

		list($editorUrl, $queryString) = explode('?', $this->page->editUrl());
		
		if(strpos($editorUrl, '/users/edit/') && !$user->isSuperuser() && !$user->hasPermission('user-admin')) {
			// to accommodate repeater in user profile, use main page editor 
			$editorUrl = str_replace('/access/users/edit/', '/page/edit/', $editorUrl); 
		}
		
		if($queryString) {}

		$this->wire()->config->js('InputfieldRepeater', array(
			'editorUrl' => $editorUrl,
			'labels' => array(
				'remove' => $this->_x('Click to delete this item, or double-click to delete all', 'repeater-item-action'),
				'removeAll' => $this->_x('Delete all items?', 'repeater-item-action'),
				'toggle' => $this->_x('Click to turn item on/off, or double-click to open/collapse all items', 'repeater-item-action'),
				'clone' => $this->_x('Clone/copy/paste actions', 'repeater-item-action'),
				'settings' => $this->_x('Show settings?', 'repeater-item-action'),
				'openAll' => $this->_x('Open all items?', 'repeater-item-action'), 
				'collapseAll' => $this->_x('Collapse all items?', 'repeater-item-action'),
				'insertBefore' => $this->_x('Insert new item before this one', 'repeater-item-action'),
				'insertAfter' => $this->_x('Insert new item after this one', 'repeater-item-action'),
				'insertHere' => $this->_x('Insert new item here', 'repeater-item-action'),
				'disabledMinMax' => $this->_('This action is disabled per min and/or max item settings.'),
				'selectAction' => $this->_x('Select an action for this item ', 'dialog-header'), 
				'copy' => $this->_x('COPY this item in memory to paste elsewhere', 'repeater-item-action'),
				'copyInMemory' => $this->_x('Item in copy/paste memory', 'dialog-note'), 
				'cloneBefore' => $this->_x('CLONE this item and insert ABOVE this', 'repeater-item-action'), 
				'cloneAfter' => $this->_x('CLONE this item and insert BELOW this', 'repeater-item-action'), 
				'pasteBefore' => $this->_x('PASTE copied item ABOVE this', 'repeater-item-action'),
				'pasteAfter' => $this->_x('PASTE copied item BELOW this', 'repeater-item-action'),
				'clear' => $this->_x('CLEAR copy/paste memory', 'repeater-item-action'), 
			)
		));

		return parent::renderReady($parent, $renderValueMode);
	}

	/**
	 * Render the footer of the repeater items, which is the "add new" item section
	 *
	 * @param string $noAjaxAdd Value is '1' if AJAX-adding is disallowed, or blank if it's allowed
	 * @return string
	 *
	 */
	protected function renderFooter($noAjaxAdd) {
		// a hidden checkbox with link that we use to identify when items have been added
		if($this->singleMode) return '';
		
		if(!empty($_COOKIE[$this->copyPasteCookieName()])) {
			$paste = ' &nbsp; ' . $this->renderPasteLink();
		} else {
			$paste = '';
		}

		$out =
			"<p class='InputfieldRepeaterAddItem'>" .
				"<input class='InputfieldRepeaterAddItemsQty' type='text' name='_{$this->name}_add_items' value='0' />" . // for noAjaxAdd
				"<a href='#' data-type='1' class='InputfieldRepeaterAddLink' data-noajax='$noAjaxAdd'>" .
					"<i class='fa fa-fw fa-plus-circle InputfieldRepeaterSpinner' " .
						"data-on='fa-spin fa-spinner' data-off='fa-plus-circle'></i>" .
					$this->renderAddLabel() .
				"</a>" . $paste . 
			"</p>";
		
		return $out;
	}

	/**
	 * Render the repeater items
	 * 
	 * @return string
	 *
	 */
	public function ___render() {
	
		$input = $this->wire()->input;
		$noAjaxAdd = $this->field->get('repeaterLoading') == FieldtypeRepeater::loadingOff ? '1' : '';
		$ajax = $this->wire()->config->ajax;
	
		if($ajax && $input->get('field') === $this->attr('name')) {
			$repeaterAdd = $input->get('repeater_add');
			$repeaterEdit = (int) $input->get('repeater_edit');
			$repeaterClone = (int) $input->get('repeater_clone');
			$repeaterCloneTo = (int) $input->get('repeater_clone_to');
			if($input->get('inrvm')) $this->renderValueMode = true; 
			if($repeaterClone) {
				return $this->renderValueMode ? '' : $this->renderAjaxNewItem($repeaterClone, $repeaterCloneTo);
			} else if($repeaterAdd !== null && !$noAjaxAdd) {
				return $this->renderValueMode ? '' : $this->renderAjaxNewItem();
			} else if($repeaterEdit) {
				if($this->renderValueMode) {
					return $this->buildForm($repeaterEdit)->renderValue(); 
				} else {
					return $this->buildForm($repeaterEdit)->render();
				}
			}
		}
	
		$out = $this->renderValueMode ? '' : $this->renderFooter($noAjaxAdd);

		$loading = $this->field->get('repeaterLoading');
		$collapse = $this->field->get('repeaterCollapse');
		$forIDs = null; 
		if($loading == FieldtypeRepeater::loadingAll && $collapse != FieldtypeRepeater::collapseNone) $forIDs = array();
		
		$form = $this->buildForm(0, $forIDs);
		$out = ($this->renderValueMode ? $form->renderValue() : $form->render()) . $out;
		
		return $out;
	}

	/**
	 * Render value (no inputs)
	 * 
	 * @return string
	 * 
	 */
	public function ___renderValue() {
		$flags = $this->getSetting('renderValueFlags');
		if($flags & Inputfield::renderValueMinimal) {
			$out = parent::___renderValue();
		} else {
			$this->renderValueMode = true;
			$out = $this->render();
			$this->renderValueMode = false;
		}
		return $out; 
	}

	/**
	 * Process the input from a submitted repeaters field
	 * 
	 * @param WireInputData $input
	 * @return $this
	 *
	 */
	public function ___processInput(WireInputData $input) {
		
		$isSingle = $this->singleMode; 
		
		/** @var PageArray $value */
		$value = $this->attr('value');
		$loadedIDs = array();
	
		// determine which repeater pages have data posted in this request
		foreach($value as $page) {
			$loadedName = "loaded_repeater$page->id";
			if($isSingle || ((int) $input->$loadedName) > 0) $loadedIDs[$page->id] = $page->id;
		}
	
		$this->buildForm(0, $loadedIDs);
		
		$numChanges = 0;
		$sortChanged = false;
		$value->setTrackChanges(true);
		$pageIDs = array();
		$_openIDs = $this->wire()->session->getFor($this, 'openIDs');
		if(!is_array($_openIDs)) $_openIDs = array();
		$openIDs = $_openIDs; // these two are compared with each other at the end
		$this->numRequiredEmpty = 0;
		$this->getErrors(true); 

		// existing items
		foreach($value as $key => $page) {
			
			/** @var RepeaterPage $page */
			$pageIDs[] = $page->id;
			
			$isHidden = $page->isHidden();
			$isUnpublished = $page->isUnpublished();
			$isOn = $page->hasStatus(Page::statusOn);

			if($isSingle) {
				$publishName = '';
			} else {
				$deleteName = "delete_repeater{$page->id}";
				$sortName = "sort_repeater{$page->id}";
				$publishName = "publish_repeater{$page->id}";
				$depthName = "depth_repeater{$page->id}";

				if($input->$deleteName == $page->id) {
					// @todo add check to Fieldgroups::isFieldNotRemoveable() before attempting remove
					$value->remove($page);
					$numChanges++;
					continue;
				}

				$sort = $input->$sortName;
				// skip pages that don't appear in the POST data (most likely ready pages)
				if(is_null($sort)) continue;

				$page->sort = (int) $sort;
				if($page->isChanged('sort')) {
					// $this->message("Sort changed for field {$this->field} page {$page->id}", Notice::debug); 
					$sortChanged = true;
				}

				if($this->repeaterDepth > 0) {
					$depth = (int) $input->$depthName;
					if($page->getDepth() != $depth) {
						$page->setDepth($depth);
						$numChanges++;
					}
				}
			}
			
			/** @var InputfieldWrapper $wrapper */
			$wrapper = $this->wrappers[$page->id]; 
			$wrapper->resetTrackChanges(true); 
			$wrapper->getErrors(true); // clear out any errors
			$wrapper->processInput($input);
			
			$numErrors = count($wrapper->getErrors());
			$numRequiredEmpty = count($wrapper->getEmpty(true));
			$page->setQuietly('_repeater_errors', $numErrors); // signal to FieldtypeRepeater::savePageField() that page has errors
			$page->setQuietly('_repeater_processed', true); // signal to FieldtypeRepeater::savePageField() that page had input processed
			$this->formToPage($wrapper, $page);
			$publish = $isSingle ? 0 : $input->$publishName;
			
			if($publish !== null) {
				$publish = (int) $publish;
				if($publish > 0 && ($isHidden || $isUnpublished)) {
					// publish requested (publish=1)
					if($isHidden) $page->removeStatus(Page::statusHidden);
					if(!$numErrors && $isUnpublished) $page->removeStatus(Page::statusUnpublished);
					if(!$isOn) $page->addStatus(Page::statusOn);

				} else if($publish < 0) {
					// unpublish requested (publish=-1)
					if($isOn) $page->removeStatus(Page::statusOn);
					if($isHidden) $page->removeStatus(Page::statusHidden);
					if(!$isUnpublished) $page->addStatus(Page::statusUnpublished);

				} else if(!$isOn) {
					// no publish change requested, just ensure page is on
					$page->addStatus(Page::statusOn);
				}
			}
			
			if($numErrors || $numRequiredEmpty) {
				$this->error(sprintf($this->_('Errors in “%s” item %d'), $this->label, $key + 1));
				if(!$page->hasStatus(Page::statusUnpublished)) $this->numRequiredEmpty += $numRequiredEmpty;
				$openIDs[$page->id] = $page->id; // force item with error to be open on next request
			} else if(isset($openIDs[$page->id])) {
				unset($openIDs[$page->id]);
			}

			if($page->isChanged() && $this->page->id) $numChanges++;
		}

		// if the sort changed, then tell the PageArray to sort by _repeater_sort
		if($sortChanged) { 
			$value->sort('sort'); 
			$numChanges++;
		}

		if(!$isSingle && $this->field->get('repeaterLoading') == FieldtypeRepeater::loadingOff) {	
			$numNewItems = (int) $input["_{$this->name}_add_items"];
			if($numNewItems) {
				// iterate through each new item added for non-ajax mode
				for($n = 0; $n < $numNewItems; $n++) {
					$page = $this->getNextReadyPage($pageIDs);
					$page->removeStatus(Page::statusHidden);
					$page->sort = count($value)+1;
					$value->add($page);
					$numChanges++;
				}
			}
		}
		
		// if changes occurred, then tell $this->page and the PageArray $value
		if($numChanges) {
			$this->page->trackChange($this->attr('name'));
			$this->trackChange('value');
		}
	
		// if openIDs value changed, update the session variable
		if($_openIDs !== $openIDs) $this->wire()->session->setFor($this, 'openIDs', $openIDs); 

		return $this; 
	}

	/**
	 * Take a form (InputfieldWrapper) and map the data to a Page that has the same fields
	 *
	 * @todo potentially convert this to it's own FormToPage class to avoid duplication between this as ProcessPageEdit
	 * 
	 * @param InputfieldWrapper $wrapper
	 * @param Page $page
	 * @param int $level
	 *
	 */
	protected function formToPage(InputfieldWrapper $wrapper, Page $page, $level = 0) {

		$languages = $this->wire()->languages; 

		foreach($wrapper as $inputfield) {
			/** @var Inputfield $inputfield */

			$name = $inputfield->attr('name');
			$name = preg_replace('/_repeater\d+$/', '', $name); 

			if($name && $inputfield->isChanged()) {
				if($languages && $inputfield->getSetting('useLanguages')) {
					$value = $page->get($name); 
					if(is_object($value)) {
						/** @var LanguagesPageFieldValue $value */
						$value->setTrackChanges(true);
						$value->setFromInputfield($inputfield); 
						$page->set($name, $value); 
					}
				} else { 
					$value = $inputfield->attr('value'); 
					$page->set($name, $value);
				}

				if($page->isChanged($name)) {
					// if a 'ready' page was changed, then we may now consider it a regular repeater page
					if($page->hasStatus(Page::statusHidden)) $page->removeStatus(Page::statusHidden); 
				}
			}

			if($inputfield instanceof InputfieldWrapper && count($inputfield->getChildren())) {
				$this->formToPage($inputfield, $page, $level + 1); 
			}
		}
	}

	/**
	 * Returns whether any values are present
	 * 
	 * @return bool
	 *
	 */
	public function isEmpty() {
		/** @var PageArray $value */
		$value = $this->attr('value');
		if(count($value) == 0) return true; 
		$cnt = 0;
		foreach($value as $item) {
			if($item->hasStatus(Page::statusHidden) && $item->hasStatus(Page::statusUnpublished)) continue;
			$cnt++;
		}
		return $cnt === 0; 
	}

	/**
	 * Return quantity of published items
	 * 
	 * @return int
	 * 
	 */
	public function numPublished() {
		/** @var PageArray $value */
		$value = $this->attr('value');
		if(empty($value) || !count($value)) return 0;
		$num = 0;
		foreach($value as $item) {
			if(!$item->hasStatus(Page::statusUnpublished)) $num++;
		}
		return $num;
	}

	/**
	 * Get number of required but empty Inputfields (across all repeater items)
	 * 
	 * @return int
	 * 
	 */
	public function numRequiredEmpty() {
		return $this->numRequiredEmpty;
	}

	/**
	 * Override the default set() to capture the required $page variable that the repeaters field lives on.
	 * 
	 * @param string $key
	 * @param mixed $value
	 * @return Inputfield|InputfieldRepeater
	 *
	 */
	public function set($key, $value) {
		if($key === 'page') {
			$this->page = $value;
		} else if($key === 'field') {
			$this->field = $value;
		} else {
			return parent::set($key, $value);
		}
		return $this;
	}

	/**
	 * Set attribute
	 * 
	 * @param array|string $key
	 * @param array|int|string $value
	 * @return InputfieldRepeater|Inputfield
	 * 
	 */
	public function setAttribute($key, $value) {
		if($key === 'value' && $value instanceof Page) {
			if($this->field && method_exists($this->field->type, 'getRepeaterPageArray')) {
				if(!$value->id) $value = null;
				$value = $this->field->type->getRepeaterPageArray($this->page, $this->field, $value); 
			}
		}
		return parent::setAttribute($key, $value); 
	}

	/**
	 * Get the repeater wrappers (InputfieldWrappers) indexed by repeater page ID
	 * 
	 * @param mixed|null Optionally specify key to retrieve just one
	 * @return array|InputfieldWrapper
	 * 
	 */
	public function getWrappers($key = null) {
		if(!is_null($key)) {
			return isset($this->wrappers[$key]) ? $this->wrappers[$key] : null;
		}
		return $this->wrappers;
	}

	/**
	 * Get the copy/paste cookie name
	 * 
	 * @return string
	 * @since 3.0.188
	 * 
	 */
	public function copyPasteCookieName() {
		return $this->field->name . '_copy';
	}

	/**
	 * @return InputfieldWrapper
	 * 
	 */
	public function ___getConfigInputfields() {
		$inputfields = parent::___getConfigInputfields();
		return $inputfields;
	}

}
