<?php namespace ProcessWire;

/**
 * ProcessWire Textarea Fieldtype
 *
 * Stores a large block of multi-line text.
 *
 * For documentation about the fields used in this class, please see:  
 * /wire/core/Fieldtype.php
 * 
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 * 
 * Properties set to $field that is using this type, acceessed by $field->get('property'):
 *
 * - contentType (int): Content type of field output using a self::contentType* constant (default=self::contentTypeUnknown)
 * - htmlOptions (array): Options for content-type Markup/HTML using self::html* constants (default=null or blank array)
 * - inputfieldClass (string): Inputfield class/module name to use for this field (default=InputfieldTextarea)
 *
 */

class FieldtypeTextarea extends FieldtypeText {
	
	public static function getModuleInfo() {
		return array(
			'title' => 'Textarea',
			'version' => 107,
			'summary' => 'Field that stores multiple lines of text',
			'permanent' => true,
		);
	}

	/**
	 * The default Inputfield class associated with this Fieldtype
	 *
	 */
	const defaultInputfieldClass = 'InputfieldTextarea';

	/**
	 * Indicates an unknown or plain text content type
	 *
 	 */
	const contentTypeUnknown = 0;

	/**
	 * Indicates a Markup/HTML content type with basic root path QA
	 *
	 */
	const contentTypeHTML = 1;

	/**
	 * Indicates a Markup/HTML content type with all htmlImage* options enabled
	 * 
	 */
	const contentTypeImageHTML = 2;

	/**
	 * HTML options: <a> tag management to abstract page URLs in href attributes so they can be dynamically updated
	 *
	 */
	const htmlLinkAbstract = 2; 
	
	/**
	 * HTML options: <img> tag management to replace blank alt attributes with file description
	 * 
	 */
	const htmlImageReplaceBlankAlt = 4;
	
	/**
	 * HTML options: <img> tag management to remove or re-create images that don't exist
	 *
	 */
	const htmlImageRemoveNoExists = 8;
	
	/**
	 * HTML options: <img> tag management to remove images that user doesn't have access to
	 *
	 */
	const htmlImageRemoveNoAccess = 16;

	/**
	 * HTML options: Update <img> tags to be <img loading='lazy'>
	 *
	 */
	const htmlImageLoadingLazy = 32;

	/**
	 * Instance of MarkupQA 
	 * 
	 * @var MarkupQA
	 * 
	 */
	protected $markupQA = null;

	/**
	 * Instanceof FieldtypeTextareaHelper 
	 * 
	 * @var FieldtypeTextareaHelper
	 * 
	 */
	protected $configHelper = null;

	public function init() {
		$this->set('inputfieldClass', self::defaultInputfieldClass); 
		$this->set('contentType', self::contentTypeUnknown); 
		$this->set('htmlOptions', array());
		parent::init();
	}

	public function ___markupValue(Page $page, Field $field, $value = null, $property = '') {
		if(is_null($value)) $value = $page->getFormatted($field->name);
		if($field->get('contentType') >= self::contentTypeHTML) {
			$value = $this->formatValue($page, $field, $value); 	
		} else {
			$value = parent::___markupValue($page, $field, $value, $property);
		}
		return $value; 
	}
	
	public function ___formatValue(Page $page, Field $field, $value) {
		$value = parent::___formatValue($page, $field, $value);
		if($field->get('contentType') >= self::contentTypeHTML) {
			$htmlOptions = $field->get('htmlOptions');
			if($htmlOptions && in_array(self::htmlImageLoadingLazy, $htmlOptions)) {
				if(strpos($value, 'loading="lazy"') === false) {
					$value = str_replace('<img ', '<img loading="lazy" ', $value);
				}
			}
		}
		return $value; 
	}

	public function ___sleepValue(Page $page, Field $field, $value) {
		$value = parent::___sleepValue($page, $field, $value);
		if($field->get('contentType') >= self::contentTypeHTML) $this->htmlReplacements($page, $field,$value, true);
		return $value; 
	}
	
