<?php namespace ProcessWire;

/**
 * ProcessWire PageimageVariations
 * 
 * Helper class for Pageimage that handles variation collection methods
 *
 * ProcessWire 3.x, Copyright 2022 by Ryan Cramer
 * https://processwire.com
 * 
 * @since 3.0.137
 * 
 */

class PageimageVariations extends Wire implements \IteratorAggregate, \Countable {

	/**
	 * @var Pageimage
	 * 
	 */
	protected $pageimage;

	/**
	 * @var Pagefiles|Pageimages
	 * 
	 */
	protected $pagefiles;

	/**
	 * @var Pageimages|null
	 * 
	 */
	protected $variations = null;

	/**
	 * Construct
	 *
	 * @param Pageimage $pageimage
	 * 
	 */
	public function __construct(Pageimage $pageimage) {
		$this->pageimage = $pageimage;
		$this->pagefiles = $pageimage->pagefiles;
		$pageimage->wire($this);
		parent::__construct();
	}

	#[\ReturnTypeWillChange] 
	public function getIterator() {
		return $this->find();
	}

	/**
	 * Return a total or filtered count of variations
	 * 
	 * This method is also here to implement the Countable interface. 
	 * 
	 * @param array $options See options for find() method
	 * @return int
	 * 
	 */
	#[\ReturnTypeWillChange] 
	public function count($options = array()) {
		if($this->variations) {
			$count = $this->variations->count();
		} else {
			$options['count'] = true;
			$count = $this->find($options);
		}
		return $count;
	}
	
