<?php namespace Intellex\Debugger;

class Debugger {

	/** @var boolean True if we are in the production mode, false otherwise. */
	private static $production = true;

	/** @var int The number of lines to show before and after the line. */
	private static $around = 8;

	/** @const string The delimiter for formatting method. */
	const DELIMITER = " /**############################**/ \n";

	/** @const string The encoded delimiter for formatting method. */
	const DELIMITER_ENCODED = '/**############################**/';

	/**
	 * Initialize the Debugger.
	 *
	 * @param bool $production The level of the error raised, as an integer.
	 * @param int  $around     The the error message, as a string.
	 */
	public static function initHandlers($production = true, $around = 8) {
		static::$around = $around;
		static::$production = $production;

		# Set the exception handler
		error_reporting(E_ALL);
		set_error_handler('\Intellex\Debugger\Debugger::handleError');
		set_exception_handler('\Intellex\Debugger\Debugger::handleException');
		register_shutdown_function('\Intellex\Debugger\Debugger::handleShutdown');
	}

	/**
	 * Dump the variable data in the appropriate format.
	 *
	 * @param mixed  $var      The variable to print.
	 * @param int    $level    The level from which this method is called, used to properly show
	 *                         from where it was called.
	 * @param string $template The template to use: 'html', 'xml', 'json' or 'plain'. Set to null
	 *                         to skip.
	 */
	public static function dump($var, $level = 0, $template = null) {

		# Get the template
		if ($template === null) {
			$template = static::getTemplate();
		}
		$template = preg_replace('~[^a-z]+~', '', $template);

		# Initialize the data
		$data = [
			'file'  => null,
			'line'  => null,
			'value' => static::getReadableValue($var),
			'trace' => null,
		];

		# From where it was called
		$trace = debug_backtrace();
		$data = array_merge($data, $trace[$level]);
		$data['trace'] = $trace;

		# Dump the variable
		$file = __DIR__ . "/templates/var/{$template}.php";
		is_readable($file) or die("Unknown template `{$template}` for Debugger.");
		require $file;
	}

	/**
	 * Get the human-friendly readable value of the supplied variable.
	 *
	 * @param mixed    $var       The variable to get the readable value.
	 * @param int|null $maxLength The maximum length of the variable, or null for unlimited.
	 *
	 * @return string The human-friendly description of the supplied value.
	 * @throws \Exception
	 */
	public static function getReadableValue($var, $maxLength = null) {

		# Get the proper value and
		$type = null;
		switch (VarType::of($var)) {
			case VarType::NULL_:
				$var = '(null value)';
				break;

			case VarType::BOOLEAN_:
				$var = $var ? 'true' : 'false';
				$type = 'boolean';
				break;

			case VarType::INTEGER_:
				$type = 'integer';
				break;

			case VarType::FLOAT_:
				$type = 'float';
				break;

			case VarType::STRING_:
				if (empty($var) || trim($var) !== $var) {
					$type = 'string(' . mb_strlen($var) . ')';
					$var = '"' . $var . '"';
				}
				break;

			case VarType::ARRAY_:
			case VarType::OBJECT_:
				$var = print_r($var, true);
				break;

			case VarType::RESOURCE_:
				$var = '#' . intval($var);
				$type = 'resource';
				break;

			default:
				throw new \Exception("Unknown variable type supplied.");
		}

		// Trim
		if ($maxLength && mb_strlen($var) > $maxLength) {
			$var = mb_substr($var, 0, $maxLength) . '…';
		}

		$template = $type ? "{$type}: %s" : '%s';
		return sprintf($template, $var);
	}

	/**
	 * Check if the script is executed from cli.
	 *
	 * @return boolean True if we are in console line interface, false otherwise.
	 */
	public static function isCli() {
		return in_array(php_sapi_name(), [ 'cli', 'cgi-fcgi' ]);
	}

	/**
	 * Check if the current request is AJAX or not.
	 *
	 * @return boolean True if this call is ajax, false otherwise.
	 */
	public static function isAjax() {
		return key_exists('HTTP_X_REQUESTED_WITH', $_SERVER) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest';
	}

	/**
	 * Handle the received error.
	 *
	 * @param int $code    The level of the error raised, as an integer.
	 * @param int $error   The the error message, as a string.
	 * @param int $file    The filename that the error was raised in, as a string.
	 * @param int $line    The line number the error was raised at, as an integer
	 * @param int $context An array that points to the active symbol table at the point the
	 *                     error occurred.
	 *
	 * @throws \Exception
	 */
	public static function handleError($code, $error, $file = null, $line = null, $context = null) {
		throw static::getExceptionFromError($code, $error, $file, $line, $context);
	}