	public function ___wakeupValue(Page $page, Field $field, $value) {
		// note: we do this here in addition to loadPageField to account for values that came
		// from external resources (not loaded from DB). 
		$value = parent::___wakeupValue($page, $field, $value);
		if($field->get('contentType') >= self::contentTypeHTML) {
			$this->htmlReplacements($page, $field, $value, false);
		}
		return $value;
	}
	
	public function ___loadPageField(Page $page, Field $field) {
		$value = parent::___loadPageField($page, $field);
		if($field->get('contentType') >= self::contentTypeHTML) {
			$this->htmlReplacements($page, $field, $value, false);
		}
		return $value; 
	}

	/**
	 * Get the MarkupQA instance
	 * 
	 * @param Page|null $page
	 * @param Field|null $field
	 * @return MarkupQA
	 * @throws WireException If called the first time without page or field arguments
	 * 
	 */
	public function markupQA(?Page $page = null, ?Field $field = null) {
		if(is_null($this->markupQA)) {
			$this->markupQA = $this->wire(new MarkupQA($page, $field));
		} else {
			if($page) $this->markupQA->setPage($page);
			if($field) $this->markupQA->setField($field);
		}
		return $this->markupQA;
	}

	/**
	 * Content Type HTML replacements accounting for href and src attributes
	 * 
	 * This ensures that sites migrated from one subdirectory to another, or from a subdirectory to
	 * a non-subdir, or non-subdir to a subdir, continue working. This adds runtime context
	 * to 'href' and 'src' attributes in HTML.
	 *
	 * This method modifies the $value directly rather than returning it.
	 *
	 * In order to make the abstracted attributes identifiable to this function (so they can be reversed)
	 * it replaces the space preceding the attribute name with a tab character. This ensures the HTML
	 * underneath still remains compliant in case it is later extracted directly from the DB for
	 * data conversion or something like that. 
	 * 
	 * This one handles a string value or array of string values (like for multi-language support)
	 * 
	 * Note: this is called by both loadPageField and wakeupValue, so will be called with the same
	 * arguments twice during load of a value
	 * 
	 * @param Page $page
	 * @param Field $field
 	 * @param string|array $value Value to look for attributes (or array of values)
	 * @param bool $sleep When true, convert links starting with root URL to "/". When false, do the reverse. 
	 * 	
	 */
	protected function htmlReplacements(Page $page, Field $field, &$value, $sleep = true) {
		
		$languages = $this->wire()->languages;
		
		if(is_array($value)) {
			// array of values, most likely multi-language data123 columns from loadPageField 
			foreach($value as $k => $v) {
				if(is_string($v)) {
					$this->_htmlReplacement($page, $field, $v, $sleep);
				} else {
					$this->htmlReplacements($page, $field, $v, $sleep); // recursive
				}
				$value[$k] = $v;
			}
			
		} else if($languages && $value instanceof LanguagesValueInterface) {
			// most likely a LanguagesPageFieldValue, but can be any type implementing LanguagesValueInterface
			/** @var Wire|LanguagesValueInterface $value */
			$trackChanges = $value->trackChanges();
			$value->setTrackChanges(false);
			foreach($languages as $language) {
				$v = $value->getLanguageValue($language->id);
				$this->_htmlReplacement($page, $field, $v, $sleep);
				$value->setLanguageValue($language, $v);
			}
			if($trackChanges) $value->setTrackChanges($trackChanges);
			
		} else if(is_string($value)) {
			// standard textarea string
			$this->_htmlReplacement($page, $field, $value, $sleep);
		}
	}

