<?php namespace ProcessWire;

/**
 * ProcessWire Modules: Loader
 *
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 *
 */ 

class ModulesLoader extends ModulesClass {
	
	/**
	 * Array of moduleName => order to indicate autoload order when necessary
	 *
	 * @var array
	 *
	 */
	protected $autoloadOrders = array();
	
	/**
	 * Array of moduleName => condition
	 *
	 * Condition can be either an anonymous function or a selector string to be evaluated at ready().
	 *
	 */
	protected $conditionalAutoloadModules = array();

	/**
	 * Cache of module information from DB used across multiple calls temporarily by loadPath() method
	 *
	 */
	protected $modulesTableCache = array();
	
	/**
	 * Module created dates indexed by module ID
	 *
	 */
	protected $createdDates = array();
	
	/**
	 * Initialize all the modules that are loaded at boot
	 *
	 * #pw-internal
	 *
	 * @param null|array|Modules $modules
	 * @param array $completed
	 * @param int $level
	 *
	 */
	public function triggerInit($modules = null, $completed = array(), $level = 0) {

		$debugKey = null;
		$debugKey2 = null;
		
		if($this->debug) {
			$debugKey = $this->modules->debugTimerStart("triggerInit$level");
			$this->message("triggerInit(level=$level)");
		}

		$queue = array();
		
		if($modules === null) $modules = $this->modules;

		foreach($modules as $class => $module) {

			if($module instanceof ModulePlaceholder) {
				// skip modules that aren't autoload and those that are conditional autoload
				if(!$module->autoload) continue;
				if(isset($this->conditionalAutoloadModules[$class])) continue;
			}

			if($this->debug) $debugKey2 = $this->modules->debugTimerStart("triggerInit$level($class)");

			$info = $this->modules->getModuleInfo($module);
			$skip = false;

			// module requires other modules
			foreach($info['requires'] as $requiresClass) {
				if(in_array($requiresClass, $completed)) continue;
				$dependencyInfo = $this->modules->getModuleInfo($requiresClass);
				if(empty($dependencyInfo['autoload'])) {
					// if dependency isn't an autoload one, there's no point in waiting for it
					if($this->debug) $this->warning("Autoload module '$module' requires a non-autoload module '$requiresClass'");
					continue;
				} else if(isset($this->conditionalAutoloadModules[$requiresClass])) {
					// autoload module requires another autoload module that may or may not load
					if($this->debug) $this->warning("Autoload module '$module' requires a conditionally autoloaded module '$requiresClass'");
					continue;
				}
				// dependency is autoload and required by this module, so queue this module to init later
				$queue[$class] = $module;
				$skip = true;
				break;
			}

			if(!$skip) {
				if($info['autoload'] !== false) {
					if($info['autoload'] === true || $this->modules->isAutoload($module)) {
						$this->initModule($module);
					}
				}
				$completed[] = $class;
			}

			if($this->debug) $this->modules->debugTimerStop($debugKey2);
		}

		// if there is a dependency queue, go recursive till the queue is completed
		if(count($queue) && $level < 3) {
			$this->triggerInit($queue, $completed, $level + 1);
		}

		$this->modules->isInitialized(true);

		if($this->debug) if($debugKey) $this->modules->debugTimerStop($debugKey);

		if(!$level && $this->modules->info->moduleInfoCacheEmpty()) {
			if($this->debug) $this->message("saveModuleInfoCache from triggerInit");
			$this->modules->info->saveModuleInfoCache();
		}
	}
	
