<?php namespace ProcessWire;

/**
 * An Inputfield for handling file uploads
 * 
 * ProcessWire 3.x, Copyright 2022 by Ryan Cramer
 * https://processwire.com
 *
 * @property string $extensions Allowed file extensions, space separated
 * @property array $okExtensions File extensions that are whitelisted if any in $extensions are problematic. (3.0.167+)
 * @property int $maxFiles Maximum number of files allowed
 * @property int $maxFilesize Maximum file size
 * @property bool $useTags Whether or not tags are enabled
 * @property string $tagsList Predefined tags
 * @property bool|int $unzip Whether or not unzip is enabled
 * @property bool|int $overwrite Whether or not overwrite mode is enabled
 * @property int $descriptionRows Number of rows for description field (default=1, 0=disable)
 * @property string $destinationPath Destination path for uploaded file
 * @property string $itemClass Class name(s) for each file item (default=InputfieldFileItem ui-widget ui-widget-content)
 * @property bool|int $noUpload Set to true or 1 to disable uploading to this field
 * @property bool|int $noLang Set to true or 1 to disable multi-language descriptions
 * @property bool|int $noAjax Set to true or 1 to disable ajax uploading
 * @property int $uploadOnlyMode Set to true or 1 to disable existing file list display, or 2 to also prevent file from having 'temp' status.
 * @property bool|int $noCollapseItem Set to true to disable collapsed items (like for LanguageTranslator tool or other things that add tools to files)
 * @property bool|int $noShortName Set to true to disable shortened filenames in output
 * @property bool|int $noCustomButton Set to true to disable use of the styled <input type='file'>
 * @property Pagefiles|Pagefile|null $value
 *
 * @method string renderItem($pagefile, $id, $n)
 * @method string renderList($value)
 * @method string renderUpload($value)
 * @method void fileAdded(Pagefile $pagefile)
 * @method array extractMetadata(Pagefile $pagefile, array $metadata = array())
 * @method Pagefile|null processInputAddFile($filename)
 * @method void processInputDeleteFile(Pagefile $pagefile)
 * @method bool processInputFile(WireInputData $input, Pagefile $pagefile, $n)
 * @method bool processItemInputfields(Pagefile $pagefile, InputfieldWrapper $inputfields, $id, WireInputData $input)
 *
 */
class InputfieldFile extends Inputfield implements InputfieldItemList, InputfieldHasSortableValue {

	public static function getModuleInfo() {
		return array(
			'title' => __('Files', __FILE__), // Module Title
			'summary' => __('One or more file uploads (sortable)', __FILE__), // Module Summary
			'version' => 128,
			'permanent' => true, 
		);
	}
	
	/**
	 * Cache of responses we'll be sending on ajax requests
	 *
	 */
	protected $ajaxResponses = array();

	/**
	 * Was a file replaced? 
	 *
	 */
	protected $singleFileReplacement = false;

	/**
	 * Saved instanceof WireUpload in case API retrieval is needed (see getWireUpload() method)
	 *
	 */
	protected $wireUpload = null;

	/**
	 * Set to the current Pagefile item when doing iteration
	 * 
	 * @var Pagefile|null
	 * 
	 */
	protected $currentItem = null;

	/**
	 * True when field should behave in an upload only mode
	 * 
	 * @var bool|int
	 * 
	 */
	protected $uploadOnlyMode = 0;

	/**
	 * This is true when we are only rendering the value rather than the inputs
	 * 
	 * @var bool
	 * 
	 */
	protected $renderValueMode = false;

	/**
	 * True when in ajax mode
	 * 
	 * @var bool
	 * 
	 */
	protected $isAjax = false;

	/**
	 * Admin theme specific settings
	 * 
	 * @var array
	 * 
	 */
	protected $themeSettings = array();

	/**
	 * Commonly used text labels, translated, indexed by label name
	 * 
	 * @var array
	 * 
	 */
	protected $labels = array();
	
	/**
	 * Cached value of Fieldgroup used for Pagefile custom fields, as used by getItemInputfields() method
	 *
	 * @var Fieldgroup|null|bool Null when not yet known, false when known not applicable, Fieldgroup when known and in use
	 *
	 */
	protected $itemFieldgroup = null;

	/**
	 * Cached result from FieldtypeFile::getValidFileExtension()
	 * 
	 * @var array
	 * 
	 */
	protected $extensionsInfo = array();

	/**
	 * Initialize the InputfieldFile
	 *
	 */
	public function init() {
		parent::init();

		// note: these two fields originate from FieldtypeFile. 
		// Initializing them here ensures this Inputfield has the values set automatically.
		$this->set('extensions', '');
		$this->set('okExtensions', array()); // manually whitelisted problematic extensions
		$this->set('maxFiles', 0); 
		$this->set('maxFilesize', 0); 
		$this->set('useTags', 0);
		$this->set('tagsList', ''); 

		// native to this Inputfield
		$this->set('unzip', 0); 
		$this->set('overwrite', 0); 
		$this->set('descriptionRows', 1); 
		$this->set('destinationPath', ''); 
		$this->set('itemClass', 'InputfieldFileItem ui-widget ui-widget-content'); 
		$this->set('noUpload', 0); // set to 1 to disable uploading to this field
		$this->set('noLang', 0); 
		$this->set('noAjax', 0); // disable ajax uploading
		$this->set('noCollapseItem', 0);
		$this->set('noShortName', 0);
		$this->set('noCustomButton', false);
		$this->attr('type', 'file'); 
		
		$this->labels = array(
			'description' => $this->_('Description'), 
			'tags' => $this->_('Tags'),
			'drag-drop' => $this->_('drag and drop files in here'), 
			'delete' => $this->_('Delete'),
			'choose-file' => $this->_('Choose File'),
			'choose-files' => $this->_('Choose Files'),
		);
		
		$input = $this->wire()->input;
		
		$this->isAjax = $input->get('InputfieldFileAjax') 
			|| $input->get('reloadInputfieldAjax')
			|| $input->get('renderInputfieldAjax');

		$this->setMaxFilesize(trim(ini_get('post_max_size'))); 
		$this->uploadOnlyMode = (int) $input->get('uploadOnlyMode');
		$this->addClass('InputfieldItemList', 'wrapClass');
		$this->addClass('InputfieldHasFileList', 'wrapClass');

		$themeDefaults = array(
			'error' => "<span class='ui-state-error-text'>{out}</span>",
		);
		$themeSettings = $this->wire()->config->InputfieldFile;
		$this->themeSettings = is_array($themeSettings) ? array_merge($themeDefaults, $themeSettings) : $themeDefaults;
	}
	