	/**
	 * Given a file name (basename), return array of info if this is a variation for this instance’s file, or false if not.
	 *
	 * Returned array includes the following indexes:
	 *
	 * - `original` (string): Original basename
	 * - `url` (string): URL to image
	 * - `path` (string): Full path + filename to image
	 * - `width` (int): Specified width in filename
	 * - `height` (int): Specified height in filename
	 * - `actualWidth` (int): Actual width when checked manually
	 * - `actualHeight` (int): Acual height when checked manually
	 * - `crop` (string): Cropping info string or blank if none
	 * - `suffix` (array): Array of suffixes
	 *
	 * The following are only present if variation is based on another variation, and thus has a parent variation
	 * image between it and the original:
	 *
	 * - `suffixAll` (array): Contains all suffixes including among parent variations
	 * - `parent` (array): Variation info array of direct parent variation file
	 *
	 * @param string $basename Filename to check (basename, which excludes path)
	 * @param array|bool $options Array of options to modify behavior, or boolean to only specify `allowSelf` option.
	 *  - `allowSelf` (bool): When true, it will return variation info even if same as current Pageimage. (default=false)
	 *  - `verbose` (bool): Return verbose array of info? If false, just returns basename (string) or false. (default=true)
	 * @return bool|string|array Returns false if not a variation, or array (verbose) or string (non-verbose) of info if it is.
	 *
	 */
	public function getInfo($basename, $options = array()) {

		$defaults = array(
			'allowSelf' => false,
			'verbose' => true,
		);

		if(!is_array($options)) $options = array('allowSelf' => (bool) $options);
		$options = array_merge($defaults, $options);

		static $level = 0;
		$variationName = basename($basename);
		$originalName = $this->pageimage->basename;

		// that that everything from the beginning up to the first period is exactly the same
		// otherwise, they are different source files
		$test1 = substr($variationName, 0, strpos($variationName, '.'));
		$test2 = substr($originalName, 0, strpos($originalName, '.'));
		if($test1 !== $test2) return false;

		// remove extension from originalName
		$originalName = basename($originalName, "." . $this->pageimage->ext());

		// if originalName is already a variation filename, remove the variation info from it.
		// reduce to original name, i.e. all info after (and including) a period
		if(strpos($originalName, '.') && preg_match('/^([^.]+)\.(?:\d+x\d+|-[_a-z0-9]+)/', $originalName, $matches)) {
			$originalName = $matches[1];
		}

		// if file is the same as the original, then it's not a variation
		if(!$options['allowSelf'] && $variationName == $this->pageimage->basename) return false;

		// if file doesn't start with the original name then it's not a variation
		if(strpos($variationName, $originalName) !== 0) return false;

		// get down to the meat and the base
		// meat is the part of the filename containing variation info like dimensions, crop, suffix, etc.
		// base is the part before that, which may include parent meat
		$pos = strrpos($variationName, '.'); // get extension
		$ext = substr($variationName, $pos);
		$base = substr($variationName, 0, $pos); // get without extension
		$rpos = strrpos($base, '.'); // get last data chunk after dot

		if($rpos !== false) {
			$meat = substr($base, $rpos+1) . $ext; // the part of the filename we're interested in
			$base = substr($base, 0, $rpos); // the rest of the filename
			$parent = "$base." . $this->pageimage->ext();
		} else {
			$meat = $variationName;
			$parent = null;
		}

		// identify parent and any parent suffixes
		$suffixAll = array();
		if($options['verbose']) {
			while(($pos = strrpos($base, '.')) !== false) {
				$part = substr($base, $pos + 1);
				$base = substr($base, 0, $pos);
				while(($rpos = strrpos($part, '-')) !== false) {
					$suffixAll[] = substr($part, $rpos + 1);
					$part = substr($part, 0, $rpos);
				}
			}
		}

		// variation name with size dimensions and optionally suffix
		$re1 = '/^'  .
			'(\d+)x(\d+)' .					// 50x50	
			'([pd]\d+x\d+|[a-z]{1,2})?' . 	// nw or p30x40 or d30x40
			'(?:-([-_a-z0-9]+))?' . 		// -suffix1 or -suffix1-suffix2, etc.
			'\.' . $this->pageimage->ext() .
			'$/';

		// variation name with suffix only
		$re2 = '/^' .
			'-([-_a-z0-9]+)' . 				// suffix1 or suffix1-suffix2, etc. 
			'(?:\.' . 						// optional extras for dimensions/crop, starts with period
			'(\d+)x(\d+)' .				    // optional 50x50	
			'([pd]\d+x\d+|[a-z]{1,2})?' .   // nw or p30x40 or d30x40
			')?' .
			'\.' . $this->pageimage->ext() .
			'$/';

		// if regex does not match, return false
		if(preg_match($re1, $meat, $matches)) {

			// this is a variation with dimensions, return array of info
			$width = (int) $matches[1];
			$height = (int) $matches[2];
			$crop = isset($matches[3]) ? $matches[3] : '';
			$suffix = isset($matches[4]) ? explode('-', $matches[4]) : array();

		} else if(preg_match($re2, $meat, $matches)) {

			// this is a variation only with suffix
			$width = isset($matches[2]) ? (int) $matches[2] : 0;
			$height = isset($matches[3]) ? (int) $matches[3] : 0;
			$crop = isset($matches[4]) ? $matches[4] : '';
			$suffix = explode('-', $matches[1]);

		} else {
			return false;
		}

		// if not in verbose mode, just return variation basename
		if(!$options['verbose']) return $variationName;

		$path = $this->pagefiles->path . $basename;
		$actualInfo = $this->pageimage->getImageInfo($path);

		$info = array(
			'name' => $basename,
			'url' => $this->pagefiles->url . $basename,
			'path' => $path,
			'original' => $originalName . '.' . $this->pageimage->ext(),
			'width' => $width,
			'height' => $height,
			'crop' => $crop,
			'suffix' => $suffix,
			'suffixAll' => array(), // present only when image has a parent variation
			'actualWidth' => $actualInfo['width'],
			'actualHeight' => $actualInfo['height'],
			'hidpiWidth' => $this->pageimage->hidpiWidth(0, $actualInfo['width']),
			'hidpiHeight' => $this->pageimage->hidpiWidth(0, $actualInfo['height']),
			'parentName' => '', // present only when image has a parent variation
			'parent' => null, // present only when image has a parent variation
			'webpUrl' => '',
			'webpPath' => '',
		);

		foreach($this->pageimage->extras() as $name => $extra) {
			
			if($extra->exists()) {
				$info["{$name}Url"] = $extra->url(false);
				$info["{$name}Path"] = $extra->filename();
				continue;
			} 
			
			$f = "$basename.$extra->ext"; // useSrcExt, i.e. file.png.webp
			if(is_readable($this->pagefiles->path . $f)) {
				$info["{$name}Url"] = $this->pagefiles->url . $f;
				$info["{$name}Path"] = $this->pagefiles->path . $f;
				continue;
			}
			
			$f = basename($basename, '.' . $this->pageimage->ext()) . ".$extra->ext";
			if(is_readable($this->pagefiles->path . $f)) {
				$info["{$name}Url"] = $this->pagefiles->url . $f;
				$info["{$name}Path"] = $this->pagefiles->path . $f;
				continue;
			}
		}

		if(empty($info['crop'])) {
			// attempt to extract crop info from suffix
			foreach($info['suffix'] as $key => $suffix) {
				if(strpos($suffix, 'cropx') === 0) {
					$info['crop'] = ltrim($suffix, 'crop'); // i.e. x123y456
				}
			}
		}

		if($parent) {
			// suffixAll includes all parent suffix in addition to current suffix
			if(!$level) $info['suffixAll'] = array_unique(array_merge($info['suffix'], $suffixAll));
			// parent property is set with more variation info, when available
			$level++;
			$info['parentName'] = $parent;
			$info['parent'] = $this->getInfo($parent);
			$level--;
		} else {
			unset($info['parent'], $info['parentName'], $info['suffixAll']);
		}

		if(!$this->pageimage->__isset('original') && $info['original']) {
			$original = $this->pagefiles->get($info['original']);
			if($original) $this->pageimage->setOriginal($original);
		}

		return $info;
	}


