<?php

require_once __DIR__ . '/dependency.php';
require_once __DIR__ . '/schedule.php';
require_once __DIR__ . '/curlobject.php';

$hash = md5(__DIR__);
// once lock file removed, stop then start apache.  A restart will not work (stats are cached).   systemctl restart apache2
define('LOCK_FILE', $hash . '.lck');		// remove this from /tmp to continue setup
if (!defined('T_NAME_QUALIFIED')) {
	define('T_NAME_QUALIFIED', 265);
}


class Module {


// TO DO:  on-same-page
// global load of enabled modules is in config::main
// TO DO: check permission: module.txt file
// TO DO: check permission: symlink

	public $settings;
	public $editHash;

	public function __construct() {
	}

/*
	general index.php information files:
	// information about this module
	//	title
	//	version number - change whenever controller public function definitions change
	//	dependencies
	//	update url
	//	name of core class
	//	(hooks are installed in the main php file)
	//	custom routes
	//	static assets, (softlink is created in /static, pointing to /module/xx/static
	//	includable js and css files
	//  settings
			name => type
				=> caption
				=> default value
				=> selection/option list
	//	error messages
	//  email templates
			[
				'internalname' => 'entrypoint',
				
			]
	//	table definitions
		['tablename'] => [ 'field' => [ 'fieldname' => ['type' => 'string',		// separate class for each type
														 'size' =>
														 'comment' =>
														 'autoinc' =>
														 'unsigned' => 
														 'nullable' =>
														 'default']
										],
							'index' => [ 'indexname' => ['fieldlist' => [],
														 'type' => ''			// primary, unique, index
						];
*/
	public function __info() {
	}
}

class loader {
	
	const FILENAME = '.modules.txt';
	const TTL = 3600;		// 600 for production; 3600 for development

	var $HOOKS = [];
	var $VIEWS = [];
	var $DATA = [];
    
    private $user_level;
	private $salt = FALSE;
	private $changed;
	public $permissions;
	private $u_id = 0;
	
	public function __construct() {
		// load cache file, create if missing
		$this->_loadCache();
	}
	
	public function __destruct() {
		$this->flush();
	}
	
	public function filename() {
		$filename = HOME . '/storage/' . self::FILENAME;
		return $filename;
	}
	
	public function SetSalt($salt) {		// deprecated. Use Salt() instead
		$this->salt = $salt;
	}

	public function Salt() {
		if (func_num_args()) {
			$this->salt = func_get_arg(0);
			return $this;
		}
		return $this->salt;
	}
	
	private function _loadCache() {
		$filename = $this->filename();
		if (file_exists($filename)) {
			$this->DATA = unserialize(file_get_contents($filename));
		} else {
			$this->DATA = [];
		}
		$this->changed = FALSE;
	}

    public function UserLevel() {
        if (func_num_args()) {
            $this->user_level = func_get_arg(0);
			$this->_LoadGroupMembership($this->user_level);
            return $this;
        }
        return $this->user_level;
    }
	
	private function _LoadGroupMembership($level = 0) {
		$permissions = [];
		$groups = hook_execute('group.enum', FALSE);
		foreach($groups as $grouplevel => $groupname) {
			if ($level >= $grouplevel) $permissions[$groupname] = 1;
		}
		if (hook_exists('user.membership')) {
			$permissions = hook_execute('user.membership', $permissions);
		}
		$this->permissions = $permissions;
	}
	

	public function UserId() {
		if (func_num_args()) {
			$this->u_id = func_get_arg(0);
			return $this;
		}
		return $this->u_id;
	}

	public function flush() {
		if ($this->changed) {
			$filename = $this->filename();
			// do not want to save: $node[$classname]['main']
			$data = $this . '';
			@file_put_contents($filename, $data);
		}
		$this->changed = FALSE;
	}
	
	// get serialized output - used to generate .modules.txt
	public function __toString() {
		$modules = $this->DATA;
		foreach($modules as $name => &$module_info) {
			if (isset($module_info['.info']['class'])) {
				unset($module_info['.info']['missing']);
				unset($module_info['current']);
				unset($module_info['public']);
				unset($module_info['main']);
			}
		}
		return serialize($modules);
	}
	
	public function GetStaticUri($path) {
		$x = HOME . '/';
		if (substr($path, 0, strlen($x)) == $x) {
			$path = substr($path, strlen($x));
		}
		while(strlen($path) > 2) {
			$name = basename($path);
			$path = dirname($path);
			if (isset($this->DATA[$name])) {
				$node = &$this->DATA[$name];
				return $node['static'];
			}
		}
		return FALSE;
	}

