<?php declare(strict_types = 1);
/**
 * PHP error collector.
 *
 * @package query-monitor
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

if ( ! defined( 'QM_ERROR_FATALS' ) ) {
	define( 'QM_ERROR_FATALS', E_ERROR | E_PARSE | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR );
}

/**
 * @extends QM_DataCollector<QM_Data_PHP_Errors>
 * @phpstan-import-type errorObject from QM_Data_PHP_Errors
 */
class QM_Collector_PHP_Errors extends QM_DataCollector {

	/**
	 * @var string
	 */
	public $id = 'php_errors';

	/**
	 * @var int|null
	 */
	private $error_reporting = null;

	/**
	 * @var string|false|null
	 */
	private $display_errors = null;

	/**
	 * @var callable|null
	 */
	private $previous_error_handler = null;

	/**
	 * @var callable|null
	 */
	private $previous_exception_handler = null;

	/**
	 * @var string|null
	 */
	private static $unexpected_error = null;

	/**
	 * @var int
	 */
	private const NON_SILENT_ERROR_TYPES = E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR | E_PARSE;

	public function get_storage(): QM_Data {
		return new QM_Data_PHP_Errors();
	}

	/**
	 * @return void
	 */
	public function set_up() {
		if ( defined( 'QM_DISABLE_ERROR_HANDLER' ) && QM_DISABLE_ERROR_HANDLER ) {
			return;
		}

		parent::set_up();

		// Capture the last error that occurred before QM loaded:
		$prior_error = error_get_last();

		// Non-fatal error handler:
		$this->previous_error_handler = set_error_handler( array( $this, 'error_handler' ), ( E_ALL ^ QM_ERROR_FATALS ) );

		// Fatal error and uncaught exception handler:
		$this->previous_exception_handler = set_exception_handler( array( $this, 'exception_handler' ) );

		$this->error_reporting = error_reporting();
		$this->display_errors = ini_get( 'display_errors' );
		ini_set( 'display_errors', '0' );

		if ( $prior_error ) {
			$this->error_handler(
				$prior_error['type'],
				$prior_error['message'],
				$prior_error['file'],
				$prior_error['line'],
				null,
				false
			);
		}
	}

	/**
	 * @return void
	 */
	public function tear_down() {
		if ( defined( 'QM_DISABLE_ERROR_HANDLER' ) && QM_DISABLE_ERROR_HANDLER ) {
			return;
		}

		if ( null !== $this->previous_error_handler ) {
			restore_error_handler();
		}

		if ( null !== $this->previous_exception_handler ) {
			restore_exception_handler();
		}

		if ( null !== $this->error_reporting ) {
			error_reporting( $this->error_reporting );
		}

		if ( is_string( $this->display_errors ) ) {
			ini_set( 'display_errors', $this->display_errors );
		}

		parent::tear_down();
	}

	/**
	 * Uncaught error handler.
	 *
	 * @param Throwable $e The error or exception.
	 * @return void
	 */
	public function exception_handler( $e ) {
		$error = 'Uncaught Error';

		if ( $e instanceof Exception ) {
			$error = sprintf(
				'Uncaught %s',
				get_class( $e )
			);
		}

		$this->output_fatal( 'Fatal error', array(
			'message' => sprintf(
				'%s: %s',
				$error,
				$e->getMessage()
			),
			'file' => $e->getFile(),
			'line' => $e->getLine(),
			'trace' => $e->getTrace(),
		) );

		// The error must be re-thrown or passed to the previously registered exception handler so that the error
		// is logged appropriately instead of discarded silently.
		if ( $this->previous_exception_handler ) {
			call_user_func( $this->previous_exception_handler, $e );
		} else {
			throw $e;
		}

		exit( 1 );
	}