	public function get($key) {
		if($key === 'renderValueMode') return $this->renderValueMode;
		if($key === 'singleFileReplacement') return $this->singleFileReplacement;
		if($key === 'descriptionFieldLabel') return $this->labels['description'];
		if($key === 'tagsFieldLabel') return $this->labels['tags'];
		if($key === 'deleteLabel') return $this->labels['delete'];
		if($key === 'themeSettings') return $this->themeSettings;
		return parent::get($key);
	}
	
	public function set($key, $value) {
		if($key == 'maxFilesize') return $this->setMaxFilesize($value);
		return parent::set($key, $value); 
	}

	/**
	 * Set the max file size in bytes or use string like "30m", "2g" "500k"
	 * 
	 * @param int|string $filesize
	 * @return $this
	 * 
	 */
	public function setMaxFilesize($filesize) {
		$max = $this->strToBytes($filesize);
		$phpMax = $this->strToBytes(ini_get('upload_max_filesize'));
		if($phpMax < $max) $max = $phpMax;	
		parent::set('maxFilesize', $max);
		return $this;
	}

	/**
	 * Convert string like "32M" to bytes (integer)
	 * 
	 * @param string|int $filesize
	 * @return int
	 * 
	 */
	protected function strToBytes($filesize) {
		if(ctype_digit("$filesize")) {
			$bytes = (int) $filesize;
		} else {
			$filesize = rtrim($filesize, 'bB'); // convert mb=>m, gb=>g, kb=>k
			$last = strtolower(substr($filesize, -1));
			if(ctype_alpha($last)) $filesize = rtrim($filesize, $last);
			$filesize = (int) $filesize;
			if($last == 'g') {
				$bytes = (($filesize * 1024) * 1024) * 1024;
			} else if($last == 'm') {
				$bytes = ($filesize * 1024) * 1024;
			} else if($last == 'k') {
				$bytes = $filesize * 1024;
			} else if($filesize > 0) {
				$bytes = $filesize;
			} else {
				$bytes = (5 * 1024) * 1024;
			}
		}
		return $bytes; 
	}

	/**
	 * Per Inputfield interface, returns true when this field is empty
	 *
	 */
	public function isEmpty() {
		return !wireCount($this->value);
	}

	/**
	 * Set an attribute
	 * 
	 * @param array|string $key
	 * @param array|int|string $value
	 * @return Inputfield|InputfieldFile
	 * 
	 */
	public function setAttribute($key, $value) {
		if($key == 'value') {
			if($value instanceof Pagefile) {
				// if given a Pagefile rather than a Pagefiles, use the Pagefiles instead
				$value = $value->pagefiles; 
			}
			if($value instanceof Pagefiles) {
				$page = $value->page;
				if($page && $page->template->noLang) $this->noLang = true;
			}
		}
		return parent::setAttribute($key, $value);
	}
	
	/**
	 * Check to ensure that the containing form as an 'enctype' attr needed for uploading files
	 *
	 */
	protected function checkFormEnctype() {
		$parent = $this->parent;
		while($parent) {
			if($parent->attr('method') == 'post') {
				if(!$parent->attr('enctype')) $parent->attr('enctype', 'multipart/form-data');
				break;
			}
			$parent = $parent->parent; 
		}
	}

	/**
	 * Set the parent of this Inputfield
	 *
	 * @param InputfieldWrapper $parent
	 * @return $this
	 *
	 */
	public function setParent(InputfieldWrapper $parent) {
		parent::setParent($parent); 
		$this->checkFormEnctype();
		return $this;
	}

	/**
	 * Get the unique 'id' attribute for the given Pagefile
	 * 
	 * @param Pagefile $pagefile
	 * @param string $context Optional context string (like for repeaters) 3.0.178+
	 * @return string
	 * 
	 */
	protected function pagefileId(Pagefile $pagefile, $context = '') {
		return $this->name . "_" . $context . $pagefile->hash; 
	}