	/**
	 * Initialize a single module
	 *
	 * @param Module $module
	 * @param array $options
	 *  - `clearSettings` (bool): When true, module settings will be cleared when appropriate to save space. (default=true)
	 *  - `configOnly` (bool): When true, module init() method NOT called, but config data still set (default=false) 3.0.169+
	 *  - `configData` (array): Extra config data merge with module’s config data (default=[]) 3.0.169+
	 *  - `throw` (bool): When true, exceptions will be allowed to pass through. (default=false)
	 * @return bool True on success, false on fail
	 * @throws \Exception Only if the `throw` option is true.
	 *
	 */
	public function initModule(Module $module, array $options = array()) {

		$result = true;
		$debugKey = null;
		$clearSettings = isset($options['clearSettings']) ? (bool) $options['clearSettings'] : true;
		$throw = isset($options['throw']) ? (bool) $options['throw'] : false;

		if($this->debug) {
			static $n = 0;
			$this->message("initModule (" . (++$n) . "): " . wireClassName($module));
		}

		// if the module is configurable, then load its config data
		// and set values for each before initializing the module
		$extraConfigData = isset($options['configData']) ? $options['configData'] : null;
		$this->modules->configs->setModuleConfigData($module, null, $extraConfigData);

		$moduleName = wireClassName($module, false);
		$moduleID = $this->modules->moduleID($moduleName);

		if($moduleID && $this->modules->info->modulesLastVersions($moduleID)) {
			$this->modules->info->checkModuleVersion($module);
		}

		if(method_exists($module, 'init') && empty($options['configOnly'])) {

			if($this->debug) {
				$debugKey = $this->modules->debugTimerStart("initModule($moduleName)");
			}

			try {
				$module->init();
			} catch(\Exception $e) {
				if($throw) throw($e);
				$this->error(sprintf($this->_('Failed to init module: %s'), $moduleName) . " - " . $e->getMessage());
				$result = false;
			}

			if($this->debug) {
				$this->modules->debugTimerStop($debugKey);
			}
		}

		// if module is autoload (assumed here) and singular, then
		// we no longer need the module's config data, so remove it
		if($clearSettings && $this->modules->isSingular($module)) {
			if(!$moduleID) $moduleID = $this->modules->getModuleID($module);
			if($moduleID && $this->modules->configs->configData($moduleID) !== null) {
				$this->modules->configs->configData($moduleID, 1);
			}
		}

		return $result;
	}

	/**
	 * Call ready for a single module
	 *
	 * @param Module $module
	 * @return bool
	 *
	 */
	public function readyModule(Module $module) {
		$result = true;
		if(method_exists($module, 'ready')) {
			$debugKey = $this->debug ? $this->modules->debugTimerStart("readyModule(" . $module->className() . ")") : null;
			try {
				$module->ready();
			} catch(\Exception $e) {
				$this->error(sprintf($this->_('Failed to ready module: %s'), $module->className()) . " - " . $e->getMessage());
				$result = false;
			}
			if($this->debug) {
				$this->modules->debugTimerStop($debugKey);
				static $n = 0;
				$this->message("readyModule (" . (++$n) . "): " . wireClassName($module));
			}
		}
		return $result;
	}
	
	/**
	 * Trigger all modules 'ready' method, if they have it.
	 *
	 * This is to indicate to them that the API environment is fully ready and $page is in fuel.
	 *
	 * This is triggered by ProcessPageView::ready
	 *
	 * #pw-internal
	 *
	 */
	public function triggerReady() {

		$debugKey = $this->debug ? $this->modules->debugTimerStart("triggerReady") : null;
		$skipped = $this->triggerConditionalAutoload();

		// trigger ready method on all applicable modules
		foreach($this->modules as $module) {
			/** @var Module $module */
			if($module instanceof ModulePlaceholder) continue;

			$class = $this->modules->getModuleClass($module);
			
			if(isset($skipped[$class])) continue;

			$id = $this->modules->moduleID($class);
			$flags = $this->modules->flags->moduleFlags($id);
			
			if($flags & Modules::flagsAutoload) $this->readyModule($module);
		}

		if($this->debug) $this->modules->debugTimerStop($debugKey);
	}


	/**
	 * Init conditional autoload modules, if conditions allow
	 *
	 * @return array of skipped module names
	 *
	 */
	public function triggerConditionalAutoload() {

		// conditional autoload modules that are skipped (className => 1)
		$skipped = array();

		// init conditional autoload modules, now that $page is known
		foreach($this->conditionalAutoloadModules as $className => $func) {

			if($this->debug) {
				$moduleID = $this->modules->getModuleID($className);
				$flags = $this->modules->flags->moduleFlags($moduleID);
				$this->message("Conditional autoload: $className (flags=$flags, condition=" . (is_string($func) ? $func : 'func') . ")");
			}

			$load = true;

			if(is_string($func)) {
				// selector string
				if(!$this->wire()->page->is($func)) $load = false;
			} else {
				// anonymous function
				if(!is_callable($func)) $load = false;
				else if(!$func()) $load = false;
			}

			if($load) {
				$module = $this->modules->newModule($className);
				if($module) {
					$this->modules->set($className, $module);
					if($this->initModule($module)) {
						if($this->debug) $this->message("Conditional autoload: $className LOADED");
					} else {
						if($this->debug) $this->warning("Failed conditional autoload: $className");
					}
				}

			} else {
				$skipped[$className] = $className;
				if($this->debug) $this->message("Conditional autoload: $className SKIPPED");
			}
		}

		// clear this out since we don't need it anymore
		$this->conditionalAutoloadModules = array();

		return $skipped;
	}
	