	// Get details about the file - classes, public functions, namespace
	// also get item permissions?
	public function GetFileInformation($filename, $extends = '?') {
		$result = [];
		$file = file_get_contents($filename);
		$namespace = $classname = '';
		$tokens = @token_get_all($file);
		$count = count($tokens);
		$isStatic = FALSE;
		$isPublic = TRUE;
		$doc_comment = '';
		$action = $depth = 0;
		
		for($i = 0; $i < $count; $i++) {
			$token = $tokens[$i];
			if (is_array($token)) {
				$value = trim($token[1]);
				$token = $token[0];
				if ($token == T_COMMENT) continue;
			} else {
				$value = trim($token);
				$token = 0;
			}
			if ($action > 0) {
				if ($value == '') continue;		// whitespace is not useful
				switch($action) {
					case 1: 			// namespace
						if ($token == T_NS_SEPARATOR || $token == T_STRING || $token == T_NAME_QUALIFIED) {	  // T_NAME_QUALIFIED = php 8
							$namespace .= $value;
						}
						if ($value == ';') $action = 0;
						break;
					case 2: 			// class
						if ($token == T_STRING) {
							$classname = strtolower($value);
							$result[$classname] = [];
						} elseif ($token == T_EXTENDS) {
							$action = 3;
						}
						if ($value == '{') $action = 0;
						break;
					case 3: 			// extends
						if ($token == T_NS_SEPARATOR || $token == T_STRING ) {
							$extends .= $value;
						}
						if ($value == '{') {
							$result[$classname]['.ext'] = $extends;
							$action = 0;
						}
						break;
					case 4:	 		// function
						if ($token == T_STRING) {
							$function_name = strtolower($value);
							if ($function_name != '__construct') {
								$result[$classname][$function_name] = $doc_comment;
							}
						}
						if ($value == '(') {
							$action = 0;
							$doc_comment = '';
						}
						break;
				}
			}
			if ($value == '{') {
					$depth++;
			} elseif ($value == '}') {
					$depth--;
					$isStatic = FALSE;
					$isPublic = TRUE;
			} else switch($token) {
				case T_NAMESPACE:
					$namespace = '';
					$action = 1;
					break;
				case T_CLASS:
					$action = 2;
					$extends = '';
					break;
				case T_FUNCTION:
					if ($isPublic && !$isStatic) $action = 4;
					$depth = 1;
					$isStatic = FALSE;
					break;
				case T_PUBLIC:
					$isPublic = TRUE;
					break;
				case T_PRIVATE:
					$isPublic = FALSE;
					break;
				case T_PROTECTED:
					$isPublic = FALSE;
					break;
				case T_STATIC:
					if ($depth > 1) break;
					// skip over spaces.  "static::" could be used within a function - we need to ignore these
					$j = $i;
					do {
						$j++;
						$tokenx = FALSE;
						if ($j >= $count) break;
						$tokenx = $tokens[$j];
						if (is_array($tokenx)) {
							$valuex = trim($tokenx[1]);
						} else {
							$valuex = trim($tokenx);
						}
					} while ($valuex == '');
					if (is_array($tokenx) && $tokenx[0] == T_VARIABLE) {		// if we have a static variable - ignore it
						break;
					}
						
					$isStatic = TRUE;
					break;
				case T_DOC_COMMENT: 
					if (substr($value, 0, 3) == '/**') $value = substr($value, 3);
					if (substr($value, -2, 2) == '*/') $value = substr($value, 0, -2);
					$doc_comment = trim($value);
					break;
			}
		}
		if ($namespace != '') $result['.ns'] = $namespace;
		return $result;
	}
	
	private function _getEntryPoint($info, $name) {
		$result = [];
		foreach($info as $classname => $info_item) {
			if (isset($info_item[$name])) {
				if (isset($info['.ns'])) {
					if (substr($classname, 0, 1) != '\\') {
						$classname = $info['.ns'] . '\\' . $classname;
					}
				}
				$result[] = $classname;
			}
		}
		return $result;
	}
	
	// scan only 1 level deep.
	public function ProcessControllers($name) {
		$result = [];
		$dirname = HOME . '/modules/' . $name . '/controller';
		$controllers = glob($dirname . '/*.php');
		foreach($controllers as $controller) {
			if (is_file($controller)) {
				$node = $this->GetFileInformation($controller);
				$segment = basename($controller, '.php');
				$result[$segment] = $node;
			}
		}
		return $result;
	}

	private function _isLoaded($name) {
		if (!isset($this->DATA[$name])) return FALSE;
		$module_info = &$this->DATA[$name];
		if (isset($module_info['.info']['class'])) {
			if (array_key_exists('current', $module_info)) return TRUE;
		}
		return FALSE;
	}
	
	// only if the .modules.txt file is missing.  
	// to do: limit to one info structure per module  (only one class in index.php)
	private function _loadModule($name, $filename) {
		$info = $this->GetFileInformation($filename);
		// use shared memory to see if DATA needs reloading?
		$fs = stat($filename);
		$module_info = ['.time' => $fs['mtime']];
		$class = $this->_getEntryPoint($info, '__info');
		if (count($class) > 0) {		// do we have any of our special entrypoints?
			try {
				require_once $filename;
			} catch (Throwable $e) {			// php 7
				die('Error:' . $e->getMessage() . ' in ' . $e->getFile() . ' at line ' . $e->getLine() . ' [4]');
			}
			$classname = array_shift($class);
			$the_object = new $classname();		// index class
			$the_info = $the_object->__info();
			if (isset($the_info['depends']) && !is_array($the_info['depends'])) {
				$the_info['depends'] = [ $the_info['depends'] ];
			}
			$module_info['.info'] = $the_info;		// info only in first class
			$module_info['.classname'] = $classname;
//			$module_info['.php'] = $info;		// probably not used
			$this->changed = TRUE;
		}
		$res = $this->ProcessControllers($name);		// http entry points
		$module_info['static'] = $this->_requireStatic($name, HOME . '/modules/' . $name . '/static');
		$module_info['http'] = $res;
		return $module_info;
	}
	
	private function _displayEntry($entry) {
		$function = $entry['function'];
		if (!empty($entry['class'])) $function = $entry['class'] . '::' . $function;
		echo $function . " [{$entry['file']}:{$entry['line']}]<br>\n";
	}
    