	/**
	 * Render a description input for the given Pagefile
	 * 
	 * @param Pagefile $pagefile
	 * @param string $id 
	 * @param int $n
	 * @return string
	 * 
	 */
	protected function renderItemDescriptionField(Pagefile $pagefile, $id, $n) {
		
		$sanitizer = $this->wire()->sanitizer;
		$languages = $this->wire()->languages;
		$user = $this->wire()->user;
	
		if($n) {}
		$out = '';
		$tabs = '';
		
		static $hasLangTabs = null;
		static $langTabSettings = array();

		if($this->renderValueMode) {
			if($languages) {
				$description = $pagefile->description($this->noLang ? $languages->getDefault() : $user->language);
			} else {
				$description = $pagefile->description;
			}
			if(strlen($description)) $description = 
				"<div class='InputfieldFileDescription detail'>" . 
					$sanitizer->entities1($description) . 
				"</div>";
			return $description;
		}
		
		if($this->descriptionRows > 0) {

			$userLanguage = $languages ? $user->language : null;
			$defaultDescriptionFieldLabel = $sanitizer->entities1($this->labels['description']);
			
			if(!$userLanguage || $languages->count() < 2 || $this->noLang) {
				$numLanguages = 0;
				$forLanguages = array(null);
			} else {
				$numLanguages = $languages->count();
				$forLanguages = $languages;
				if(is_null($hasLangTabs)) {
					$modules = $this->wire()->modules;
					$hasLangTabs = $modules->isInstalled('LanguageTabs');
					if($hasLangTabs) {
						/** @var LanguageTabs $languageTabs */
						$languageTabs = $modules->getModule('LanguageTabs');
						$langTabSettings = $languageTabs->getSettings();
					}
				}
			}

			foreach($forLanguages as $language) {

				$descriptionFieldName = "description_$id";
				$descriptionFieldLabel = $defaultDescriptionFieldLabel;
				$labelClass = "detail";
				$attrStr = '';

				if($language) {
					/** @var Language $language */
					$tabField = empty($langTabSettings['tabField']) ? 'title' : $langTabSettings['tabField'];
					$descriptionFieldLabel = (string) $language->getUnformatted($tabField);
					if(empty($descriptionFieldLabel)) $descriptionFieldLabel = $language->get('name');
					$descriptionFieldLabel = $sanitizer->entities($descriptionFieldLabel);
					if(!$language->isDefault()) $descriptionFieldName = "description{$language->id}_$id";
					$labelClass .= ' LanguageSupportLabel';
					if(!$languages->editable($language)) {
						$labelClass .= ' LanguageNotEditable';
						$descriptionFieldLabel = "<s>$descriptionFieldLabel</s>";
					}
					$tabID = "langTab_{$id}__$language";
					$aClass = "langTab$language";
					if(!empty($langTabSettings['aClass'])) $aClass .= " " . $langTabSettings['aClass'];
					$tabs .= "<li><a data-lang='$language' class='$aClass' href='#$tabID'>$descriptionFieldLabel</a></li>";
					$out .= "<div class='InputfieldFileDescription LanguageSupport' data-language='$language' id='$tabID'>"; // open wrapper
				} else {
					$out .= "<div class='InputfieldFileDescription'>"; // open wrapper
					$attrStr = "placeholder='$descriptionFieldLabel&hellip;'";
					$labelClass = 'detail pw-hidden';
					
					// for the $pagefile->description($language) call further below
					if($languages && $this->noLang) $language = $languages->getDefault();
				}
				
				$attrStr = "name='$descriptionFieldName' id='$descriptionFieldName' $attrStr";
				
				$out .= "<label for='$descriptionFieldName' class='$labelClass'>$descriptionFieldLabel</label>";

				$description = $sanitizer->entities($pagefile->description($language));

				if($this->descriptionRows > 1) {
					$out .= "<textarea $attrStr rows='$this->descriptionRows'>$description</textarea>";
				} else {
					$out .= "<input type='text' $attrStr value='$description' />";
				}

				$out .= "</div>"; // close wrapper
			}
			
			if($numLanguages && $hasLangTabs) {
				$ulClass = empty($langTabSettings['ulClass']) ? '' : " class='$langTabSettings[ulClass]'";
				$ulAttr = empty($langTabSettings['ulAttrs']) ? '' : " $langTabSettings[ulAttrs]";
				
				$out = 
					"<div class='hasLangTabs langTabsContainer'>" . 
						"<div class='langTabs'>" . 
							"<ul $ulAttr$ulClass>$tabs</ul>" . 
							$out . 
						"</div>" . 
					"</div>";
				
				if($this->isAjax) {
					$js = 'script';
					$out .= "<$js>setupLanguageTabs($('#wrap_" . $this->attr('id') . "'));</$js>";
				}
			}

		}

		if($this->useTags) $out .= $this->renderItemTagsField($pagefile, $id, $n); 

		return $out;
	}

	/**
	 * Render the tags input for the given Pagefile
	 * 
	 * @param Pagefile $pagefile
	 * @param string $id
	 * @param int $n
	 * @return string
	 * 
	 */
	protected function renderItemTagsField(Pagefile $pagefile, $id, $n) {
		
		$sanitizer = $this->wire()->sanitizer;

		if($n) {}
		$tagsLabel = $sanitizer->entities($this->labels['tags']) . '&hellip;';
		$tagsStr = $sanitizer->entities($pagefile->tags);
		$tagsAttr = '';

		if($this->useTags >= FieldtypeFile::useTagsPredefined) {
			// select predefined
			$tagsClass = 'InputfieldFileTagsSelect';
			$tagsAttr = "data-cfgname='InputfieldFileTags_{$this->hasField->name}' ";

		} else {
			// text input
			$tagsClass = 'InputfieldFileTagsInput';
		}

		$out = 
			"<div class='InputfieldFileTags'>" . 
				"<label for='tags_$id' class='detail pw-hidden'>$tagsLabel</label>" . 
				"<input type='text' name='tags_$id' id='tags_$id' value='$tagsStr' " . 
					"placeholder='$tagsLabel' class='$tagsClass' $tagsAttr/>" . 
			"</div>";
		
		return $out;
	}

	/**
	 * Get a basename for the file, possibly shortened, suitable for display in InputfieldFileList
	 * 
	 * @param Pagefile $pagefile
	 * @param int $maxLength
	 * @return string
	 * 
	 */
	public function getDisplayBasename(Pagefile $pagefile, $maxLength = 25) {
		$displayName = $pagefile->basename;
		if($this->noShortName) return $displayName;
		if(strlen($displayName) > $maxLength) {
			$ext = ".$pagefile->ext";
			$maxLength -= (strlen($ext) + 1);
			$displayName = basename($displayName, $ext);
			$displayName = substr($displayName, 0, $maxLength);
			$displayName .= "&hellip;" . ltrim($ext, '.');
		}
		return $displayName; 	
	}

	/**
	 * Render markup for a file item
	 * 
	 * @param Pagefile $pagefile
	 * @param string $id
	 * @param int $n
	 * @return string
	 * 
	 */
	protected function ___renderItem($pagefile, $id, $n) {
	
		$displayName = $this->getDisplayBasename($pagefile);
		$deleteLabel = $this->labels['delete'];
		$uploadName = $pagefile->uploadName();
		$icon = wireIconMarkupFile($pagefile->basename, "fa-fw HideIfEmpty");
	
		$tooltip = $this->wire()->sanitizer->entities($pagefile->basename);
		
		if($uploadName && $uploadName != $pagefile->basename) {
			$uploadName = $this->wire()->sanitizer->entities($uploadName);
			$icon = "<span class='pw-tooltip' title='$uploadName'>$icon</span>";
		}
	
		$out = 
			"<p class='InputfieldFileInfo InputfieldItemHeader ui-state-default ui-widget-header'>" . 
			"$icon&nbsp;" . 
			"<a class='InputfieldFileName pw-tooltip' title='$tooltip' target='_blank' href='$pagefile->url' download>$displayName</a> " . 
			"<span class='InputfieldFileStats'>" . str_replace(' ', '&nbsp;', $pagefile->filesizeStr) . "</span> ";
		
		if(!$this->renderValueMode) $out .=
			"<label class='InputfieldFileDelete'>" . 
				"<input type='checkbox' name='delete_$id' value='1' title='$deleteLabel' />" . 
				"<i class='fa fa-fw fa-trash'></i></label>";
		
		$description = $this->renderItemDescriptionField($pagefile, $id, $n);
		$class = 'InputfieldFileData ';
		$class .= $description ? 'description ui-widget-content' : 'InputfieldFileFields';
		
		$out .= "</p><div class='$class'>" . $description;

		$inputfields = $this->getItemInputfields($pagefile);
		if($inputfields) $out .= $inputfields->render();
		
		if(!$this->renderValueMode) {
			$out .= "<input class='InputfieldFileSort' type='text' name='sort_$id' value='$n' />";
		}
		
		$out .= "</div>";
		
		return $out; 
	}