	/**
	 * Retrieve the installed module info as stored in the database
	 *
	 */
	public function loadModulesTable() {

		$this->autoloadOrders = array();
		$database = $this->wire()->database;

		// skip loading dymanic caches at this stage
		$skipCaches = array(
			ModulesInfo::moduleInfoCacheUninstalledName,
			ModulesInfo::moduleInfoCacheVerboseName
		);

		$query = $database->query(
		// Currently: id, class, flags, data, with created added at sysupdate 7
			"SELECT * FROM modules " .
			"WHERE class NOT IN('" . implode("','", $skipCaches) . "') " .
			"ORDER BY class",
			"modules.loadModulesTable()"
		);

		/** @noinspection PhpAssignmentInConditionInspection */
		while($row = $query->fetch(\PDO::FETCH_ASSOC)) {

			$moduleID = (int) $row['id'];
			$flags = (int) $row['flags'];
			$class = $row['class'];

			if($flags & Modules::flagsSystemCache) {
				// system cache names are prefixed with a '.' so they load first
				$this->modules->memcache(ltrim($class, '.'), $row['data']);
				continue;
			}

			$this->modules->moduleID($class, $moduleID);
			$this->modules->moduleName($moduleID, $class);
			$this->modules->flags->moduleFlags($moduleID, $flags);

			$autoload = $flags & Modules::flagsAutoload;
			$loadSettings = $autoload || ($flags & Modules::flagsDuplicate) || ($class === 'SystemUpdater');

			if($loadSettings) {
				// preload config data for autoload modules since we'll need it again very soon
				$data = $row['data'] ? json_decode($row['data'], true) : array();
				$this->modules->configs->configData($moduleID, $data);
				// populate information about duplicates, if applicable
				if($flags & Modules::flagsDuplicate) $this->modules->duplicates()->addFromConfigData($class, $data);

			} else if(!empty($row['data'])) {
				// indicate that it has config data, but not yet loaded
				$this->modules->configs->configData($moduleID, 1);
			}

			if(isset($row['created']) && $row['created'] != '0000-00-00 00:00:00') {
				$this->createdDates[$moduleID] = $row['created'];
			}

			if($autoload) {
				$value = $this->modules->info->moduleInfoCache($moduleID, 'autoload');
				if(!empty($value)) {
					$autoload = $value;
					$disabled = $flags & Modules::flagsDisabled;
					if(is_int($autoload) && $autoload > 1 && !$disabled) {
						// autoload specifies an order > 1, indicating it should load before others
						$this->autoloadOrders[$class] = $autoload;
					}
				}
			}

			unset($row['data'], $row['created']); // info we don't want stored in modulesTableCache
			$this->modulesTableCache[$class] = $row;
		}

		$query->closeCursor();
	}

