<?php

/**
 * @since 1.0.1
 * @package mmalferov/enot
 *
 * Plugin Name: Error Monitor & Notifier
 * Plugin URI: https://wp-error.ru/
 * Description: Automatically PHP, JavaScript, and DB error monitoring and instant notification with detailed technical information about the failure
 * Version: 1.0.4
 * Requires at least: 5.8
 * Requires PHP: 7.4.0
 * Author: Mikhail Alferov <malferov@gmail.com>
 * Author URI: https://github.com/mmalferov
 * License: GPLv2 or later
 * Text Domain: error-monitor-and-notifier
 * Domain Path: /lang
 */

/**
 * @copyright Copyright (c) 2017. All rights reserved.
 *
 * @license Released under the GPL license http://www.opensource.org/licenses/gpl-license.php
 *
 * **********************************************************************
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * **********************************************************************
 */

declare(strict_types=1);

namespace Enot;

use Throwable;
use ErrorException;
use WP_Filesystem_Base;

// Avoid direct access to this piece of code
defined('ABSPATH') || exit;

require_once __DIR__ . '/vendor/autoload.php';

if (!class_exists('\Enot\Enot')) {
    define('ENOT_PLUGIN_VERSION', '1.0.4');
    define('ENOT_PLUGIN_NAME', 'Error Monitor & Notifier');
    define('ENOT_PLUGIN_SITE_URL', 'https://wp-error.ru');
    define('ENOT_PLUGIN_BASENAME', \plugin_basename(__FILE__)); // 'error-monitor-and-notifier/error-monitor-and-notifier.php'
    define('ENOT_PLUGIN_SLUG', \plugin_basename(__DIR__)); // 'error-monitor-and-notifier'
    define('ENOT_PLUGIN_FILE', __FILE__);
    define('ENOT_PLUGIN_ROOT_PATH', \plugin_dir_path(__FILE__));
    define('ENOT_PLUGIN_ROOT_URL', \plugin_dir_url(__FILE__));
    define('ENOT_PLUGIN_ADMIN_MENU_SETTINGS_SLUG', 'enot-settings');


	// phpcs:disable WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting
	// phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
	// phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_trigger_error

    final class Enot
    {
        private static ?self $instance = null;

        private static ?int $previous_error_reporting_level = null;

        private static string $domain = 'error-monitor-and-notifier';

        public static array $error_levels = [];

        public static array $config = [
            'option_name'                     => 'enot_free_option',
            'email'                           => '',
            'error_log'                       => '',
            'log_errors'                      => true,
            'display_errors'                  => false,
            'html_errors'                     => false,
            'log_e_user_errors'               => true,
            'log_full_backtrace'              => false,
            'log_suppressed_errors'           => false,
            'log_js_errors'                   => false,
            'default_severities'              => [
                -1,
                E_USER_ERROR,
                E_RECOVERABLE_ERROR,
                E_ERROR,
                E_PARSE,
                E_COMPILE_ERROR,
                E_CORE_ERROR,
            ],
            'severities'                      => [],
            'exclude_duplicate_notifications' => true,
            'database_max_errors'             => 5,
            'fatal_error_text'                => 'Error',
            'telegram'                        => null,
            'license'                         => null,
            'trigger_error_level'             => E_USER_WARNING,
            'trigger_error_types'             => ['email', 'telegram',],
            'trigger_error_message'           => '',
            'trigger_error_response_message'  => '',
        ];

        /** @var callable */
        private $previously_error_handler;

        /** @var callable */
        private $previously_exception_handler;

        /** @var array<EnotNotifier> Array of notification observers */
        private array $notifiers = [];

        private static bool $is_trigger_error_test = false;

		private static string $cwd;

        private function __construct()
        {
            if (\apply_filters('enot_force_error_reporting_all', true)) {
            	self::$previous_error_reporting_level = error_reporting(E_ALL);
            }

            self::initConfig();

			// Save the current working directory
            self::$cwd = getcwd();
            register_shutdown_function(function (): void {
                $this->shutdownHandler();
            });

            $this->previously_exception_handler = set_exception_handler(function (Throwable $e): void {
                $this->exceptionHandler($e);
            });

            $this->previously_error_handler = set_error_handler(function (
                int $errno,
                string $errstr,
                ?string $errfile = null,
                ?int $errline = null
            ): void {
                $this->errorHandler($errno, $errstr, $errfile, $errline);
            }, E_ALL);

            self::substituteConfigByOptions();
            self::initErrorLevels();

            \add_action('init', static function (): void {
                self::initAdminIncludes();
            });

            \add_action('init', static function (): void {
                self::initTranslations();
            });

            $this->notifiers = [
                new EnotNotifierLog(),
                new EnotNotifierEmail(),
            ];
        }

        public static function version(): string
        {
            $plugin_data = \get_plugin_data(ENOT_PLUGIN_FILE);
            return $plugin_data['Version'];
        }

        private static function initConfig(): void
        {
            self::$config['error_log'] = ini_get('error_log') ?: '';
            self::$config['log_errors'] = (bool) ini_get('log_errors');
            self::$config['display_errors'] = (bool) ini_get('display_errors');
            self::$config['html_errors'] = (bool) ini_get('html_errors');

            \add_action('init', static function () {
                self::$config['trigger_error_message'] = self::__(
                    "This is a user-level test exception that the plugin simulated solely for the purpose of testing notifications. The error does not require fixing and has no impact on the website's operation",
                    "error-monitor-and-notifier"
                );
            });
        }

        /**
         * Do NOT rely on current default literal values, as they may change in new PHP versions
         */
        private static function initErrorLevels(): void
        {
            self::$error_levels = [
                -1                  => ['name' => 'EXCEPTION',           'catchable' => true, 'fatal' => true,],

                E_USER_NOTICE       => ['name' => 'E_USER_NOTICE',       'catchable' => true, 'fatal' => false,],
                E_USER_WARNING      => ['name' => 'E_USER_WARNING',      'catchable' => true, 'fatal' => false,],
                E_USER_ERROR        => ['name' => 'E_USER_ERROR',        'catchable' => true, 'fatal' => true,],
                /** Will be removed in PHP 9.0.0 */
                E_USER_DEPRECATED   => ['name' => 'E_USER_DEPRECATED',   'catchable' => true, 'fatal' => false,],

                E_NOTICE            => ['name' => 'E_NOTICE',            'catchable' => true, 'fatal' => false,],
                E_WARNING           => ['name' => 'E_WARNING',           'catchable' => true, 'fatal' => false,],
                /** Will be removed in PHP 9.0.0 */
                E_RECOVERABLE_ERROR => ['name' => 'E_RECOVERABLE_ERROR', 'catchable' => true, 'fatal' => true,],
                /** Will be removed in PHP 9.0.0 */
                E_DEPRECATED        => ['name' => 'E_DEPRECATED',        'catchable' => true, 'fatal' => false,],
                E_ERROR             => ['name' => 'E_ERROR',             'catchable' => true, 'fatal' => true,],
                E_PARSE             => ['name' => 'E_PARSE',             'catchable' => true, 'fatal' => true,],

                E_COMPILE_WARNING   => ['name' => 'E_COMPILE_WARNING',   'catchable' => true, 'fatal' => false,],
                E_COMPILE_ERROR     => ['name' => 'E_COMPILE_ERROR',     'catchable' => true, 'fatal' => true,],

                E_CORE_WARNING      => ['name' => 'E_CORE_WARNING',      'catchable' => true, 'fatal' => false,],
                E_CORE_ERROR        => ['name' => 'E_CORE_ERROR',        'catchable' => true, 'fatal' => true,],

                /** Fatal errors that are ignored by the silence operator */
                // E_FATAL_ERRORS (E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR | E_PARSE)

                /** The same as (E_ERROR | E_WARNING | E_PARSE | E_NOTICE | E_CORE_ERROR | E_CORE_WARNING | E_COMPILE_ERROR | E_COMPILE_WARNING | E_USER_ERROR | E_USER_WARNING | E_USER_NOTICE | E_RECOVERABLE_ERROR | E_DEPRECATED | E_USER_DEPRECATED) */
                E_ALL               => ['name' => 'E_ALL',               'catchable' => null,  'fatal' => null,],
            ];

            if (version_compare(PHP_VERSION, '8.4.0', '<')) {
                /** Deprecated as of PHP 8.4.0 and will be removed in PHP 9.0.0 */
                self::$error_levels[E_STRICT] = ['name' => 'E_STRICT', 'catchable' => true, 'fatal' => false,];
            }
        }

        public static function errorLevels(): array
        {
            if (self::$error_levels !== []) {
                return self::$error_levels;
            }

            self::initErrorLevels();

            return self::$error_levels;
        }

        private static function substituteConfigByOptions(): void
        {
            if (($options = \get_option(self::$config['option_name'])) !== false) {
                self::$config['severities'] = $options['severities'] !== null ? $options['severities'] : self::$config['default_severities'];

                if (isset($options['email'])) {
                    self::$config['email'] = $options['email'];
                }

                if (isset($options['exclude_duplicate_notifications'])) {
                    self::$config['exclude_duplicate_notifications'] = (bool) $options['exclude_duplicate_notifications'];
                }

                if (isset($options['fatal_error_text'])) {
                    self::$config['fatal_error_text'] = $options['fatal_error_text'];
                }
            }
        }

        private static function initAdminIncludes(): void
        {
            if (\is_admin()) {
                EnotAdmin::init();
            }
        }

        private static function initTranslations(): void
        {
            \load_plugin_textdomain(
                self::$domain,
                false,
                ENOT_PLUGIN_SLUG . DIRECTORY_SEPARATOR . 'lang/',
            );

            $error_levels = &self::$error_levels;

            foreach ($error_levels as $severity => &$error_level) {
                switch ($severity) {
                    case -1:
                        $error_level['description'] = self::__('Exceptions', 'error-monitor-and-notifier');
                        break;
                    case E_USER_NOTICE:
                        $error_level['description'] = self::__('User defined notices generated by the trigger_error() function', 'error-monitor-and-notifier');
                        break;
                    case E_USER_WARNING:
                        $error_level['description'] = self::__('User defined warnings generated by the trigger_error() function', 'error-monitor-and-notifier');
                        break;
                    case E_USER_ERROR:
                        $error_level['description'] = self::__('User defined errors generated by the trigger_error() function', 'error-monitor-and-notifier');
                        break;
                    case E_USER_DEPRECATED:
                        $error_level['description'] = self::__('Runtime user defined notices generated by the trigger_error() function', 'error-monitor-and-notifier');
                        break;
                    case E_NOTICE:
                        $error_level['description'] = self::__('Runtime notices', 'error-monitor-and-notifier');
                        break;
                    case E_WARNING:
                        $error_level['description'] = self::__('Runtime warnings', 'error-monitor-and-notifier');
                        break;
                    case E_RECOVERABLE_ERROR:
                        $error_level['description'] = self::__('Catchable fatal error, legacy', 'error-monitor-and-notifier');
                        break;
                    case E_DEPRECATED:
                        $error_level['description'] = self::__('Runtime deprecated notices', 'error-monitor-and-notifier');
                        break;
                    case E_ERROR:
                        $error_level['description'] = self::__('Runtime fatal errors', 'error-monitor-and-notifier');
                        break;
                    case E_PARSE:
                        $error_level['description'] = self::__('Compile-time errors', 'error-monitor-and-notifier');
                        break;
                    case E_COMPILE_WARNING:
                        $error_level['description'] = self::__('Compile time warnings', 'error-monitor-and-notifier');
                        break;
                    case E_COMPILE_ERROR:
                        $error_level['description'] = self::__('Compile time fatal errors', 'error-monitor-and-notifier');
                        break;
                    case E_CORE_WARNING:
                        $error_level['description'] = self::__('The core warnings', 'error-monitor-and-notifier');
                        break;
                    case E_CORE_ERROR:
                        $error_level['description'] = self::__('The core fatal errors', 'error-monitor-and-notifier');
                        break;
                    case E_ALL:
                        $error_level['description'] = self::__('All errors, warnings and notices', 'error-monitor-and-notifier');
                        break;
                }
            }

            if (version_compare(PHP_VERSION, '8.4.0', '<') && array_key_exists(E_STRICT, $error_levels)) {
                $error_levels[E_STRICT]['description'] = self::__('Runtime suggestions', 'error-monitor-and-notifier');
            }
        }

        private static function setDefaultOptions(): void
        {
            \add_option(
                self::$config['option_name'],
                [
                    'email'                           => \get_option('admin_email', ''),
                    'severities'                      => self::$config['default_severities'],
                    'exclude_duplicate_notifications' => self::$config['exclude_duplicate_notifications'],
                    'fatal_error_text'                => self::$config['fatal_error_text'],
                    'license'                         => self::$config['license'],
                    'telegram'                        => self::$config['telegram'],
                    'is_option_autoload_enabled'      => true,
                ],
                '',
                true,
            );
        }

        private static function initOptions(): void
        {
			$options = \get_option(self::$config['option_name']);

            if ($options === false) {
                self::setDefaultOptions();
            } else {
				// Enable autoload of the option
				$options['is_option_autoload_enabled'] = true;
				\update_option(self::$config['option_name'], $options, 'yes');
			}
        }

		/**
         * @return WP_Filesystem_Base|false|null
         */
        private static function getWpFilesystem()
        {
            // Load the Filesystem API
            require_once ABSPATH . '/wp-admin/includes/file.php';

            // Initializes and connects the WordPress Filesystem Abstraction classes
            if (!function_exists('\WP_Filesystem') || !\WP_Filesystem(false)) {
                return false;
            }

            global $wp_filesystem;

            return $wp_filesystem;
        }

        private static function createMuPlugin(): bool
        {
            if (!($wp_filesystem = self::getWpFilesystem())) {
                return false;
            }

            if (!file_exists(WPMU_PLUGIN_DIR)) {
                if (!$wp_filesystem->mkdir(WPMU_PLUGIN_DIR, 0755)) {
                    return false;
                }
            }

            $mu_plugin_source = ENOT_PLUGIN_ROOT_PATH . DIRECTORY_SEPARATOR . ENOT_PLUGIN_SLUG . '-mu.php';

			if (!file_exists($mu_plugin_source)) {
                return false;
            }

            $mu_plugin_destination = WPMU_PLUGIN_DIR . DIRECTORY_SEPARATOR . ENOT_PLUGIN_SLUG . '-mu.php';

            if ($wp_filesystem->is_writable(WPMU_PLUGIN_DIR)) {
                if (!$wp_filesystem->copy($mu_plugin_source, $mu_plugin_destination, false, 0644)) {
                    return false;
                };

                return true;
            }

			return false;
        }

        private static function unlinkMuPlugin(): bool
        {
            if (!($wp_filesystem = self::getWpFilesystem())) {
                return false;
            }

            $mu_plugin_destination = WPMU_PLUGIN_DIR . DIRECTORY_SEPARATOR . ENOT_PLUGIN_SLUG . '-mu.php';

            if (!file_exists($mu_plugin_destination)) {
                return false;
            }

            if (!@$wp_filesystem->delete($mu_plugin_destination)) {
            	return false;
            }

            return true;
        }

        public static function config(): array
        {
            return self::$config;
        }

        public function __clone()
        {
            throw new \Exception(__METHOD__ . ' Cannot create an instance of a Singletone ' . __CLASS__);
        }

        public function __wakeup(): void
        {
            throw new \Exception(__METHOD__ . ' Cannot create an instance of a Singletone' . __CLASS__);
        }

        public static function getInstance(): self
        {
            return self::$instance ??= new self();
        }

        private function shutdownHandler(): void
        {
        	// Restore the initial current working directory
            chdir(self::$cwd);

            $last_error = error_get_last();

            // Do nothing if there are no errors, just shutdown
            if (is_null($last_error)) {
                return;
            }

            $this->exceptionHandler(new ErrorException(
                $last_error['message'], // Message
                1003,                   // Code
                $last_error['type'],    // Severity
                $last_error['file'],    // Filename
                $last_error['line'],    // Line
            ));
        }

        /**
         * The method will not work if the previously defined exception handler,
         * if any, does not return control
         *
         * @param Throwable|Error|Exception $e
         */
        private function exceptionHandler(Throwable $e): void
        {
        	if (
                \wp_doing_ajax()
                && (\current_action() === 'wp_ajax_delete-plugin' || \current_filter() === 'wp_ajax_delete-plugin')
            ) {
                return;
            }

            $severity = self::getSeverityLevel($e);

            if (\apply_filters('enot_ignore_error', false, new ErrorException(
                $e->getMessage(),
                $e->getCode(),
                $severity,
                str_replace(ABSPATH, '', $e->getFile()),
                $e->getLine(),
            ))) {
                return;
            }

            $this->notifyAll($e);

           	// Restore a previousl error reporting level
           	if (is_int(self::$previous_error_reporting_level)) {
           		error_reporting(self::$previous_error_reporting_level);
           	}

            if (self::$is_trigger_error_test) {
                return;
            }

            /**
             * Do not exit if user defined PHP error exclude the E_RECOVERABLE_ERROR
             *
             * 1001 error
             *
             */
            if (
                in_array($e->getCode(), [1001,], true)
                && $severity !== E_RECOVERABLE_ERROR
                && self::$config['display_errors'] === false
            ) {
                return;
            }

            // Output fatal error text
            if (self::isInFatal($severity)) {
                echo \wp_kses(\wp_specialchars_decode(\wp_unslash(self::$config['fatal_error_text'])), 'post');
            }

            // Return control to a previously defined exception handler, if any
            if ($this->previously_exception_handler !== null) {
                ($this->previously_exception_handler)($e);
            }

            if (self::$config['display_errors']) {
                echo \wp_kses(EnotNotifierLog::$log, 'post');
            }

            exit;
        }

        private function notifyAll(Throwable $e): void
        {
            // Do not handle a repeated notification
            if (self::$config['exclude_duplicate_notifications'] && EnotCache::exists($e)) {
                return;
            }

            foreach ($this->notifiers as $notifier) {
                $is_notified = $notifier->notify($e);

                // Cache only if at least one notification has been successfully sent,
                // otherwise the next notifications will not have a chance to be sent
                if (self::$config['exclude_duplicate_notifications'] && $is_notified && !EnotCache::exists($e)) {
                    EnotCache::add($e);
                }

                if (\apply_filters('enot_log_has_notification_sent', false)) {
                	// phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_error_log
                	// phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_var_export
                    error_log(get_class($notifier) . ' is sent: ' . var_export($is_notified, true));
                }
            }
        }

        /**
         * @param Throwable|ErrorException $e
         */
        public static function getSeverityLevel(Throwable $e): int
        {
            return method_exists($e, 'getSeverity') ? $e->getSeverity() : -1;
        }

        /**
         * The set_error_handler()'s callback for custom error handling of E_USER_* error levels
         */
        private function errorHandler(
            int $errno,
            string $errstr,
            ?string $errfile = null,
            ?int $errline = null
        ): bool {
            $this->exceptionHandler(
                new ErrorException(
                    // $message, // Message
                    $errstr,     // Message
                    1001,        // Code
                    $errno,      // Severity
                    $errfile,    // Filename
                    $errline,    // Line
                ),
            );

            // Return control to a previously defined error handler, if any
            if ($this->previously_error_handler !== null) {
                ($this->previously_error_handler)($errno, $errstr, $errfile, $errline);
            }

            // If true, the normal error handler is NOT called
            return true;
        }

        public static function isInFatal(int $severity): bool
        {
            return in_array($severity, [-1, E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR, E_PARSE,], true);
        }

        private static function processTriggerErrorEmail(?string $email = null): void
        {
            self::$is_trigger_error_test = true;
            self::$config['severities'] = [self::$config['trigger_error_level']];

            if ($email !== null && filter_var($email, FILTER_VALIDATE_EMAIL)) {
                self::$config['email'] = $email;
            }

            self::$config['log_errors'] = false;

            trigger_error(
                \wp_kses(self::$config['trigger_error_message'], 'post'),
                self::$config['trigger_error_level'],
            );

            $response = [
                'message' => self::$config['trigger_error_response_message']
                    ?: self::__(
                        "You're great! Please check your email to confirm that the error notification has been successfully delivered.",
                        "error-monitor-and-notifier"
                    ),
            ];

            echo \wp_json_encode($response);

            exit;
        }

        public static function triggerError(string $error_type, ?string $email = null): void
        {
            if ($error_type === 'email') {
                self::processTriggerErrorEmail($email);
            }
        }

        public static function __(string $text, string $domain): string
        {
        	// phpcs:disable WordPress.WP.I18n.NonSingularStringLiteralText
        	// phpcs:disable WordPress.WP.I18n.NonSingularStringLiteralDomain
        	return \did_action('init') ? \__($text, $domain) : $text;
        }

        public static function activate(): void
        {
            self::initOptions();

            $is_mu_plugin_created = self::createMuPlugin();

            $options = \get_option(self::$config['option_name']);
            $options['is_muplugin_active'] = $is_mu_plugin_created;
            \update_option(self::$config['option_name'], $options, 'yes');
        }

        public static function deactivate(): void
        {
            $is_mu_plugin_unlinked = self::unlinkMuPlugin();

            if (($options = \get_option(self::$config['option_name'])) !== false) {
                $options['is_muplugin_active'] = !$is_mu_plugin_unlinked;
                $options['is_option_autoload_enabled'] = false;
                \update_option(self::$config['option_name'], $options, 'no');
            }
        }

        public static function uninstall(): void
        {
        	restore_error_handler();
            restore_exception_handler();

            \delete_option(self::$config['option_name']);
        }
    }

    \register_activation_hook(__FILE__,   [namespace\Enot::class, 'activate']);
    \register_deactivation_hook(__FILE__, [namespace\Enot::class, 'deactivate']);

    $enot = Enot::getInstance();
}