	/**
	 * Wrap output of files list item
	 * 
	 * @param string $out
	 * @return string
	 * 
	 */
	protected function renderItemWrap($out) {
		// note: using currentItem rather than a new argument since there are now a few modules extending
		// this one and if they implement their own calls to this method or version of this method then 
		// they will get strict notices from php if we add a new argument here. 
		$item = $this->currentItem; 
		$id = $item && !$this->renderValueMode ? " id='file_$item->hash'" : "";
		return "<li$id class='{$this->itemClass}'>$out</li>";
	}

	/**
	 * Render files list ready
	 * 
	 * @param Pagefiles|null $value
	 * @throws WireException
	 * @throws WirePermissionException
	 * 
	 */
	protected function renderListReady($value) {
		if(!$this->renderValueMode) {
			// if just rendering the files list (as opposed to saving it), delete any temp files that may have accumulated
			if(!$this->overwrite && !count($_POST) && !$this->isAjax && !$this->uploadOnlyMode && !$this->wire()->config->ajax) {
				$input = $this->wire()->input;
				// don't delete files when in render single field or fields mode
				if(!$input->get('field') && !$input->get('fields')) {
					if($value instanceof Pagefiles) $value->deleteAllTemp();
				}
			}
		}
	}

	/**
	 * Render files list
	 * 
	 * @param Pagefiles|null $value
	 * @return string
	 * 
	 */
	protected function ___renderList($value) {

		if(!$value) return '';
		$out = '';
		$n = 0; 
	
		$this->renderListReady($value);

		if(!$this->uploadOnlyMode && WireArray::iterable($value)) {
			foreach($value as $pagefile) {
				$id = $this->pagefileId($pagefile);
				$this->currentItem = $pagefile;
				$out .= $this->renderItemWrap($this->renderItem($pagefile, $id, $n++));
			}
		}

		$class = 'InputfieldFileList ui-helper-clearfix';
		if($this->overwrite && !$this->renderValueMode) $class .= " InputfieldFileOverwrite";
		if($out) $out = "<ul class='$class'>$out</ul>";
		
		return $out; 
	}

	/**
	 * Render upload area
	 * 
	 * @param Pagefiles|null $value
	 * @return string
	 * 
	 */
	protected function ___renderUpload($value) {
		if($value) {}
		if($this->noUpload || $this->renderValueMode) return '';

		// enables user to choose more than one file
		if($this->maxFiles != 1) $this->setAttribute('multiple', 'multiple'); 

		$attrs = $this->getAttributes();
		unset($attrs['value']); 
		if(substr($attrs['name'], -1) != ']') $attrs['name'] .= '[]';

		$extensions = $this->getAllowedExtensions();
		$formatExtensions = $this->formatExtensions();
		$chooseLabel = $this->labels['choose-file'];
		$dragDropLabel = $this->labels['drag-drop'];
		$attrStr = $this->getAttributesString($attrs);

		$out =
			"<div " .
				"data-maxfilesize='$this->maxFilesize' " .
				"data-extensions='$extensions' " .
				"data-fieldname='$attrs[name]' " .
				"class='InputfieldFileUpload'>
				";
	
		if($this->getSetting('noCustomButton')) {
			$out .= "<input $attrStr>";
			
		} else {
			$out .= "
					<div class='InputMask ui-button ui-state-default'>
						<span class='ui-button-text'>
							<i class='fa fa-fw fa-folder-open-o'></i>$chooseLabel
						</span>
						<input $attrStr>
					</div>
					";
		}
		
		$out .= "  		
				<span class='InputfieldFileValidExtensions detail'>$formatExtensions</span>
				<input type='hidden' class='InputfieldFileMaxFiles' value='$this->maxFiles' />
			";
		
		if(!$this->noAjax) $out .= "
				<span class='AjaxUploadDropHere description'>
					<span>
						<i class='fa fa-cloud-upload'></i>&nbsp;$dragDropLabel
					</span>
				</span>
			";	
		
		$out .= "</div>"; // .InputfieldFileUpload

		return $out; 
	}

	/**
	 * Render ready
	 * 
	 * @param Inputfield|null $parent
	 * @param bool $renderValueMode
	 * @return bool
	 * 
	 */
	public function renderReady(Inputfield $parent = null, $renderValueMode = false) {
		
		$config = $this->wire()->config;
		
		$this->addClass('InputfieldNoFocus', 'wrapClass');
		if(!$renderValueMode) $this->addClass('InputfieldHasUpload', 'wrapClass');
		
		if($this->useTags) {
			$jQueryUI = $this->wire()->modules->get('JqueryUI'); /** @var JqueryUI $jQueryUI */
			$jQueryUI->use('selectize');
			$this->addClass('InputfieldFileHasTags', 'wrapClass');
			if($this->useTags >= FieldtypeFile::useTagsPredefined && $this->hasField) {
				// predefined tags
				$fieldName = $this->hasField->name;
				$jsName = "InputfieldFileTags_$fieldName";
				$allowUserTags = $this->useTags & FieldtypeFile::useTagsNormal;
				$data = $config->js($jsName);
				if(!is_array($data)) $data = array();
				if(empty($data['tags'])) {
					$tags = array();
					foreach(explode(' ', (string) $this->get('tagsList')) as $tag) {
						$tag = trim($tag);
						if(!strlen($tag)) continue;
						$tags[strtolower($tag)] = $tag;
					}
					if($allowUserTags) {
						$pagefiles = $this->val();
						if($pagefiles instanceof Pagefiles) {
							$_tags = $pagefiles->tags(true);
							if(count($_tags)) $tags = array_merge($tags, $_tags);
						}
					}
					$data['tags'] = array_values($tags);
					$data['allowUserTags'] = $allowUserTags;
					$config->js($jsName, $data); 
				}
				$this->wrapAttr('data-configName', $jsName);
			} else {
				// regular tags text input
			}
		}
		
		$data = $config->js('InputfieldFile');
		if(!is_array($data)) $data = array();
		if(empty($data['labels'])) $data['labels'] = array();
		if(empty($data['labels']['bad-ext'])) {
			$data['labels']['bad-ext'] = $this->_('Unsupported file extension, please use only: EXTENSIONS');
			$data['labels']['too-big'] = $this->_('File is too big - maximum allowed size is MAX_KB kb');
			$config->js('InputfieldFile', $data); 
		}
		
		$this->getItemInputfields(); // custom fields ready
		
		return parent::renderReady($parent, $renderValueMode); 
	}

