<?php namespace ProcessWire;

/**
 * ProcessWire Hooks Manager
 * 
 * This class is for internal use. You should manipulate hooks from Wire-derived classes instead. 
 *
 * ProcessWire 3.x, Copyright 2021 by Ryan Cramer
 * https://processwire.com
 *
 */

class WireHooks {

	/**
	 * Debug hooks
	 *
	 */
	const ___debug = false;

	/**
	 * Refers to ALL hooks
	 * 
	 */
	const getHooksAll = 0;
	
	/**
	 * Refers only to LOCAL hooks
	 *
	 */
	const getHooksLocal = 1;
	
	/**
	 * Refers only to STATIC hooks
	 *
	 */
	const getHooksStatic = 2;

	/**
	 * When a hook is specified, there are a few options which can be overridden: This array outlines those options and the defaults.
	 *
	 * - type: may be either 'method' or 'property'. If property, then it will respond to $obj->property rather than $obj->method().
	 * - before: execute the hook before the method call? Not applicable if 'type' is 'property'.
	 * - after: execute the hook after the method call? (allows modification of return value). Not applicable if 'type' is 'property'.
	 * - priority: a number determining the priority of a hook, where lower numbers are executed before higher numbers.
	 * - allInstances: attach the hook to all instances of this object? (store in staticHooks rather than localHooks). Set automatically, but you may still use in some instances.
	 * - fromClass: the name of the class containing the hooked method, if not the object where addHook was executed. Set automatically, but you may still use in some instances.
	 * - argMatch: array of Selectors objects where the indexed argument (n) to the hooked method must match, order to execute hook.
	 * - objMatch: Selectors object that the current object must match in order to execute hook
	 * - public: auto-assigned to true or false by addHook() as to whether the method is public or private/protected.
	 *
	 */
	protected $defaultHookOptions = array(
		'type' => 'method',
		'before' => false,
		'after' => true,
		'priority' => 100,
		'allInstances' => false,
		'fromClass' => '',
		'argMatch' => null,
		'objMatch' => null,
	);

	/**
	 * Static hooks are applicable to all instances of the descending class.
	 *
	 * This array holds references to those static hooks, and is shared among all classes descending from Wire.
	 * It is for internal use only. See also $defaultHookOptions[allInstances].
	 *
	 */
	protected $staticHooks = array(
		// 'SomeClass' => [
		//   'someMethod' => [ hooks ],
		//   'someOtherMethod' => [ hooks ]
		// ],
		// 'AnotherClass' => [
		//   'anotherMethod' => [ hooks ] 
		// ]
	);

	/**
	 * @var array
	 * 
	 */
	protected $pathHooks = array(
		// 'HookID' => [
		//    'match' => '/foo/bar/{baz}/(.+)/', 
		//    'filters' => [ 0 => '/foo/', 2 => '/bar/' ], 
		//   ], ... 
		// ]
	);

	/**
	 * A cache of all hook method/property names for an optimization.
	 *
	 * Hooked methods end with '()' while hooked properties don't.
	 *
	 * This does not distinguish which instance it was added to or whether it was removed.
	 * This cache exists primarily to gain some speed in our __get and __call methods.
	 *
	 */
	protected $hookMethodCache = array(
		// 'method()' => true,
		// 'property' => true, 
	);

	/**
	 * Same as hook method cache but for "Class::method"
	 * 
	 * @var array
	 * 
	 */
	protected $hookClassMethodCache = array(
		// 'Class::method()' => true, 
		// 'Class::property' => true, 
	);

	/**
	 * Cache of all local hooks combined, for debugging purposes
	 *
	 */
	protected $allLocalHooks = array();

	/**
	 * Cached parent classes and interfaces
	 * 
	 * @var array of class|interface => [ 'parentClass', 'parentClass', 'interface', 'interface', 'etc.' ]
	 * 
	 */
	protected $parentClasses = array();
	
	/**
	 * @var Config
	 * 
	 */
	protected $config;

	/**
	 * @var array
	 * 
	 */
	protected $debugTimers = array();

	/**
	 * Characters that can begin a path hook definition (i.e. '/path/' or '!regex!', etc.)
	 * 
	 * @var string
	 * 
	 */
	protected $pathHookStarts = '/!@#%.([^';

	/**
	 * Allow use of path hooks?
	 * 
	 * This should be set to false once reaching the boot stage where it no longer applies. 
	 * 
	 * @var bool
	 * 
	 */
	protected $allowPathHooks = true;

	/**
	 * Populated when a path hook requires a redirect
	 * 
	 * @var string
	 * 
	 */
	protected $pathHookRedirect = '';

	/**
	 * @var ProcessWire
	 * 
	 */
	protected $wire;

	/**
	 * Construct WireHooks
	 * 
	 * @param ProcessWire $wire
	 * @param Config $config
	 * 
	 */
	public function __construct(ProcessWire $wire, Config $config) {
		$this->wire = $wire;
		$this->config = $config;
	}
	
