<?php namespace ProcessWire;

/**
 * ProcessWire Debug 
 *
 * Provides methods useful for debugging or development. 
 *
 * Currently only provides timer capability. 
 * 
 * This file is licensed under the MIT license
 * https://processwire.com/about/license/mit/
 * 
 * ProcessWire 3.x, Copyright 2020 by Ryan Cramer
 * https://processwire.com
 * 
 * ~~~~~
 * $timer = Debug::startTimer();
 * execute_some_code();
 * $elapsed = Debug::stopTimer($timer);
 * ~~~~~
 *
 */

class Debug {

	/**
	 * Current timers
	 * 
	 * @var array
	 * 
	 */
	static protected $timers = array();

	/**
	 * Timers that have been saved
	 * 
	 * @var array
	 * 
	 */
	static protected $savedTimers = array();

	/**
	 * Notes for saved timers
	 * 
	 * @var array
	 * 
	 */
	static protected $savedTimerNotes = array();

	/**
	 * Use hrtime()?
	 * 
	 * @var null|bool
	 * 
	 */
	static protected $useHrtime = null;

	/**
	 * Key of last started timer
	 * 
	 * @var string
	 * 
	 */
	static protected $lastTimerKey = '';

	/**
	 * Timer precision (digits after decimal)
	 * 
	 * @var int
	 * 
	 */
	static protected $timerSettings = array(
		'useMS' => false, // use milliseconds?
		'precision' => 4, 
		'precisionMS' => 1,
		'useHrtime' => null,
		'suffix' => '',
		'suffixMS' => 'ms',
	);

	/**
	 * Measure time between two events
	 *
	 * First call should be to $key = Debug::timer() with no params, or provide your own key that's not already been used
	 * Second call should pass the key given by the first call to get the time elapsed, i.e. $time = Debug::timer($key).
	 * Note that you may make multiple calls back to Debug::timer() with the same key and it will continue returning the 
	 * elapsed time since the original call. If you want to reset or remove the timer, call removeTimer or resetTimer.
	 *
	 * @param string $key 
	 * 	Leave blank to start timer. 
	 *	Specify existing key (string) to return timer. 
	 *	Specify new made up key to start a named timer. 
	 * @param bool $reset If the timer already exists, it will be reset when this is true. 
	 * @return string|int
	 *
	 */
	static public function timer($key = '', $reset = false) {
		
		// returns number of seconds elapsed since first call
		if($reset && $key) self::removeTimer($key);

		if(!$key || !isset(self::$timers[$key])) {
			$value = self::startTimer($key);
		} else {
			$value = self::stopTimer($key, null, false);
		}
		
		return $value; 
	}

	/**
	 * Start a new timer
	 * 
	 * @param string $key Optionally specify name for new timer
	 * @return string
	 * 
	 */
	static public function startTimer($key = '') {
		
		if(self::$timerSettings['useHrtime'] === null) {
			self::$timerSettings['useHrtime'] = function_exists("\\hrtime");
		}

		$startTime = self::$timerSettings['useHrtime'] ? hrtime(true) : -microtime(true);
		
		if($key === '') {
			$key = (string) $startTime;
			while(isset(self::$timers[$key])) $key .= "0";
		}
		
		$key = (string) $key;
		
		self::$timers[$key] = $startTime;
		self::$lastTimerKey = $key;
		
		return $key;
	}

	/**
	 * Get elapsed time for given timer and stop
	 * 
	 * @param string $key Timer key returned by startTimer(), or omit for last started timer
	 * @param null|int|string $option Specify override precision (int), suffix (string), or "ms" for milliseconds and suffix.
	 * @param bool $clear Also clear the timer? (default=true)
	 * @return string
	 * @since 3.0.158
	 * 
	 */
	static public function stopTimer($key = '', $option = null, $clear = true) {
	
		if(empty($key) && self::$lastTimerKey) $key = self::$lastTimerKey;
		if(!isset(self::$timers[$key])) return '';

		$value = self::$timers[$key];
		$useMS = $option === 'ms' || (self::$timerSettings['useMS'] && $option !== 's');
		$suffix = $useMS ? self::$timerSettings['suffixMS'] : self::$timerSettings['suffix'];
		$precision = $useMS ? self::$timerSettings['precisionMS'] : self::$timerSettings['precision'];
		
		if(self::$timerSettings['useHrtime']) {
			// existing hrtime timer
			$value = ((hrtime(true) - $value) / 1e+6) / 1000;
		} else {
			// existing microtime timer
			$value = $value + microtime(true);
		}
		
		if($option === null) {
			// no option specified
		} else if(is_int($option)) {
			// precision override
			$precision = $option;
		} else if(is_string($option)) {
			// suffix specified
			$suffix = $option;
		}
		
		if($useMS) {
			$value = round($value * 1000, $precision);
		} else {
			$value = number_format($value, $precision);
		}
		
		if($clear) self::removeTimer($key);
		if($suffix) $value .= $suffix;
		
		return $value;
	}