	/**
	 * Render Inputfield input
	 * 
	 * @return string
	 * 
	 */
	public function ___render() {
		
		if(!$this->extensions) {
			$this->error($this->_('No file extensions are defined for this field.'));
		}
		
		if($this->allowCollapsedItems()) {
			$this->addClass('InputfieldItemListCollapse', 'wrapClass');
		}
		
		$numItems = (int) wireCount($this->value);
		
		if($numItems === 0) {
			$this->addClass('InputfieldFileEmpty', 'wrapClass');
		} else if($numItems === 1) {
			$this->addClass('InputfieldFileSingle', 'wrapClass');
		} else {
			$this->addClass('InputfieldFileMultiple', 'wrapClass');
		}
		
		return $this->renderList($this->value) . $this->renderUpload($this->value);
	}

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

	/**
	 * File added hook
	 * 
	 * @param Pagefile $pagefile
	 * @throws WireException
	 * 
	 */
	protected function ___fileAdded(Pagefile $pagefile) {
		
		if($this->noUpload) return;
		
		$sanitizer = $this->wire()->sanitizer;
		
		$isValid = $sanitizer->validateFile($pagefile->filename(), array(
			'pagefile' => $pagefile
		));
		
		if($isValid === false) {
			$errors = $sanitizer->errors('clear array');
			throw new WireException(
				"$pagefile->basename - " . $this->_('File failed validation') . 
				(count($errors) ? ": " . implode(', ', $errors) : "")
			);
		} else if($isValid === null) {
			// there was no validator available for this file type
		}

		$message = $this->_('Added file:') . " {$pagefile->basename}"; // Label that precedes an added filename

		if($this->isAjax && !$this->noAjax) {
			$n = count($this->value); 
			if($n) $n--; // for sorting
			$this->currentItem = $pagefile; 
			$markup = $this->fileAddedGetMarkup($pagefile, $n);
			$this->ajaxResponse(false, $message, $pagefile->url, $pagefile->filesize(), $markup); 
		} else {
			$this->message($message); 
		}
	
		$user = $this->wire()->user;
		$pagefile->createdUser = $user;
		$pagefile->modifiedUser = $user;
	}

	/**
	 * Get markup for added file
	 * 
	 * @param Pagefile $pagefile
	 * @param int $n
	 * @return string
	 * 
	 */
	protected function fileAddedGetMarkup(Pagefile $pagefile, $n) {
		return $this->renderItemWrap($this->renderItem($pagefile, $this->pagefileId($pagefile), $n));	
	}

	/**
	 * Given a Pagefile return array of meta data pulled from it
	 * 
	 * @param Pagefile $pagefile
	 * @param array $metadata Existing metadata, if applicable
	 * @return array Associative array of meta data (i.e. description and tags)
	 * 
	 */
	protected function ___extractMetadata(Pagefile $pagefile, array $metadata = array()) {
	
		$languages = $this->wire()->languages;
		
		if($languages) {
			$metadata['description'] = $pagefile->description($languages->getDefault());
		} else {
			$metadata['description'] = $pagefile->description;
		}
		
		if($languages && !$this->noLang) {
			foreach($languages as $language) {
				/** @var Language $language */
				if($language->isDefault()) continue;
				$metadata["description$language->id"] = $pagefile->description($language);
			}
		}
		
		$metadata['tags'] = $pagefile->tags;
		$filedata = $pagefile->filedata();
		
		if(count($filedata)) {
			$metadata['filedata'] = $filedata;
		}
		
		return $metadata;
	}

	/**
	 * Process input to add a file
	 * 
	 * @param string $filename
	 * @return Pagefile|null Returns Pagefile (added 3.0.212+)
	 * @throws WireException
	 * 
	 */
	protected function ___processInputAddFile($filename) {

		$total = count($this->value); 
		$metadata = array();

		if($this->maxFiles > 1 && $total >= $this->maxFiles) return null; 
		
		// allow replacement of file if maxFiles is 1
		if($this->maxFiles == 1 && $total) {
			/** @var Pagefile $pagefile */
			$pagefile = $this->value->first();
			$metadata = $this->extractMetadata($pagefile, $metadata);
			$rm = true; 
			if($filename == $pagefile->basename) {
				// use overwrite mode rather than replace mode when single file and same filename
				if($this->overwrite) $rm = false;
			}
			if($rm) {
				if($this->overwrite) $this->processInputDeleteFile($pagefile);
				$this->singleFileReplacement = true; 
			}
		} 
	
		if($this->overwrite) {
			$pagefile = $this->value->get($filename); 
			clearstatcache();
			if($pagefile) {
				// already have a file of the same name
				if($pagefile instanceof Pageimage) $pagefile->removeVariations(); 
				$metadata = $this->extractMetadata($pagefile, $metadata);
			} else {
				// we don't have a file with the same name as the one that was uploaded
				// file must be in another files field on the same page, that could be problematic
				$ul = $this->getWireUpload();
				// see if any files were overwritten that weren't part of our field
				// if so, we need to restore them and issue an error
				$err = false;
				$files = $this->wire()->files;
				foreach($ul->getOverwrittenFiles() as $bakFile => $newFile) {
					if(basename($newFile) != $filename) continue; 
					$files->unlink($newFile); 	
					$files->rename($bakFile, $newFile); // restore
					$ul->error(sprintf($this->_('Refused file %s because it is already on the file system and owned by a different field.'), $filename)); 
					$err = true; 
				}
				if($err) return null;
			}
		}

		$this->value->add($filename); 
		
		/** @var Pagefile $item */
		$item = $this->value->last();
		
		try {
			foreach($metadata as $key => $val) {
				if($val) $item->$key = $val;
			}
			// items saved in ajax or uploadOnly mode are temporary till saved in non-ajax/non-uploadOnly
			if($this->isAjax && !$this->overwrite) {
				if($this->wire()->input->get('InputfieldFileAjax') !== 'noTemp') {
					$item->isTemp(true);
				}
			}
			$this->fileAdded($item); 
		} catch(\Exception $e) {
			$item->unlink();
			$this->value->remove($item); 
			throw new WireException($e->getMessage()); 
		}
		
		return $item;
	}