	/**
	 * Get all size variations of this image
	 *
	 * This is useful after a delete of an image (for example). This method can be used to track down all the
	 * child files that also need to be deleted.
	 *
	 * @param array $options Optional, one or more options in an associative array of the following:
	 * 	- `info` (bool): when true, method returns variation info arrays rather than Pageimage objects (default=false).
	 *  - `count` (bool): when true, only a count of variations is returned (default=false). 
	 *  - `verbose` (bool|int): Return verbose array of info. If false, returns only filenames (default=true).
	 *     This option does nothing unless the `info` option is true. Also note that if verbose is false, then all options
	 *     following this one no longer apply (since it is no longer returning width/height info).
	 *     When integer 1, returned info array also includes Pageimage variation options in 'pageimage' index of
	 *     returned arrays (since 3.0.137).
	 * 	- `width` (int): only variations with given width will be returned
	 * 	- `height` (int): only variations with given height will be returned
	 * 	- `width>=` (int): only variations with width greater than or equal to given will be returned
	 * 	- `height>=` (int): only variations with height greater than or equal to given will be returned
	 * 	- `width<=` (int): only variations with width less than or equal to given will be returned
	 * 	- `height<=` (int): only variations with height less than or equal to given will be returned
	 * 	- `suffix` (string): only variations having the given suffix will be returned
	 *  - `suffixes` (array): only variations having one of the given suffixes will be returned
	 *  - `noSuffix` (string): exclude variations having this suffix
	 *  - `noSuffixes` (array): exclude variations having any of these suffixes
	 *  - `name` (string): only variations containing this text in filename will be returned (case insensitive)
	 *  - `noName` (string): only variations NOT containing this text in filename will be returned (case insensitive)
	 *  - `regexName` (string): only variations that match this PCRE regex will be returned
	 * @return Pageimages|array|int Returns Pageimages array of Pageimage instances.
	 *  Only returns regular array if provided `$options['info']` is true.
	 *  Returns integer if count option is specified. 
	 *
	 */
	public function find(array $options = array()) {

		if(!is_null($this->variations) && empty($options)) return $this->variations;

		$defaults = array(
			'info' => false,
			'verbose' => true,
			'count' => false, 
		);

		$options = array_merge($defaults, $options);
		if($options['count']) {
			$options['verbose'] = false;
			$options['info'] = false;
		} else if(!$options['verbose'] && !$options['info']) {
			$options['verbose'] = true; // non-verbose only allowed if info==true
		}

		$variations = null;
		$dir = new \DirectoryIterator($this->pagefiles->path);
		$infos = array();
		$count = 0;
		
		if(!$options['info'] && !$options['count']) {
			$variations = $this->wire(new Pageimages($this->pagefiles->page));
		}

		// if suffix or noSuffix option contains space, convert it to suffixes or noSuffixes array option
		foreach(array('suffix', 'noSuffix') as $key) {
			if(!isset($options[$key])) continue;
			if(strpos(trim($options[$key]), ' ') === false) continue;
			$keyPlural = $key . 'es';
			$value = isset($options[$keyPlural]) ? $options[$keyPlural] : array();
			$options[$keyPlural] = array_merge($value, explode(' ', trim($options[$key])));
			unset($options[$key]);
		}

		foreach($dir as $file) {

			if($file->isDir() || $file->isDot()) continue;
			
			$info = $this->getInfo($file->getFilename(), array('verbose' => $options['verbose']));
			if(!$info) continue;
			
			if($options['info'] && !$options['verbose']) {
				$infos[] = $info;
				continue;
			}

			$allow = true;

			foreach($options as $option => $value) {
				switch($option) {
					case 'width': $allow = $info['width'] == $value; break;
					case 'width>=': $allow = $info['width'] >= $value; break;
					case 'width<=': $allow = $info['width'] <= $value; break;
					case 'height': $allow = $info['height'] == $value; break;
					case 'height>=': $allow = $info['height'] >= $value; break;
					case 'height<=': $allow = $info['height'] <= $value; break;
					case 'name': $allow = stripos($file->getBasename(), $value) !== false; break;
					case 'noName': $allow = stripos($file->getBasename(), $value) === false; break;
					case 'regexName': $allow = preg_match($value, $file->getBasename()); break;
					case 'suffix': $allow = in_array($value, $info['suffix']); break;
					case 'noSuffix': $allow = !in_array($value, $info['suffix']); break;
					case 'suffixes':
						// any one of given suffixes will allow the variation
						$allow = false;
						foreach($value as $suffix) {
							$allow = in_array($suffix, $info['suffix']);
							if($allow) break;
						}
						break;
					case 'noSuffixes':
						// any one of the given suffixes will disallow the variation
						$allow = true;
						foreach($value as $noSuffix) {
							if(!in_array($noSuffix, $info['suffix'])) continue;
							$allow = false;
							break;
						}
						break;
				}
				if(!$allow) break;
			}

			if(!$allow) continue;
			
			$basename = $file->getBasename();
			
			if($options['count']) {
				$count++;
				continue;
			}
			
			if(empty($options['info']) || $options['verbose'] === 1) {
				$pageimage = clone $this->pageimage;
				$pathname = $file->getPathname();
				if(DIRECTORY_SEPARATOR != '/') $pathname = str_replace(DIRECTORY_SEPARATOR, '/', $pathname);
				$pageimage->setFilename($pathname);
				$pageimage->setOriginal($this->pageimage);
				if($options['verbose'] === 1) {
					$info['pageimage'] = $pageimage;
				} else if($variations) {
					$variations->add($pageimage);
				}
			}
			if(!empty($options['info'])) {
				$infos[$basename] = $info;
			}
		}
		
		if($options['count']) return $count;
		if(!empty($options['info'])) return $infos;
		if(empty($options)) $this->variations = $variations;

		return $variations;
	}