	/**
	 * Return all hooks associated with $object or method (if specified)
	 *
	 * @param Wire $object
	 * @param string $method Optional method that hooks will be limited to. Or specify '*' to return all hooks everywhere.
	 * @param int $getHooks Get hooks of type, specify one of the following constants:
	 * 	- WireHooks::getHooksAll returns all hooks [0] (default)
	 * 	- WireHooks::getHooksLocal returns local hooks [1] only
	 * 	- WireHooks::getHooksStatic returns static hooks [2] only
	 * @return array
	 *
	 */
	public function getHooks(Wire $object, $method = '', $getHooks = self::getHooksAll) {

		$hooks = array();

		// see if we can do a quick exit
		if($method && $method !== '*' && !$this->isHookedOrParents($object, $method)) return $hooks;

		// first determine which local hooks when should include
		if($getHooks !== self::getHooksStatic) {
			$localHooks = $object->getLocalHooks();
			if($method && $method !== '*') {
				// populate all local hooks for given method
				if(isset($localHooks[$method])) $hooks = $localHooks[$method];
			} else {
				// populate all local hooks, regardless of method
				// note: sort of return hooks is no longer priority based
				// @todo account for '*' method, which should return all hooks regardless of instance
				foreach($localHooks as $method => $methodHooks) {
					$hooks = array_merge(array_values($hooks), array_values($methodHooks));
				}
			}
		}

		// if only local hooks requested, we can return them now
		if($getHooks === self::getHooksLocal) return $hooks;

		$needSort = false;
		$namespace = __NAMESPACE__ ? __NAMESPACE__ . "\\" : "";
		$objectParentNamespaces = array();

		// join in static hooks
		foreach($this->staticHooks as $className => $staticHooks) {
			$_className = $namespace . $className;
			if(!$object instanceof $_className && $method !== '*') {
				$_namespace = wireClassName($object, 1) . "\\";
				if($_namespace !== $namespace) {
					// objects in other namespaces
					$_className = $_namespace . $className;
					if(!$object instanceof $_className && $method !== '*') {
						// object likely extends a class not in PW namespace, so check class parents instead
						if(empty($objectParentNamespaces)) {
							foreach(wireClassParents($object) as $nscn => $cn) {
								list($ns,) = explode("\\", $nscn); 
								$objectParentNamespaces[$ns] = $ns;	
							}
						}
						$nsok = false;
						foreach($objectParentNamespaces as $ns) {
							$_className = "$ns\\$className";
							if(!$object instanceof $_className) continue;
							$nsok = true;
							break;
						}
						if(!$nsok) continue;
					}
				} else {
					continue;
				}
			}
			// join in any related static hooks to the local hooks
			if($method && $method !== '*') {
				// retrieve all static hooks for method
				if(!empty($staticHooks[$method])) {
					if(count($hooks)) {
						$collisions = array_intersect_key($hooks, $staticHooks[$method]);
						$hooks = array_merge($hooks, $staticHooks[$method]);
						if(count($collisions)) {
							// identify and resolve priority collisions
							foreach($collisions as $priority => $hook) {
								$n = 0;
								while(isset($hooks["$priority.$n"])) $n++;
								$hooks["$priority.$n"] = $hook;
							}
						}
						$needSort = true;
					} else {
						$hooks = $staticHooks[$method];
					}
				}
			} else {
				// no method specified, retrieve all for class
				// note: priority-based array indexes are no longer in tact
				$hooks = array_values($hooks);
				foreach($staticHooks as $_method => $methodHooks) {
					$hooks = array_merge($hooks, array_values($methodHooks));
				}
			}
		}

		if($needSort && count($hooks) > 1) {
			defined("SORT_NATURAL") ? ksort($hooks, SORT_NATURAL) : uksort($hooks, "strnatcmp");
		}
		
		return $hooks;
	}

	/**
	 * Returns true if the method/property hooked, false if it isn't.
	 *
	 * This is for optimization use. It does not distinguish about class instance.
	 * It only distinguishes about class if you provide a class with the $method argument (i.e. Class::).
	 * As a result, a true return value indicates something "might" be hooked, as opposed to be
	 * being definitely hooked.
	 *
	 * If checking for a hooked method, it should be in the form `Class::method()` or `method()` (with parenthesis).
	 * If checking for a hooked property, it should be in the form `Class::property` or `property`.
	 * 
	 * If you need to check if a method/property is hooked, including any of its parent classes, use
	 * the `WireHooks::isMethodHooked()`, `WireHooks::isPropertyHooked()`, or `WireHooks::hasHook()` methods instead. 
	 *
	 * @param string $method Method or property name in one of the following formats:
	 * 	Class::method()
	 * 	Class::property
	 * 	method()
	 * 	property
	 * @param Wire|null $instance Optional instance to check against (see hasHook method for details)
	 * 	Note that if specifying an $instance, you may not use the Class::method() or Class::property options for $method argument.
	 * @return bool
	 * @see WireHooks::isMethodHooked(), WireHooks::isPropertyHooked(), WireHooks::hasHook()
	 *
	 */
	public function isHooked($method, Wire $instance = null) {
		if($instance) return $this->hasHook($instance, $method);
		if(strpos($method, ':') !== false) {
			$hooked = isset($this->hookClassMethodCache[$method]); // fromClass::method() or fromClass::property
		} else {
			$hooked = isset($this->hookMethodCache[$method]); // method() or property
		}
		return $hooked;
	}