	/**
	 * Process input to delete a Pagefile item
	 * 
	 * @param Pagefile $pagefile
	 * 
	 */
	protected function ___processInputDeleteFile(Pagefile $pagefile) {
		$fileLabel = $this->wire()->config->debug ? $pagefile->url() : $pagefile->name;
		$this->message($this->_("Deleted file:") . " $fileLabel"); // Label that precedes a deleted filename
		$this->value->delete($pagefile); 
		$this->trackChange('value');
	}

	/**
	 * Process input for one Pagefile
	 * 
	 * @param WireInputData $input
	 * @param Pagefile $pagefile
	 * @param int $n
	 * @return bool
	 * 
	 */
	protected function ___processInputFile(WireInputData $input, Pagefile $pagefile, $n) {

		$saveFields = false; // allow custom Inputfields to be saved?
		$changed = false; // are there any changes to this file?
		$id = $this->name . '_' . $pagefile->hash;
	
		if($this->uploadOnlyMode) {
			// skip files that aren't present as just uploaded
			$key = "sort_$id";
			if($input->$key === null) return false;
		}
		
		// replace (currently only used by InputfieldImage)
		$key = "replace_$id";
		$replace = $input->$key;
		if($replace) {
			if(strpos($replace, '?') !== false) {
				list($replace, $unused) = explode('?', $replace);
				if($unused) {}
			}
			$replaceFile = $this->value->getFile($replace);
			if($replaceFile instanceof Pagefile) {
				// $this->processInputDeleteFile($replaceFile);
				// PR#229 to fix #1586:
				if($replaceFile->basename !== $pagefile->basename) { 
					$this->processInputDeleteFile($replaceFile);
				}
				// ---
				if(strtolower($pagefile->ext()) == strtolower($replaceFile->ext())) {
					$this->value->rename($pagefile, $replaceFile->name);
				}
				$changed = true; 
			}
		}
	
		// rename (currently only used by InputfieldImage)
		$key = "rename_$id";
		$rename = (string) $input->$key;
		if(strlen($rename) && $rename != $pagefile->basename(false)) {
			$name = $pagefile->basename();
			$rename .= "." . $pagefile->ext();
			// cleanBasename($basename, $originalize = false, $allowDots = true, $translate = false) 
			$rename = $pagefile->pagefiles->cleanBasename($rename, true, true, true);
			if(strlen($rename)) {
				$message = sprintf($this->_('Renamed file "%1$s" to "%2$s"'), $name, $rename);
				if($pagefile->rename($rename) !== false) {
					$this->message($message);
					$changed = true;
				} else {
					$this->warning($this->_('Failed') . " - $message");
				}
			}
		}
	
		// description and tags
		// $languages = $this->noLang ? null : $this->wire()->languages;
		$languages = $this->wire()->languages;
		$useLanguages = $languages && !$this->noLang;
		$keys = $useLanguages ? array('tags') : array('description', 'tags'); 

		foreach($keys as $key) { 
			if(isset($input[$key . '_' . $id])) { 
				$value = $input[$key . '_' . $id]; 
				if(is_array($value)) $value = implode(' ', $value);
				$value = trim($value); 
				if($value != $pagefile->$key) {
					$pagefile->$key = $value; 
					$changed = true; 
				}
			}
		}

		// multi-language descriptions
		if($languages) {
			foreach($languages as $language) {
				/** @var Language $language */
				if(!$useLanguages && !$language->isDefault()) continue;
				if(!$languages->editable($language)) continue;
				$key = $language->isDefault() ? "description_$id" : "description{$language->id}_$id";
				if(!isset($input[$key])) continue;
				$value = trim($input[$key]);
				if($value != $pagefile->description($language)) {
					$pagefile->description($language, $value);
					$changed = true;
				}
			}
		}
	
		if($this->uploadOnlyMode) {
			if($this->uploadOnlyMode === 2) {
				$sort = 0; // ensures an isTemp(false) call occurs below
			} else {
				$sort = null;
			}
			$changed = true;
		} else {
			$key = "sort_$id";
			$sort = $input->$key;
			if($sort !== null) {
				$sort = (int) $sort; 
				$pagefile->set('sort', $sort);
				if($n !== $sort) $changed = true;
				$saveFields = true;
			}
		}
		
		if($saveFields) {
			// save custom Inputfields
			$inputfields = $this->getItemInputfields($pagefile);
			if($inputfields && $this->processItemInputfields($pagefile, $inputfields, $id, $input)) $changed = true;
		}

		$delete = isset($input['delete_' . $id]) ? (int) $input['delete_' . $id] : 0;
		if(!empty($delete)) {
			$this->processInputDeleteFile($pagefile); 
			$changed = true; 
			
		} else if(!$this->isAjax && !$this->overwrite && $pagefile->isTemp() && $sort !== null) {
			// if page saved with temporary items when not ajax, those temporary items become non-temp
			$pagefile->isTemp(false);
			// @todo should the next statement instead be this below?
			// if($this->maxFiles > 0) while(count($this->value) > $this->>maxFiles) { ... } ?
			if(((int) $this->maxFiles) === 1) {
				while(count($this->value) > 1) {
					$item = $this->value->first();
					$this->value->remove($item);
				}
			}
			$changed = true;
		}

		return $changed; 
	}

	/**
	 * Process custom Inputfields for Pagefile item
	 * 
	 * @param Pagefile $pagefile
	 * @param InputfieldWrapper $inputfields
	 * @param string $id Pagefile ID string
	 * @param WireInputData $input
	 * @return bool True if changes detected, false if not
	 * @since 3.0.142
	 * 
	 */
	protected function ___processItemInputfields(Pagefile $pagefile, InputfieldWrapper $inputfields, $id, WireInputData $input) {
		
		$changed = false;
		$inputfields->resetTrackChanges(true);
		$inputfields->processInput($input);
		
		foreach($inputfields->getAll() as $f) {
			/** @var Inputfield $f */
			foreach($f->getErrors(true) as $error) {
				$msg = "$this->label ($pagefile->name): $error";
				$this->error($msg);
				$f->error($msg);
			}
			if(!$f->isChanged() && !$pagefile->isTemp()) {
				continue;
			}
			$name = str_replace("_$id", '', $f->attr('name'));
			if($f->getSetting('useLanguages')) {
				$value = $pagefile->getFieldValue($name);
				if(is_object($value)) $value->setFromInputfield($f);
			} else {
				$value = $f->val();
			}
			$pagefile->setFieldValue($name, $value, true);
			$changed = true;
		}
		
		return $changed;
	}

