<?php
declare(strict_types = 1);

/**
 * Gestionnaire d'erreur.
 *
 * @license https://www.gnu.org/licenses/gpl-3.0.html
 * @link https://www.igalerie.org/
 */
class ErrorHandler
{
	/**
	 * Erreurs enregistrées.
	 *
	 * @var array
	 */
	public static $errors = [];



	/**
	 * Décide si les erreurs doivent être affichées au moment où elles se
	 * produisent, ou bien si elles ne doivent l'être que lorsque la méthode
	 * displayErrors() aura été appelée.
	 *
	 * @var int
	 */
	private static $_displayNow = CONF_ERRORS_DISPLAY_NOW;

	/**
	 * Types d'erreurs PHP.
	 *
	 * @var array
	 */
	private static $_errorType =
	[
		   0 => 'EXCEPTION',
		   1 => 'ERROR',
		   2 => 'WARNING',
		   4 => 'PARSE',
		   8 => 'NOTICE',
		  16 => 'CORE_ERROR',
		  32 => 'CORE_WARNING',
		  64 => 'COMPILE_ERROR',
		 128 => 'COMPILE_WARNING',
		 256 => 'USER_ERROR',
		 512 => 'USER_WARNING',
		1024 => 'USER_NOTICE',
		2048 => 'STRICT',
		4096 => 'RECOVERABLE_ERROR',
		8192 => 'E_DEPRECATED',
	   16384 => 'E_USER_DEPRECATED'
	];



	/**
	 * Récupère les erreurs de base de données.
	 *
	 * @param PDOException $e
	 *
	 * @return void
	 */
	public static function dbError(PDOException $e): void
	{
		$type = strtoupper(CONF_DB_TYPE) . '_ERROR';
		$file = self::_reduceFilePath($e->getFile());
		$line = $e->getLine();
		$message = $e->getMessage();
		$trace = self::_getTrace();

		self::_display($type, $file, $line, $message, $trace);
	}

	/**
	 * Supprime les erreurs $files.
	 *
	 * @param array $files
	 *
	 * @return int
	 */
	public static function delete(array $files): int
	{
		if (!$files = self::_checkFiles($files))
		{
			return 0;
		}

		$deleted = 0;
		foreach ($files as &$f)
		{
			if (File::unlink($f))
			{
				$deleted++;
			}
		}

		return $deleted;
	}

	/**
	 * Récupère les exceptions.
	 *
	 * @param object $e
	 *
	 * @return void
	 */
	public static function exception($e): void
	{
		self::$_displayNow = 1;
		self::phpError($e->getCode(), $e->getMessage(), $e->getFile(),
			$e->getLine(), $e->getTrace());
	}

	/**
	 * Envoi une archive ZIP des erreurs $files.
	 *
	 * @param array $files
	 *
	 * @return void
	 */
	public static function export(array $files): void
	{
		if (!$files = self::_checkFiles($files))
		{
			return;
		}

		Zip::archive('errors.zip', $files);
	}

	/**
	 * Récupère les erreurs PHP.
	 *
	 * @param int $errno
	 * @param string $errstr
	 * @param string $errfile
	 * @param int $errline
	 * @param array $trace
	 *
	 * @return void
	 */
	public static function phpError(int $errno, string $errstr,
	string $errfile, int $errline, array $trace = []): void
	{
		$trace = self::_getTrace();

		// Erreurs à ignorer.
		if (!CONF_DEBUG_MODE)
		{
			// On désactive la prise en compte des erreurs générées
			// par la fonction exif_read_data() car cette fonction
			// génère très souvent des erreurs de niveau WARNING du
			// genre "Illegal format code 0x0000, suppose BYTE",
			// sans que cela ne l'empêche, dans la plupart des cas,
			// de récupérer correctement les métadonnées !
			if (strstr($errstr, 'exif_read_data'))
			{
				return;
			}

			// Message d'erreur impliquant la fonction exif_imagetype()
			// qui peut survenir avec des images corrompues.
			if (strstr($errstr, 'exif_imagetype'))
			{
				return;
			}
		}

		$type = (isset(self::$_errorType[$errno]))
			  ? self::$_errorType[$errno]
			  : $errno;
		$errfile = self::_reduceFilePath($errfile);

		self::_display('PHP_' . $type, $errfile, $errline, $errstr, $trace);
	}