	/**
	 * Helper for htmlReplacements, to process single value
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param string $value
	 * @param bool $sleep
	 * 
	 */
	protected function _htmlReplacement(Page $page, Field $field, &$value, $sleep) {
	
		if(!strlen($value)) return;
		
		$markupQA = $this->markupQA($page, $field);
		$contentType = $field->get('contentType');
		$htmlOptions = $field->get('htmlOptions');
		
		if(!is_array($htmlOptions)) $htmlOptions = array();

		if($sleep) {
			$markupQA->sleepUrls($value);
			if(in_array(self::htmlLinkAbstract, $htmlOptions)) $markupQA->sleepLinks($value);
		} else {
			if(in_array(self::htmlLinkAbstract, $htmlOptions)) $markupQA->wakeupLinks($value);
			$markupQA->wakeupUrls($value);
			$useCheckImg = false;
			if($contentType == self::contentTypeImageHTML) {
				// keep default options, which means all enabled
				$opts = array();
				$useCheckImg = true;
			} else {
				// set image options specifically
				$opts = array(
					'replaceBlankAlt' => in_array(self::htmlImageReplaceBlankAlt, $htmlOptions),
					'removeNoExists' => in_array(self::htmlImageRemoveNoExists, $htmlOptions),
					'removeNoAccess' => in_array(self::htmlImageRemoveNoAccess, $htmlOptions)
				);
				foreach($opts as $val) if($val) $useCheckImg = true;
			}
			if($useCheckImg) $markupQA->checkImgTags($value, $opts);
		}

		static $lsep = null;
		if($lsep === null) $lsep = $this->wire()->sanitizer->unentities('&#8232;');
		if(strpos($value, $lsep) !== false) $value = str_replace($lsep, '', $value);
	}

	/**
	 * Get the Inputfield module that provides input for Field
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @return Inputfield
	 * 
	 */
	public function getInputfield(Page $page, Field $field) {

		$modules = $this->wire()->modules;
		$inputfieldClass = $field->get('inputfieldClass');
		
		if($inputfieldClass) {
			$inputfield = $modules->getModule($inputfieldClass, array('noSubstitute' => true)); 
		} else {
			$inputfield = $modules->get(self::defaultInputfieldClass); 
		}
		
		if(!$inputfield) {
			$inputfield = $modules->get(self::defaultInputfieldClass);
			$this->configHelper()->getInputfieldError($field);
		}
		
		/** @var InputfieldTextarea|InputfieldCKEditor $inputfield */
		$inputfield->class = $this->className();
		return $inputfield; 
	}
	
	/**
	 * Get setup options and setup functions for new fields
	 *
	 * @return array
	 * @since 3.0.213
	 *
	 */
	public function ___getFieldSetups() {
		$class = $this->className();
		$setups = parent::___getFieldSetups();
		if($class !== 'FieldtypeTextarea' && $class !== 'FieldtypeTextareaLanguage') {
			// limit setups options to the base types 
			return $setups;
		}
		$setups['default'] = array('title' => 'Textarea');
		$modules = $this->wire()->modules;
		$editors = array('CKEditor', 'TinyMCE');
		foreach($editors as $name) {
			if($modules->isInstalled("Inputfield$name")) $setups[$name] = array(
				'title' => $name,
				'inputfieldClass' => "Inputfield$name", 
				'contentType' => self::contentTypeHTML, 
				'textformatters' => array()
			);
		}
		return $setups;
	}

	/**
	 * Get database schema used by the Field
	 * 
	 * @param Field $field
	 * @return array
	 * 
	 */
	public function getDatabaseSchema(Field $field) {
		$schema = parent::getDatabaseSchema($field); 
		$schema['data'] = 'mediumtext NOT NULL';
		$schema['keys']['data'] = 'FULLTEXT KEY data (data)'; 
		return $schema;
	}

	/**
	 * Get an instance of the FieldtypeTextareaHelper config helper
	 * 
	 * @return FieldtypeTextareaHelper
	 * 
	 */
	public function configHelper() {
		if(is_null($this->configHelper)) {
			require_once($this->wire()->config->paths('FieldtypeTextarea') . 'FieldtypeTextareaHelper.php');
			$this->configHelper = new FieldtypeTextareaHelper();
		}
		return $this->configHelper;
	}

	/**
	 * Get Inputfields to configure the Field
	 * 
	 * @param Field $field
	 * @return InputfieldWrapper
	 * 
	 */
	public function ___getConfigInputfields(Field $field) {
		$this->markupQA()->verbose(true);
		$inputfields = parent::___getConfigInputfields($field);
		$inputfields = $this->configHelper()->getConfigInputfields($field, $inputfields);
		return $inputfields; 
	}

	/**
	 * Export value
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param array|int|object|string $value
	 * @param array $options
	 * @return array|string
	 *
	 */
	public function ___exportValue(Page $page, Field $field, $value, array $options = array()) {
		$value = parent::___exportValue($page, $field, $value, $options); 
		if(!empty($options['system'])) {
			if($field->get('contentType') >= self::contentTypeHTML) {
				$this->htmlReplacements($page, $field, $value, false);
			}
		}
		return $value; 
	}
	