	/**
	 * Process input
	 * 
	 * @param WireInputData $input
	 * @return self
	 * 
	 */
	public function ___processInput(WireInputData $input) {
		
		if(is_null($this->value)) {
			$this->value = $this->wire(new Pagefiles($this->wire()->page));
		}
		
		if(!$this->destinationPath) {
			$this->destinationPath = $this->value->path();
		}
		
		if(!$this->destinationPath || !is_dir($this->destinationPath)) {
			return $this->error($this->_("destinationPath is empty or does not exist"));
		}
		if(!is_writable($this->destinationPath)) {
			return $this->error($this->_("destinationPath is not writable"));
		}

		$changed = false; 
		$total = count($this->value); 

		if(!$this->noUpload) { 

			if($this->maxFiles <= 1 || $total < $this->maxFiles) { 

				$ul = $this->getWireUpload();
				$ul->setName($this->attr('name')); 
				$ul->setDestinationPath($this->destinationPath); 
				$ul->setOverwrite($this->overwrite); 
				$ul->setAllowAjax($this->noAjax ? false : true);
				if($this->maxFilesize) $ul->setMaxFileSize($this->maxFilesize); 

				if($this->maxFiles == 1) {
					$ul->setMaxFiles(1); 

				} else if($this->maxFiles) {
					$maxFiles = $this->maxFiles - $total; 
					$ul->setMaxFiles($maxFiles); 

				} else if($this->unzip) { 
					$ul->setExtractArchives(true); 
				}

				$ul->setValidExtensions($this->getAllowedExtensions(true));
				
				$filenames = $ul->execute();
				$originalFilenames = $ul->getOriginalFilenames();

				foreach($filenames as $filename) {
					$pagefile = $this->processInputAddFile($filename); 
					if($pagefile && isset($originalFilenames[$filename]) && $originalFilenames[$filename] != $filename) {
						$pagefile->filedata('uploadName', $originalFilenames[$filename]);
					}
					$changed = true; 
				}

				if($this->isAjax && !$this->noAjax) foreach($ul->getErrors() as $error) { 
					$this->ajaxResponse(true, $error); 
				}

			} else if($this->maxFiles) {
				// over the limit
				$this->ajaxResponse(true, $this->_("Max file upload limit reached")); 
			}
		}

		$n = 0; 

		foreach($this->value as $pagefile) {
			if($this->processInputFile($input, $pagefile, $n)) $changed = true; 
			$n++; 
		}

		if($changed) {
			$this->value->sort('sort'); 
			$this->trackChange('value'); 
		}

		if(count($this->ajaxResponses) && $this->isAjax) {
			echo $this->renderAjaxResponse();
		}

		return $this; 
	}

	/**
	 * Render JSON response to AJAX request
	 * 
	 * @return string
	 * 
	 */
	protected function renderAjaxResponse() {
		if($this->wire()->input->get('ckeupload')) {
			// https://docs.ckeditor.com/ckeditor4/docs/#!/guide/dev_file_upload
			$a = $this->ajaxResponses[0];
			$response = array(
				'uploaded' => $a['error'] ? 0 : 1,
				'fileName' => basename($a['file']),
				'url' => $a['file'],
				'ajaxResponse' => $a, // for InputfieldImage.js
			);
			if($a['error']) {
				$response['error'] = array(
					'message' => $a['message']
				);
			}
			return json_encode($response);
		} else {
			return json_encode($this->ajaxResponses);
		}
	}

	/**
	 * Send an ajax response
	 *
	 * @param bool $error Whether it was successful
	 * @param string $message Message you want to return
	 * @param string $file Full path and filename or blank if not applicable
	 * @param string $size 
	 * @param string $markup
	 *
	 */
	protected function ajaxResponse($error, $message, $file = '', $size = '', $markup = '') {
		$response = array(
			'error' => $error, 
			'message' => $message, 
			'file' => $file,
			'size' => $size,
			'markup' => $markup, 
			'replace' => $this->singleFileReplacement,
			'overwrite' => $this->overwrite
		);

		$this->ajaxResponses[] = $response; 
	}

	/**
	 * Return the current WireUpload instance or create a new one if not yet created
	 *
	 * @return WireUpload
	 *
	 */
	public function getWireUpload() {
		if(is_null($this->wireUpload)) {
			$this->wireUpload = $this->wire(new WireUpload($this->attr('name')));
		}
		return $this->wireUpload; 
	}

	/**
	 * Template method: allow items to be collapsed?
	 *
	 * @return bool
	 *
	 */
	protected function allowCollapsedItems() {
		$allow = $this->descriptionRows == 0 && !$this->useTags && !$this->noCollapseItem;
		if($allow && $this->hasField) {
			/** @var FieldtypeFile $fieldtype */
			$fieldtype = $this->hasField->type;
			if($fieldtype->getFieldsTemplate($this->hasField)) $allow = false;
		}
		return $allow;
	}

	/**
	 * Format list of file extensions for output with upload field
	 *
	 * @param array|string $extensions
	 * @return string
	 *
	 */
	protected function formatExtensions($extensions = '') {
		$sanitizer = $this->wire()->sanitizer;
		$badExtensions = array();
		if(empty($extensions)) {
			$info = $this->getExtensionsInfo();
			$extensions = $info['valid'];
			$badExtensions = $info['invalid'];
		} else if(is_string($extensions)) {
			while(strpos($extensions, '  ') !== false) $extensions = str_replace('  ', ' ', $extensions);
			$extensions = explode(' ', trim($extensions)); 
		}
		$out = $sanitizer->entities(implode(', ', $extensions));
		if(count($badExtensions)) {
			if($out) $out .= ', ';
			$out .= '<s>' . $sanitizer->entities(implode(', ', $badExtensions)) . '</s>'; 
		}
		return $out;
	}
	