	/**
	 * Similar to isHooked() method but also checks parent classes for the hooked method as well 
	 * 
	 * This method is designed for fast determinations of whether something is hooked
	 * 
	 * @param string|Wire $class
	 * @param string $method Name of method or property
	 * @param string $type May be either 'method', 'property' or 'either'
	 * @return bool
	 * 
	 */
	protected function isHookedOrParents($class, $method, $type = 'either') {
		
		$property = '';
		if(is_object($class)) {
			$className = wireClassName($class);
			$object = $class;
		} else {
			$className = $class;
			$object = null;
		}
		
		if($object) {
			// first check local hooks attached to this instance
			$localHooks = $object->getLocalHooks();
			if(!empty($localHooks[rtrim($method, '()')])) {
				return true;
			}
		}

		if($type == 'method' || $type == 'either') {
			if(strpos($method, '(') === false) $method .= '()';
			if($type == 'either') $property = rtrim($method, '()');
		} else {
			$property = rtrim($method, '()');
		}

		if($type == 'method') {
			if(!isset($this->hookMethodCache[$method])) return false; // not hooked for any class
			$hooked = isset($this->hookClassMethodCache["$className::$method"]);
		} else if($type == 'property') {
			if(!isset($this->hookMethodCache[$property])) return false; // not hooked for any class
			$hooked = isset($this->hookClassMethodCache["$className::$property"]);
		} else {
			if(!isset($this->hookMethodCache[$method]) 
				&& !isset($this->hookMethodCache[$property])) return false;
			$hooked = isset($this->hookClassMethodCache["$className::$property"]) || 
				isset($this->hookClassMethodCache["$className::$method"]);
		}
		
		if(!$hooked) {
			foreach($this->getClassParents($class) as $parentClass) {
				if($type == 'method') {
					if(isset($this->hookClassMethodCache["$parentClass::$method"])) {
						$hooked = true;
						$this->hookClassMethodCache["$class::$method"] = true;
					}
				} else if($type == 'property') {
					if(isset($this->hookClassMethodCache["$parentClass::$property"])) {
						$hooked = true;
						$this->hookClassMethodCache["$class::$property"] = true;
					}
				} else {
					if(isset($this->hookClassMethodCache["$parentClass::$method"])) {
						$hooked = true;
						$this->hookClassMethodCache["$class::$method"] = true;
					}
					if(!$hooked && isset($this->hookClassMethodCache["$parentClass::$property"])) {
						$hooked = true;
						$this->hookClassMethodCache["$class::$property"] = true;
					}
				}
				if($hooked) break;	
			}
		}
		
		return $hooked;
	}

	/**
	 * Similar to isHooked() method but also checks parent classes for the hooked method as well
	 *
	 * This method is designed for fast determinations of whether something is hooked
	 *
	 * @param string|Wire $class
	 * @param string $method Name of method
	 * @return bool
	 *
	 */
	public function isMethodHooked($class, $method) {
		return $this->isHookedOrParents($class, $method, 'method');
	}

	/**
	 * Similar to isHooked() method but also checks parent classes for the hooked property as well
	 *
	 * This method is designed for fast determinations of whether something is hooked
	 *
	 * @param string|Wire $class
	 * @param string $property Name of property
	 * @return bool
	 *
	 */
	public function isPropertyHooked($class, $property) {
		return $this->isHookedOrParents($class, $property, 'property');
	}
	
	/**
	 * Similar to isHooked(), returns true if the method or property hooked, false if it isn't.
	 *
	 * Accomplishes the same thing as the isHooked() method, but this is more accurate,
	 * and potentially slower than isHooked(). Less for optimization use, more for accuracy use.
	 *
	 * It checks for both static hooks and local hooks, but only accepts a method() or property
	 * name as an argument (i.e. no Class::something) since the class context is assumed from the current
	 * instance. Unlike isHooked() it also analyzes the instance's class parents for hooks, making it
	 * more accurate. As a result, this method works well for more than just optimization use.
	 *
	 * If checking for a hooked method, it should be in the form "method()".
	 * If checking for a hooked property, it should be in the form "property".
	 *
	 * @param Wire $object
	 * @param string $method Method() or property name
	 * @return bool
	 * @throws WireException whe you try to call it with a Class::something() type method.
	 * @todo differentiate between "method()" and "property"
	 *
	 */
	public function hasHook(Wire $object, $method) {

		$hooked = false;
		if(strpos($method, '::') !== false) {
			throw new WireException("You may only specify a 'method()' or 'property', not 'Class::something'.");
		}

		// quick exit when possible
		if(!isset($this->hookMethodCache[$method])) return false;

		$_method = rtrim($method, '()');
		$localHooks = $object->getLocalHooks();

		if(!empty($localHooks[$_method])) {
			// first check local hooks attached to this instance
			$hooked = true;
		} else if(!empty($this->staticHooks[$object->className()][$_method])) {
			// now check if hooked in this class
			$hooked = true;
		} else {
			// check parent classes and interfaces
			foreach($this->getClassParents($object) as $class) {
				if(!empty($this->staticHooks[$class][$_method])) {
					$hooked = true;
					$this->hookClassMethodCache["$class::$method"] = true;
					break;
				}
			}
		}

		return $hooked;
	}

	/**
	 * Get an array of parent classes and interfaces for the given object
	 * 
	 * @param Wire|string $object Maybe either object instance or class name
	 * @param bool $cache Allow use of cache for getting or storing? (default=true)
	 * @return array
	 * 
	 */
	public function getClassParents($object, $cache = true) {
		if(is_string($object)) {
			$className = $object;
		} else {
			$className = $object->className();
		}
		if($cache && isset($this->parentClasses[$className])) {
			$classes = $this->parentClasses[$className];
		} else {
			$classes = wireClassParents($object, false);
			$interfaces = wireClassImplements($object);
			if(is_array($interfaces)) $classes = array_merge($interfaces, $classes);
			if($cache) $this->parentClasses[$className] = $classes;
		}
		return $classes;
	}