	/**
	 * @param int     $errno    The error number.
	 * @param string  $message  The error message.
	 * @param string  $file     The file location.
	 * @param int     $line     The line number.
	 * @param mixed[] $context  The context being passed.
	 * @param bool    $do_trace Whether a stack trace should be included in the logged error data.
	 * @return bool
	 */
	public function error_handler( $errno, $message, $file = null, $line = null, $context = null, $do_trace = true ) {
		$type = null;

		/**
		 * Fires before logging the PHP error in Query Monitor.
		 *
		 * @since 2.7.0
		 *
		 * @param int          $errno   The error number.
		 * @param string       $message The error message.
		 * @param string|null  $file    The file location.
		 * @param int|null     $line    The line number.
		 * @param mixed[]|null $context The context being passed.
		 */
		do_action( 'qm/collect/new_php_error', $errno, $message, $file, $line, $context );

		switch ( $errno ) {

			case E_WARNING:
			case E_USER_WARNING:
				$type = 'warning';
				break;

			case E_NOTICE:
			case E_USER_NOTICE:
				$type = 'notice';
				break;

			case E_DEPRECATED:
			case E_USER_DEPRECATED:
				$type = 'deprecated';
				break;

		}

		// E_STRICT is deprecated in PHP 8.4 so it needs to be behind a version check.
		if ( null === $type && version_compare( PHP_VERSION, '8.4', '<' ) && E_STRICT === $errno ) {
			$type = 'strict';
		}

		if ( null === $type ) {
			return false;
		}

		if ( ! class_exists( 'QM_Backtrace' ) ) {
			return false;
		}

		$error_group = 'errors';

		if ( $this->is_error_suppressed() && 0 !== $this->error_reporting ) {
			// This is most likely an @-suppressed error
			$error_group = 'suppressed';
		}

		if ( ! isset( self::$unexpected_error ) ) {
			// These strings are from core. They're passed through `__()` as variables so they get translated at runtime
			// but do not get seen by GlotPress when it populates its database of translatable strings for QM.
			$unexpected_error = 'An unexpected error occurred. Something may be wrong with WordPress.org or this server&#8217;s configuration. If you continue to have problems, please try the <a href="%s">support forums</a>.';
			$wordpress_forums = 'https://wordpress.org/support/forums/';

			self::$unexpected_error = sprintf(
				call_user_func( '__', $unexpected_error ),
				call_user_func( '__', $wordpress_forums )
			);
		}

		// Intentionally skip reporting these core warnings. They're a distraction when developing offline.
		// The failed HTTP request will still appear in QM's output so it's not a big problem hiding these warnings.
		if ( false !== strpos( $message, self::$unexpected_error ) ) {
			return false;
		}

		$trace = new QM_Backtrace();
		$trace->push_frame( array(
			'file' => $file,
			'line' => $line,
		) );
		$caller = $trace->get_caller();

		if ( $caller ) {
			$key = md5( $message . $file . $line . $caller['id'] );
		} else {
			$key = md5( $message . $file . $line );
		}

		if ( isset( $this->data->{$error_group}[ $type ][ $key ] ) ) {
			$this->data->{$error_group}[ $type ][ $key ]['calls']++;
		} else {
			$this->data->{$error_group}[ $type ][ $key ] = array(
				'errno' => $errno,
				'type' => $type,
				'message' => wp_strip_all_tags( $message ),
				'file' => $file,
				'filename' => ( $file ? QM_Util::standard_dir( $file, '' ) : '' ),
				'line' => $line,
				'filtered_trace' => ( $do_trace ? $trace->get_filtered_trace() : null ),
				'component' => $trace->get_component(),
				'calls' => 1,
			);
		}

		/**
		 * Filters the PHP error handler return value. This can be used to control whether or not the default error
		 * handler is called after Query Monitor's.
		 *
		 * @since 2.7.0
		 *
		 * @param bool $return_value Error handler return value. Default false.
		 */
		return apply_filters( 'qm/collect/php_errors_return_value', false );
	}

	private function is_error_suppressed(): bool {
		$current_level = error_reporting();

		if ( PHP_MAJOR_VERSION > 7 ) {
			// https://www.php.net/manual/en/language.operators.errorcontrol.php
			return $current_level === self::NON_SILENT_ERROR_TYPES;
		}

		return 0 === $current_level;
	}

	/**
	 * @param string $error
	 * @param mixed[] $e
	 * @phpstan-param array{
	 *   message: string,
	 *   file: string,
	 *   line: int,
	 *   type?: int,
	 *   trace?: mixed|null,
	 * } $e
	 * @return void
	 */
	protected function output_fatal( $error, array $e ) {
		$is_rest_request = ( defined( 'REST_REQUEST' ) && REST_REQUEST );
		$is_ajax_request = ( defined( 'DOING_AJAX' ) && DOING_AJAX );

		if ( $is_rest_request ) {
			$dispatcher = QM_Dispatchers::get( 'rest' );
		} elseif ( $is_ajax_request ) {
			$dispatcher = QM_Dispatchers::get( 'ajax' );
		} else {
			$dispatcher = QM_Dispatchers::get( 'html' );
		}

		if ( empty( $dispatcher ) ) {
			return;
		}

		if ( empty( $this->display_errors ) && ! $dispatcher::user_can_view() ) {
			return;
		}

		if ( ! headers_sent() ) {
			status_header( 500 );
		}

		$message = sprintf(
			'%s in %s on line %d',
			$e['message'],
			$e['file'],
			$e['line']
		);

		$dispatcher->output_fatal( $message, $e );
		exit;
	}