	// the main index.php file is only loaded once.
	// if loadOnly, then the module is not activated
	public function LoadModule($name, $loadOnly = FALSE) {
		$filename = HOME . '/modules/' . $name . '/index.php';
		if (!file_exists($filename)) {
			$bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
			foreach($bt as $bt_item) {
				echo $this->_displayEntry($bt_item);
			}
            echo "Unable to find dependency: $filename<br>\n";
			return $name;
		}
//		echo "\nloadModule $name";
		$fs = stat($filename);
//		if ($_SERVER['REMOTE_ADDR'] == '173.48.93.56') {
			//error_log($filename);
//		}
		
		// has the php source changed?
		$lasttime = isset($this->DATA[$name]) ? $this->DATA[$name]['.time'] : 0;
		if ($fs['mtime'] > $lasttime || $loadOnly) {						// only if file has changed, or is missing in config file
			$module_info = $this->_loadModule($name, $filename);
			if ($loadOnly) 	$module_info['active'] = FALSE;
			$this->DATA[$name] = $module_info;
		}
		
		// Load all dependencies
		$module_info = &$this->DATA[$name];
		$missing = [];
		if (isset($module_info['.info']['depends'])) {
			foreach($module_info['.info']['depends'] as $module) {
				if (!$this->_isLoaded($module)) {
					$x = $this->LoadModule($module, $loadOnly);
					if (is_string($x)) {			// module is missing
						$missing[$x] = 1;
					}
				}
			}
		}
		if (count($missing)) {
			$module_info['.info']['missing'] = array_keys($missing);
			$loadOnly = TRUE;			// do not activate if dependencies are missing
		}
		$first = TRUE;		// assume no Main class

		if (isset($module_info['.info']['class'])) {
			if (!array_key_exists('current', $module_info)) {
				$module_info['current'] = $this->LoadModuleSettings($name);	// load current settings
//				if ($name == 'slack') var_dump($module_info['current']);
			}
			if (!$loadOnly && !isset($module_info['main'])) {
				$classclass = $module_info['.info']['class'];
				if (method_exists($classclass, 'getHandle')) {
					$item = $classclass::getHandle();
				} else {
					$item = new $classclass();	// main class
				}
				$module_info['public'] = $this->_publics($classclass);		// module entrypoints - "/~/modulename/public", must be marked as Final
				if (!isset($item->settings)) {
					$item->settings = &$module_info['current'];				// add current settings to the class.
					$item->CONFIG = $module_info['.info'];
					$item->Activate($name);
					if ($this->salt !== FALSE) {
						$classname = $module_info['.classname'];
						$item->editHash = md5("{$this->salt}:$classname");
					} else {
						$item->editHash = FALSE;		// config module has not been loaded
					}
				}
				$module_info['main'] = $item;
			}
			if ($first === TRUE && isset($module_info['main'])) {
				$first = $module_info['main'];
			}
		}
		
		return $first;
	}
	
	public function ActivateModule($name) {
		$module_info = &$this->DATA[$name];
		$item = $module_info['main'];
		$item->CONFIG = $module_info['.info'];
		$item->Activate($name);
	}
		
	// get all Final functions
	public function _publics($class) {
		$reflection = new \ReflectionClass($class);
		$x = $reflection->GetMethods(ReflectionMethod::IS_FINAL);
		$result = [];
		foreach($x as $function) {
			$result[$function->name] = 1;
		}
		return $result;
	}

/*	private function GetSystemSalt() {
		$x = hook_execute('');
		$settings = $this->SettingGet('config.module_info', NULL);
		$this->salt = isset($settings['key']) ? $settings['secret'] : LOCK_FILE;
	} */

	public function getModuleName($modulename) {
		if (strpos($modulename, '/') !== FALSE) {	// a file or folder
			if (substr($modulename, 0, strlen(HOME)) == HOME) {
				$modulename = trim(substr($modulename, strlen(HOME)), '/');
			}
			$elements = explode('/', $modulename);
			if ($elements[0] == 'modules') {
				$modulename = $elements[1];
			} else {
				return '';
			}
		}
		return $modulename;
	}

	public function LoadModuleSettings($modulename) {
		$result = [];
		$original = $modulename;
		$modulename = $this->getModuleName($modulename);
		if ($modulename == '') {
			die('Unknown module name: ' . $original);
		}
		if (!isset($this->DATA[$modulename])) {		// not loaded
			return FALSE;
		}
		// get definitions
		$info = $this->DATA[$modulename]['.info'];
		
		// load defaults
		if (isset($info['settings'])) {
			foreach($info['settings'] as $field_def) {
				if (is_string($field_def)) {
					echo "Invalid field definition: " . $field_def . "\n";
					exit;
				}
				$key = $field_def['name'];
				if (array_key_exists('default', $field_def)) {
					$result[$key] = $field_def['default'];
				}
			}
		}
		if (isset($info['user_settings'])) {
			foreach($info['user_settings'] as $field_def) {
				$key = $field_def['name'];
				if (array_key_exists('default', $field_def)) {
					$result[$key] = $field_def['default'];
				}
			}
		}
		
		// load using hook
		$result = Hook_Execute('config.get', $result, $modulename, $this->u_id);		// loads global settings first, then user-specific.
		
		// select - put in the descriptions
		if (isset($info['settings'])) {
			foreach($info['settings'] as $field_def) {
				$type = $field_def['type'] ?? '';
				if ($type == 'select') {
					$key = $field_def['name'];
					$value = $result[$key] ?? '';
					if (isset($field_def['options'][$value])) {
						$result[".{$key}"] = $field_def['options'][$value];
					}
				}
			}
		}
		if (isset($info['user_settings'])) {
			foreach($info['user_settings'] as $field_def) {
				$type = $field_def['type'] ?? '';
				if ($type == 'select') {
					$key = $field_def['name'];
					$value = $result[$key] ?? '';
					if (isset($field_def['options'][$value])) {
						$result[".{$key}"] = $field_def['options'][$value];
					}
				}
			}
		}
		return $result;
	}
	
	
	public function MarkEnabled($name) {
		if (!isset($this->DATA[$name])) return FALSE;
		$module_info = &$this->DATA[$name];
		if (isset($module_info['.info']['class'])) {
			if (isset($module_info['main'])) {
				$module_info['enabled'] = TRUE;
			}
		}
		return FALSE;
	}
	
	public function isModuleActive($name, $checkIsEnabled = FALSE) {			// $checkIsEnabled = only if module has been Enabled
		if (!isset($this->DATA[$name])) return FALSE;
		if ($checkIsEnabled) {
			$enabledModules = hook_execute('module.list', []);					// get a list of all enabled moduled
			return in_array($name, $enabledModules);
		} 
		$module_info = $this->DATA[$name];
		if (isset($module_info['.info']['class'])) {
			if (isset($module_info['main'])) return TRUE;
		}
		return FALSE;
	}
	