	/**
	 * Hook a function/method to a hookable method call in this object
	 *
	 * Hookable method calls are methods preceded by three underscores.
	 * You may also specify a method that doesn't exist already in the class
	 * The hook method that you define may be part of a class or a globally scoped function.
	 *
	 * If you are hooking a procedural function, you may omit the $toObject and instead just call via:
	 * $this->addHook($method, 'function_name'); or $this->addHook($method, 'function_name', $options);
	 *
	 * @param Wire $object
	 * @param string|array $method Method name to hook into, NOT including the three preceding underscores.
	 * 	May also be Class::Method for same result as using the fromClass option.
	 *  May also be array OR CSV string of either of the above to add multiple (since 3.0.137). 
	 * @param object|null|callable $toObject Object to call $toMethod from,
	 * 	Or null if $toMethod is a function outside of an object,
	 * 	Or function|callable if $toObject is not applicable or function is provided as a closure.
	 * @param string|array $toMethod Method from $toObject, or function name to call on a hook event, or $options array. 
	 * @param array $options See $defaultHookOptions at the beginning of this class. Optional.
	 * @return string A special Hook ID that should be retained if you need to remove the hook later.
	 *  If the $method argument was a CSV string or array of multiple methods to hook, then CSV string of hook IDs 
	 *  will be returned, and the same CSV string can be used with removeHook() calls. (since 3.0.137). 
	 * @throws WireException
	 *
	 */
	public function addHook(Wire $object, $method, $toObject, $toMethod = null, $options = array()) {
		
		if(empty($options['noAddHooks']) && (is_array($method) || strpos($method, ',') !== false)) {
			// potentially multiple methods to hook in $method argument
			return $this->addHooks($object, $method, $toObject, $toMethod, $options);
		}
		
		if(is_array($toMethod)) {
			// $options array specified as 3rd argument
			if(count($options)) {
				// combine $options from addHookBefore/After and user specified options
				$options = array_merge($toMethod, $options);
			} else {
				$options = $toMethod;
			}
			$toMethod = null;
		}

		if($toMethod === null) {
			// $toObject has been omitted and a procedural function specified instead
			// $toObject may also be a closure
			$toMethod = $toObject;
			$toObject = null;
		}
		
		if($toMethod === null) {
			throw new WireException("Method to call is required and was not specified (toMethod)");
		}
		
		if(strpos($method, '___') === 0) {
			$method = substr($method, 3);
		} else if(strpos($this->pathHookStarts, $method[0]) !== false) {
			return $this->addPathHook($object, $method, $toObject, $toMethod, $options);
		}
		
		if(method_exists($object, $method)) {
			throw new WireException("Method " . $object->className() . "::$method is not hookable");
		}
		
		$options = array_merge($this->defaultHookOptions, $options);
	
		// determine whether the hook handling method is public or private/protected
		$toPublic = true; 
		if($toObject) {
			if(method_exists($toObject, $toMethod)) $_toMethod = $toMethod;
				else if(method_exists($toObject, "___$toMethod")) $_toMethod = "___$toMethod";
				else $_toMethod = null;
			if($_toMethod) {
				try {
					$ref = new \ReflectionMethod($toObject, $_toMethod);
					$toPublic = $ref->isPublic();
				} catch(\Exception $e) {
					$toPublic = false;
				}
			}
			unset($ref);
		}
		
		if(strpos($method, '::')) {
			list($fromClass, $method) = explode('::', $method, 2);
			if(strpos($fromClass, '(') !== false) {
				// extract object selector match string
				list($fromClass, $objMatch) = explode('(', $fromClass, 2);
				$objMatch = trim($objMatch, ') ');
				if(Selectors::stringHasSelector($objMatch)) {
					$selectors = $this->wire->wire(new Selectors());
					$selectors->init($objMatch);
					$objMatch = $selectors;
				}
				if($objMatch) $options['objMatch'] = $objMatch;
			}
			$options['fromClass'] = $fromClass;
		}

		$argOpen = strpos($method, '(');
		if($argOpen) { 
			// arguments to match may be specified in method name
			$argClose = strpos($method, ')'); 
			if($argClose === $argOpen+1) {
				// method just has a "()" which can be discarded
				$method = rtrim($method, '() ');
			} else if($argClose > $argOpen+1) {
				// extract argument selector match string(s), arg 0: Something::something(selector_string)
				// or: Something::something(1:selector_string, 3:selector_string) matches arg 1 and 3. 
				list($method, $argMatch) = explode('(', $method, 2);
				$argMatch = trim($argMatch, ') ');
				if(strpos($argMatch, ':') !== false) {
					// zero-based argument indexes specified, i.e. 0:template=product, 1:order_status
					$args = preg_split('/\b([0-9]):/', trim($argMatch), -1, PREG_SPLIT_DELIM_CAPTURE);
					if(count($args)) {
						$argMatch = array();
						array_shift($args); // blank
						while(count($args)) {
							$argKey = (int) trim(array_shift($args));
							$argVal = trim(array_shift($args), ', ');
							$argMatch[$argKey] = $argVal;
						}
					}
				} else {
					// just single argument specified, so argument 0 is assumed
				}
				if(is_string($argMatch)) $argMatch = array(0 => $argMatch);
				foreach($argMatch as $argKey => $argVal) {
					if(Selectors::stringHasSelector($argVal)) {
						$selectors = $this->wire->wire(new Selectors());
						$selectors->init($argVal);
						$argMatch[$argKey] = $selectors;
					}
				}
				if(count($argMatch)) $options['argMatch'] = $argMatch;
			}
		}
		
		$localHooks = $object->getLocalHooks();
		
		if($options['allInstances'] || $options['fromClass']) {
			// hook all instances of this class
			$hookClass = $options['fromClass'] ? $options['fromClass'] : $object->className();
			if(!isset($this->staticHooks[$hookClass])) $this->staticHooks[$hookClass] = array();
			$hooks =& $this->staticHooks[$hookClass];
			$options['allInstances'] = true;
			$local = 0;

		} else {
			// hook only this instance
			$hookClass = '';
			$hooks =& $localHooks;
			$local = 1;
		}

		$priority = (string) $options['priority'];
		
		if(!isset($hooks[$method])) {
			if(ctype_digit($priority)) $priority = "$priority.0";
			
		} else {
			if(strpos($priority, '.')) {
				// priority already specifies a sub value: extract it
				list($priority, $n) = explode('.', $priority);
				$options['priority'] = $priority; // without $n
				$priority .= ".$n";
			} else {
				$n = 0;
				$priority .= ".0";
			}
			// come up with a priority that is unique for this class/method across both local and static hooks
			while(($hookClass && isset($this->staticHooks[$hookClass][$method][$priority]))
				|| isset($localHooks[$method][$priority])) {
				$n++;
				$priority = "$options[priority].$n";
			}
		}
	
		// Note hookClass is always blank when this is a local hook
		$id = "$hookClass:$priority:$method";
		$options['priority'] = $priority;
		
		$hook = array(
			'id' => $id,
			'method' => $method,
			'toObject' => $toObject,
			'toMethod' => $toMethod,
			'toPublic' => $toPublic, 
			'options' => $options,
		);
		$hooks[$method][$priority] = $hook;

		// cache record known hooks so they can be detected quickly
		$cacheValue = $options['type'] == 'method' ? "$method()" : "$method";
		if($options['fromClass']) $this->hookClassMethodCache["$options[fromClass]::$cacheValue"] = true;
		$this->hookMethodCache[$cacheValue] = true;
		if($options['type'] === 'either') {
			$cacheValue = "$cacheValue()";
			$this->hookMethodCache[$cacheValue] = true;
			if($options['fromClass']) $this->hookClassMethodCache["$options[fromClass]::$cacheValue"] = true;
		}

		// keep track of all local hooks combined when debug mode is on
		if($local && $this->config->debug) {
			$debugClass = $object->className();
			$debugID = ($local ? $debugClass : '') . $id;
			while(isset($this->allLocalHooks[$debugID])) $debugID .= "_";
			$debugHook = $hooks[$method][$priority];
			$debugHook['method'] = $debugClass . "->" . $debugHook['method'];
			$this->allLocalHooks[$debugID] = $debugHook;
		}

		// sort by priority, if more than one hook for the method
		if(count($hooks[$method]) > 1) {
			if(defined("SORT_NATURAL")) {
				ksort($hooks[$method], SORT_NATURAL);
			} else {
				uksort($hooks[$method], "strnatcmp");
			}
		}
		
		if($local) {
			$object->setLocalHooks($hooks);
		}

		return $id;
	}