	/**
	 * Given a disk path to the modules, determine all installed modules and keep track of all uninstalled (installable) modules.
	 *
	 * @param string $path
	 *
	 */
	public function loadPath($path) {

		$config = $this->wire()->config;
		$debugKey = $this->debug ? $this->modules->debugTimerStart("loadPath($path)") : null;
		$installed =& $this->modulesTableCache;
		$modulesLoaded = array();
		$modulesDelayed = array();
		$modulesRequired = array();
		$modulesFiles = $this->modules->files;
		$rootPath = $config->paths->root;
		$basePath = substr($path, strlen($rootPath));

		foreach($modulesFiles->findModuleFiles($path, true) as $pathname) {

			$pathname = trim($pathname);
			if(empty($pathname)) continue;
			$basename = basename($pathname);
			list($moduleName, $ext) = explode('.', $basename, 2); // i.e. "module.php" or "module"

			$modulesFiles->moduleFileExt($moduleName, $ext === 'module' ? 1 : 2);
			// @todo next, remove the 'file' property from verbose module info since it is redundant

			$requires = array();
			$name = $moduleName;
			$moduleName = $this->loadModule($path, $pathname, $requires, $installed);
			if(!$config->paths->get($name)) $modulesFiles->setConfigPaths($name, dirname($basePath . $pathname));
			if(!$moduleName) continue;

			if(count($requires)) {
				// module not loaded because it required other module(s) not yet loaded
				foreach($requires as $requiresModuleName) {
					if(!isset($modulesRequired[$requiresModuleName])) $modulesRequired[$requiresModuleName] = array();
					if(!isset($modulesDelayed[$moduleName])) $modulesDelayed[$moduleName] = array();
					// queue module for later load
					$modulesRequired[$requiresModuleName][$moduleName] = $pathname;
					$modulesDelayed[$moduleName][] = $requiresModuleName;
				}
				continue;
			}

			// module was successfully loaded
			$modulesLoaded[$moduleName] = 1;
			$loadedNames = array($moduleName);

			// now determine if this module had any other modules waiting on it as a dependency
			/** @noinspection PhpAssignmentInConditionInspection */
			while($moduleName = array_shift($loadedNames)) {
				// iternate through delayed modules that require this one
				if(empty($modulesRequired[$moduleName])) continue;

				foreach($modulesRequired[$moduleName] as $delayedName => $delayedPathName) {
					$loadNow = true;
					if(isset($modulesDelayed[$delayedName])) {
						foreach($modulesDelayed[$delayedName] as $requiresModuleName) {
							if(!isset($modulesLoaded[$requiresModuleName])) {
								$loadNow = false;
							}
						}
					}
					if(!$loadNow) continue;
					// all conditions satisified to load delayed module
					unset($modulesDelayed[$delayedName], $modulesRequired[$moduleName][$delayedName]);
					$unused = array();
					$loadedName = $this->loadModule($path, $delayedPathName, $unused, $installed);
					if(!$loadedName) continue;
					$modulesLoaded[$loadedName] = 1;
					$loadedNames[] = $loadedName;
				}
			}
		}

		if(count($modulesDelayed)) {
			foreach($modulesDelayed as $moduleName => $requiredNames) {
				$this->error("Module '$moduleName' dependency not fulfilled for: " . implode(', ', $requiredNames), Notice::debug);
			}
		}

		if($this->debug) $this->modules->debugTimerStop($debugKey);
	}