	// add inactive modules - mark as disabled
	public function Unscanned($load_settings = FALSE) {
		try {
			$filelist = glob(HOME . '/modules/*');
			foreach($filelist as $filename) {
				if (is_dir($filename)) {
					$name = basename($filename);
					if (file_exists($filename . '/index.php')) {
						if (!$this->isModuleActive($name)) {
							$res = $this->LoadModule($name, $load_settings);
						}
					}
				}
			}
		} catch (Throwable $e) {			// php 7
			die('Error:' . $e->getMessage() . ' in ' . $e->getFile() . ' at line ' . $e->getLine() . ' [5]');
		}
	}

	// get all dependent modules
	public function GetDependencies(&$dependencylist, $module = '') {
		if ($module == '') {
			foreach($this->DATA as $module => $module_info) {
				if ($this->isModuleActive($module)) {
					$this->GetDependencies($dependencylist, $module);
				}
			}
			return;
		}
		
		if (isset($dependencylist[$module])) return;	// already processed
		if (!isset($this->DATA[$module])) {				// missing
			$dependencylist[$module] = FALSE;
			return;
		}
		$dependencylist[$module] = TRUE;
		$module_info = &$this->DATA[$module];
		if (isset($module_info['.info']['composer'])) {
			$dependencylist[$module] = $module_info['.info']['composer'];
		}
		if (isset($module_info['.info']['depends'])) {
			foreach($module_info['.info']['depends'] as $a_module) {
				$this->GetDependencies($dependencylist, $a_module);
			}
		}
	}

	// load module, don't activate(). Aim: to verify parameters
	//		however, dependencies need to be active
	//		(settings were loaded earlier)
	public function VerifyModuleSettings($module) {
		$module_info = &$this->DATA[$module];
		if (isset($module_info['.info']['depends'])) {
			foreach($module_info['.info']['depends'] as $module) {
				$x = $this->LoadModule($module);
				if (is_string($x)) {
//						echo "Module load error: $x\n";		// missing module
					return $x;
				}
			}
		}
		
		if (isset($module_info['.info']['class'])) {
			if (!isset($module_info['main'])) {
				$classclass = $module_info['.info']['class'];
				if (method_exists($classclass, 'getHandle')) {
					$item = $classclass::getHandle();
				} else {
					$item = new $classclass();	// main class
				}
				$module_info['main'] = $item;
			}
			$module_info['main']->settings = &$module_info['current'];
			if (method_exists($module_info['main'], 'Validate')) {
				return $module_info['main']->Validate();
			} else {
				return TRUE;
			}
		}
		return TRUE;
	}
	
	// what active modules have a file with this basename?
	public function getActiveControllers($basename) {
		$result = [];
		foreach($this->DATA as $module => $module_info) {
			if ($this->isModuleActive($module)) {
				if (isset($module_info['http'][$basename])) {
					$classes = array_keys($module_info['http'][$basename]);
					$result[] = [
						'filename' => HOME . '/modules/' . $module . '/controller/' . $basename . '.php',
						'classes' => $classes,
					];
				}
			}
		}
		return $result;
	}
	
	// do we have a static folder needing to be made public?
	// delete .modules.txt to scan/recreate softlink folders.
	private function _requireStatic($name, $source) {
		$targetdir = HOME . '/public/static';
		$hash = md5($targetdir . ':' . strtoupper($name));
		$target = $targetdir . '/' . $hash;
		$need_static = file_exists($source);
		$has_static = is_link($target);
		if ($need_static != $has_static) {
			if ($has_static) {
				@unlink($target);
			} else {
				@symlink($source, $target);
			}
			clearstatcache(TRUE, $target);
			//to do:  display permission error
		}
		$has_static = is_link($target);
		if ($has_static) return "/static/$hash";
		return "/~/$name";		// slower, as a backup
	}
	
	public function hook_add($hookname, $callback, $priority = 50) {
		// is it added already?
		$priority = intval($priority);
		if (isset($this->HOOKS[$hookname][$priority])) {
			foreach($this->HOOKS[$hookname][$priority] as $installed_callback) {
				if (is_callable($installed_callback) && 
					$installed_callback[0] == $callback[0] &&
					$installed_callback[1] == $callback[1]) return;	// already installed
			}
		}
		$this->HOOKS[$hookname][$priority][] = $callback;
		ksort($this->HOOKS[$hookname], SORT_NUMERIC);
	}

	public function hook_exists($hookname) {
		return isset($this->HOOKS[$hookname]);
	}

	public function hook_list($hookname) {
		if (isset($this->HOOKS[$hookname])) return $this->HOOKS[$hookname];
		return NULL;
	}
	
	public function hook_remove($hookname, $priority) {
		unset($this->HOOKS[$hookname][$priority]);
	}

	// returns filtered value
	// smaller $prority is executed first
	public function hook_execute($hookname, $initial_value = null /*, additional parameters*/ ) {
//		if ($hookname == 'xx') $this->hook_trace();
		$params = func_get_args();
		array_shift($params); 			// $hookname
		$params[0] = &$initial_value;	// pass by reference
		for($i = 1; $i < count($params); $i++) {		// allow passing/changing between callback entries
			$params[$i] = &$params[$i];
		}
//		if ($hookname == 'db.migrate') var_dump($this->HOOKS[$hookname]);
		if (isset($this->HOOKS[$hookname])) {
			$j = 0;
			while($j < count($this->HOOKS[$hookname])) {			// if a module adds to current hook list, it will be picked up
				$keys = array_keys($this->HOOKS[$hookname]);
				$priority = $keys[$j]; $j++;
				$hook_entries = &$this->HOOKS[$hookname][$priority];
				$i = 0; 
				while($i < count($hook_entries)) {
					$callback = $hook_entries[$i]; $i++;
					@call_user_func_array($callback, $params);
//					if ($_SERVER['REMOTE_ADDR'] == '173.48.93.56' && $hookname == 'component.add') {
//						error_log($callback[1] . ' ' . json_encode($params));
//					}

				}
			}
		}
		return $initial_value;
	}
	