	/**
	 * Get or set timer setting
	 * 
	 * ~~~~~~
	 * // Example of changing precision to 2
	 * Debug::timerSetting('precision', 2); 
	 * ~~~~~~
	 * 
	 * @param string $key
	 * @param mixed|null $value
	 * @return mixed
	 * @since 3.0.154
	 * 
	 */
	static public function timerSetting($key, $value = null) {
		if($value !== null) self::$timerSettings[$key] = $value;
		return self::$timerSettings[$key];
	}

	/**
	 * Save the current time of the given timer which can be later retrieved with getSavedTimer($key)
	 * 
	 * Note this also stops/removes the timer. 
	 * 
	 * @param string $key
	 * @param string $note Optional note to include in getSavedTimer
	 * @return bool|string Returns elapsed time, or false if timer didn't exist
	 *
	 */
	static public function saveTimer($key, $note = '') {
		if(!isset(self::$timers[$key])) return false;
		self::$savedTimers[$key] = self::stopTimer($key); 
		if($note) self::$savedTimerNotes[$key] = $note; 
		return self::$savedTimers[$key]; 
	}
	
	/**
	 * Return the time recorded in the saved timer $key
	 * 
	 * @param string $key
	 * @return string Blank if timer not recognized
	 *
	 */
	static public function getSavedTimer($key) {
		$value = isset(self::$savedTimers[$key]) ? self::$savedTimers[$key] : null;	
		if(!is_null($value) && isset(self::$savedTimerNotes[$key])) $value = "$value - " . self::$savedTimerNotes[$key];
		return $value; 
	}

	/**
	 * Return all saved timers in associative array indexed by key
	 *
	 * @return array
	 *
	 */
	static public function getSavedTimers() {
		$timers = self::$savedTimers;
		arsort($timers); 
		foreach($timers as $key => $timer) {
			$timers[$key] = self::getSavedTimer($key); // to include notes
		}
		return $timers; 
	}

	/**
	 * Reset a timer so that it starts timing again from right now
	 * 
	 * @param string $key
	 * @return string|int
	 *
	 */
	static public function resetTimer($key) {
		self::removeTimer($key); 
		return self::timer($key); 
	}

	/**
	 * Remove a timer completely
	 * 
	 * @param string $key
	 *
	 */
	static public function removeTimer($key) {
		unset(self::$timers[$key]); 
	}

	/**
	 * Remove all active timers
	 *
	 */
	static public function removeAll() {
		self::$timers = array();
	}
	
	/**
	 * Get all active timers in array with timer name (key) and start time (value)
	 *
	 * @return array
	 * @since 3.0.158
	 *
	 */
	static public function getAll() {
		return self::$timers;
	}