	/**
	 * Rebuilds variations of this image
	 *
	 * By default, this excludes crops and images with suffixes, but can be overridden with the `$mode` and `$suffix` arguments.
	 *
	 * **Options for $mode argument**
	 *
	 * - `0` (int): Rebuild only non-suffix, non-crop variations, and those w/suffix specified in $suffix argument. ($suffix is INCLUSION list)
	 * - `1` (int): Rebuild all non-suffix variations, and those w/suffix specifed in $suffix argument. ($suffix is INCLUSION list)
	 * - `2` (int): Rebuild all variations, except those with suffix specified in $suffix argument. ($suffix is EXCLUSION list)
	 * - `3` (int): Rebuild only variations specified in the $suffix argument. ($suffix is ONLY-INCLUSION list)
	 * - `4` (int): Rebuild only non-proportional, non-crop variations (variations that specify both width and height)
	 *
	 * Mode 0 is the only truly safe mode, as in any other mode there are possibilities that the resulting
	 * rebuild of the variation may not be exactly what was intended. The issues with other modes primarily
	 * arise when the suffix means something about the technical details of the produced image, or when
	 * rebuilding variations that include crops from an original image that has since changed dimensions or crops.
	 *
	 * @param int $mode See the options for $mode argument above (default=0).
	 * @param array $suffix Optional argument to specify suffixes to include or exclude (according to $mode).
	 * @param array $options See $options for `Pageimage::size()` for details.
	 * @return array Returns an associative array with with the following indexes:
	 *  - `rebuilt` (array): Names of files that were rebuilt.
	 *  - `skipped` (array): Names of files that were skipped.
	 *  - `errors` (array): Names of files that had errors.
	 *  - `reasons` (array): Reasons why files were skipped or had errors, associative array indexed by file name.
	 *
	 */
	public function rebuild($mode = 0, array $suffix = array(), array $options = array()) {
		
		$files = $this->wire()->files;

		$skipped = array();
		$rebuilt = array();
		$errors = array();
		$reasons = array();
		$options['forceNew'] = true;

		foreach($this->find(array('info' => true)) as $info) {

			$o = $options;
			unset($o['cropping']);
			$skip = false;
			$name = $info['name'];
			$hadWebp = false;

			if($info['crop'] && !$mode) {
				// skip crops when mode is 0
				$reasons[$name] = "$name: Crop is $info[crop] and mode is 0";
				$skip = true;

			} else if(count($info['suffix'])) {
				// check suffixes 
				foreach($info['suffix'] as $k => $s) {
					if($s === 'hidpi') {
						// allow hidpi to passthru
						$o['hidpi'] = true;
					} else if($s == 'is') {
						// this is a known core suffix that we allow
					} else if(strpos($s, 'cropx') === 0) {
						// skip cropx suffix (already known from $info[crop])
						unset($info['suffix'][$k]);
						continue;
					} else if(strpos($s, 'pid') === 0 && preg_match('/^pid\d+$/', $s)) {
						// allow pid123 to pass through 
					} else if(in_array($s, $suffix)) {
						// suffix is one provided in $suffix argument
						if($mode == 2) {
							// mode 2 where $suffix is an exclusion list
							$skip = true;
							$reasons[$name] = "$name: Suffix '$s' is one provided in exclusion list (mode==true)";
						} else {
							// allowed suffix
						}
					} else {
						// image has suffix not specified in $suffix argument
						if($mode == 0 || $mode == 1 || $mode == 3) {
							$skip = true;
							$reasons[$name] = "$name: Image has suffix '$s' not provided in allowed list: " . implode(', ', $suffix);
						}
					}
				}
			}

			if($mode == 4 && ($info['width'] == 0 || $info['height'] == 0)) {
				// skip images that don't specify both width and height
				$skip = true;
			}

			if($skip) {
				$skipped[] = $name;
				continue;
			}

			// rebuild the variation
			$o['forceNew'] = true;
			$o['suffix'] = $info['suffix'];

			if(is_file($info['path'])) {
				$files->unlink($info['path'], true);
				if(!empty($info['webpPath']) && $files->exists($info['webpPath'])) {
					$files->unlink($info['webpPath'], true);
					$hadWebp = true;
				}
			}

			/*
			if(!$info['width'] && $info['actualWidth']) {
				$info['width'] = $info['actualWidth'];
				$o['nameWidth'] = 0;
			}
			if(!$info['height'] && $info['actualHeight']) {
				$info['height'] = $info['actualHeight'];
				$o['nameHeight'] = 0;
			}
			*/

			if($info['crop'] && preg_match('/^x(\d+)y(\d+)$/', $info['crop'], $matches)) {
				// dimensional cropping info contained in filename
				$cropX = (int) $matches[1];
				$cropY = (int) $matches[2];
				$variation = $this->pageimage->crop($cropX, $cropY, $info['width'], $info['height'], $o);

			} else if($info['crop']) {
				// direct cropping info contained in filename
				$options['cropping'] = $info['crop'];
				$variation = $this->pageimage->size($info['width'], $info['height'], $o);

			} else if($this->pageimage->hasFocus) {
				// crop to focus area, which the size() method will determine on its own
				$variation = $this->pageimage->size($info['width'], $info['height'], $o);

			} else {
				// no crop, no focus, just resize
				$variation = $this->pageimage->size($info['width'], $info['height'], $o);
			}

			if($variation) {
				if($variation->name != $name) {
					$files->rename($variation->filename(), $info['path']);
					$variation->data('basename', $name);
				}
				$rebuilt[] = $name;
				if($hadWebp) {
					// forces create of webp version
					$webpName = basename($variation->webp()->url());
					if($webpName) $rebuilt[] = $webpName;
				}
			} else {
				$errors[] = $name;
			}
		}

		return array(
			'rebuilt' => $rebuilt,
			'skipped' => $skipped,
			'reasons' => $reasons,
			'errors' => $errors
		);
	}