	public function listRegisteredHandlers($key) {
		$result = [];
		$events = $this->hook_list($key);
		if ($events !== NULL) {
			foreach($events as $priority => $hook_entries) {
				foreach($hook_entries as $event) {
					$info = call_user_func($event);
					$entrypoint = $info->entrypoint;
					$info->md5 = md5(get_class($entrypoint[0]) . '::' . $entrypoint[1]);
					$result[] = [
						'key' 	=> $info->md5,
						'value' => $info->title,
					];
				}
			}
		}
		return $result;
	}
	
	public function getRegisteredHandler($key, $md5) {
		$events = $this->hook_list($key);
		if ($events !== NULL) {
			foreach($events as $priority => $hook_entries) {
				foreach($hook_entries as $event) {
					$info = call_user_func($event);
					$entrypoint = $info->entrypoint;
					$info->md5 = md5(get_class($entrypoint[0]) . '::' . $entrypoint[1]);
					
					if ($md5 == '' || $info->md5 == $md5) return $info;
				}
			}
		}
		return NULL;
	}

	//to do: move to debug module
	public function hook_trace($specific = '') {
		$x = $this->HOOKS;
		echo "Hooks ===========\n";
		foreach($x as $name => $name_data) {
			if ($specific == '' || $name == $specific) {
				foreach($name_data as $priority => $callback_list) {
					foreach($callback_list as $index => $callback) {
						echo $index . '.  ' . $name . ' => ' . get_class($callback[0]) . '::' . $callback[1] ." (priority $priority)\n";
					}
				}
			}
		}
		echo "=================\n";
	}
	
	public function autoload($classname) {
//		echo "loading class $classname\n";
		$classname = str_replace('\\', '/', $classname);
		$filename = HOME . '/' . $classname . '.php';
		$ok = file_exists($filename);
		if ($ok) {
			try {
				require_once $filename;
			} catch (Throwable $e) {			// php 7
				die('Error:' . $e->getMessage() . ' in ' . $e->getFile() . ' at line ' . $e->getLine() . ' [6]');
			}
		}
		return $ok;
	}
	
	public function executeClassMethod($file, $name, $class_name, $method, $params = []) {
		$filename = HOME . '/modules/' . $name . '/controller/' . $file . '.php';
		try {
			require_once $filename;
		} catch (Throwable $e) {			// php 7
			die('Error:' . $e->getMessage() . ' in ' . $e->getFile() . ' at line ' . $e->getLine() . ' [7]');
		}
		// for unusual errors with the next line, delete the .modules.txt file and try again
		$object = $class_name::GetHandle();
        return call_user_func_array([$object, $method], $params);	// return NULL if not suitable
	}
	
	public function Route($segments, $request_method = 'get', $checkWild = FALSE) {
		$controller = isset($segments[0]) ? strtolower($segments[0]) : '';
		array_shift($segments);
		$method = isset($segments[0]) ? strtolower($segments[0]) : '';
		if ($method == '') $method = 'index';
		$method = str_replace('-', '_', $method);
		$controller = str_replace('-', '_', $controller);
		$original_method = $method;
		if ($method == 'list' || $method == 'new') $method = '_' . $method;
		array_shift($segments);
		if ($checkWild) $method = '__call';
		$method_specific = $method . '_' . strtolower($request_method);
		
		// locate all entry points
		$suitable = [];
		foreach($this->DATA as $name => $module_info) {
			if (count($module_info['http'])) {
				foreach($module_info['http'] as $file => $file_info) {
					if (strtolower($file) == $controller) {
						foreach($file_info as $class_name => $class_info) {
							foreach($class_info as $public => $doc_comment) {		// check request_method specific
								if ($public == $method_specific) {
									return [
										'file' => $file,
										'name' => $name,
										'class_name' => $class_name,
										'doc_comment' => $doc_comment,
										'method' => $method_specific,
										'params' => $segments,
									];
								}
							}
							foreach($class_info as $public => $doc_comment) {
								if (substr($public, 0, 1) == '.') continue;
								if ($public == $method) {
									if ($checkWild) {
										// does this wildcard method support the item? If not ignore it
										$res = $this->executeClassMethod($file, $name, $class_name, $original_method, $segments);
										if ($res === FALSE || $res === NULL) break;
										$segments = $res;
										$original_method = array_shift($segments);
									}
									return [
										'file' => $file,
										'name' => $name,
										'class_name' => $class_name,
										'doc_comment' => $doc_comment,
										'method' => $original_method,
										'params' => $segments,
									];
								}
							}
						}
					}
				}
			}
		}
		return FALSE;
	}
	
// what controller is used by this class? (controller is name of file, without extension)
	public function getControllerForMethod($method_name) {
		list($myClass, $myMethod) = explode('::', strtolower($method_name) . '::');
		foreach($this->DATA as $name => $module_info) {
			if (count($module_info['http'])) {
				foreach($module_info['http'] as $controller => $classes) {
					if (isset($classes[$myClass])) {
						return $controller;
					}
				}
			}
		}
		return FALSE;
	}
	
	private function _hasAccess($doc_comment) {
		return TRUE;
	}
	
	public function LoadAllViews($myController) {
		if ($myController === FALSE || $myController === '') return FALSE;		// nothing found
		foreach($this->DATA as $name => $module_info) {
			if (count($module_info['http']) && isset($module_info['http'][$myController])) {
				$classes = &$module_info['http'][$myController];
				foreach($classes as $class => $class_details) {
					if (isset($class_details['__register'])) {
						$this->executeClassMethod($myController, $name, $class, '__register');
					}
				}
			}
		}
		return TRUE;
	}
	
	private function _hasPermission($groupnames, $info) {
		$grouplist = explode(' ', trim(preg_replace('/\s+/', ' ', $groupnames)));
		foreach($grouplist as $item) {
            if (is_numeric($item)) {
                if ($this->user_level >= $item) return TRUE;
            } elseif (isset($this->permissions[$item])) {
                return TRUE;
            }
		}
		return hook_execute('user.permission', FALSE, $info);	// custom permissions for individual items
	}