	/**
	 * Load a module into memory (companion to load bootstrap method)
	 *
	 * @param string $basepath Base path of modules being processed (path provided to the load method)
	 * @param string $pathname
	 * @param array $requires This method will populate this array with required dependencies (class names) if present.
	 * @param array $installed Array of installed modules info, indexed by module class name
	 * @return string Returns module name (classname)
	 *
	 */
	public function loadModule($basepath, $pathname, array &$requires, array &$installed) {

		$pathname = $basepath . $pathname;
		$dirname = dirname($pathname);
		$filename = basename($pathname);
		$basename = basename($filename, '.php');
		$basename = basename($basename, '.module');
		$requires = array();
		$duplicates = $this->modules->duplicates();

		// check if module has duplicate files, where one to use has already been specified to use first
		$currentFile = $duplicates->getCurrent($basename); // returns the current file in use, if more than one
		if($currentFile) {
			// there is a duplicate file in use
			$file = rtrim($this->wire()->config->paths->root, '/') . $currentFile;
			if(file_exists($file) && $pathname != $file) {
				// file in use is different from the file we are looking at
				// check if this is a new/yet unknown duplicate
				if(!$duplicates->hasDuplicate($basename, $pathname)) {
					// new duplicate
					$duplicates->recordDuplicate($basename, $pathname, $file, $installed);
				}
				return '';
			}
		}

		// check if module has already been loaded, or maybe we've got duplicates
		if(wireClassExists($basename, false)) {
			$module = $this->modules->offsetGet($basename);
			$dir = rtrim((string) $this->wire()->config->paths($basename), '/');
			if($module && $dir && $dirname != $dir) {
				$duplicates->recordDuplicate($basename, $pathname, "$dir/$filename", $installed);
				return '';
			}
			if($module) return $basename;
		}

		// if the filename doesn't end with .module or .module.php, then stop and move onto the next
		if(strpos($filename, '.module') === false) return false;
		list(, $ext) = explode('.module', $filename, 2);
		if(!empty($ext) && $ext !== '.php') return false;

		//  if the filename doesn't start with the requested path, then skip
		if(strpos($pathname, $basepath) !== 0) return '';

		// if the file isn't there, it was probably uninstalled, so ignore it
		// if(!file_exists($pathname)) return ''; 

		// if the module isn't installed, then stop and move on to next
		if(!isset($installed[$basename])) {
			// array_key_exists is used as secondary to check the null case
			$this->modules->installableFile($basename, $pathname);
			return '';
		}

		$info = $installed[$basename];
		$this->modules->files->setConfigPaths($basename, $dirname);
		$module = null;
		$autoload = false;

		if($info['flags'] & Modules::flagsAutoload) {

			// this is an Autoload module. 
			// include the module and instantiate it but don't init() it,
			// because it will be done by Modules::init()

			// determine if module has dependencies that are not yet met
			$requiresClasses = $this->modules->info->getModuleInfoProperty($basename, 'requires');
			if(!empty($requiresClasses)) {
				foreach($requiresClasses as $requiresClass) {
					$nsRequiresClass = $this->modules->getModuleClass($requiresClass, true);
					if(!wireClassExists($nsRequiresClass, false)) {
						$requiresInfo = $this->modules->getModuleInfo($requiresClass);
						if(!empty($requiresInfo['error'])
							|| $requiresInfo['autoload'] === true
							|| !$this->modules->isInstalled($requiresClass)) {
							// we only handle autoload===true since load() only instantiates other autoload===true modules
							$requires[] = $requiresClass;
						}
					}
				}
				if(count($requires)) {
					// module has unmet requirements
					return $basename;
				}
			}
			// if not defined in getModuleInfo, then we'll accept the database flag as enough proof
			// since the module may have defined it via an isAutoload() function
			/** @var bool|string|callable $autoload */
			$autoload = $this->modules->info->moduleInfoCache($basename, 'autoload');
			if(empty($autoload)) $autoload = true;
			if($autoload === 'function') {
				// function is stored by the moduleInfo cache to indicate we need to call a dynamic function specified with the module itself
				$i = $this->modules->info->getModuleInfoExternal($basename);
				if(empty($i)) {
					$this->modules->files->includeModuleFile($pathname, $basename);
					$namespace = $this->modules->info->getModuleNamespace($basename);
					$className = $namespace . $basename;
					if(method_exists($className, 'getModuleInfo')) {
						$i = $className::getModuleInfo();
					} else {
						$i = $this->modules->getModuleInfo($className);
					}
				}
				$autoload = isset($i['autoload']) ? $i['autoload'] : true;
				unset($i);
			}
			// check for conditional autoload
			if(!is_bool($autoload) && (is_string($autoload) || is_callable($autoload)) && !($info['flags'] & Modules::flagsDisabled)) {
				// anonymous function or selector string
				$this->conditionalAutoloadModules[$basename] = $autoload;
				$this->modules->moduleID($basename, (int) $info['id']); 
				$this->modules->moduleName((int) $info['id'], $basename);
				$autoload = true;
			} else if($autoload) {
				$this->modules->files->includeModuleFile($pathname, $basename);
				if(!($info['flags'] & Modules::flagsDisabled)) {
					if($this->modules->refreshing) {
						$module = $this->modules->offsetGet($basename);
					} else if(isset($this->autoloadOrders[$basename]) && $this->autoloadOrders[$basename] >= 10000) {
						$module = $this->modules->offsetGet($basename); // preloaded module
					}
					if(!$module) $module = $this->modules->newModule($basename);
				}
			}
		}

		if($module === null) {
			// placeholder for a module, which is not yet included and instantiated
			$ns = $this->modules->info->moduleInfoCache($basename, 'namespace');
			if(empty($ns)) $ns = __NAMESPACE__ . "\\";
			$singular = $info['flags'] & Modules::flagsSingular;
			$module = $this->newModulePlaceholder($basename, $ns, $pathname, $singular, $autoload);
		}

		$this->modules->moduleID($basename, (int) $info['id']);
		$this->modules->moduleName((int) $info['id'], $basename);
		$this->modules->set($basename, $module);

		return $basename;
	}
	