	/**
	 * Add a hooks to multiple methods at once
	 *
	 * This is the same as addHook() except that the $method argument is an array or CSV string of hook definitions.
	 * See the addHook() method for more detailed info on arguments.
	 *
	 * @param Wire $object
	 * @param array|string $methods Array of one or more strings hook definitions, or CSV string of hook definitions
	 * @param object|null|callable $toObject
	 * @param string|array|null $toMethod
	 * @param array $options
	 * @return string CSV string of hook IDs that were added
	 * @throws WireException
	 * @since 3.0.137
	 *
	 */
	protected function addHooks(Wire $object, $methods, $toObject, $toMethod = null, $options = array()) {
		
		if(!is_array($methods)) {
			// potentially multiple methods defined in a CSV string
			// could also be a single method with CSV arguments
			
			$str = (string) $methods;
			$argSplit = '|';

			// skip optional useless parenthesis in definition to avoid unnecessary iterations
			if(strpos($str, '()') !== false) $str = str_replace('()', '', $str); 
			
			if(strpos($str, '(') === false) {
				// If there is a parenthesis then it is multi-method definition without arguments
				// Example: "Pages::saveReady, Pages::saved" 
				$methods = explode(',', $str);
				
			} else {
				// Single or multi-method definitions, at least one with arguments
				// Isolate commas that are for arguments versus comments that separate multiple hook methods: 
				// Single method example: "Page(template=order)::changed(0:order_status, 1:name=pending)"
				// Multi method example: "Page(template=order)::changed(0:order_status, 1:name=pending), Page::saved"
				
				while(strpos($str, $argSplit) !== false) $argSplit .= '|';
				$strs = explode('(', $str);
				
				foreach($strs as $key => $val) {
					if(strpos($val, ')') === false) continue;
					list($a, $b) = explode(')', $val, 2);
					if(strpos($a, ',') !== false) $a = str_replace(array(', ', ','), $argSplit, $a);
					$strs[$key] = "$a)$b";
				}
				
				$str = implode('(', $strs);
				$methods = explode(',', $str);
				
				foreach($methods as $key => $method) {
					if(strpos($method, $argSplit) === false) continue;
					$methods[$key] = str_replace($argSplit, ', ', $method);
				}
			}
		}
		
		$result = array();
		$options['noAddHooks'] = true; // prevent addHook() from calling addHooks() again
		
		foreach($methods as $method) {
			$method = trim($method);
			$hookID = $this->addHook($object, $method, $toObject, $toMethod, $options);
			$result[] = $hookID;
		}
	
		$result = implode(',', $result);
		
		return $result;
	}

	/**
	 * Add a hook that handles a request path
	 * 
	 * @param Wire $object
	 * @param string $path
	 * @param Wire|null|callable $toObject
	 * @param string $toMethod
	 * @param array $options
	 * @return string
	 * @throws WireException
	 * 
	 */
	protected function addPathHook(Wire $object, $path, $toObject, $toMethod, $options = array()) {
		
		if(!$this->allowPathHooks) {
			throw new WireException('Path hooks must be attached during init or ready states');
		}
		
		$method = 'ProcessPageView::pathHooks';
		$id = $this->addHook($object, $method, $toObject, $toMethod, $options); 
		$filters = array();
		$path = trim($path);
		$pathParts = explode('/', trim($path, '/'));
		$key = null;
		
		foreach($pathParts as $index => $filter) {

			// see if it is alphanumeric, other than dash or underscore
			if(!ctype_alnum($filter) && !ctype_alnum(str_replace(array('-', '_'), '', $filter))) {
				// likely a regex pattern or named argument, see if we can use some from beginning
				$filterNew = '';
				for($n = 0; $n < strlen($filter); $n++) {
					$test = substr($filter, 0, $n+1);
					if(!ctype_alnum($test)) break;
					$filterNew = $test;
				}
				if(!strlen($filterNew)) continue;
				$filter = $filterNew;
			}
			
			// test the filter to see which one will match
			$pos = false;
			foreach(array("/$filter/", "/$filter", "$filter/") as $test) {
				$pos = strpos($path, $test); 
				if($pos === false) continue;
				$filter = $test;
				break;
			}
	
			// ensure array index 0 only ever refers to match at beginning
			$key = $pos === 0 && $index === 0 ? 0 : $index + 1;
			$filters[$key] = $filter;
		}
	
		// trailing slash on last filter is optional
		if($key !== null) $filters[$key] = rtrim($filters[$key], '/');
		
		$this->pathHooks[$id] = array(
			'match' => $path,
			'filters' => $filters, 
		);
		
		return $id; 
	}