	/**
	 * Show the details about the uncatched exception.
	 *
	 * @param \Exception $exception The exception to show.
	 */
	public static function handleException($exception) {
		$history = [];

		# Define the steps
		$steps = $exception->getTrace();
		if (method_exists($exception, 'getFile')) {
			array_unshift($steps, [
				'file' => $exception->getFile(),
				'line' => $exception->getLine()
			]);
		}

		# Get the true origin
		$origin = null;
		if (preg_match('~ in (?<file>.+?)::(?<line>\d+\b)~ Uui', $exception->getMessage(), $match)) {
			$origin['file'] = $match['file'];
			$origin['line'] = $match['line'];
		}

		# Add to steps
		foreach ($steps as $step) {
			$step['snippet'] = [];

			# Open file
			if (key_exists('file', $step)) {
				$file = explode("\n", file_get_contents($step['file']));
				$from = max(0, $step['line'] - static::$around - 1);
				$to = min(sizeof($file), $step['line'] + static::$around);
				$snippet = array_slice($file, $from, $to - $from);

				$step['snippet'] = [];
				foreach ($snippet as $i => $line) {
					$step['snippet'][$from + $i + 1] = str_replace("\t", ' ', $line);
				}
			}

			$history[] = $step;
		}

		(headers_sent() || static::isCli()) or header($_SERVER['SERVER_PROTOCOL'] . ' ' . 500 . ' Internal Server Error', true, 500);
		require 'templates/exception/' . static::getTemplate() . '.php';
		die($exception->getCode());
	}

	/**
	 * Create an exception from an error.
	 *
	 * @param int $code  The level of the error raised, as an integer.
	 * @param int $error The the error message, as a string.
	 * @param int $file  The filename that the error was raised in, as a string.
	 * @param int $line  The line number the error was raised at, as an integer
	 *
	 * @return \Exception  Built exception.
	 */
	private static function getExceptionFromError($code, $error, $file = null, $line = null) {
		$type = static::getErrorType($code);
		return new \Exception("{$type}: {$error} in {$file}::{$line}", 500);
	}

	/**
	 * Make sure the parse errors are captured as well.
	 */
	public static function handleShutdown() {
		$error = error_get_last();
		if ($error) {
			static::handleException(static::getExceptionFromError($error['type'], $error['message'], $error['file'], $error['line']));
		}
	}

	/**
	 * Get the template to use.
	 *
	 * @return string The template to use.
	 */
	private static function getTemplate() {
		if (key_exists('HTTP_ACCEPT', $_SERVER)) {
			$map = [
				'/html\b' => 'html',
				'/json\b' => 'json',
				'/xml\b'  => 'xml',
			];
			foreach ($map as $regexp => $template) {
				if (preg_match("~{$regexp}~ i", $_SERVER['HTTP_ACCEPT'])) {
					return $template;
				}
			}
		}

		return 'plain';
	}

	/**
	 * Get the error type as string.
	 *
	 * @param int $error The integer representing the error.
	 *
	 * @return string  The human-friendly name of the error.
	 */
	public static function getErrorType($error) {
		switch ($error) {
			case E_ERROR:
				return 'ERROR';
			case E_WARNING:
				return 'WARNING';
			case E_PARSE:
				return 'PARSE';
			case E_NOTICE:
				return 'NOTICE';
			case E_CORE_ERROR:
				return 'CORE_ERROR';
			case E_CORE_WARNING:
				return 'CORE_WARNING';
			case E_COMPILE_ERROR:
				return 'COMPILE_ERROR';
			case E_COMPILE_WARNING:
				return 'COMPILE_WARNING';
			case E_USER_ERROR:
				return 'USER_ERROR';
			case E_USER_WARNING:
				return 'USER_WARNING';
			case E_USER_NOTICE:
				return 'USER_NOTICE';
			case E_STRICT:
				return 'STRICT';
			case E_RECOVERABLE_ERROR:
				return 'RECOVERABLE_ERROR';
			case E_DEPRECATED:
				return 'DEPRECATED';
			case E_USER_DEPRECATED:
				return 'USER_DEPRECATED';
		}

		return 'UNKNOWN';
	}

	/**
	 * Format the supplied PHP code.
	 *
	 * @param string $code   The code to format.
	 * @param string $select The line to highlight.
	 * @param string $from   The first line to print.
	 * @param string $to     The last line to print.
	 *
	 * @return string  The human-friendly formated code.
	 */
	public static function formatCode($code, $select = null, $from = null, $to = null) {

		# Normalize input
		$code = is_array($code) ? $code : explode("\n", $code);
		$count = sizeof($code);
		$from = $from ? max($from, 0) : 0;
		$to = $to ? min($to, $count) : $count;

		# Set custom markers
		$markers = [ 'comment', 'default', 'html', 'keyword', 'string' ];
		foreach ($markers as $marker) {
			ini_set("highlight.{$marker}", "Debugger::highlight.{$marker}");
		}

		# Highlight
		$text = implode(static::DELIMITER, $code);
		$text = highlight_string($text, true);
		$text = str_replace('&lt;?php&nbsp;', '', $text);

		# Replace markers with actual color
		foreach ($markers as $marker) {
			$text = str_replace("<span style=\"color: Debugger::highlight.{$marker}\">", "<span class=\"php-{$marker}\">", $text);
		}

		# Set numbers
		$output = [];
		$lines = explode(static::DELIMITER_ENCODED, $text);
		for ($i = $from; $i < $to; $i++) {
			$n = $i + 1;
			$line = preg_replace('~^&nbsp;<br */?>~', '', $lines[$i]);
			$number = $n . str_repeat('&nbsp;', strlen($to) - strlen($n));
			$output[$n] = "<span class=\"php-line\">{$number}</span>&nbsp;{$line}";

			$output[$n] = '<div class="' . ($n === $select ? 'highlighted ' : null) . 'line">' . $output[$n] . '</div>';
		}
		$text = implode(PHP_EOL, $output);

		return $text;
	}

}