	/**
	 * Delete all the alternate sizes associated with this Pageimage
	 *
	 * @param array $options See options for getVariations() method to limit what variations are removed, plus these:
	 *  - `dryRun` (bool): Do not remove now and instead only return the filenames of variations that would be deleted (default=false).
	 *  - `getFiles` (bool): Return deleted filenames? Also assumed if the test option is used (default=false).
	 * @return $this|array Returns $this by default, or array of deleted filenames if the `getFiles` option is specified
	 *
	 */
	public function remove(array $options = array()) {

		$defaults = array(
			'dryRun' => false,
			'getFiles' => false
		);

		$variations = $this->find($options);
		if(!empty($options['dryrun'])) $defaults['dryRun'] = $options['dryrun']; // case insurance
		$options = array_merge($defaults, $options); // placement after getVariations() intended

		/** @var WireFileTools $files */
		$files = $this->wire('files');
		$deletedFiles = array();
		
		$this->removeExtras($this->pageimage, $deletedFiles, $options);

		foreach($variations as $variation) {
			/** @var Pageimage $variation */
			$filename = $variation->filename;
			if(!is_file($filename)) continue;
			if($options['dryRun']) {
				$success = true;
			} else {
				$success = $files->unlink($filename, true);
			}
			if($success) $deletedFiles[] = $filename;
			$this->removeExtras($variation, $deletedFiles, $options);
		}

		if(!$options['dryRun']) $this->variations = null;

		return ($options['dryRun'] || $options['getFiles'] ? $deletedFiles : $this);
	}

	/**
	 * Remove extras
	 * 
	 * @param Pageimage $pageimage
	 * @param array $deletedFiles
	 * @param array $options See options for remove() method
	 * 
	 */
	protected function removeExtras(Pageimage $pageimage, array &$deletedFiles, array $options) {
		foreach($pageimage->extras() as $extra) {
			if(!$extra->exists()) {
				// nothing to do
			} else if(!empty($options['dryRun'])) {
				$deletedFiles[] = $extra->filename();
			} else if($extra->unlink()) {
				$deletedFiles[] = $extra->filename();
			}
		}
	}
}