	// locate a class_info structure having the requested controller, class and method
	private function _locateViewFolder($myController, $myClass, $myMethod) {
		foreach($this->DATA as $name => $module_info) {
			if (isset($module_info['http']) && count($module_info['http'])) {
				if (isset($module_info['http'][$myController])) {
					$classes = &$module_info['http'][$myController];
					if (isset($classes[$myClass][$myMethod])) {
						$info = [
							'name'   => $name,
							'method' => $myMethod,
						];
						$doc_comment = $classes[$myClass][$myMethod];
						if ($doc_comment != '' && !$this->_hasPermission($doc_comment, $info)) return FALSE;
						return $name;
					}
				}
			}
		}
		return FALSE;
	}

	public function HasPermission($modulename, $groupnames, $myMethod = '') {
		$info = [
			'name'   => $modulename,
			'method' => $myMethod,
		];
		return $this->_hasPermission($groupnames, $info);
	}

	public function getModuleList() {
		return $this->DATA;
	}
	
	public function LoadCurrentView($method_name, $view_structure, $module_name = NULL) {
		if ($module_name === NULL) {
//			var_dump($method_name, $view_structure);
			$myController = $this->getControllerForMethod($method_name);
			
			if ($myController === FALSE) return;		// nothing found
			list($myClass, $myMethod) = explode('::', strtolower($method_name));
			
			$module_name = $this->_locateViewFolder($myController, $myClass, $myMethod);
//			var_dump($module_name, $myController, $myClass, $myMethod);
			
			if ($module_name === FALSE) return;		// no match (should not happen), or no permission
		} else {
			$elements = explode('/', $module_name);
			$module_name = array_pop($elements);
		}

		$dirname = HOME . '/modules/' . $module_name . '/view';
		foreach($view_structure as $view_name => $entrypoints) {
			$view_name = strtolower($view_name);
			$this->loadView($module_name, $view_name);

			foreach($entrypoints as $entrypoint) {
				if (!isset($this->VIEWS[$view_name][$entrypoint])) {
					$this->VIEWS[$view_name]['*']->$entrypoint();       // this attaches the handlebars via IncludeTemplate()
					$this->VIEWS[$view_name][$entrypoint] = 1;
				}
			}
		}
		hook_execute('profile.views', NULL, $this, $view_structure, $dirname);		// override with domain specific stuff (profiles), if enabled
	}
	
	// include the handlebars for the specified view in the output
	public function IncludeView($module_name, $view_name, $entrypoint) {
		$this->loadView($module_name, $view_name);
		$this->VIEWS[$view_name]['*']->$entrypoint();			// add the template to the lsit
		$this->VIEWS[$view_name][$entrypoint] = 1;
	}

	private function _debug_displayEntry($entry) {
		$function = $entry['function'];
		if (!empty($entry['class'])) $function = $entry['class'] . '::' . $function;
		echo $function . " [{$entry['file']}:{$entry['line']}]<br>\n";
	}

	protected function loadView($module_name, $view_name) {
		$dirname = HOME . '/modules/' . $module_name . '/view';
		$view_name = strtolower($view_name);
		if (!isset($this->VIEWS[$view_name])) {
			$filename = $dirname . '/' . $view_name . '.php';
			try {
				require_once $filename;
			} catch (Throwable $e) {			// php 7
			    // dump stack
				$bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
				foreach($bt as $bt_item) {
					echo $this->_debug_displayEntry($bt_item);
				}
				
				die('Error:' . $e->getMessage() . ' in ' . $e->getFile() . ' at line ' . $e->getLine() . ' [8]');
			}
			$this->VIEWS[$view_name]['*'] = new $view_name;
		}
	}

	public function getView($view_name, $dir) {
		// get the modulename from the $dir directory
		$elements = explode('/', $dir);
		$module_name = array_pop($elements);
		if ($module_name == 'controller' ||  $module_name == 'model' || $module_name == 'view' || $module_name == 'static' || $module_name == '') {
			$module_name = array_pop($elements);
		}
		if (!isset($this->VIEWS[$view_name]['*'])) {
			$this->loadView($module_name, $view_name);
		}
		return $this->VIEWS[$view_name]['*'];
	}

	private function _parseTags($items) {
		$result = [];
		preg_match_all('/(\\w+)\s*=\\s*("[^"]*"|\'[^\']*\'|[^"\'\\s>]*)/', $items, $matches, PREG_SET_ORDER);
		foreach ($matches as $match) {
			$val = $match[2]; $c = strlen($val);
			if ( ($val[0] == '"' || $val[0] == "'") && $val[0] == $val[$c - 1]) $val = substr($val, 1, -1);
			$name = strtolower($match[1]);
			$result[$name] = trim(html_entity_decode($val));
		}
		return $result;
	}

	function _addIcon4($class, $param, $full) {
		if ($full) {
			if (isset($param['class'])) $class .= ' ' . $param['class'];
		}
		return "<i class=\"fa $class\"></i>";
	}

	function _addIcon5($class, $param, $full) {
		if ($full) {
			if (isset($param['class'])) $class .= ' ' . $param['class'];
		}
		return "<i class=\"$class\"></i>";
	}

	private function _getCompany() {
		$settings = hook_execute('config.get', NULL, 'config', 0);
		return $settings['companyname'] ?? 'Acme Corporation';
	}
	
	private function _perform_replacement($action, $full, $param) {
		switch(strtolower($action)) {
			case 'yy' : 
				return date('Y');
			case 'coname':
				return $this->_getCompany();
			case 'a':
				$href = isset($param['href']) ? $param['href'] : '';
				if ($full) {
					return "<a href=\"{$href}\">{$href}</a>";
				} else {
					return "<b>{$href}</b>";
				}
			case 'tel':
				$tel = isset($param['digits']) ? $param['digits'] : 'digits';
				$dtel = $this->_addIcon5('fas fa-phone-alt', $param, $full) . ' ' . $tel;
				if ($full) {
					$num = preg_replace('/[^0-9]/', '', $tel);
					return '<a class="tel" href="tel:+1' . $num . '">' . $dtel . '</a>';
				} else {
					return '<b>' . $dtel . '</b>';
				}
			case 'email':
				$href = isset($param['href']) ? $param['href'] : '';
				if ($full) {
					list($name, $domain) = explode('@', $href, 2);
					return "<script>antispam(\"$domain\",\"$name\");</script>";
				} else {
					return '<b>' . $href . '</b>';
				}
			case 'top':
				$action = 'Top ' . $this->_addIcon5('fas fa-chevron-up', $param, $full);
				if ($full) $action = "<a href=\"#top\">$action</a>";
				return $action;
		}
	}
	
