<?php

namespace XF;

use XF\Util\File;
use XF\Util\Ip;
use XF\Util\Str;

use function get_class, gettype, intval, strlen, strval;

class Error
{
	/**
	 * @var App
	 */
	protected $app;

	protected $ignorePendingUpgrade = false;
	protected $forceShowTrace = false;

	protected $hasPendingUpgrade = null;

	public function __construct(App $app)
	{
		$this->app = $app;
	}

	public function setIgnorePendingUpgrade($pending)
	{
		$this->ignorePendingUpgrade = $pending;
	}

	public function setForceShowTrace($force)
	{
		$this->forceShowTrace = $force;
	}

	public function hasPendingUpgrade()
	{
		if ($this->hasPendingUpgrade !== null)
		{
			return $this->hasPendingUpgrade;
		}

		$this->hasPendingUpgrade = false;

		try
		{
			$db = $this->app->db();
			if (@$db->getConnection())
			{
				$dbVersionId = @$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id = 'currentVersionId'");
				if ($dbVersionId)
				{
					if ($dbVersionId != \XF::$versionId)
					{
						$this->hasPendingUpgrade = true;
					}
					else
					{
						$processingAddOn = @$db->fetchOne("
							SELECT addon_id
							FROM xf_addon
							WHERE is_processing = 1
							LIMIT 1
						");
						$this->hasPendingUpgrade = $processingAddOn ? true : false;
					}
				}
			}
		}
		catch (\Exception $e)
		{
		}

		return $this->hasPendingUpgrade;
	}

	public function logError($message, $forceLog = false)
	{
		$this->logException(new \ErrorException($message), false, '', $forceLog);
	}

	public function logException($e, $rollback = false, $messagePrefix = '', $forceLog = false)
	{
		/** @var \Throwable $e */

		try
		{
			$db = $this->app->db();
			if (@$db->getConnection())
			{
				if ($rollback)
				{
					@$db->rollbackAll();
				}

				if (!$forceLog)
				{
					if ($this->hasPendingUpgrade() && !$this->ignorePendingUpgrade)
					{
						// don't log when upgrades are pending
						return false;
					}

					if (!File::installLockExists(false))
					{
						// install hasn't finished yet, don't write
						return false;
					}
				}

				$isValidArg = ($e instanceof \Exception || $e instanceof \Throwable);
				if (!$isValidArg)
				{
					$e = new \ErrorException('Non-exception passed to logException. See trace for details.');
				}

				$rootDir = \XF::getRootDirectory() . \XF::$DS;
				$file = str_replace($rootDir, '', $e->getFile());

				$requestInfo = $this->getRequestDataForExceptionLog();

				if ($messagePrefix)
				{
					$messagePrefix = trim($messagePrefix) . ' ';
				}

				$trace = $this->getTraceStringFromThrowable($e);

				$traceExtras = $this->addExtrasToTrace($e);
				if ($traceExtras)
				{
					$trace = $traceExtras . "\n------------\n\n" . $trace;
				}

				$exceptionMessage = $this->adjustExceptionMessage($e->getMessage(), $e);

				try
				{
					$userId = \XF::visitor()->user_id;
				}
				catch (\Throwable $e)
				{
					$userId = null;
				}

				$db->insert('xf_error_log', [
					'exception_date' => \XF::$time,
					'user_id' => $userId,
					'ip_address' => Ip::stringToBinary($this->app->request()->getIp()),
					'exception_type' => Str::substr(get_class($e), 0, 75),
					'message' => Str::substr($messagePrefix . $exceptionMessage, 0, 20000),
					'filename' => Str::substr($file, 0, 255),
					'line' => $e->getLine(),
					'trace_string' => $trace,
					'request_state' => json_encode($requestInfo, JSON_PARTIAL_OUTPUT_ON_ERROR),
				]);

				return true;
			}
		}
		catch (\Exception $e)
		{
		}

		return false;
	}

	protected function getTraceStringFromThrowable($e)
	{
		/** @var \Throwable $e */

		$trace = $this->buildTraceString($e);

		while ($e->getPrevious())
		{
			$e = $e->getPrevious();

			$trace .= "\n\n-------------\n\n"
				. "Previous " . get_class($e) . ": " . $e->getMessage()
				. " - " . $e->getFile() . ':' . $e->getLine() . "\n"
				. $this->buildTraceString($e);
		}

		$rootDir = \XF::getRootDirectory() . \XF::$DS;
		$trace = str_replace($rootDir, '', $trace);

		return $trace;
	}

	protected function buildTraceString($e)
	{
		/** @var \Throwable $e */

		$traceElements = $e->getTrace();

		$traceString = "";

		$num = 0;

		foreach ($traceElements AS $num => $element)
		{
			if (isset($element['file']))
			{
				$file = $element['file'];
				$location = "$file({$element['line']})";
			}
			else
			{
				$location = "[internal function]";
			}

			$traceString .= "#$num $location: ";

			if (isset($element['class']) && isset($element['type']))
			{
				$traceString .= $element['class'] . $element['type'];
			}

			$traceString .= $element['function'] . '(';
			$traceString .= implode(', ', $this->buildTraceArgs($element));
			$traceString .= ")\n";
		}

		$traceString .= "#" . strval($num + 1) . " {main}";

		return $traceString;
	}

	protected function buildTraceArgs(array $traceElement)
	{
		$methodParameters = [];

		try
		{
			if (isset($traceElement['class']))
			{
				$class = new \ReflectionClass($traceElement['class']);
				$method = $class->getMethod($traceElement['function']);
				$methodParameters = $method->getParameters();
			}
			else if (isset($traceElement['function']))
			{
				$method = new \ReflectionFunction($traceElement['function']);
				$methodParameters = $method->getParameters();
			}
		}
		catch (\ReflectionException $e)
		{
			// Can happen with closures
		}

		if (empty($traceElement['args']))
		{
			return [];
		}

		$args = [];

		foreach ($traceElement['args'] AS $key => $arg)
		{
			// This might not be set
			$methodParameter = $methodParameters[$key] ?? null;

			switch (gettype($arg))
			{
				case 'NULL':
					$args[] = "NULL";
					break;
				case 'string':
					if ($methodParameter && stripos($methodParameter->getName(), 'password') !== false)
					{
						$arg = '*****';
					}

					$tmp = substr($arg, 0, min(strlen($arg), 15));
					if (strlen($arg) > 15)
					{
						$tmp .= '...';
					}
					$tmp = str_replace('\\', '\\\\', $tmp);
					if (preg_match("//u", $tmp))
					{
						$args[] = "'$tmp'";
					}
					else
					{
						$args[] = "[invalid]";
					}
					break;
				case 'boolean':
					if ($arg)
					{
						$args[] = 'true';
					}
					else
					{
						$args[] = 'false';
					}
					break;
				case 'resource (closed)':
				case 'resource':
					$args[] = "Resource id #" . intval($arg);
					break;
				case 'integer':
					$args[] = $arg;
					break;
				case 'double':
					$args[] = sprintf('%G', $arg);
					break;
				case 'array':
					$args[] = 'Array';
					break;
				case 'object':
					$args[] = 'Object(' . get_class($arg) . ')';
					break;
				default:
					$args[] = '(Unknown parameter type)';
					break;
			}
		}

		return $args;
	}

	protected function addExtrasToTrace($e)
	{
		if ($e instanceof Db\Exception && $e->query)
		{
			return $e->query;
		}

		if ($e instanceof CssRenderException)
		{
			return implode("\n", $e->getContextLinesPrintable());
		}

		return '';
	}

	protected function adjustExceptionMessage($message, $e)
	{
		return $message;
	}

	protected function getRequestDataForExceptionLog()
	{
		if (PHP_SAPI == 'cli')
		{
			$command = isset($GLOBALS['argv']) ? implode(' ', $GLOBALS['argv']) : '';

			return [
				'cli' => $command,
			];
		}

		$request = $this->app->request();

		return [
			'url' => $request->getRequestUri(),
			'referrer' => $request->getReferrer(),
			'_GET' => $_GET,
			'_POST' => $request->filterForLog($_POST),
		];
	}

	public function displayFatalExceptionMessage($e)
	{
		$upgradePending = $this->hasPendingUpgrade();
		$isInstalled = File::installLockExists(false);
		$ignorePendingUpgrade = (!$isInstalled || $this->ignorePendingUpgrade || $this->forceShowTrace);

		if (\XF::$debugMode || !$isInstalled || $this->forceShowTrace)
		{
			$showTrace = true;
		}
		else
		{
			$showTrace = false;

			try
			{
				$visitor = \XF::visitor();
				$showTrace = $visitor->user_id && $visitor->is_admin && $visitor->hasAdminPermission('viewLogs');
			}
			catch (\Throwable $e)
			{
			}
		}

		@header('Content-Type: text/html; charset=utf-8', true, 500);

		if ($upgradePending && !$ignorePendingUpgrade)
		{
			echo $this->getPhrasedTextIfPossible(
				'The site is currently being upgraded. Please check back later.',
				'site_currently_being_upgraded'
			);
		}
		else if ($showTrace)
		{
			echo $this->getExceptionTraceHtml($e);
		}
		else if ($e instanceof Db\Exception)
		{
			$message = $e->getMessage();

			echo $this->getPhrasedTextIfPossible(
				'An unexpected database error occurred. Please try again later.',
				'unexpected_database_error_occurred'
			);
			echo "\n<!-- " . htmlspecialchars($message) . " -->";
		}
		else
		{
			echo $this->getPhrasedTextIfPossible(
				'An unexpected error occurred. Please try again later.',
				'unexpected_error_occurred'
			);
		}
	}

	protected function getPhrasedTextIfPossible($fallbackText, $phraseName, array $params = [])
	{
		try
		{
			$output = \XF::phrase($phraseName, $params)->render();
		}
		catch (\Exception $e)
		{
			$output = false;
		}

		if ($output === false || $output === $phraseName)
		{
			$output = $fallbackText;
		}

		return $output;
	}

	public function getExceptionTraceHtml($e)
	{
		/** @var \Throwable $e */

		$rootDirRaw = \XF::getRootDirectory();
		$rootDir = $rootDirRaw . \XF::$DS;

		if (PHP_SAPI == 'cli' || \XF::app()->request()->isXhr())
		{
			$file = str_replace($rootDir, '', $e->getFile());
			$trace = str_replace($rootDir, '', $this->buildTraceString($e));

			$class = get_class($e);
			$message = str_replace($rootDirRaw, '', $e->getMessage());

			return PHP_EOL
				. "An exception occurred: [$class] {$message} in {$file} on line {$e->getLine()}"
				. PHP_EOL . $trace . PHP_EOL;
		}

		$traceHtml = '';

		foreach ($e->getTrace() AS $traceEntry)
		{
			$function = (isset($traceEntry['class']) ? $traceEntry['class'] . $traceEntry['type'] : '') . $traceEntry['function'];
			if (isset($traceEntry['file']))
			{
				$file = str_replace('\\', '/', str_replace($rootDir, '', $traceEntry['file']));
			}
			else
			{
				$file = '';
			}
			$traceHtml .= "\t<li><b>" . htmlspecialchars($function) . "()</b>" . (isset($traceEntry['file']) && isset($traceEntry['line']) ? ' <span>in</span> <b>' . $file . "</b> <span>at line</span> <b>$traceEntry[line]</b>" : '') . "</li>\n";
		}

		$class = htmlspecialchars(get_class($e));
		$message = htmlspecialchars(str_replace($rootDirRaw, '', $e->getMessage()));
		$file = htmlspecialchars(str_replace($rootDir, '', $e->getFile()));
		$line = $e->getLine();

		return "<p>An exception occurred: [$class] $message in $file on line $line</p><ol>$traceHtml</ol>";
	}
}