	/**
	 * Provides the implementation for calling hooks in ProcessWire
	 *
	 * Unlike __call, this method won't trigger an Exception if the hook and method don't exist.
	 * Instead it returns a result array containing information about the call.
	 *
	 * @param Wire $object
	 * @param string $method Method or property to run hooks for.
	 * @param array $arguments Arguments passed to the method and hook.
	 * @param string|array $type May be any one of the following: 
	 *  - method: for hooked methods (default)
	 *  - property: for hooked properties
	 *  - before: only run before hooks and do nothing else
	 *  - after: only run after hooks and do nothing else
	 *  - Or array[] of hooks (from getHooks method) to run (does not call hooked method)
	 * @return array Returns an array with the following information:
	 * 	[return] => The value returned from the hook or NULL if no value returned or hook didn't exist.
	 *	[numHooksRun] => The number of hooks that were actually run.
	 *	[methodExists] => Did the hook method exist as a real method in the class? (i.e. with 3 underscores ___method).
	 *	[replace] => Set by the hook at runtime if it wants to prevent execution of the original hooked method.
	 *
	 */
	public function runHooks(Wire $object, $method, $arguments, $type = 'method') {

		$hookTimer = self::___debug ? $this->hookTimer($object, $method, $arguments) : null;
		$realMethod = "___$method";
		$cancelHooks = false;
		$profiler = $this->wire->wire('profiler');
		$hooks = null;
		$methodExists = false;
		$useHookReturnValue = false; // allow use of "return $value;" in hook in addition to $event->return ?
		
		if($type === 'method') {
			$methodExists = method_exists($object, $realMethod); 
			if(!$methodExists && method_exists($object, $method)) {
				// non-hookable method exists, indicating we may be in a manually called runHooks()
				$methodExists = true;
				$realMethod = $method;
			}
		}
		
		if(is_array($type)) {
			// array of hooks to run provided in $type argument
			$hooks = $type;
			$type = 'custom';
		}

		$result = array(
			'return' => null,
			'numHooksRun' => 0,
			'methodExists' => $methodExists,
			'replace' => false,
		);
		
		if($type === 'method' || $type === 'property' || $type === 'either') {
			if(!$methodExists && !$this->isHookedOrParents($object, $method, $type)) {
				return $result; // exit quickly when we can
			}
		}
		
		if($hooks === null) $hooks = $this->getHooks($object, $method);
	
		foreach(array('before', 'after') as $when) {

			if($type === 'method') {
				if($when === 'after' && $result['replace'] !== true) {
					if($methodExists) {
						$result['return'] = $object->_callMethod($realMethod, $arguments);
					} else {
						$result['return'] = null;
					}
				}
			} else if($type === 'after') {
				if($when === 'before') continue;
			} else if($type === 'before') {
				if($when === 'after') break;
			}

			foreach($hooks as $priority => $hook) {

				if(!$hook['options'][$when]) continue;
				if($type === 'property' && $hook['options']['type'] === 'method') continue;
				if($type === 'method' && $hook['options']['type'] === 'property') continue;

				if(!empty($hook['options']['objMatch'])) {
					/** @var Selectors $objMatch */
					$objMatch = $hook['options']['objMatch'];
					// object match comparison to determine at runtime whether to execute the hook
					if(is_object($objMatch)) {
						if(!$objMatch->matches($object)) continue;
					} else {
						if(((string) $object) != $objMatch) continue;
					}
				}

				if($type == 'method' && !empty($hook['options']['argMatch'])) {
					// argument comparison to determine at runtime whether to execute the hook
					$argMatches = $hook['options']['argMatch'];
					$matches = true;
					foreach($argMatches as $argKey => $argMatch) {
						/** @var Selectors $argMatch */
						$argVal = isset($arguments[$argKey]) ? $arguments[$argKey] : null;
						if(is_object($argMatch)) {
							// Selectors object
							if(is_object($argVal)) {
								$matches = $argMatch->matches($argVal);
							} else {
								// we don't work with non-object here
								$matches = false;
							}
						} else {
							if(is_array($argVal)) {
								// match any array element
								$matches = in_array($argMatch, $argVal);
							} else {
								// exact string match
								$matches = $argMatch == $argVal;
							}
						}
						if(!$matches) break;
					}
					if(!$matches) continue; // don't run hook
				}
				
				if($this->allowPathHooks && isset($this->pathHooks[$hook['id']])) {
					$allowRunPathHook = $this->allowRunPathHook($hook['id'], $arguments);
					$this->removeHook($object, $hook['id']); // once only
					if(!$allowRunPathHook) continue;
					$useHookReturnValue = true;
				}

				$event = new HookEvent(array(
					'object' => $object,
					'method' => $method,
					'arguments' => $arguments,
					'when' => $when,
					'return' => $result['return'],
					'id' => $hook['id'],
					'options' => $hook['options']
				));
				$this->wire->wire($event);

				$toObject = $hook['toObject'];
				$toMethod = $hook['toMethod'];
			
				if($profiler) {
					$profilerEvent = $profiler->start($hook['id'], $this, array(
						'event' => $event, 
						'hook' => $hook,
					));
				} else {
					$profilerEvent = false;
				}

				if(is_null($toObject)) {
					$toMethodCallable = is_callable($toMethod);
					if(!$toMethodCallable && strpos($toMethod, "\\") === false && __NAMESPACE__) {
						$_toMethod = $toMethod;
						$toMethod = "\\" . __NAMESPACE__ . "\\$toMethod";
						$toMethodCallable = is_callable($toMethod);
						if(!$toMethodCallable) {
							$toMethod = "\\$_toMethod";
							$toMethodCallable = is_callable($toMethod);
						}
					}
					if($toMethodCallable) {
						$returnValue = $toMethod($event);
					} else {
						// hook fail, not callable
						$returnValue = null;
					}
				} else {
					/** @var Wire $toObject */
					if($hook['toPublic']) {
						// public
						$returnValue = $toObject->$toMethod($event);
					} else {
						// protected or private
						$returnValue = $toObject->_callMethod($toMethod, array($event));
					}
					$toMethodCallable = true; 
				}

				if($returnValue !== null) {
					// hook method/func had an explicit 'return $value;' statement 
					// we can optionally use this rather than $event->return. Can be useful
					// in cases where a return value doesn’t need to be passed around to
					// more than one hook
					if($useHookReturnValue) {
						$event->return = $returnValue;
					}
				}
				
				if($profilerEvent) $profiler->stop($profilerEvent);
				
				if(!$toMethodCallable) continue;

				$result['numHooksRun']++;
				
				if($event->cancelHooks === true) $cancelHooks = true;

				if($when == 'before') {
					$arguments = $event->arguments;
					$result['replace'] = $event->replace === true || $result['replace'] === true;
					if($result['replace']) $result['return'] = $event->return;
				}

				if($when == 'after') $result['return'] = $event->return;
				if($cancelHooks) break;
			}
			if($cancelHooks) break;
		}
		
		if($hookTimer) Debug::saveTimer($hookTimer);

		return $result;
	}