	/**
	 * Include the file for a given module, but don't instantiate it
	 *
	 * #pw-internal
	 *
	 * @param ModulePlaceholder|Module|string Expects a ModulePlaceholder or className
	 * @param string $file Optionally specify the module filename if you already know it
	 * @return bool true on success, false on fail or unknown
	 *
	 */
	public function includeModule($module, $file = '') {

		$className = '';
		$moduleName = '';

		if(is_string($module)) {
			$moduleName = ctype_alnum($module) ? $module : wireClassName($module);
			$className = wireClassName($module, true);
		} else if(is_object($module)) {
			if($module instanceof ModulePlaceholder) {
				$moduleName = $module->className();
				$className = $module->className(true);
			} else if($module instanceof Module) {
				return true; // already included
			}
		} else {
			$moduleName = $this->modules->getModuleClass($module, false);
			$className = $this->modules->getModuleClass($module, true);
		}

		if(!$className) return false;

		// already included
		if(class_exists($className, false)) return true;

		// attempt to retrieve module
		$module = $this->modules->offsetGet($moduleName); 

		if($module) {
			// module found, check to make sure it actually points to a module	
			if(!$module instanceof Module) $module = false;

		} else if($moduleName) {
			// This is reached for any of the following:
			// 1. an uninstalled module
			// 2. an installed module that has changed locations
			// 3. a module outside the \ProcessWire\ namespace
			// 4. a module that does not exist
			$fast = true;
			if(!$file) {
				// determine module file, if not already provided to the method
				$file = $this->modules->getModuleFile($moduleName, array('fast' => true));
				if(!$file) {
					$fast = false;
					$file = $this->modules->getModuleFile($moduleName, array('fast' => false));
				}
				// still can't figure out what file is? fail
				if(!$file) return false;
			}

			if(!$this->modules->files->includeModuleFile($file, $moduleName)) {
				// module file failed to include(), try to identify and include file again
				if($fast) {
					$filePrev = $file;
					$file = $this->modules->getModuleFile($moduleName, array('fast' => false));
					if($file && $file !== $filePrev) {
						if($this->modules->files->includeModuleFile($file, $moduleName)) {
							// module is missing a module file
							return false;
						}
					}
				} else {
					// we already tried this earlier, no point in doing it again
				}
			}

			// now check to see if included file resulted in presence of module class
			if(class_exists($className)) {
				// module in ProcessWire namespace
				$module = true;
			} else {
				// module in root namespace or some other namespace
				$namespace = (string) $this->modules->info->getModuleNamespace($moduleName, array('file' => $file));
				$className = trim($namespace, "\\") . "\\$moduleName";
				if(class_exists($className, false)) {
					// successful include module
					$module = true;
				}
			}
		}

		if($module === true) {
			// great
			return true;

		} else if(!$module) {
			// darn
			return false;

		} else if($module instanceof ModulePlaceholder) {
			// the ModulePlaceholder indicates what file to load
			return $this->modules->files->includeModuleFile($module->file, $moduleName);

		} else if($module instanceof Module) {
			// it's already been included, since we have a real module
			return true;

		} else {
			return false;
		}
	}

	/**
	 * Check if user has permission for given module
	 *
	 * #pw-internal
	 *
	 * @param string|object $moduleName Module instance or module name
	 * @param User $user Optionally specify different user to consider than current.
	 * @param Page $page Optionally specify different page to consider than current.
	 * @param bool $strict If module specifies no permission settings, assume no permission.
	 *   - Default (false) is to assume permission when module doesn't say anything about it.
	 *   - Process modules (for instance) generally assume no permission when it isn't specifically defined
	 *     (though this method doesn't get involved in that, leaving you to specify $strict instead).
	 *
	 * @return bool
	 *
	 */
	public function hasPermission($moduleName, User $user = null, Page $page = null, $strict = false) {

		if(is_object($moduleName)) {
			$module = $moduleName;
			$className = $module->className(true);
			$moduleName = $module->className(false);
		} else {
			$module = null;
			$className = $this->modules->getModuleClass($moduleName, true); // ???
			$moduleName = wireClassName($moduleName, false);
		}

		$info = $this->modules->getModuleInfo($module ? $module : $moduleName);
		
		if(empty($info['permission']) && empty($info['permissionMethod'])) {
			return ($strict ? false : true);
		}

		if(!$user instanceof User) $user = $this->wire()->user;
		if($user && $user->isSuperuser()) return true;

		if(!empty($info['permission'])) {
			if(!$user->hasPermission($info['permission'])) return false;
		}

		if(!empty($info['permissionMethod'])) {
			// module specifies a static method to call for permission
			if(is_null($page)) $page = $this->wire()->page;
			$data = array(
				'wire' => $this->wire(),
				'page' => $page,
				'user' => $user,
				'info' => $info,
			);
			$method = $info['permissionMethod'];
			$this->includeModule($moduleName);
			
			return method_exists($className, $method) ? $className::$method($data) : false;
		}

		return true;
	}
	