	public function __apply_macro($text, $full) {
		$content = trim($text);
		if (($p = strpos($content, ' ')) !== FALSE) {
			$param = $this->_parseTags($content);
			$content = trim(substr($content, 0, $p));
		} else {
			$param = [];
		}
		
		$content = str_replace('/', ' ', $content);
        $k = substr($content, 0, 4);
        if ($k == 'fas ' || $k == 'fab ' || $k == 'far ') {
            return $this->_addIcon5($content, $param, $full);
        } elseif (substr($content, 0, 3) == 'fa.') {
			return $this->_addIcon4(substr($content, 3), $param, $full);
        } elseif (substr($content, 0, 5) == 'icon ') {
			return '{{' . $content . '}}';
		} elseif (hook_exists($content)) {
			return $full ? hook_execute($content, '', $param, $text) : "{\{$content\}}";
		} else {
			return $this->_perform_replacement($content, $full, $param);
		}
		return $content;
	}
	
	public function ExpandMacro($caption, $full = TRUE) {
		$caption = preg_replace_callback('~\{\{(.*?)\}\}~', function($matches) use ($full) {
			$text = $matches[1];
			return $this->__apply_macro($text, $full);
				}, $caption);
		return $caption;
	}
	
	public function EmailTemplates_List() {
		$result = [];
		foreach($this->DATA as $name => $module_info) {
			if (is_array($module_info) && isset($module_info['.info']['email'])) {
				foreach($module_info['.info']['email'] as $internal => $email_entry) {
					$hash = md5($email_entry);			// identifier for /admin/email/xxx link
					$node = [
						'internal' => $internal,		// internal name - used to select template for emailing
						'module' => $name,
						'class_name' => $module_info['.classname'],
						'entrypoint' => $email_entry,
					];
					$result[$hash] = $node;
				}
			}
		}
		return $result;
	}
	
	function AuthenticationModules($all = FALSE) {		// true = all, false = active only
		if ($all) {		// merge in unscanned modules
			$this->Unscanned();
		}
		$output = [];	// collect just authentication modules
		foreach($this->DATA as $name => $module_info) {
			$isAuth = 0;
			if (!empty($module_info['.info']['authentication'])) {
				$isAuth = 1;
				$output[$name] = $module_info;

				// load module, (install hooks)
				if (isset($class_data['info']['class'])) {
					$classname = $class_data['info']['class'];
					if (method_exists($classname, 'getHandle')) {
						$item = $classname::getHandle();
					} else {
						$item = new $classname();
					}
					$this->DATA[$name][$class_name]['main'] = $item;
					$item->Activate();
				} 
			}
			if ($isAuth) {
				$this->LoadModule($name);
			}
		}
		return $output;
	}
	
	public function GenerateProviderList($providers) {
		$output = [];
		foreach($providers as $name => $provider_info) {
			$tag = $provider_info['.info']['authentication'];
			$html = hook_execute("auth.{$tag}.button", '');
			if ($html != '') {
				$output[] = ['link' => '?tag=' . $tag, 'value' => $html];
			}
		}
		return $output;
	}
	
	public function & GetModuleInfo($module_name) {
		list($name, $module_info) = $this->locateModule($module_name);
		return $module_info;
	}
	
	public function locateModule($module) {
		if (isset($this->DATA[$module])) return [$module, $this->DATA[$module]];
		$module = strtolower($module);
		foreach($this->DATA as $key => $info) {
			$lkey = strtolower($key);
			if ($lkey == $module) return [$key, $info];
		}
		return [FALSE, FALSE];
	}

	public function Intercept(&$result, $segments) {
		$module = isset($segments[1]) ? $segments[1] : FALSE;
		if ($module === FALSE) {
			$result = FALSE;
			return;		// no module specified
		}
		// enable module on a user basis, or similar?
		hook_execute('uri.intercept', $module);

		list($modname, $info_item) = $this->locateModule($module);
		if ($info_item === FALSE) {
			$result = FALSE;
			return;		// module not loaded (not enabled)
		}
		$action = isset($segments[2]) ? $segments[2] : '';
		if ($action == '') $action = 'index';
		$action = str_replace('-', '_', $action);
		if ($action == 'new' || $action == 'list') $action = '_' . $action;

		if (isset($info_item['public'][$action])) {
			$result =  [
				'file' => '',
				'name' => '',
				'main' => $info_item['main'],
				'class_name' => $info_item['.info']['class'],
				'doc_comment' => '',
				'method' => $action,
				'params' => array_slice($segments, 3),
			];
			return;
		}
		$result = FALSE;	// not found
	}
	
	public function LoadAndExecuteFunction($functionname) {
		$p = strpos($functionname, '::');
		$class = substr($functionname, 0, $p);		// assume already loaded
		$method = substr($functionname, $p + 2);

		$output = '';
		$reflection = new \ReflectionClass($class);
		try {
			$info = $reflection->getMethod($method);
			if ($info->isStatic()) {
				$class::$method();
			} else {
				call_user_func([$class, $method]);
			}
		} catch(Exception $e) {
			$output .= '        ' . $e->getMessage() . "\n";
		}
		return $output;
	}
	