	/**
	 * Get allowed file extensions
	 * 
	 * @param bool $getArray
	 * @return array|string
	 * @since 3.0.167
	 * 
	 */
	protected function getAllowedExtensions($getArray = false) {
		$info = $this->getExtensionsInfo();
		$extensions = $info['valid'];
		if($this->unzip && !$this->maxFiles) if(!in_array('zip', $extensions)) $extensions[] = 'zip';
		return $getArray ? $extensions : implode(' ', $extensions);
	}

	/**
	 * Get extensions info (see FieldtypeFile::getValidFileExtensions)
	 * 
	 * @return array
	 * @since 3.0.167
	 * 
	 */
	protected function getExtensionsInfo() {
		if(empty($this->extensionsInfo)) {
			$this->extensionsInfo = $this->wire()->fieldtypes->FieldtypeFile->getValidFileExtensions($this);
		}
		return $this->extensionsInfo;
	}

	/**
	 * Get custom Inputfields for editing given Pagefile 
	 * 
	 * @param Pagefile|null $item Specify Pagefile item, or omit to prepare for render ready
	 * @return bool|InputfieldWrapper
	 * @since 3.0.142
	 * 
	 */
	public function getItemInputfields(Pagefile $item = null) {
	
		/** @var Pagefiles $pagefiles */
		$value = $this->val();
		$pagefiles = $value instanceof Pagefile ? $value->pagefiles : $value;
		
		if(!$pagefiles instanceof Pagefiles) {
			if($this->hasPage && $this->hasField) {
				$value = $this->hasPage->get($this->hasField->name);
				$pagefiles = $value instanceof Pagefile ? $value->pagefiles : $value;
			}
			if(!$pagefiles instanceof Pagefiles) {
				// no value present on this Inputfield
				return false;
			}
		}
		
		if($this->itemFieldgroup === false) {
			// item fieldgroup already determined not in use
			return false;
		}
	
		if($this->itemFieldgroup === null) {
			// item fieldgroup not yet determined
			$this->itemFieldgroup = false;
			$template = $pagefiles->getFieldsTemplate();
			if(!$template) return false;
			if($this->noLang) $template->setQuietly('noLang', 1);
			$this->itemFieldgroup = $template->fieldgroup;
		}

		$context = '';
		if($item) {
			$hasPage = $this->hasPage;
			if($hasPage && wireInstanceOf($hasPage, 'RepeaterPage')) {
				if(strpos($this->name, '_repeater') === false) {
					// ensures that custom fields are properly namespaced within repeater
					// though note that this prevents it from working when editing a repeater
					// page directly, independently of its forPage
					$context = "repeater{$hasPage->id}_";
				}
			}
			/*
			 * The following does not work with nested repeaters, fixed by the above, but kept here for reference
			$process = $this->wire()->process; 
			if($item && $process instanceof WirePageEditor) { 
				$contextPage = $process->getPage();
				if(wireInstanceOf($contextPage, 'RepeaterPage') && strpos($this->name, '_repeater') === false) {
					// @var RepeaterPage $contextPage 
					$forPage = $contextPage->getForPage();
					if($forPage->id) $contextPage = $forPage;
					$context = "repeater{$contextPage->id}_";
				}
			}
			*/
		}
		

		/** @var Page $page */
		$page = $pagefiles->getFieldsPage();
		$id = $item ? ('_' . $this->pagefileId($item, $context)) : '';
		$inputfields = $this->itemFieldgroup->getPageInputfields($page, $id, '', false); 
		if(!$inputfields) return false;
		
		$languages = $this->wire()->languages;

		foreach($inputfields->getAll() as $f) {
			/** @var Inputfield $f  */
			
			if($f->get('requiredAttr') || $f->attr('required')) {
				// required attribute not possible for dynamically changed inputs
				$f->set('requiredAttr', 0);
				$f->removeAttr('required');
			}
			
			if(wireInstanceOf($f, 'InputfieldCKEditor')) {
				/** @var InputfieldCKEditor $f */
				$ckeField = $f->hasField;
				if($ckeField) {
					$f->configName = $f->className() . "_$ckeField->name";
					$imagesField = $this->hasField;
					if($imagesField && $this->itemFieldgroup && $this->itemFieldgroup->hasFieldContext($ckeField)) {
						$f->configName .= "_$imagesField->name";
					}
				}
			}

			if(!$item) {
				// prepare inputfields for render rather than populating them
				$f->renderReady();
				continue;
			}
			
			/** @var Inputfield $f */
			$name = str_replace($id, '', $f->name);
			$value = $item->getFieldValue($name);
			if($value === null) continue;
			
			if($languages && $f->getSetting('useLanguages') && $value instanceof LanguagesValueInterface) {
				foreach($languages as $language) {
					/** @var Language $language  */
					$v = $value->getLanguageValue($language->id);
					if($language->isDefault()) $f->val($v);
					$f->set("value$language->id", $v);
				}
			} else if($f instanceof InputfieldCheckbox) {
				if($value) $f->attr('checked', 'checked');
			} else if($f instanceof InputfieldText && is_array($value) && isset($value['data'])) {
				// a previously multi-language value that's now a single-language value
				$f->val($value['data']);
			} else {
				$f->val($value);
			}
		
			/*
			if($f->className() === 'InputfieldCKEditor') {
				// CKE does not like being placed in file/image fields.
				// I'm sure it's possible, but needs more work and debugging, so it's disabled for now.
				$allow = false;
			} else {
				$allow = true;
			}
			
			if(!$allow) {
				$inputfields->remove($f);
				$this->prependMarkup =
					"<p class='ui-state-error-text'>" .
					sprintf($this->_('Field “%1$s” type “%2$s” is not supported in field “%3$s”'), $f->label, $f->className(), $this->label) .
					'</p>';
				$f->getParent()->remove($f);
			}
			*/
		}
		
		return $inputfields;
	}

	/**
	 * Configuration settings for InputfieldFile
	 * 
	 * @return InputfieldWrapper
	 * 
	 */
	public function ___getConfigInputfields() {
		$inputfields = parent::___getConfigInputfields();
		require_once($this->wire()->config->paths('InputfieldFile') . 'config.php');
		$configuration = new InputfieldFileConfiguration();
		$this->wire($configuration);
		$configuration->getConfigInputfields($this, $inputfields);
		return $inputfields; 	
	}
}
