<?php namespace ProcessWire;

/**
 * ProcessWire Modules Duplicates
 *
 * Provides functions for managing sitautions where more than one
 * copy of the same module is intalled. This is a helper for the Modules class.
 *
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 *
 */

class ModulesDuplicates extends Wire {

	/**
	 * Array of modules where more than one copy was found
	 *
	 * Associative array of 'ModuleName' => array(path1, path2, ...)
	 *
	 * @var array
	 *
	 */
	protected $duplicates = array();

	/**
	 * Specifies which module file to use in cases where there is more than one
	 *
	 * Array of 'ModuleName' => '/path/to/file/from/pw/root/file.module'
	 *
	 * @var array
	 *
	 */
	protected $duplicatesUse = array();

	/**
	 * Number of new duplicates found while loading modules
	 *
	 * @var int
	 *
	 */
	protected $numNewDuplicates = 0;
	

	/**
	 * Return quantity of new duplicates found while loading modules
	 * 
	 * @return int
	 * 
	 */
	public function numNewDuplicates() {
		return $this->numNewDuplicates;
	}

	/**
	 * Get the current duplicate in use (string) or null if not specified
	 * 
	 * @param $className
	 * @return string|null Pathname or null
	 * 
	 */
	public function getCurrent($className) {
		return isset($this->duplicatesUse[$className]) ? $this->duplicatesUse[$className] : null;
	}
	
	/**
	 * Does the given module class have a duplicate?
	 * 
	 * @param string $className
	 * @param string $pathname Optionally specify the duplicate to check
	 * @return bool
	 * 
	 */
	public function hasDuplicate($className, $pathname = '') {
		if(!isset($this->duplicates[$className])) return false;
		if($pathname) {
			$rootPath = $this->wire()->config->paths->root;
			if(strpos($pathname, $rootPath) === 0) $pathname = str_replace($rootPath, '/', $pathname);
			return in_array($pathname, $this->duplicates[$className]);
		}
		return true; 
	}

	/**
	 * Add a duplicate to the list
	 * 
	 * @param $className
	 * @param $pathname
	 * @param bool $current Is this the current one in use?
	 *
	 */
	public function addDuplicate($className, $pathname, $current = false) {
		if(!isset($this->duplicates[$className])) $this->duplicates[$className] = array();
		$rootPath = $this->wire()->config->paths->root;
		if(strpos($pathname, $rootPath) === 0) $pathname = str_replace($rootPath, '/', $pathname);
		if(!in_array($pathname, $this->duplicates[$className])) {
			$this->duplicates[$className][] = $pathname;
		}
		if($current) {
			$this->duplicatesUse[$className] = $pathname;
		}
	}

	/**
	 * Add multiple duplicates
	 * 
	 * @param $className
	 * @param array $files
	 * 
	 */
	public function addDuplicates($className, array $files) {
		foreach($files as $file) {
			$this->addDuplicate($className, $file); 
		}
	}

	/**
	 * Add duplicates from module config data
	 * 
	 * @param $className
	 * @param array $data
	 * 
	 */
	public function addFromConfigData($className, array $data) {
		$files = isset($data['-dups']) ? $data['-dups'] : array();
		$using = isset($data['-dups-use']) ? $data['-dups-use'] : '';
		if(count($files)) $this->addDuplicates($className, $files);
		if($using) $this->addDuplicate($className, $using, true); // set current, in-use
	}
	