	/**
	 * Affiche désormais immédiatement les erreurs, ainsi que les
	 * erreurs qui se sont produites avant l'appel à cette méthode.
	 *
	 * @return void
	 */
	public static function printErrors(): void
	{
		self::$_displayNow = 1;
		$e = self::$errors;
		for ($i = 0, $count = count($e); $i < $count; $i++)
		{
			self::_print($e[$i][0], $e[$i][1], $e[$i][2], $e[$i][3], $e[$i][4]);
		}
	}

	/**
	 * Récupère la dernière erreur PHP à l'arrêt du script.
	 *
	 * @return void
	 */
	public static function shutdown(): void
	{
		$last_error = error_get_last();

		if ($last_error)
		{
			self::phpError(
				$last_error['type'], $last_error['message'],
				$last_error['file'], $last_error['line']
			);
		}
	}



	/**
	 * Vérifie et retourne une liste de fichiers d'erreurs.
	 *
	 * @param array $files
	 *
	 * @return array
	 */
	private static function _checkFiles(array $files): array
	{
		$files_check = [];
		foreach ($files as &$file)
		{
			if (!preg_match('`^[a-z0-9_]{32,99}\.xml$`i', $file))
			{
				continue;
			}

			$xml_file = GALLERY_ROOT . '/errors/' . $file;
			if (!file_exists($xml_file))
			{
				continue;
			}

			$files_check[] = $xml_file;
		}
		return $files_check;
	}

	/**
	 * Gère l'affichage des erreurs.
	 *
	 * @param string $type
	 * @param string $file
	 * @param int $line
	 * @param string $message
	 * @param array $trace
	 *
	 * @return void
	 */
	private static function _display(string $type, string $file,
	int $line, string $message = '', array $trace = []): void
	{
		self::_log($type, $file, $line, $message, $trace);
		if (!CONF_ERRORS_DISPLAY)
		{
			return;
		}
		if (self::$_displayNow)
		{
			self::_print($type, $file, $line, $message, $trace);
			return;
		}
		self::$errors[] = [$type, $file, $line, $message, $trace];
	}

	/**
	 * Récupère des informations de debogage.
	 *
	 * @return array
	 */
	private static function _getTrace(): array
	{
		$e = new Exception();
		$trace = explode("\n", $e->getTraceAsString());
		$trace = array_reverse($trace);
		array_shift($trace);

		for ($i = 0, $r = []; $i < count($trace); $i++)
		{
			if (!strstr($trace[$i], __CLASS__) && !strstr($trace[$i], 'DB.class.php'))
			{
				$t = trim(substr($trace[$i], strpos($trace[$i], ' ')));
				$t = str_replace('\\\\', '\\', $t);

				// On supprime les arguments de certaines fonctions sensibles.
				$t = preg_replace('`(Auth::form)\([^\)]+\)`', '\1()', $t);

				$r[] = '#' . ($i+1) . ' ' . self::_reduceFilePath($t);
			}
		}

		return $r;
	}

