<?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 (string) $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; 
	}

	/**
	 * Remove a previously saved timer
	 * 
	 * @param string $key
	 * @since 3.0.202
	 * 
	 */
	static public function removeSavedTimer($key) {
		unset(self::$savedTimers[$key]); 
		unset(self::$savedTimerNotes[$key]); 
	}

	/**
	 * Remove all saved timers
	 * 
	 * @since 3.0.202
	 * 
	 */
	static public function removeSavedTimers() {
		self::$savedTimers = array();
		self::$savedTimerNotes = array();
	}

	/**
	 * 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
	 * - `limit` (int): The limit for the backtrace or 0 for no limit. (default=0)
	 * - `flags` (int): Flags as used by PHP’s debug_backtrace() function. (default=DEBUG_BACKTRACE_PROVIDE_OBJECT)
	 * - `showHooks` (bool): Show inernal methods for hook calls? (default=false)
	 * - `getString` (bool): Get newline separated string rather than array? (default=false)
	 * - `getCnt` (bool): Get index number count, used for getString option only. (default=true)
	 * - `getFile` (bool|string): Get filename? Specify one of true, false or 'basename'. (default=true)
	 * - `maxCount` (int): Max size for arrays (default=10)
	 * - `maxStrlen` (int): Max length for strings (default=100)
	 * - `maxDepth` (int): Max allowed recursion depth when converting variables to strings.  (default=5)
	 * - `ellipsis` (string): Show this ellipsis when a long value is truncated (default='…')
	 * - `skipCalls` (array): Method/function calls to skip. 
	 * @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 function
			'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 $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::traceStr($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 if($arg === null) {
						$arg = 'null';
					} else {
						// leave as-is (int, float, etc.)
					}
					$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 traceStr($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) {
					if(is_string($k) && strlen($k)) {
						$value[$k] = "$$k => " . self::traceStr($v, $options);
					} else {
						$value[$k] = self::traceStr($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;
	}

	/**
	 * Dump any variable to a debug string
	 * 
	 * @param int|float|object|string|array $value
	 * @param array $options
	 *  - `method` (string): Dump method to use, one of: json_encode, var_dump, var_export, print_r (default=json_encode)
	 *  - `html` (bool): Return output-ready HTML string? (default=false)
	 * @return string
	 * @since 3.0.208
	 * 
	 */
	static public function toStr($value, array $options = array()) {
		
		$defaults = array(
			'method' => 'json_encode',
			'html' => false,
		);
		
		$options = array_merge($defaults, $options);
		$method = $options['method'];
		$prepend = '';
		
		if(is_object($value)) { 
			// we format objects to arrays or strings
			$className = wireClassName($value);
			$classInfo = "object:$className";
			$objectValue = $value;
			if($objectValue instanceof \Countable) {
				$classInfo .= '(' . count($objectValue) . ')';
			}
			if($value instanceof Wire) {
				$value = $value->debugInfoSmall();
			} else if(method_exists($value, '__debugInfo')) {
				$value = $value->__debugInfo();
			} else if(method_exists($value, '__toString')) {
				$value = $classInfo . ":\"$value\"";
			} else {
				$value = $classInfo;
			}
			if(is_array($value)) {
				if(empty($value)) {
					$value = $classInfo;
					if(method_exists($objectValue, '__toString')) {
						$stringValue = (string) $objectValue;
						if($stringValue != $className) $value .= ":\"$stringValue\"";
					}
				} else {
					$prepend = "$classInfo ";
				}
			}
			if(is_string($value)) {
				$method = '';
			}
		} else if(is_int($value)) {
			$prepend = 'int:';
		} else if(is_float($value)) {
			$prepend = 'float:';
		} else if(is_string($value)) {
			$prepend = 'string:';
		} else if(is_callable($value)) {
			$prepend = 'callable:';
		} else if(is_resource($value)) {
			$prepend = 'resource:';
		}
		
		switch($method) {
			case 'json_encode':
				$value = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
				$value = str_replace('    ', '  ', $value);
				if(strpos($value, '\\"') !== false) $value = str_replace('\\"', "'", $value);
				break;
			case 'var_export':
				$value = var_export($value, true);
				break;
			case 'var_dump':	
				ob_start();
				var_dump($value);
				$value = ob_get_contents();
				ob_end_clean();
				break;
			case 'print_r':	
				$value = print_r($value, true);
				break;
			default:	
				$value = (string) $value;
		}
		
		if($method && $method != 'json_encode') {
			// array is obvious and needs no label
			if(stripos($value, 'array') === 0) $value = trim(substr($value, 5));
		}
		
		if($prepend) $value = $prepend . trim($value);
		if($options['html']) $value = '<pre>' . wire()->sanitizer->entities($value) . '</pre>';

		return $value;
	}
}