	/**
	 * Include site preload modules
	 *
	 * Preload modules load before all other modules, including core modules. In order
	 * for a module to be a preload module, it must meet the following conditions:
	 *
	 * - Module info `autoload` value is integer of 10000 or greater, i.e. `[ 'autoload' => 10000 ]`
	 * - Module info `singular` value must be non-empty, i.e. `[ 'singular' => true ]`
	 * - Module file is located in: /site/modules/ModuleName/ModuleName.module.php
	 * - Module cannot load any other modules at least until ready() method called.
	 * - Module cannot have any `requires` dependencies to any other modules.
	 *
	 * Please note the above is specifically stating that the module must be in its
	 * own “site/ModuleName/” directory and have the “.module.php” extension. Using
	 * just the “.module” extension is not supported for preload modules.
	 *
	 * @param string $path
	 * @since 3.0.173
	 *
	 */
	public function preloadModules($path) {
		
		if(empty($this->autoloadOrders)) return;
		
		arsort($this->autoloadOrders);
		
		foreach($this->autoloadOrders as $moduleName => $order) {
			if($order < 10000) break;
			$info = $this->modules->info->moduleInfoCache($moduleName);
			if(empty($info)) continue;
			if(empty($info['singular'])) continue;
			$file = $path . "$moduleName/$moduleName.module.php";
			if(!file_exists($file) || !$this->modules->files->includeModuleFile($file, $moduleName)) continue;
			if(!isset($info['namespace'])) $info['namespace'] = '';
			$className = $info['namespace'] . $moduleName;
			$module = $this->modules->newModule($className, $moduleName);
			if($module) {
				$this->modules->offsetSet($moduleName, $module);
			}
		}
	}


	/**
	 * Get or set created date for given module ID
	 *
	 * #pw-internal
	 *
	 * @param int $moduleID Module ID or omit to get all
	 * @param string $setValue Set created date value
	 * @return string|array|null
	 * @since 3.0.219
	 *
	 */
	public function createdDate($moduleID = null, $setValue = null) {
		if($moduleID === null) return $this->createdDates;
		if($setValue) {
			$this->createdDates[$moduleID] = $setValue;
			return $setValue;
		}
		return isset($this->createdDates[$moduleID]) ? $this->createdDates[$moduleID] : null;
	}

	/**
	 * Return a new ModulePlaceholder for the given className
	 *
	 * #pw-internal
	 *
	 * @param string $className Module class this placeholder will stand in for
	 * @param string $ns Module namespace
	 * @param string $file Full path and filename of $className
	 * @param bool $singular Is the module a singular module?
	 * @param bool $autoload Is the module an autoload module?
	 * @return ModulePlaceholder
	 *
	 */
	public function newModulePlaceholder($className, $ns, $file, $singular, $autoload) {
		/** @var ModulePlaceholder $module */
		$module = $this->wire(new ModulePlaceholder());
		$module->setClass($className);
		$module->setNamespace($ns);
		$module->singular = $singular;
		$module->autoload = $autoload;
		$module->file = $file;
		return $module;
	}

	/**
	 * Called by Modules class when init has finished
	 * 
	 */
	public function loaded() {
		$this->modulesTableCache = array();
	}

	/**
	 * Get the autoload orders
	 * 
	 * @return array Array of [ moduleName (string => order (int) ]
	 * 
	 */
	public function getAutoloadOrders() {
		return $this->autoloadOrders;
	}

	public function getDebugData() {
		return array(
			'autoloadOrders' => $this->autoloadOrders,
			'conditionalAutoloadModules' => $this->conditionalAutoloadModules,
			'modulesTableCache' => $this->modulesTableCache,
			'createdDates' => $this->createdDates,
		);
	}

}