	/**
	 * Enregistre une erreur dans un fichier XML.
	 *
	 * @staticvar bool $error_limit
	 *
	 * @param string $type
	 * @param string $file
	 * @param int $line
	 * @param string $message
	 * @param array $trace
	 *
	 * @return void
	 */
	private static function _log(string $type, string $file,
	int $line, string $message = '', array $trace = []): void
	{
		if (!CONF_ERRORS_LOG
		|| (isset(Config::$params['app_version'])
		&& System::APP_VERSION != Config::$params['app_version']
		&& !class_exists('Update', FALSE) && !class_exists('Upgrade', FALSE)))
		{
			return;
		}

		// Erreurs à ne pas enregistrer, sauf en mode débogage.
		if (!CONF_DEBUG_MODE)
		{
			// Manque de ressources.
			if (strstr($message, 'Maximum execution time')
			 || strstr($message, 'Out of memory'))
			{
				return;
			}

			// Images non valides.
			if (strstr($message, 'getimagesize'))
			{
				return;
			}

			// Images corrompues.
			if (strstr($message, 'imagecreatefrom')
			&& (strstr($message, 'is not a valid') || strstr($message, 'recoverable error')))
			{
				return;
			}

			// Images WEBP animées.
			if (strstr($message, 'gd-webp cannot allocate temporary buffer'))
			{
				return;
			}

			// Images PNG.
			if (strstr($message, 'Interlace handling should be turned on'
			. ' when using png_read_image')
			 || strstr($message, 'iCCP: known incorrect sRGB profile')
			 || strstr($message, 'iCCP: extra compressed data'))
			{
				return;
			}

			// Archives Zip.
			// ob_flush(): failed to flush buffer. No buffer to flush
			if (strstr($message, 'No buffer to flush'))
			{
				return;
			}

			// Poids de fichier envoyé trop grand.
			if (strstr($message, 'POST Content-Length of')
			 && strstr($message, 'bytes exceeds the limit of'))
			{
				return;
			}

			// Mise à jour automatique.
			if (strstr($file, 'Update.class.php'))
			{
				return;
			}
		}

		static $error_limit = FALSE;

		$dir_errors = GALLERY_ROOT . '/errors/';

		// On remplace le contenu de certains messages pour
		// éviter un nombre élevé d'erreurs identiques.
		$md5_message = preg_replace('`\d+\s+bytes`i', 'NUM bytes', $message);
		$md5_message = preg_replace('`allocated\s+\d+`i', 'allocated NUM', $md5_message);

		$key = defined('CONF_KEY') ? CONF_KEY : '';
		$md5 = md5(implode('|', [$key, $type, $file, $line, $md5_message]));
		$xml_file = $dir_errors . $type . '_' . $md5 . '.xml';
		$date = date('Y-m-d H:i:s') . preg_replace('`\s.+`', '', substr(microtime(), 1, 7));

		if (file_exists($xml_file))
		{
			File::touch($xml_file);
		}
		else
		{
			if (!$error_limit)
			{
				// On limite le nombre d'erreurs stockées.
				$files = scandir($dir_errors);
				$xml_nb = count($files) - 2;
				if ($xml_nb >= (CONF_ERRORS_LOG_MAX - 1))
				{
					$error_limit = TRUE;
					$message = 'Maximum number of errors reached.';
					trigger_error($message, E_USER_NOTICE);
					return;
				}
			}

			$q = isset($_GET['q']) ? urlencode($_GET['q']) : '';
			Utility::arrayRemoveObject($trace);

			$xml_data = '<?xml version="1.0" encoding="UTF-8" ?>' . "\n";
			$xml_data .= '<error md5="' . $md5 . '">' . "\n\t";
			$xml_data .= '<app_version>' . urlencode(System::APP_VERSION)
				. '</app_version>' . "\n\t";
			$xml_data .= '<php_version>' . PHP_VERSION . '</php_version>' . "\n\t";
			$xml_data .= '<type>' . $type . '</type>' . "\n\t";
			$xml_data .= '<date>' . $date . '</date>' . "\n\t";
			$xml_data .= '<q>' . $q . '</q>' . "\n\t";
			$xml_data .= '<file>' . urlencode($file) . '</file>' . "\n\t";
			$xml_data .= '<line>' . $line . '</line>' . "\n\t";
			$xml_data .= '<message>' . urlencode($message) . '</message>' . "\n\t";
			$xml_data .= '<trace>' . urlencode(json_encode($trace)) . '</trace>' . "\n";
			$xml_data .= '</error>' . "\n";

			File::putContents($xml_file, $xml_data);
		}

		$error_limit = FALSE;
	}

	/**
	 * Affiche une erreur.
	 *
	 * @param string $type
	 * @param string $file
	 * @param int $line
	 * @param string $message
	 * @param array $trace
	 *
	 * @return void
	 */
	private static function _print(string $type, string $file,
	int $line, string $message = '', array $trace = []): void
	{
		static $error_num = 1;

		if ($error_num < 100)
		{
			$link = "[$error_num]";
			$type = '<strong>' . htmlspecialchars($type) . '</strong>';
			$file = '<strong>' . htmlspecialchars($file) . '</strong>';
			$line = '<strong>' . $line . '</strong>';
			$error = sprintf('%s %s in %s on line %s: %s',
				$link, $type, $file, $line, htmlspecialchars($message));
			HTML::specialchars($trace);

			echo "\n<br>\n<span class=\"error\">$error</span><br>\n";
			if (CONF_ERRORS_DISPLAY_TRACE)
			{
				echo "<div class=\"error_trace\">\n";
				echo "trace :\n<pre>";
				print_r($trace);
				echo "</pre>\n";
				echo "</div>\n";
			}
		}
		$error_num++;
	}

	/**
	 * Simplifie le chemin du fichier.
	 *
	 * @param string $file
	 *
	 * @return string
	 */
	private static function _reduceFilePath(string $file): string
	{
		if (strpos($file, GALLERY_ROOT) === 0)
		{
			return substr($file, strlen(GALLERY_ROOT) + 1);
		}

		return $file;
	}
}
?>