	/**
	 * Return a list of duplicate modules that were found
	 *
	 * If given a module className, the following is returned:
	 *
	 * Array(
	 *    'files' => array(file1, file2, ...)
	 *    'using' => '/path/to/file/from/pw/root/ModuleName.module' or blank if not defined
	 * )
	 *
	 * If no className is specivied, the following is returned:
	 *
	 * Array(
	 *    'ModuleName' => array(file1, file2, ...),
	 *    'ModuleName' => array(file1, file2, ...),
	 *    ...and so on...
	 * )
	 *
	 * @param string|Module|int $className Optionally return only duplicates for given module name
	 *
	 * @return array
	 *
	 */
	public function getDuplicates($className = '') {

		if(!$className) return $this->duplicates;
		
		$modules = $this->wire()->modules;
		$className = $modules->getModuleClass($className);
		$files = isset($this->duplicates[$className]) ? $this->duplicates[$className] : array();
		$using = isset($this->duplicatesUse[$className]) ? $this->duplicatesUse[$className] : '';
		$rootPath = $this->wire()->config->paths->root;

		foreach($files as $key => $file) {
			$file = rtrim($rootPath, '/') . $file;
			if(!file_exists($file)) {
				unset($files[$key]);
			}
		}

		if(count($files) > 1 && !$using) {
			$using = $modules->getModuleFile($className);
			$using = str_replace($rootPath, '/', $using);
		}

		if(count($files) < 2) {
			// no need to store duplicate info if only 0 or 1
			//unset($this->duplicates[$className], $this->duplicatesUse[$className]); 
			$files = array();
			$using = '';
		}

		return array('files' => $files, 'using' => $using);
	}

	/**
	 * For a module that has duplicates, tell it which file to use
	 *
	 * @param string $className
	 * @param string $pathname Full path and filename to module file
	 *
	 * @throws WireException if given information that can't be resolved
	 *
	 */
	public function setUseDuplicate($className, $pathname) {
		$modules = $this->wire()->modules;
		$className = $modules->getModuleClass($className);
		$rootPath = $this->wire()->config->paths->root;
		if(!isset($this->duplicates[$className])) {
			throw new WireException("Module $className does not have duplicates");
		}
		$pathname = str_replace($rootPath, '/', $pathname);
		if(!in_array($pathname, $this->duplicates[$className])) {
			throw new WireException("Duplicate module pathname must be one of: " . implode(" \n", $this->duplicates[$className]));
		}
		if(!file_exists($rootPath . ltrim($pathname, '/'))) {
			throw new WireException("Duplicate module file does not exist: $pathname");
		}
		$this->duplicatesUse[$className] = $pathname;
		$configData = $modules->getModuleConfigData($className);
		$configData['-dups-use'] = $pathname;
		$modules->saveModuleConfigData($className, $configData);
	}

	/**
	 * Update the database so that modules have information on their duplicates
	 *
	 */
	public function updateDuplicates() {
		
		$modules = $this->wire()->modules;
		$rootPath = $this->wire()->config->paths->root;

		// store duplicate information in each module's data field
		foreach($this->getDuplicates() as $moduleName => $files) {
			$dup = $this->getDuplicates($moduleName); // so that we also have 'using' info
			$files = $dup['files'];
			$using = $dup['using'];
			foreach($files as $key => $file) {
				// make files relative to site root, for portability
				$file = str_replace($rootPath, '/', $file);
				$files[$key] = $file;
			}
			$files = array_unique($files);
			$configData = $modules->getModuleConfigData($moduleName);
			if((empty($configData['-dups']) && !empty($files))
				|| (empty($configData['-dups-use']) || $configData['-dups-use'] != $using)
				|| (isset($configData['-dups']) && implode(' ', $configData['-dups']) != implode(' ', $files))
			) {
				$this->duplicates[$moduleName] = $files;
				$this->duplicatesUse[$moduleName] = $using;
				$configData['-dups'] = $files;
				$configData['-dups-use'] = $using;
				$modules->saveModuleConfigData($moduleName, $configData);
			}
		}

		// update any modules that no longer have duplicates
		$removals = array();
		$query = $this->wire()->database->prepare("SELECT `class`, `flags` FROM modules WHERE `flags` & :flag");
		$query->bindValue(':flag', Modules::flagsDuplicate, \PDO::PARAM_INT);
		$query->execute();

		/** @noinspection PhpAssignmentInConditionInspection */
		while($row = $query->fetch(\PDO::FETCH_NUM)) {
			list($class, $flags) = $row;
			if(empty($this->duplicates[$class])) {
				$flags = $flags & ~Modules::flagsDuplicate;
				$removals[$class] = $flags;
			}
			unset($this->duplicatesUse[$class]); // just in case
		}

		foreach($removals as $class => $flags) {
			$modules->setFlags($class, $flags); 
			$configData = $modules->getModuleConfigData($class);
			unset($configData['-dups'], $configData['-dups-use']);
			$modules->saveModuleConfigData($class, $configData);
		}
	}