	/**
	 * Runs post-processing on the collected errors and updates the
	 * errors collected in the data->errors property.
	 *
	 * Any unreportable errors are placed in the data->filtered_errors
	 * property.
	 *
	 * @return void
	 */
	public function process() {
		$components = array();

		if ( ! empty( $this->data->errors ) ) {
			/**
			 * Filters the levels used for reported PHP errors on a per-component basis.
			 *
			 * Error levels can be specified in order to silence certain error levels from
			 * plugins or the current theme. Most commonly, you may wish to use this filter
			 * in order to silence annoying notices from third party plugins that you do not
			 * have control over.
			 *
			 * Silenced errors will still appear in Query Monitor's output, but will not
			 * cause highlighting to appear in the top level admin toolbar.
			 *
			 * For example, to show all errors in the 'foo' plugin except PHP notices use:
			 *
			 *     add_filter( 'qm/collect/php_error_levels', function( array $levels ) {
			 *         $levels['plugin']['foo'] = ( E_ALL & ~E_NOTICE );
			 *         return $levels;
			 *     } );
			 *
			 * Errors from themes, WordPress core, and other components can also be filtered:
			 *
			 *     add_filter( 'qm/collect/php_error_levels', function( array $levels ) {
			 *         $levels['theme']['stylesheet'] = ( E_WARNING & E_USER_WARNING );
			 *         $levels['theme']['template']   = ( E_WARNING & E_USER_WARNING );
			 *         $levels['core']['core']        = ( 0 );
			 *         return $levels;
			 *     } );
			 *
			 * Any component which doesn't have an error level specified via this filter is
			 * assumed to have the default level of `E_ALL`, which shows all errors.
			 *
			 * Valid PHP error level bitmasks are supported for each component, including `0`
			 * to silence all errors from a component. See the PHP documentation on error
			 * reporting for more info: http://php.net/manual/en/function.error-reporting.php
			 *
			 * @since 2.7.0
			 *
			 * @param array<string,array<string,int>> $levels The error levels used for each component.
			 */
			$levels = apply_filters( 'qm/collect/php_error_levels', array() );

			array_map( array( $this, 'filter_reportable_errors' ), $levels, array_keys( $levels ) );

			foreach ( $this->data->errors as $errors ) {
				foreach ( $errors as $error ) {
					$components[ $error['component']->get_id() ] = $error['component'];
				}
			}
			foreach ( $this->data->suppressed as $errors ) {
				foreach ( $errors as $error ) {
					$components[ $error['component']->get_id() ] = $error['component'];
				}
			}
			foreach ( $this->data->silenced as $errors ) {
				foreach ( $errors as $error ) {
					$components[ $error['component']->get_id() ] = $error['component'];
				}
			}
		}

		$this->data->components = $components;
	}

	/**
	 * Filters the reportable PHP errors using the table specified. Users can customize the levels
	 * using the `qm/collect/php_error_levels` filter.
	 *
	 * @param array<string, int> $components     The error levels keyed by component name.
	 * @param string             $component_type The component type, for example 'plugin' or 'theme'.
	 * @return void
	 */
	public function filter_reportable_errors( array $components, $component_type ) {
		$all_errors = $this->data->errors;

		foreach ( $components as $component_context => $allowed_level ) {
			foreach ( $all_errors as $error_level => $errors ) {
				foreach ( $errors as $error_id => $error ) {
					if ( $this->is_reportable_error( $error['errno'], $allowed_level ) ) {
						continue;
					}

					if ( ! $this->is_affected_component( $error['component'], $component_type, $component_context ) ) {
						continue;
					}

					unset( $this->data->errors[ $error_level ][ $error_id ] );

					$this->data->silenced[ $error_level ][ $error_id ] = $error;
				}
			}
		}

		$this->data->errors = array_filter( $this->data->errors );
	}

	/**
	 * Checks if the component is of the given type and has the given context. This is
	 * used to scope an error to a plugin or theme.
	 *
	 * @param QM_Component $component         The component.
	 * @param string       $component_type    The component type for comparison.
	 * @param string       $component_context The component context for comparison.
	 * @return bool
	 */
	public function is_affected_component( $component, $component_type, $component_context ) {
		return ( $component->type === $component_type && $component->context === $component_context );
	}

	/**
	 * Checks if the error number specified is viewable based on the
	 * flags specified.
	 *
	 * Eg:- If a plugin had the config flags,
	 *
	 *     E_ALL & ~E_NOTICE
	 *
	 * then,
	 *
	 *     is_reportable_error( E_NOTICE, E_ALL & ~E_NOTICE ) is false
	 *     is_reportable_error( E_WARNING, E_ALL & ~E_NOTICE ) is true
	 *
	 * If the `$flag` is null, all errors are assumed to be
	 * reportable by default.
	 *
	 * @param int      $error_no The errno from PHP
	 * @param int|null $flags The config flags specified by users
	 * @return bool Whether the error is reportable.
	 */
	public function is_reportable_error( $error_no, $flags ) {
		$result = true;

		if ( null !== $flags ) {
			$result = (bool) ( $error_no & $flags );
		}

		return $result;
	}

	/**
	 * For testing purposes only. Sets the errors property manually.
	 * Needed to test the filter since the data property is protected.
	 *
	 * @param array<string, mixed> $errors The list of errors
	 * @return void
	 */
	public function set_php_errors( $errors ) {
		$this->data->errors = $errors;
	}
}

# Load early to catch early errors
QM_Collectors::add( new QM_Collector_PHP_Errors() );