	/**
	 * Return a backtrace array that is simpler and more PW-specific relative to PHP’s debug_backtrace
	 * 
	 * @param array $options
	 * @return array|string
	 * @since 3.0.136
	 * 
	 */
	static public function backtrace(array $options = array()) {
		
		$defaults = array(
			'limit' => 0, // the limit argument for the debug_backtrace call
			'flags' => DEBUG_BACKTRACE_PROVIDE_OBJECT, // flags for PHP debug_backtrace method
			'showHooks' => false, // show internal methods for hook calls?
			'getString' => false, // get newline separated string rather than array?
			'getCnt' => true, // get index number count (for getString only)
			'getFile' => true,  // get filename? true, false or 'basename'
			'maxCount' => 10, // max size for arrays
			'maxStrlen' => 100, // max length for strings
			'maxDepth' => 5, // max allowed recursion depth when converting variables to strings
			'ellipsis' => ' …', // show this ellipsis when a long value is truncated
			'skipCalls' => array(), // method/function calls to skip
		);
		
		$options = array_merge($defaults, $options);
		if($options['limit']) $options['limit']++;
		$traces = @debug_backtrace($options['flags'], $options['limit']);
		$config = wire('config');
		$rootPath = ProcessWire::getRootPath(true);
		$rootPath2 = $config && $config->paths ? $config->paths->root : $rootPath;
		array_shift($traces); // shift of the simpleBacktrace call, which is not needed
		$apiVars = array();
		$result = array();
		$cnt = 0;
		
		foreach(wire('all') as $name => $value) {
			if(!is_object($value)) continue;
			$apiVars[wireClassName($value)] = '$' . $name;
		}
		
		foreach($traces as $n => $trace) {
			
			if(!is_array($trace) || !isset($trace['function']) || !isset($trace['file'])) {
				continue;
			} else if(count($options['skipCalls']) && in_array($trace['function'], $options['skipCalls'])) {
				continue;
			}

			$obj = null;
			$class = '';
			$type = '';
			$args = $trace['args'];
			$argStr = '';
			$file = $trace['file'];
			$basename = basename($file); 
			$function = $trace['function'];	
			$isHookableCall = false;
			
			if(isset($trace['object'])) {
				$obj = $trace['object'];
				$class = wireClassName($obj);
			} else if(isset($trace['class'])) {
				$class = wireClassName($trace['class']); 
			}
			
			if($class) {
				$type = isset($trace['type']) ? $trace['type'] : '.';
			}

			if(!$options['showHooks']) {
				if($basename === 'Wire.php' && !wireMethodExists('Wire', $function)) continue;
				if($class === 'WireHooks' || $basename === 'WireHooks.php') continue;
			}
			
			if(strpos($function, '___') === 0) {
				$isHookableCall = '___';
			} else if($obj && !method_exists($obj, $function) && method_exists($obj, "___$function")) {
				$isHookableCall = true;
			}
			
			if($type === '->' && isset($apiVars[$class])) {
				// use API var name when available
				if(strtolower($class) === strtolower(ltrim($apiVars[$class], '$'))) {
					$class = $apiVars[$class];
				} else {
					$class = "$class " . $apiVars[$class];
				}
			}
			
			if($basename === 'Wire.php' && $class !== 'Wire') {
				$ref = new \ReflectionClass($trace['class']);
				$file = $ref->getFileName();
			}

			// rootPath and rootPath2 can be different if one of them represented by a symlink
			$file = str_replace($rootPath, '/', $file);
			if($rootPath2 !== $rootPath) $file = str_replace($rootPath2, '/', $file);
			
			if(($function === '__call' || $function == '_callMethod') && count($args)) {
				$function = array_shift($args); 
			}
			
			if(!$options['showHooks'] && $isHookableCall === '___') {
				$function = substr($function, 3);
			}
			
			if(!empty($args)) {
				$newArgs = array();
				if($isHookableCall && count($args) === 1 && is_array($args[0])) {
					$newArgs = $args[0];
				}
				foreach($args as $arg) {
					if(is_object($arg)) {
						$arg = wireClassName($arg) . ' $obj';
					} else if(is_array($arg)) { 
						$count = count($arg); 
						if($count < 4) {
							$arg = $count ? self::toStr($arg, array('maxDepth' => 2)) : '[]';
						} else {
							$arg = 'array(' . count($arg) . ')';
						}
					} else if(is_string($arg)) {
						if(strlen("$arg") > $options['maxStrlen']) $arg = substr($arg, 0, $options['maxStrlen']) . ' …';
						$arg = '"' . $arg . '"';
					} else if(is_bool($arg)) {
						$arg = $arg ? 'true' : 'false';
					} else {
						// leave as-is
					}
					$newArgs[] = $arg;
				}
				$argStr = implode(', ', $newArgs); 
				if($argStr === '[]') $argStr = '';
			}

			if($options['getFile'] === 'basename') $file = basename($file);
			
			$call = "$class$type$function($argStr)";
			$file = "$file:$trace[line]";
			
			if($options['getString']) {
				$str = '';
				if($options['getCnt']) $str .= "$cnt. ";
				$str .= "$file » $call";
				$result[] = $str;
			} else {
				$result[] = array(
					'file' => $file,
					'call' => $call,
				);
			}
			
			$cnt++;
		}
		
		if($options['getString']) $result = implode("\n", $result); 
		
		return $result;
	}

	/**
	 * Convert value to string for backtrace method
	 * 
	 * @param $value
	 * @param array $options
	 * @return null|string
	 * 
	 */
	static protected function toStr($value, array $options = array()) {
		
		$defaults = array(
			'maxCount' => 10, // max size for arrays
			'maxStrlen' => 100, // max length for strings
			'maxDepth' => 5,
			'ellipsis' => ' …'
		);

		static $depth = 0;
		$options = count($options) ? array_merge($defaults, $options) : $defaults; 
		$depth++;
		
		if(is_object($value)) {
			// object
			$str = wireClassName($value);
			if($str === 'HookEvent') {
				$str .= ' $event';
			} else if(method_exists($value, '__toString')) {
				$value = (string) $value;
				if($value !== $str) {
					if(strlen($value) > $options['maxStrlen']) {
						$value = substr($value, 0, $options['maxStrlen']) . $options['ellipsis'];
					}
					$str .= "($value)";
				}
			}
		} else if(is_array($value)) {
			// array
			if(empty($value)) {
				$str = '[]';
			} else if($depth >= $options['maxDepth']) {
				$str = "array(" . count($value) . ")";
			} else {
				$suffix = '';
				if(count($value) > $options['maxCount']) {
					$value = array_slice($value, 0, $options['maxCount']);
					$suffix = $options['ellipsis'];
				}
				foreach($value as $k => $v) {
					$value[$k] = self::toStr($v, $options); 
				}
				$str = '[ ' . implode(', ', $value) . $suffix . ' ]';
			}
		} else if(is_string($value)) {
			// string
			if(strlen($value) > $options['maxStrlen']) {
				$value = substr($value, 0, $options['maxStrlen']) . $options['ellipsis'];
			}
			$hasDQ = strpos($value, '"') !== false;
			$hasSQ = strpos($value, "'") !== false;
			if(($hasDQ && $hasSQ) || $hasSQ) {
				$value = str_replace('"', '\\"', $value);
				$str = '"' . $value . '"';
			} else {
				$str = "'$value'";
			}
		} else if(is_bool($value)) {
			// true or false
			$str = $value ? 'true' : 'false';
		} else {
			// int, float or other
			$str = $value;
		}
		
		$depth--;
		
		return $str;
	}
}