	/**
	 * Record a duplicate at runtime
	 *
	 * @param string $basename Name of module
	 * @param string $pathname Path of module
	 * @param string $pathname2 Second path of module
	 * @param array $installed Installed module info array 
	 *
	 */
	public function recordDuplicate($basename, $pathname, $pathname2, &$installed) {
		$config = $this->wire()->config;
		$modules = $this->wire()->modules;
		$rootPath = $config->paths->root;
		// ensure paths start from root of PW install
		if(strpos($pathname, $rootPath) === 0) $pathname = str_replace($rootPath, '/', $pathname);
		if(strpos($pathname2, $rootPath) === 0) $pathname2 = str_replace($rootPath, '/', $pathname2);
		// there are two copies of the module on the file system (likely one in /site/modules/ and another in /wire/modules/)
		if(!isset($this->duplicates[$basename])) {
			$this->duplicates[$basename] = array($pathname, $pathname2); // array(str_replace($rootPath, '/', $this->getModuleFile($basename)));
			$this->numNewDuplicates++;
		}
		if(!in_array($pathname, $this->duplicates[$basename])) {
			$this->duplicates[$basename][] = $pathname;
			$this->numNewDuplicates++;
		}
		if(!in_array($pathname2, $this->duplicates[$basename])) {
			$this->duplicates[$basename][] = $pathname2;
			$this->numNewDuplicates++;
		}
		if(isset($installed[$basename]['flags'])) {
			$flags = $installed[$basename]['flags'];
		} else {
			$flags = $modules->getFlags($basename);
		}
		if($flags & Modules::flagsDuplicate) {
			// flags already represent duplicate status
		} else {
			// make database aware this module has multiple files by adding the duplicate flag
			$this->numNewDuplicates++; // trigger update needed
			$flags = $flags | Modules::flagsDuplicate;
			$modules->setFlags($basename, $flags); 
		}
		$err = sprintf($this->_('There appear to be multiple copies of module "%s" on the file system.'), $basename) . ' ';
		$this->wire()->log->save('modules', $err);
		$user = $this->wire()->user;
		if($user && $user->isSuperuser()) {
			$err .= $this->_('Please edit the module settings to tell ProcessWire which one to use:') . ' ' .
				"<a href='" . $config->urls->admin . 'module/edit?name=' . $basename . "'>$basename</a>";
			$this->warning($err, Notice::allowMarkup);
		}
		//$this->message("recordDuplicate($basename, $pathname) $this->numNewDuplicates"); //DEBUG
		//$this->message($this->duplicates[$basename]);//DEBUG
	}

	/**
	 * Populate duplicates info into config data, when applicable
	 *
	 * @param $className
	 * @param array $configData
	 *
	 * @return array Updated configData
	 *
	 */
	public function getDuplicatesConfigData($className, array $configData = array()) {
		$config = $this->wire()->config;
		// ensure original duplicates info is retained and validate that it is still current
		if(isset($this->duplicates[$className])) {
			foreach($this->duplicates[$className] as $key => $file) {
				$pathname = rtrim($config->paths->root, '/') . $file;
				if(!file_exists($pathname)) {
					unset($this->duplicates[$className][$key]);
				}
			}
			if(count($this->duplicates[$className]) < 2) {
				// no need to store any info for this if there's only 0 or 1
				unset($this->duplicates[$className], $this->duplicatesUse[$className], $configData['-dups'], $configData['-dups-use']);
			} else {
				$configData['-dups'] = $this->duplicates[$className];
				if(isset($this->duplicatesUse[$className])) {
					$pathname = rtrim($config->paths->root, '/') . $this->duplicatesUse[$className];
					if(file_exists($pathname)) {
						$configData['-dups-use'] = $this->duplicatesUse[$className];
					} else {
						unset($configData['-dups-use'], $this->duplicatesUse[$className]);
					}
				}
			}
		} else if(empty($this->duplicates[$className]) && isset($configData['-dups'])) {
			unset($configData['-dups'], $configData['-dups-use']);
		}
		return $configData;
	}
	
	public function getDebugData() {
		return array(
			'duplicates' => $this->duplicates,
			'duplicatesUse' => $this->duplicatesUse
		);
	}
}