	/**
	 * Allow given path hook to run?
	 * 
	 * This checks if the hook’s path matches the request path, allowing for both 
	 * regular and regex matches and populating parenthesized portions to arguments
	 * that will appear in the HookEvent.
	 * 
	 * @param string $id Hook ID
	 * @param array $arguments
	 * @return bool
	 * @since 3.0.173
	 * 
	 */
	protected function allowRunPathHook($id, array &$arguments) {
		
		$pathHook = $this->pathHooks[$id];
		$requestPath = $arguments[0];
		$filterFail = false;
		
		// first pre-filter the requestPath against any words matchPath (filters)
		foreach($pathHook['filters'] as $key => $filter) {
			$pos = strpos($requestPath, $filter); 
			if($pos === false || ($key === 0 && $pos !== 0)) $filterFail = true;
			if($filterFail) break;
		}
		
		if($filterFail) return false;
		
		// at this point the path hook passed pre-filters and might match
		
		$pageNum = $this->wire->wire()->input->pageNum();
		$slashed = substr($requestPath, -1) === '/' && strlen($requestPath) > 1;
		$matchPath = $pathHook['match'];
		$regexDelim = ''; // populated only for user-specified regex
		$pageNumArgument = 0; // populate in $arguments when {pageNum} present in match pattern
	
		if(strpos('!@#%', $matchPath[0]) !== false) {
			// already in delimited regex format
			$regexDelim = $matchPath[0];
		} else {
			// needs to be in regex format
			if(strpos($matchPath, '/') === 0) $matchPath = "^$matchPath";
			$matchPath = "#$matchPath$#";
		}

		if(strpos($matchPath, '{pageNum}') !== false) {
			// the {pageNum} named argument maps to $input->pageNum. remove the {pageNum} argument
			// from the match path since it is handled differently from other named arguments
			$find = array('/{pageNum}/', '/{pageNum}', '{pageNum}');
			$matchPath = str_replace($find, '/', $matchPath);
			$pathHook['match'] = str_replace($find, '/', $pathHook['match']); 
			$pageNumArgument = $pageNum;
		} else if($pageNum > 1) {
			// hook does not handle pagination numbers above 1
			return false;
		}

		if(strpos($matchPath, ':') && strpos($matchPath, '(') !== false) {
			// named arguments in format “(name: value)” converted to named PCRE capture groups
			$matchPath = preg_replace('#\(([-_a-z0-9]+):#i', '(?P<$1>', $matchPath);
		}
		
		if(strpos($matchPath, '{') !== false) {
			// named arguments in format “{name}” converted to named PCRE capture groups
			// note that the match pattern of any URL segment is assumed for this case
			$matchPath = preg_replace('#\{([_a-z][-_a-z0-9]*)\}#i', '(?P<$1>[^/]+)', $matchPath); 
		}

		if(!preg_match($matchPath, $requestPath, $matches)) {
			// if match fails, try again with trailing slash state reversed
			if($slashed) {
				$requestPath2 = rtrim($requestPath, '/');
			} else {
				$requestPath2 = "$requestPath/";
			}
			if(!preg_match($matchPath, $requestPath2, $matches)) return false;
		}
		
		// check on trailing slash
		if(strpos($matchPath, '/?') === false) {
			// either slash or no-slash is required, depending on whether match pattern ends with one
			$slashRequired = substr(rtrim($pathHook['match'], $regexDelim . '$)+'), -1) === '/';
			$this->pathHookRedirect = '';
			if($slashRequired && !$slashed) {
				// trailing slash required and not present
				$this->pathHookRedirect = $requestPath . '/';
				return false;
			} else if(!$slashRequired && $slashed) {
				// lack of trailing slash required and one is present
				$this->pathHookRedirect = rtrim($requestPath, '/');
				return false;
			}
		}
		
		// success: at this point the requestPath has matched
		$arguments['path'] = $arguments[0];
		if($pageNumArgument) $arguments['pageNum'] = $pageNumArgument;

		foreach($matches as $key => $value) {
			// populate requested arguments
			if($key !== 0) $arguments[$key] = $value;
		}
		
		return true;
	}