	/**
	 * Import value
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param array|int|object|string $value
	 * @param array $options
	 * @return array|string
	 *
	 */
	public function ___importValue(Page $page, Field $field, $value, array $options = array()) {
		$value = parent::___importValue($page, $field, $value, $options);
		
		// update changed IDs represented in asset paths
		if(strpos($value, '/assets/files/') !== false) {
			$originalID = (int) $page->get('_importOriginalID');
			if($originalID && $page->id && strpos($value, "/$originalID/")) {
				$value = str_replace("/assets/files/$originalID/", "/assets/files/$page->id/", $value);
			}
		}
	
		$contentType = $field->get('contentType');
		if($contentType == self::contentTypeHTML || $contentType == self::contentTypeImageHTML) {
			$value = $this->importValueHTML($value, $options);
		}
		
		return $value;
	}

	/**
	 * Helper to importValue function for HTML-specific content
	 * 
	 * This primarily updates references to export-site URLs to the current site
	 * 
	 * @param string $value
	 * @param array $options
	 * @return string
	 * 
	 */
	protected function importValueHTML($value, array $options) {
		// update changed root URLs in href or src attributes 
		$config = $this->wire()->config;
		$url = $config->urls->root;
		$host = $config->httpHost;
		$_url = isset($options['originalRootUrl']) ? $options['originalRootUrl'] : $url; // original URL
		$_host = isset($options['originalHost']) ? $options['originalHost'] : $host; // original host
		
		if($_url === $url && $_host === $host) return $value;

		$findReplace = array();
		$href = 'href="';
		$src = 'src="';
		
		if($_host != $host) {
			$schemes = array('http://', 'https://');
			foreach($schemes as $scheme) {
				$findReplace[$href . $scheme . $_host . '/'] = $href . '/';
				$findReplace[$href . $scheme . $_host . '/'] = $href . '/';
			}
		}
		
		if($_url != $url) {
			$findReplace[$href . $_url] = $href . $url;
			$findReplace[$src . $_url] = $src . $url;
		}
		
		foreach($findReplace as $find => $replace) {
			if($find === $replace) continue;
			if(strpos($value, $find) === false) continue;
			$value = preg_replace('!(\s)' . $find . '!', '$1' . $replace, $value);
		}
		
		return $value;
	}

	/**
	 * Find abstracted HTML/href attribute Textarea links to given $page
	 * 
	 * @param Page $page Find links to this page
	 * @param string|bool $selector Optionally filter by selector or specify boolean true to assume "include=all". 
	 * @param string|Field $field Optionally limit to searching given field name/instance.
	 * @param array $options Options to modify return value: 
	 *  - `getIDs` (bool): Return array of page IDs rather than Page instances. (default=false)
	 *  - `getCount` (bool): Return a total count (int) of found pages rather than Page instances. (default=false)
	 *  - `confirm` (bool): Confirm that the links are present by looking at the actual page field data. (default=true)
	 *     You can specify false for this option to make it perform faster, but with a potentially less accurate result.
	 * @return PageArray|array|int
	 * 
	 */
	public function findLinks(Page $page, $selector = '', $field = '', array $options = array()) {

		$searchFields = array();
		if($selector === true) $selector = "include=all";
		
		foreach($this->wire()->fields as $f) {
			if($field) {
				if("$f" != "$field") continue;
			} else {
				// limit to fields with contentTypeHTML and htmlLinkAbstract
				$contentType = $f->get('contentType');
				if(empty($contentType)) continue;
				if($contentType != self::contentTypeHTML && $contentType != self::contentTypeImageHTML) continue;
				$htmlOptions = $f->get('htmlOptions');
				if(!is_array($htmlOptions) || !in_array(self::htmlLinkAbstract, $htmlOptions)) continue;
			}
			$searchFields[$f->name] = $f->name;
		}
		
		if(!count($searchFields)) return $this->wire()->pages->newPageArray();
		
		return $this->markupQA()->findLinks($page, $searchFields, $selector, $options); 
	}

}