	// we wish to send some info to the next page
	public function CreateEphemeral($payload, $reusekey = '', $ttl = 0) {
		if (!isset($_SESSION['ephemeral'])) {
			$_SESSION['ephemeral'] = [];
		}
		$time = time();
		if (!$ttl) $ttl = self::TTL;
		$oldest = ''; $oldesttime = FALSE;
		if (count($_SESSION['ephemeral']) > 30) {		// remove expired
			foreach($_SESSION['ephemeral'] as $hash => $info) {
				if ($info['expiry'] <= $time) {
					unset($_SESSION['ephemeral'][$hash]);
				} else {
					if ($oldesttime === FALSE || $info['expiry'] < $oldesttime) {
						$oldest = $hash;
						$oldesttime = $info['expiry'];
					}
				}
			}
		}
		if ($reusekey != '') {
			foreach($_SESSION['ephemeral'] as $hash => &$info) {
				if ($info['key'] == $reusekey) {
					$info['expiry'] = $time + $ttl;
					$info['payload'] = $payload;
					return $hash;
				}
			}
		}
		if (count($_SESSION['ephemeral']) > 30) {		// remove oldest
			unset($_SESSION['ephemeral'][$oldest]);
		}
		$hash = md5($time . ':' . getmypid() . ':' . mt_rand());
		$_SESSION['ephemeral'][$hash] = [
			'expiry' => $time + $ttl,
			'key' => $reusekey,
			'payload' => $payload,
		];
		return $hash;
	}

	public function GetUserSettingsDefinition(&$fields, $fieldname = 'user_settings') {
		foreach($this->DATA as $module_name => $module_info) {
			$active = $GLOBALS['loader']->isModuleActive($module_name);
			if (!empty($module_info['.info'][$fieldname]) && $active) {
				$node = ['name' => $module_info['.info']['name'], 'fieldlist' => $module_info['.info'][$fieldname], 'prefix' => $module_name];
				$fields[] = $node;
			}
		}
	}
    
    public function GetModulesWithUserSettings($fieldname = 'user_settings') {
        $result = [];
		foreach($this->DATA as $module_name => $module_info) {
			if (!empty($module_info['.info'][$fieldname])) {
				$result[] = $module_name;
			}
        }
        return $result;
    }
	
	public function GetEphemeral($hash) {
		if (!isset($_SESSION['ephemeral'][$hash])) return FALSE;
		$node = $_SESSION['ephemeral'][$hash];
		if (time() > $node['expiry']) return FALSE;
		return $node['payload'];
	}
    
	public function UpdateEphemeral($hash, $payload) {
        $time = time() + self::TTL;
		if (!isset($_SESSION['ephemeral'][$hash])) {
            $_SESSION['ephemeral'][$hash] = [
                'expiry' => $time,
                'key' => '',
                'payload' => $payload,
            ];
            return;
        }
		$node = &$_SESSION['ephemeral'][$hash];
        $node['payload'] = $payload;
        $node['expiry'] = $time;
	}
	
	// load all entry points from the modules file
	public function getCorePages() {
		$result = [];
		$modules = $this->DATA;
		foreach($modules as $name => &$module_info) {
			$http = $module_info['http'] ?? [];
			foreach($http as $controller => $classname) {
				foreach($classname as $entrypoints) {
					foreach($entrypoints as $entrypoint => $doc_comment) {
						if ($entrypoint != '.ext' && $entrypoint != '__register') {

							$ok = FALSE;
							$grouplist = explode(' ', trim(preg_replace('/\s+/', ' ', $doc_comment)));
							foreach($grouplist as $item) {
								if (is_numeric($item)) {
									if (!$item) {
										$ok = TRUE;
										break;
									};
								} elseif ($item == 'everyone') {
									$ok = TRUE;
									break;
								}
							}

							if ($ok) {
								if ($entrypoint == 'index') $entrypoint = '';
								$entrypoint = str_replace('_', '-', $entrypoint);
								$result[] = [ 'key' =>  "/$controller/$entrypoint", 'value' => "Core: /$controller/$entrypoint" ];
							}
							
						}
					}
				}
			}
		}
		return $result;
	}

}

global $loader;
$loader = new loader();

if (!function_exists('load_module')) {
	function load_module($module_name) {
		static $modules = [];
		if (!isset($modules[$module_name])) {
			$modules[$module_name] = $GLOBALS['loader']->LoadModule($module_name);
		}
		return $modules[$module_name];
	}

	function hook_add($hookname, $callback, $priority = 50) {
		return $GLOBALS['loader']->hook_add($hookname, $callback, $priority);
	}

	function hook_exists($hookname) {
		return $GLOBALS['loader']->hook_exists($hookname);
	}

	function hook_list($hookname) {
		return $GLOBALS['loader']->hook_list($hookname);
	}
	
	function hook_remove($hookname, $priority) {
		return $GLOBALS['loader']->hook_remove($hookname, $priority);
	}

	function hook_execute($hookname, $initial_value = null /*, additional parameters*/ ) {
		$params = func_get_args();
		$params[1] = &$initial_value;
		return call_user_func_array(array($GLOBALS['loader'], 'hook_execute'), $params);
	}

	// used in themes (so must return string), where result of function is
	// not immediately available.  returns a token which will be
	// replaced by text later, but only for html output.
	// [theme hooks are not added until very late]
	function hook_execute_late($hookname, $initial_value = '' /*, additional parameters*/ ) {
			$output = $GLOBALS['output_trait_info'];
			return $output->hook_execute_late(func_get_args());
	}
	
	function ExpandMacro($text, $full = TRUE) {
		return $GLOBALS['loader']->ExpandMacro($text, $full);
	}

	function normalize_path($basepath, $relativepath, $isFile = TRUE) {
		if ($relativepath == '' || $relativepath[0] == '/') return $relativepath;
		if ($isFile) {
			$x = pathinfo($relativepath);
			$basename = $x['basename'];
			$relativepath = $x['dirname'];
		} else {
			$basename = '';
		}
		$basepath = explode('/', $basepath);
		$relativepath = explode('/', $relativepath);
		foreach($relativepath as $element) {
			if ($element == '..') {
				array_pop($basepath);
			} elseif ($element != '' && $element != '.') {
				$basepath[] = $element;
			}
		}
		$basepath = join('/', $basepath);
		if ($basename != '') $basepath .= '/' . $basename;
		return $basepath;
	}
	
}

spl_autoload_register(function ($classname) {
	$GLOBALS['loader']->autoload($classname);
});