	/**
	 * Filter and return hooks matching given property and value
	 * 
	 * @param array $hooks Hooks from getHooks() method
	 * @param string $property Property name from hook (or hook options)
	 * @param string|bool|int $value Value to match
	 * @return array
	 * 
	 */
	public function filterHooks(array $hooks, $property, $value) {
		foreach($hooks as $key => $hook) {
			if(array_key_exists($property, $hook)) {
				if($hook[$property] !== $value) unset($hooks[$key]); 
			} else if(array_key_exists($property, $hook['options'])) {
				if($hook['options'][$property] !== $value) unset($hooks[$key]); 
			}
		}
		return $hooks;	
	}

	/**
	 * Start timing a hook and return the timer name
	 * 
	 * @param Wire $object
	 * @param String $method
	 * @param array $arguments
	 * @return string
	 * 
	 */
	protected function hookTimer($object, $method, $arguments) {
		$timerName = $object->className() . "::$method";
		$notes = array();
		foreach($arguments as $argument) {
			if(is_object($argument)) $notes[] = get_class($argument);
			else if(is_array($argument)) $notes[] = "array(" . count($argument) . ")";
			else if(strlen($argument) > 20) $notes[] = substr($argument, 0, 20) . '...';
		}
		$timerName .= "(" . implode(', ', $notes) . ")";
		if(isset($this->debugTimers[$timerName])) {
			$this->debugTimers[$timerName]++;
			$timerName .= " #" . $this->debugTimers[$timerName];
		} else {
			$this->debugTimers[$timerName] = 1;
		}
		Debug::timer($timerName);
		return $timerName;
	}

	/**
	 * Given a Hook ID provided by addHook() this removes the hook
	 *
	 * To have a hook function remove itself within the hook function, say this is your hook function:
	 * function(HookEvent $event) {
	 *   $event->removeHook(null); // remove self
	 * }
	 *
	 * @param Wire $object
	 * @param string|array|null $hookID Can be single hook ID, array of hook IDs, or CSV string of hook IDs
	 * @return Wire
	 *
	 */
	public function removeHook(Wire $object, $hookID) {
		if(is_array($hookID) || strpos($hookID, ',')) {
			return $this->removeHooks($object, $hookID);
		}
		if(!empty($hookID) && substr_count($hookID, ':') === 2) {
			// local hook ID ":100.0:methodName" or static hook ID "ClassName:100.0:methodName"
			list($hookClass, $priority, $method) = explode(':', $hookID);
			if(empty($hookClass)) {
				// local hook
				$localHooks = $object->getLocalHooks();
				unset($localHooks[$method][$priority]);
				$object->setLocalHooks($localHooks);
			} else {
				// static hook
				unset($this->staticHooks[$hookClass][$method][$priority], $this->pathHooks[$hookID]);
				if(empty($this->staticHooks[$hookClass][$method])) {
					unset($this->hookClassMethodCache["$hookClass::$method"]);
				}
			}
		}
		return $object;
	}

	/**
	 * Given a hook ID or multiple hook IDs (in array or CSV string) remove the hooks
	 * 
	 * @param Wire $object
	 * @param array|string $hookIDs
	 * @return Wire
	 * @since 3.0.137
	 * 
	 */
	protected function removeHooks(Wire $object, $hookIDs) {
		if(!is_array($hookIDs)) $hookIDs = explode(',', $hookIDs); 
		foreach($hookIDs as $hookID) {
			$this->removeHook($object, $hookID);
		}
		return $object;
	}

	/**
	 * Return the "all local hooks" cache
	 * 
	 * @return array
	 * 
	 */
	public function getAllLocalHooks() {
		return $this->allLocalHooks;
	}

	/**
	 * Return all pending path hooks
	 *
	 * @return array
	 * @since 3.0.173
	 *
	 */
	public function getAllPathHooks() {
		return $this->pathHooks;
	}

	/**
	 * Return whether or not any path hooks are pending
	 *
	 * @param string $requestPath Optionally provide request path to determine if any might match (3.0.174+)
	 * @return bool
	 * @since 3.0.173
	 *
	 */
	public function hasPathHooks($requestPath = '') {
		// first pre-filter the requestPath against any words matchPath (filters)
		if(strlen($requestPath)) return $this->filterPathHooks($requestPath, true);
		return count($this->pathHooks) > 0;
	}

	/**
	 * Return path hooks that have potential to match given request path
	 * 
	 * @param string $requestPath
	 * @param bool $has Specify true to change return value to boolean as to whether any can match (default=false)
	 * @return array|bool
	 * @since 3.0.174
	 * 
	 */
	public function filterPathHooks($requestPath, $has = false) {
		$pathHooks = array();
		foreach($this->pathHooks as $id => $pathHook) {
			$fail = false;
			foreach($pathHook['filters'] as $filter) {
				$fail = strpos($requestPath, $filter) === false;
				if($fail) break;
			}
			if(!$fail) {
				$pathHooks[$id] = $pathHook;
				if($has) break;
			}
		}
		return $has ? count($pathHooks) > 0 : $pathHooks;
	}

	/**
	 * Get or set whether path hooks are allowed
	 * 
	 * @param bool|null $allow
	 * @return bool
	 * @since 3.0.173
	 * 
	 */
	public function allowPathHooks($allow = null) {
		if($allow !== null) $this->allowPathHooks = (bool) $allow;
		return $this->allowPathHooks;
	}

	/**
	 * Return redirect URL required by an applicable path hook, or blank otherwise
	 * 
	 * @return string
	 * @since 3.0.173
	 * 
	 */
	public function getPathHookRedirect() {
		return $this->pathHookRedirect;
	}

	/**
	 * @return string
	 * 
	 */
	public function className() {
		return wireClassName($this, false);
	}
	
	public function __toString() {
		return $this->className();
	}

}