<?php

namespace Simple_History\Loggers;

use Simple_History\Event_Details\Event_Details_Container;
use Simple_History\Event_Details\Event_Details_Group;
use Simple_History\Event_Details\Event_Details_Item;
use Simple_History\Helpers;
use Simple_History\Log_Initiators;

/**
 * Logs plugin related things, for example installs, updates, and deletions.
 */
class Plugin_Logger extends Logger {

	/**
	 * The logger slug.
	 *
	 * @var string $slug
	 */
	public $slug = 'SimplePluginLogger';

	/**
	 * This variable is set if a plugins has been disabled due to an error,
	 * like when the plugin file does not exist. We need to store this in this
	 * weird way because there is no other way for us to get the reason.
	 *
	 * @var array $latest_plugin_deactivation_because_of_error_reason
	 */
	public $latest_plugin_deactivation_because_of_error_reason = [];

	/**
	 * Array with package results.
	 * The result can contain errors, like if the old plugin could not be removed.
	 * These errors are not added in the final upgrader class response when using bulk update.
	 * So this is the only way to get them.
	 * Used to detect rollback scenarios (WordPress 6.3+ feature).
	 *
	 * Structure: [
	 *   'plugin-slug/plugin-file.php' => [
	 *     'result' => array|WP_Error,
	 *     'hook_extra' => array,
	 *     'rollback_will_occur' => bool,
	 *     'rollback_info' => [
	 *       'backup_slug' => string,
	 *       'backup_dir' => string,
	 *       'error_code' => string,
	 *       'error_message' => string,
	 *     ]
	 *   ]
	 * ]
	 *
	 * @var array $package_results
	 */
	public $package_results = [];

	/**
	 * Used to collect information about a plugin (using get_plugin_data()) before it is deleted.
	 * Plugin info is stored with plugin file as the key.
	 *
	 * @var array
	 */
	protected $plugins_data = array();

	/**
	 * Get array with information about this logger
	 *
	 * @return array
	 */
	public function get_info() {
		return array(
			'name'        => __( 'Plugin Logger', 'simple-history' ),
			'description' => __( 'Logs plugin installs, uninstalls and updates', 'simple-history' ),
			'capability'  => 'activate_plugins',
			'messages'    => array(

				'plugin_activated'              => _x(
					'Activated plugin "{plugin_name}"',
					'Plugin was non-silently activated by a user',
					'simple-history'
				),

				'plugin_deactivated'            => _x(
					'Deactivated plugin "{plugin_name}"',
					'Plugin was non-silently deactivated by a user',
					'simple-history'
				),

				'plugin_installed'              => _x(
					'Installed plugin "{plugin_name}"',
					'Plugin was installed',
					'simple-history'
				),

				'plugin_installed_failed'       => _x(
					'Failed to install plugin "{plugin_name}"',
					'Plugin failed to install',
					'simple-history'
				),

				'plugin_updated'                => _x(
					'Updated plugin "{plugin_name}" to version {plugin_version} from {plugin_prev_version}',
					'Plugin was updated',
					'simple-history'
				),

				'plugin_update_failed'          => _x(
					'Failed to update plugin "{plugin_name}"',
					'Plugin update failed',
					'simple-history'
				),

				'plugin_update_rolled_back'     => _x(
					'Plugin "{plugin_name}" was rolled back to version {plugin_prev_version} after update to {plugin_version} failed',
					'Plugin update was rolled back',
					'simple-history'
				),

				'plugin_deleted'                => _x(
					'Deleted plugin "{plugin_name}"',
					'Plugin files was deleted',
					'simple-history'
				),

				// Bulk versions.
				'plugin_bulk_updated'           => _x(
					'Updated plugin "{plugin_name}" to {plugin_version} from {plugin_prev_version}',
					'Plugin was updated in bulk',
					'simple-history'
				),

				'plugin_bulk_updated_failed'    => _x(
					'Failed to update plugin "{plugin_name}"',
					'Plugin failed to update in bulk',
					'simple-history'
				),

				// Plugin disabled due to some error.
				'plugin_disabled_because_error' => _x(
					'Deactivated plugin "{plugin_slug}" because of an error ("{deactivation_reason}").',
					'Plugin was disabled because of an error',
					'simple-history'
				),

				'plugin_auto_updates_enabled'   => _x(
					'Enabled auto-updates for plugin "{plugin_name}"',
					'Plugin was enabled for auto-updates',
					'simple-history'
				),
				'plugin_auto_updates_disabled'  => _x(
					'Disabled auto-updates for plugin "{plugin_name}"',
					'Plugin was enabled for auto-updates',
					'simple-history'
				),

			), // Messages.
			'labels'      => array(
				'search' => array(
					'label'     => _x( 'Plugins', 'Plugin logger: search', 'simple-history' ),
					'label_all' => _x( 'All plugin activity', 'Plugin logger: search', 'simple-history' ),
					'options'   => array(
						_x( 'Activated plugins', 'Plugin logger: search', 'simple-history' ) => array(
							'plugin_activated',
						),
						_x( 'Deactivated plugins', 'Plugin logger: search', 'simple-history' ) => array(
							'plugin_deactivated',
							'plugin_disabled_because_error',
						),
						_x( 'Installed plugins', 'Plugin logger: search', 'simple-history' ) => array(
							'plugin_installed',
						),
						_x( 'Failed plugin installs', 'Plugin logger: search', 'simple-history' ) => array(
							'plugin_installed_failed',
						),
						_x( 'Updated plugins', 'Plugin logger: search', 'simple-history' ) => array(
							'plugin_updated',
							'plugin_bulk_updated',
						),
						_x( 'Failed plugin updates', 'Plugin logger: search', 'simple-history' ) => array(
							'plugin_update_failed',
							'plugin_bulk_updated_failed',
						),
						_x( 'Deleted plugins', 'Plugin logger: search', 'simple-history' ) => array(
							'plugin_deleted',
						),
					),
				), // search array.
			), // labels.
		);
	}

	/**
	 * Plugin loaded.
	 */
	public function loaded() {
		/**
		 * At least the plugin bulk upgrades fires this action before upgrade
		 * We use it to fetch the current version of all plugins, before they are upgraded
		 */
		add_filter( 'upgrader_pre_install', array( $this, 'save_versions_before_update' ), 10, 2 );

		// Fires after a plugin has been activated.
		// If a plugin is silently activated (such as during an update),
		// this hook does not fire.
		add_action( 'activated_plugin', array( $this, 'on_activated_plugin' ), 10, 2 );

		// Fires after a plugin is deactivated.
		// If a plugin is silently deactivated (such as during an update),
		// this hook does not fire.
		add_action( 'deactivated_plugin', array( $this, 'on_deactivated_plugin' ), 10, 1 );

		// Fires after plugin install/update completes (but BEFORE health check for auto-updates).
		// Logs: plugin_installed, plugin_updated, plugin_bulk_updated, etc.
		// Also adds plugin_update_type context: forced_security, user_enabled, or manual.
		add_action( 'upgrader_process_complete', array( $this, 'on_upgrader_process_complete' ), 10, 2 );

		// Fires after automatic updates complete, including any rollbacks (WP 6.3+).
		// This hook fires AFTER health check, so we can detect rollbacks here.
		// Logs: plugin_update_rolled_back when fatal error caused rollback.
		add_action( 'automatic_updates_complete', array( $this, 'on_automatic_updates_complete' ), 10, 1 );

		// Ajax function to get info from GitHub repo. Used by "View plugin info"-link for plugin installs.
		add_action( 'wp_ajax_SimplePluginLogger_GetGitHubPluginInfo', array( $this, 'ajax_GetGitHubPluginInfo' ) );

		// If the Github Update plugin is not installed we need to get extra fields used by it.
		// So need to hook filter "extra_plugin_headers" ourself.
		add_filter(
			'extra_plugin_headers',
			function ( $arr_headers ) {
				$arr_headers[] = 'GitHub Plugin URI';
				return $arr_headers;
			}
		);

		// There is no way to use a filter and detect a plugin that is disabled because it can't be found or similar error.
		// So we hook into gettext and look for the usage of the error that is returned when this happens.
		// Only register gettext filters and auto-update hooks when on plugins.php page for better performance.
		add_action( 'load-plugins.php', array( $this, 'on_load_plugins_page' ) );
		add_action( 'wp_ajax_toggle-auto-updates', array( $this, 'handle_auto_update_change' ), 1, 1 );

		// Log plugin deletions, i.e. when a user click "Delete" in the plugins listing
		// or choose plugin(s) and select Bulk actions -> Delete.
		// Since WordPress 4.4 filters exists that are fired before and after plugin deletion.
		add_action( 'delete_plugin', array( $this, 'on_action_delete_plugin' ), 10, 1 );
		add_action( 'deleted_plugin', array( $this, 'on_action_deleted_plugin' ), 10, 2 );

		// Capture individual plugin install/update errors. These errors are not included in the final
		// upgrader response during bulk updates, so this is the only way to get them.
		add_filter( 'upgrader_install_package_result', [ $this, 'on_upgrader_install_package_result' ], 10, 2 );
	}

	/**
	 * Register gettext filters and handle auto-update when loading the plugins.php page for performance optimization.
	 * Only register these filters and run functionality when they're actually needed.
	 */
	public function on_load_plugins_page() {
		add_filter( 'gettext', array( $this, 'on_gettext_detect_plugin_error_deactivation_reason' ), 10, 3 );
		add_filter( 'gettext', array( $this, 'on_gettext' ), 10, 3 );

		// Handle auto-update change detection.
		$this->handle_auto_update_change();
	}

	/**
	 * Filters the result of WP_Upgrader::install_package().
	 * Fired from WP_Upgrader class (or a subclass like Plugin_Upgrader in the run() function.
	 * Here we can get access to any errors that happen during plugin install/update.
	 * This hook is fired once for each plugin.
	 *
	 * @param array|\WP_Error $result     Result from WP_Upgrader::install_package().
	 * @param array           $hook_extra Extra arguments passed to hooked filters.
	 */
	public function on_upgrader_install_package_result( $result, $hook_extra ) {
		/*
		$result example:

		WP_Error Object
		(
			[errors] => Array
				(
					[remove_old_failed] => Array
						(
							[0] => Could not remove the old plugin.
						)

				)

			[error_data] => Array
				(
				)

			[additional_data:protected] => Array
				(
				)

		)

		$hook_extra example:

		Array
		(
			[plugin] => classic-widgets/classic-widgets.php
			[temp_backup] => Array
				(
					[slug] => classic-widgets
					[src] => /var/www/html/wp-content/plugins
					[dir] => plugins
				)

		)
		*/

		$plugin_main_file_path = $hook_extra['plugin'] ?? null;

		if ( ! $plugin_main_file_path ) {
			return $result;
		}

		$this->package_results[ $plugin_main_file_path ] = [
			'result'     => $result,
			'hook_extra' => $hook_extra,
		];

		// Detect if rollback will occur (WordPress 6.3+ feature).
		// Rollback happens when:
		// 1. This is an update (temp_backup exists in hook_extra)
		// 2. The update failed (result is WP_Error)
		// When both conditions are true, WordPress will automatically restore
		// the previous version from the temporary backup on shutdown.
		$is_update = isset( $hook_extra['temp_backup'] );
		$has_error = is_wp_error( $result );

		if ( $is_update && $has_error ) {
			$this->package_results[ $plugin_main_file_path ]['rollback_will_occur'] = true;
			$this->package_results[ $plugin_main_file_path ]['rollback_info']       = [
				'backup_slug'   => $hook_extra['temp_backup']['slug'] ?? '',
				'backup_dir'    => $hook_extra['temp_backup']['dir'] ?? '',
				'error_code'    => $result->get_error_code(),
				'error_message' => $result->get_error_message(),
			];
		}

		return $result;
	}

	/**
	 * Store information about a plugin before it gets deleted.
	 * Called from action `deleted_plugin` that is fired just before the plugin will be deleted.
	 *
	 * @param string $plugin_file Path to the plugin file relative to the plugins directory.
	 * @return void
	 */
	public function on_action_delete_plugin( $plugin_file ) {
		$this->plugins_data[ $plugin_file ] = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file, true, false );
	}

	/**
	 * Log plugin deletion.
	 * Called from action `deleted_plugin` that is fired just after a plugin has been deleted.
	 *
	 * @param string $plugin_file Path to the plugin file relative to the plugins directory.
	 * @param bool   $deleted     Whether the plugin deletion was successful.
	 * @return void
	 */
	public function on_action_deleted_plugin( $plugin_file, $deleted ) {
		if ( ! $deleted ) {
			return;
		}

		if ( empty( $this->plugins_data[ $plugin_file ] ) ) {
			return;
		}

		$plugin_data = $this->plugins_data[ $plugin_file ];

		$context = array(
			'plugin'             => $plugin_file,
			'plugin_name'        => $plugin_data['Name'],
			'plugin_title'       => $plugin_data['Title'],
			'plugin_description' => $plugin_data['Description'],
			'plugin_author'      => $plugin_data['Author'],
			'plugin_version'     => $plugin_data['Version'],
			'plugin_url'         => $plugin_data['PluginURI'],
		);

		$this->info_message(
			'plugin_deleted',
			$context
		);
	}

	/**
	 * Detect when a plugin is enable or disabled to be auto updated.
	 * This can be changed from the plugins-page, either from a GET request to
	 * the plugins-page or via an AJAX call.
	 *
	 * The result of the action is stored in
	 * site_option 'auto_update_plugins'.
	 * Check the value of that option after the option is updated.
	 */
	public function handle_auto_update_change() {
		$option = 'auto_update_plugins';

		add_action(
			"update_option_{$option}",
			// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
			function ( $old_value, $value, $option ) {
				/**
				 * Option contains array with plugin that are set to be auto updated.
				 * Example:
				 * Array
				 *   (
				 *       [1] => query-monitor/query-monitor.php
				 *       [2] => akismet/akismet.php
				 *       [3] => wp-crontrol/wp-crontrol.php
				 *       [4] => redirection/redirection.php
				 *   )
				 *
				 * $_GET when opening single item enable/disable auto update link in plugin list in new window
				 *   Array
				 *   (
				 *       [action] => disable-auto-update | enable-auto-update
				 *       [plugin] => akismet/akismet.php
				 *   )
				 *
				 *
				 * $_POST from ajax call when clicking single item enable/disable link in plugin list
				 *    [action] => toggle-auto-updates
				 *    [state] => disable | enable
				 *    [type] => plugin
				 *    [asset] => redirection/redirection.php
				 *
				 *
				 * $_POST when selecting multiple plugins and choosing Enable auto updates or Disable auto updates
				 *     [action] => enable-auto-update-selected | disable-auto-update-selected
				 *     [checked] => Array
				 *         (
				 *             [0] => query-monitor/query-monitor.php
				 *             [1] => redirection/redirection.php
				 *         )
				 */

				// phpcs:ignore WordPress.Security.NonceVerification.Recommended
				$action = sanitize_text_field( wp_unslash( $_GET['action'] ?? '' ) );
				if ( ! $action ) {
					// phpcs:ignore WordPress.Security.NonceVerification.Missing
					$action = sanitize_text_field( wp_unslash( $_POST['action'] ?? '' ) );
				}

				// Bail if doing ajax and
				// - action is not toggle-auto-updates.
				// - type is not plugin.
				if ( wp_doing_ajax() ) {
					if ( $action !== 'toggle-auto-updates' ) {
						return;
					}

					// phpcs:ignore WordPress.Security.NonceVerification.Missing
					$type = sanitize_text_field( wp_unslash( $_POST['type'] ?? '' ) );
					if ( $type !== 'plugin' ) {
						return;
					}
				}

				// Bail if screen and not plugin screen.
				$current_screen = get_current_screen();
				if ( is_a( $current_screen, 'WP_Screen' ) && ( $current_screen->base !== 'plugins' ) ) {
					return;
				}

				// Enable or disable, string "enable" or "disable".
				$enableOrDisable = null;

				// Plugin slugs that actions are performed against.
				$plugins = array();

				if ( in_array( $action, array( 'enable-auto-update', 'disable-auto-update' ), true ) ) {
					// Opening single item enable/disable auto update link in plugin list in new window.
					// phpcs:ignore WordPress.Security.NonceVerification.Recommended
					$plugin = sanitize_text_field( wp_unslash( $_GET['plugin'] ?? '' ) );

					if ( $plugin ) {
						$plugins[] = sanitize_text_field( urldecode( $plugin ) );
					}

					if ( $action === 'enable-auto-update' ) {
						$enableOrDisable = 'enable';
					} elseif ( $action === 'disable-auto-update' ) {
						$enableOrDisable = 'disable';
					}
				} elseif ( $action === 'toggle-auto-updates' ) {
					// Ajax post call when clicking single item enable/disable link in plugin list.
					// *    [action] => toggle-auto-updates
					// *    [state] => disable | enable
					// *    [type] => plugin
					// *    [asset] => redirection/redirection.php.
					// phpcs:ignore WordPress.Security.NonceVerification.Missing
					$state = sanitize_text_field( wp_unslash( $_POST['state'] ?? '' ) );
					// phpcs:ignore WordPress.Security.NonceVerification.Missing
					$asset = sanitize_text_field( wp_unslash( $_POST['asset'] ?? '' ) );

					if ( $state === 'enable' ) {
						$enableOrDisable = 'enable';
					} elseif ( $state === 'disable' ) {
						$enableOrDisable = 'disable';
					}

					if ( $asset ) {
						$plugins[] = sanitize_text_field( urldecode( $asset ) );
					}
				} elseif ( in_array( $action, array( 'enable-auto-update-selected', 'disable-auto-update-selected' ), true ) ) {
					// $_POST when checking multiple plugins and choosing Enable auto updates or Disable auto updates.
					// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
					$checked = wp_unslash( $_POST['checked'] ?? null );
					if ( $checked ) {
						$plugins = (array) $checked;
					}

					if ( $action === 'enable-auto-update-selected' ) {
						$enableOrDisable = 'enable';
					} elseif ( $action === 'disable-auto-update-selected' ) {
						$enableOrDisable = 'disable';
					}
				}

				// Now we have:
				// - an array of plugin slugs in $plugins.
				// - if plugin auto updates is to be enabled or disabled din $enableOrDisable.

				// Bail if required values not set.
				if ( ! $plugins || ! $enableOrDisable ) {
					return;
				}

				// Finally log each plugin.
				foreach ( $plugins as $onePluginSlug ) {
					$this->logPluginAutoUpdateEnableOrDisable( $onePluginSlug, $enableOrDisable );
				}
			},
			10,
			3
		);
	}

	/**
	 * Log plugin that is enable or disabled for auto updates.
	 *
	 * @param string $onePluginSlug slug of plugin, i.e. "hello-dolly/hello.php".
	 * @param string $enableOrDisable String "enable" or "disable".
	 */
	public function logPluginAutoUpdateEnableOrDisable( $onePluginSlug, $enableOrDisable ) {
		$pluginFile = WP_PLUGIN_DIR . '/' . $onePluginSlug;
		$pluginData = get_plugin_data( $pluginFile, true, false );

		$context = array(
			'plugin_slug'    => $onePluginSlug,
			'plugin_name'    => $pluginData['Name'] ?? null,
			'plugin_version' => $pluginData['Version'] ?? null,
		);

		if ( $enableOrDisable === 'enable' ) {
			$this->info_message( 'plugin_auto_updates_enabled', $context );
		} elseif ( $enableOrDisable === 'disable' ) {
			$this->info_message( 'plugin_auto_updates_disabled', $context );
		}
	}

	/**
	 * Detect when a plugin is deactivated due to an error, like if the plugin file has been deleted.
	 *
	 * @param string $translation Translation.
	 * @param string $text Text.
	 * @param string $domain Domain.
	 */
	public function on_gettext_detect_plugin_error_deactivation_reason( $translation, $text, $domain ) {
		global $pagenow;

		// We only act on page plugins.php.
		if ( ! isset( $pagenow ) || $pagenow !== 'plugins.php' ) {
			return $translation;
		}

		// We only act if the untranslated text is among the following ones
		// (Literally these, no translation).
		$untranslated_texts = array(
			'Plugin file does not exist.',
			'Invalid plugin path.',
			'The plugin does not have a valid header.',
		);

		if ( ! in_array( $text, $untranslated_texts, true ) ) {
			return $translation;
		}

		// Text was among our wanted texts.
		switch ( $text ) {
			case 'Plugin file does not exist.':
				$this->latest_plugin_deactivation_because_of_error_reason[] = 'file_does_not_exist';
				break;
			case 'Invalid plugin path.':
				$this->latest_plugin_deactivation_because_of_error_reason[] = 'invalid_path';
				break;
			case 'The plugin does not have a valid header.':
				$this->latest_plugin_deactivation_because_of_error_reason[] = 'no_valid_header';
				break;
		}

		return $translation;
	}

	/**
	 * There is no way to use a filter and detect a plugin that is disabled because it can't be found or similar error.
	 * we hook into gettext and look for the usage of the error that is returned when this happens.
	 *
	 * A plugin gets deactivated when plugins.php is visited function validate_active_plugins()
	 *      return new WP_Error('plugin_not_found', __('Plugin file does not exist.'));
	 * and if invalid plugin is found then this is outputted
	 *  printf(
	 *  /* translators: 1: plugin file 2: error message
	 *  __( 'The plugin %1$s has been <strong>deactivated</strong> due to an error: %2$s' ),
	 *  '<code>' . esc_html( $plugin_file ) . '</code>',
	 *  $error->get_error_message() );
	 *
	 * @param string $translation Translation.
	 * @param string $text Text.
	 * @param string $domain Domain.
	 */
	public function on_gettext( $translation, $text, $domain ) {

		global $pagenow;

		// We only act on page plugins.php.
		if ( ! isset( $pagenow ) || $pagenow !== 'plugins.php' ) {
			return $translation;
		}

		// We only act if the untranslated text is among the following ones
		// (Literally these, no translation).
		$untranslated_texts = array(
			// This string is called later than the above.
			'The plugin %1$s has been <strong>deactivated</strong> due to an error: %2$s',
		);

		if ( ! in_array( $text, $untranslated_texts, true ) ) {
			return $translation;
		}

		// Directly after the string is translated 'esc_html' is called with the plugin name.
		// This is one of the few ways we can get the name of the plugin.
		// The esc_html filter is used pretty much but we make sure we only do our.
		// stuff the first time it's called (directly after the gettet for the plugin disabled-error..).
		$logger_instance = $this;

		add_filter(
			'esc_html',
			function ( $safe_text, $text ) use ( $logger_instance ) {
				static $is_called = false;

				if ( $is_called === false ) {
					$is_called = true;

					$deactivation_reason = array_shift( $logger_instance->latest_plugin_deactivation_because_of_error_reason );

					// We don't know what plugin that was that got this error and currently there does not seem to be a way to determine that.
					// So that's why we use such generic log messages.
					$logger_instance->warning_message(
						'plugin_disabled_because_error',
						array(
							'_initiator'          => Log_Initiators::WORDPRESS,
							'plugin_slug'         => $text,
							'deactivation_reason' => $deactivation_reason,
						)
					);
				}

				return $safe_text;
			},
			10,
			2
		);

		return $translation;
	}

	/**
	 * Show readme from github in a modal win
	 */
	public function ajax_GetGitHubPluginInfo() {

		check_admin_referer( 'simple-history-github-plugin-info' );

		if ( ! current_user_can( 'install_plugins' ) ) {
			wp_die( esc_html__( "You don't have access to this page.", 'simple-history' ) );
		}

		$repo = isset( $_GET['repo'] ) ? (string) sanitize_text_field( wp_unslash( $_GET['repo'] ) ) : '';

		if ( $repo === '' ) {
			wp_die( esc_html__( 'Could not find GitHub repository.', 'simple-history' ) );
		}

		$repo_parts = explode( '/', rtrim( $repo, '/' ) );
		if ( count( $repo_parts ) !== 5 ) {
			wp_die( esc_html__( 'Could not find GitHub repository.', 'simple-history' ) );
		}

		$repo_username = $repo_parts[3];
		$repo_repo     = $repo_parts[4];

		// https://developer.github.com/v3/repos/contents/.
		// https://api.github.com/repos/<username>/<repo>/readme.
		$api_url = sprintf(
			'https://api.github.com/repos/%1$s/%2$s/readme',
			rawurlencode( $repo_username ),
			rawurlencode( $repo_repo ) 
		);

		// Get file. Use accept-header to get file as HTML instead of JSON.
		// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get
		$response = wp_remote_get(
			$api_url,
			array(
				'headers' => array(
					'accept' => 'application/vnd.github.VERSION.html',
				),
			)
		);

		$response_body = wp_remote_retrieve_body( $response );

		$repo_info = '<p>' . sprintf(
			// translators: %1$s is a link to the repo, %2$s is the repo name.
			__( 'Viewing <code>readme</code> from repository <code><a target="_blank" href="%1$s">%2$s</a></code>.', 'simple-history' ),
			esc_url( $repo ),
			esc_html( $repo )
		) . '</p>';

		$github_markdown_css_path = SIMPLE_HISTORY_PATH . '/css/github-markdown.css';

		$escaped_response_body = wp_kses(
			$response_body,
			array(
				'p'    => array(),
				'div'  => array(),
				'h1'   => array(),
				'h2'   => array(),
				'h3'   => array(),
				'code' => array(),
				'a'    => array(
					'href' => array(),
				),
				'img'  => array(
					'src' => array(),
				),
				'ul'   => array(),
				'li'   => array(),
			)
		);

		printf(
			'
				<!doctype html>
				<style>
					body {
						font-family: sans-serif;
						font-size: 16px;
					}
					.repo-info {
						padding: 1.25em 1em;
						background: #fafafa;
						line-height: 1;
					}
					.repo-info p {
						margin: 0;
					}
					    .markdown-body {
				        min-width: 200px;
				        max-width: 790px;
				        margin: 0 auto;
				        padding: 30px;
				    }

					@import url("%3$s");

				</style>

				<base href="%4$s/raw/master/">
				<base target="_blank">

				<header class="repo-info">
					%1$s
				</header>

				<div class="markdown-body readme-contents">
					%2$s
				</div>
			',
			$repo_info, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			$escaped_response_body, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			esc_url( $github_markdown_css_path ), // 3
			esc_url( $repo ) // 4
		);

		exit;
	}

	/**
	 * Saves info about all installed plugins to an option.
	 * When we are done logging then we remove the option.
	 * Fired from filter `upgrader_pre_install`.
	 *
	 * @param bool|\WP_Error $bool_or_error Default null.
	 * @param array          $hook_extra Default null.
	 */
	public function save_versions_before_update( $bool_or_error = null, $hook_extra = null ) {
		update_option(
			$this->get_slug() . '_plugin_info_before_update',
			Helpers::json_encode( get_plugins() ),
			false
		);

		return $bool_or_error;
	}

	/**
	 * Called when plugins is updated or installed
	 * Called from class-wp-upgrader.php
	 *
	 * @param \Plugin_Upgrader $plugin_upgrader_instance Plugin_Upgrader instance. In other contexts, $this, might
	 *                                                  be a Theme_Upgrader or Core_Upgrade instance.
	 * @param array            $arr_data                 Array of bulk item update data.
	 */
	public function on_upgrader_process_complete( $plugin_upgrader_instance, $arr_data ) {
		// Bail if not plugin update data.
		if ( ! isset( $arr_data['type'] ) || $arr_data['type'] !== 'plugin' ) {
			return;
		}

		$this->on_upgrader_process_complete_log_single_plugin_install( $plugin_upgrader_instance, $arr_data );
		$this->on_upgrader_process_complete_log_single_plugin_update( $plugin_upgrader_instance, $arr_data );
		$this->on_upgrader_process_complete_log_bulk_plugin_update( $plugin_upgrader_instance, $arr_data );
	}

	/**
	 * Log single, non bulk, plugin update or update failure.
	 * Not sure when this happens anymore, I always get bulk results, no matter where I
	 * update from.
	 *
	 * @param \Plugin_Upgrader $plugin_upgrader_instance Plugin_Upgrader instance.
	 * @param array            $arr_data                 Array of bulk item update data.
	 */
	protected function on_upgrader_process_complete_log_single_plugin_update( $plugin_upgrader_instance, $arr_data ) {
		// Bail if not single plugin non bulk update.
		if ( ! isset( $arr_data['action'] ) || $arr_data['action'] !== 'update' || $plugin_upgrader_instance->bulk ) {
			return;
		}

		// No plugin info in instance, so get it ourself.
		$plugin_data = [];
		if ( file_exists( WP_PLUGIN_DIR . '/' . $arr_data['plugin'] ) ) {
			$plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $arr_data['plugin'], true, false );
		}

		// Fall back to pre-update stored data when get_plugin_data() returns empty Name.
		// Custom updaters (like Code Profiler Pro) may not have the file in place yet.
		$plugins_before_update = json_decode( get_option( $this->get_slug() . '_plugin_info_before_update', false ), true );
		if ( empty( $plugin_data['Name'] ) && is_array( $plugins_before_update ) && isset( $plugins_before_update[ $arr_data['plugin'] ] ) ) {
			$plugin_data = $plugins_before_update[ $arr_data['plugin'] ];
		}

		// autoptimize/autoptimize.php.
		$plugin_slug = dirname( $arr_data['plugin'] );

		$context = [
			'plugin_slug'        => $plugin_slug,
			'request'            => Helpers::json_encode( $_REQUEST ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			'plugin_name'        => $plugin_data['Name'] ?? '',
			'plugin_title'       => $plugin_data['Title'] ?? '',
			'plugin_description' => $plugin_data['Description'] ?? '',
			'plugin_author'      => $plugin_data['Author'] ?? '',
			'plugin_version'     => $plugin_data['Version'] ?? '',
			'plugin_url'         => $plugin_data['PluginURI'] ?? '',
			'plugin_was_active'  => is_plugin_active( $arr_data['plugin'] ) ? '1' : '0',
		];

		// Add Update URI if it is set. Available since WP 5.8.
		if ( isset( $plugin_data['UpdateURI'] ) ) {
			$context['plugin_update_uri'] = $plugin_data['UpdateURI'];
		}

		// update status for plugins are in response
		// plugin folder + index file = key
		// use transient to get url and package.
		$update_plugins = get_site_transient( 'update_plugins' );
		if ( $update_plugins && isset( $update_plugins->response[ $arr_data['plugin'] ] ) ) {
			$plugin_update_info = $update_plugins->response[ $arr_data['plugin'] ];

			// autoptimize/autoptimize.php.
			if ( isset( $plugin_update_info->plugin ) ) {
				$context['plugin_update_info_plugin'] = $plugin_update_info->plugin;
			}

			// https://downloads.wordpress.org/plugin/autoptimize.1.9.1.zip.
			if ( isset( $plugin_update_info->package ) ) {
				$context['plugin_update_info_package'] = $plugin_update_info->package;
			}

			// Upgrade notice from WordPress.org, e.g. "Version X contains security fixes...".
			if ( ! empty( $plugin_update_info->upgrade_notice ) ) {
				$context['plugin_upgrade_notice'] = $plugin_update_info->upgrade_notice;
			}
		}

		// To get old version we use the pre-update data (already fetched above for name fallback).
		if ( is_array( $plugins_before_update ) && isset( $plugins_before_update[ $arr_data['plugin'] ] ) ) {
			$context['plugin_prev_version'] = $plugins_before_update[ $arr_data['plugin'] ]['Version'];
		}

		// Add update type context (forced_security, user_enabled, manual).
		$context = $this->add_update_type_context( $context, $arr_data['plugin'], $update_plugins );

		if ( is_a( $plugin_upgrader_instance->skin->result, 'WP_Error' ) ) {
			// Add errors
			// Errors is in original wp admin language.
			// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
			$context['error_messages'] = json_encode( $plugin_upgrader_instance->skin->result->errors );
			// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
			$context['error_data'] = json_encode( $plugin_upgrader_instance->skin->result->error_data );

			// Add rollback context if rollback will occur.
			$context = $this->add_rollback_context( $context, $arr_data['plugin'] );

			$this->info_message(
				'plugin_update_failed',
				$context
			);
		} elseif ( ( $context['plugin_update_type'] ?? '' ) === 'forced_security' ) {
			// Use notice level for forced security updates (WordPress.org override).
			$this->notice_message(
				'plugin_updated',
				$context
			);
		} else {
			$this->info_message(
				'plugin_updated',
				$context
			);
		}
	}

	/**
	 * Log bulk plugin update.
	 *
	 * @param \Plugin_Upgrader $plugin_upgrader_instance Plugin_Upgrader instance.
	 * @param array            $arr_data {
	 *     Array of bulk item update data.
	 *
	 *     @type string $action       Type of action. Default 'update'.
	 *     @type string $type         Type of update process. Accepts 'plugin', 'theme', 'translation', or 'core'.
	 *     @type bool   $bulk         Whether the update process is a bulk update. Default true.
	 *     @type array  $plugins      Array of the basename paths of the plugins' main files.
	 *     @type array  $themes       The theme slugs.
	 *     @type array  $translations {
	 *         Array of translations update data.
	 *
	 *         @type string $language The locale the translation is for.
	 *         @type string $type     Type of translation. Accepts 'plugin', 'theme', or 'core'.
	 *         @type string $slug     Text domain the translation is for. The slug of a theme/plugin or
	 *                                'default' for core translations.
	 *         @type string $version  The version of a theme, plugin, or core.
	 *     }
	 * }
	 */
	protected function on_upgrader_process_complete_log_bulk_plugin_update( $plugin_upgrader_instance, $arr_data ) {
		// Bail if not bulk plugin update.
		if ( ! isset( $arr_data['bulk'] ) || ! $arr_data['bulk'] || ! isset( $arr_data['action'] ) || $arr_data['action'] !== 'update' ) {
			return;
		}

		$plugins_updated       = isset( $arr_data['plugins'] ) ? (array) $arr_data['plugins'] : [];
		$plugins_before_update = json_decode( get_option( $this->get_slug() . '_plugin_info_before_update', false ), true );

		/** @var string $plugin_main_file_path Plugin folder and main file, i.e. classic-widgets/classic-widgets.php */
		foreach ( $plugins_updated as $plugin_main_file_path ) {
			$plugin_data = [];
			if ( file_exists( WP_PLUGIN_DIR . '/' . $plugin_main_file_path ) ) {
				$plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_main_file_path, true, false );
			}

			// Fall back to pre-update stored data when get_plugin_data() returns empty Name.
			// Custom updaters (like Code Profiler Pro) may not have the file in place yet.
			if ( empty( $plugin_data['Name'] ) && is_array( $plugins_before_update ) && isset( $plugins_before_update[ $plugin_main_file_path ] ) ) {
				$plugin_data = $plugins_before_update[ $plugin_main_file_path ];
			}

			$plugin_slug = dirname( $plugin_main_file_path );

			$context = [
				'plugin_main_file_path' => $plugin_main_file_path,
				'plugin_slug'           => $plugin_slug,
				'plugin_name'           => $plugin_data['Name'] ?? '',
				'plugin_title'          => $plugin_data['Title'] ?? '',
				'plugin_description'    => $plugin_data['Description'] ?? '',
				'plugin_author'         => $plugin_data['Author'] ?? '',
				'plugin_version'        => $plugin_data['Version'] ?? '',
				'plugin_url'            => $plugin_data['PluginURI'] ?? '',
				'plugin_was_active'     => is_plugin_active( $plugin_main_file_path ) ? '1' : '0',
			];

			// Add Update URI if it is set. Available since WP 5.8.
			if ( isset( $plugin_data['UpdateURI'] ) ) {
				$context['plugin_update_uri'] = $plugin_data['UpdateURI'];
			}

			// Get url and package.
			$update_plugins = get_site_transient( 'update_plugins' );
			if ( $update_plugins && isset( $update_plugins->response[ $plugin_main_file_path ] ) ) {
				// phpcs:ignore Squiz.PHP.CommentedOutCode.Found, Squiz.Commenting.BlockComment.NoEmptyLineBefore
				/*
				$update_plugins[plugin_path/slug]:
				{
					"id": "8986",
					"slug": "autoptimize",
					"plugin": "autoptimize/autoptimize.php",
					"new_version": "1.9.1",
					"url": "https://wordpress.org/plugins/autoptimize/",
					"package": "https://downloads.wordpress.org/plugin/autoptimize.1.9.1.zip"
				}
				*/

				$plugin_update_info = $update_plugins->response[ $plugin_main_file_path ];

				// autoptimize/autoptimize.php.
				if ( isset( $plugin_update_info->plugin ) ) {
					$context['plugin_update_info_plugin'] = $plugin_update_info->plugin;
				}

				// https://downloads.wordpress.org/plugin/autoptimize.1.9.1.zip.
				if ( isset( $plugin_update_info->package ) ) {
					$context['plugin_update_info_package'] = $plugin_update_info->package;
				}

				// Upgrade notice from WordPress.org, e.g. "Version X contains security fixes...".
				if ( ! empty( $plugin_update_info->upgrade_notice ) ) {
					$context['plugin_upgrade_notice'] = $plugin_update_info->upgrade_notice;
				}
			}

			// To get old version we use the pre-update data (already fetched above for name fallback).
			if ( is_array( $plugins_before_update ) && isset( $plugins_before_update[ $plugin_main_file_path ] ) ) {
				$context['plugin_prev_version'] = $plugins_before_update[ $plugin_main_file_path ]['Version'];
			}

			// Add update type context (forced_security, user_enabled, manual).
			$context = $this->add_update_type_context( $context, $plugin_main_file_path, $update_plugins );

			$plugin_errors = $this->package_results[ $plugin_main_file_path ]['result']->errors ?? [];

			if ( count( $plugin_errors ) > 0 ) {
				$context['plugin_errors'] = $plugin_errors;

				// Add rollback context if rollback will occur.
				$context = $this->add_rollback_context( $context, $plugin_main_file_path );

				$this->warning_message(
					'plugin_bulk_updated_failed',
					$context
				);
			} elseif ( ( $context['plugin_update_type'] ?? '' ) === 'forced_security' ) {
				// Use notice level for forced security updates (WordPress.org override).
				$this->notice_message(
					'plugin_bulk_updated',
					$context
				);
			} else {
				$this->info_message(
					'plugin_bulk_updated',
					$context
				);
			}
		}
	}

	/**
	 * Log single, non bulk, plugin install or install failure.
	 *
	 * @param \Plugin_Upgrader $plugin_upgrader_instance Plugin_Upgrader instance.
	 * @param array            $arr_data                 Array of bulk item update data.
	 */
	protected function on_upgrader_process_complete_log_single_plugin_install( $plugin_upgrader_instance, $arr_data ) {
		// Bail if not single plugin install data.
		if ( ! isset( $arr_data['action'] ) || $arr_data['action'] !== 'install' || $plugin_upgrader_instance->bulk ) {
			return;
		}

		$upgrader_skin_options = isset( $plugin_upgrader_instance->skin->options ) && is_array( $plugin_upgrader_instance->skin->options ) ? $plugin_upgrader_instance->skin->options : array();
		$upgrader_skin_result  = isset( $plugin_upgrader_instance->skin->result ) && is_array( $plugin_upgrader_instance->skin->result ) ? $plugin_upgrader_instance->skin->result : array();
		$new_plugin_data       = $plugin_upgrader_instance->new_plugin_data ?? array();
		$plugin_slug           = $upgrader_skin_result['destination_name'] ?? '';

		$context = [
			'plugin_slug'         => $plugin_slug,
			'plugin_name'         => $new_plugin_data['Name'] ?? '',
			'plugin_url'          => $new_plugin_data['PluginURI'] ?? '',
			'plugin_version'      => $new_plugin_data['Version'] ?? '',
			'plugin_author'       => $new_plugin_data['Author'] ?? '',
			'plugin_requires_wp'  => $new_plugin_data['RequiresWP'] ?? '',
			'plugin_requires_php' => $new_plugin_data['RequiresPHP'] ?? '',
		];

		// Add Update URI if it is set. Available since WP 5.8.
		if ( isset( $new_plugin_data['UpdateURI'] ) ) {
			$context['plugin_update_uri'] = $new_plugin_data['UpdateURI'];
		}

		/*
		Detect install plugin from wordpress.org
			- options[type] = "web"
			- options[api] contains all we need

		Detect install from upload ZIP
			- options[type] = "upload"

		Also: plugins hosted at GitHub have a de-facto standard field of "GitHub Plugin URI"
		*/
		$install_source = 'web';
		if ( isset( $upgrader_skin_options['type'] ) ) {
			$install_source = (string) $upgrader_skin_options['type'];
		}

		$context['plugin_install_source'] = $install_source;

		// If uploaded plugin store name of ZIP.
		// phpcs:ignore WordPress.Security.NonceVerification.Missing
		if ( $install_source === 'upload' && isset( $_FILES['pluginzip']['name'] ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Missing
			$plugin_upload_name            = sanitize_text_field( $_FILES['pluginzip']['name'] );
			$context['plugin_upload_name'] = $plugin_upload_name;
		}

		if ( is_a( $plugin_upgrader_instance->skin->result, 'WP_Error' ) ) {
			// Add errors
			// Errors is in original wp admin language.
			$context['error_messages'] = Helpers::json_encode( $plugin_upgrader_instance->skin->result->errors );
			$context['error_data']     = Helpers::json_encode( $plugin_upgrader_instance->skin->result->error_data );

			$this->info_message(
				'plugin_installed_failed',
				$context
			);
		} else {
			// Plugin was successfully installed
			// Try to grab more info from the readme
			// Would be nice to grab a screenshot, but that is difficult since they often are stored remotely.
			$plugin_destination = $plugin_upgrader_instance->result['destination'] ?? null;

			if ( $plugin_destination ) {
				$plugin_info = $plugin_upgrader_instance->plugin_info();

				$plugin_data = array();
				if ( file_exists( WP_PLUGIN_DIR . '/' . $plugin_info ) ) {
					$plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_info, true, false );
				}

				$context['plugin_name']        = $plugin_data['Name'] ?? '';
				$context['plugin_description'] = $plugin_data['Description'] ?? '';
				$context['plugin_url']         = $plugin_data['PluginURI'] ?? '';
				$context['plugin_version']     = $plugin_data['Version'] ?? '';
				$context['plugin_author']      = $plugin_data['AuthorName'] ?? '';

				if ( ! empty( $plugin_data['GitHub Plugin URI'] ) ) {
					$context['plugin_github_url'] = $plugin_data['GitHub Plugin URI']; // @phpstan-ignore-line offsetAccess.notFound
				}
			}

			$this->info_message(
				'plugin_installed',
				$context
			);
		}
	}

	/**
	 * Log plugin updates that were rolled back.
	 *
	 * WordPress 6.3+ has automatic rollback for plugin updates that cause fatal errors.
	 * The upgrader_process_complete hook fires BEFORE the health check, so we need
	 * to hook into automatic_updates_complete to detect rollbacks that happen AFTER.
	 *
	 * Timeline of a failed update with rollback:
	 * 1. Plugin files updated to new version
	 * 2. upgrader_process_complete fires → Simple History logs "Updated to X.X.X"
	 * 3. WordPress performs loopback health check
	 * 4. Health check fails (fatal error detected)
	 * 5. WordPress restores from temp backup (rollback)
	 * 6. automatic_updates_complete fires → This method logs the rollback
	 *
	 * WordPress uses these error codes for rollbacks (defined in class-wp-automatic-updater.php):
	 * - plugin_update_fatal_error_rollback_successful: Update caused fatal, rollback succeeded
	 * - plugin_update_fatal_error_rollback_failed: Update caused fatal, rollback also failed
	 *
	 * @see wp-admin/includes/class-wp-automatic-updater.php lines 566-619 for rollback code.
	 *
	 * @param array $update_results Results of all update attempts.
	 */
	public function on_automatic_updates_complete( $update_results ) {
		if ( empty( $update_results['plugin'] ) ) {
			return;
		}

		$rollback_error_codes = [
			'plugin_update_fatal_error_rollback_successful',
			'plugin_update_fatal_error_rollback_failed',
		];

		foreach ( $update_results['plugin'] as $update ) {
			if ( ! is_wp_error( $update->result ) ) {
				continue;
			}

			$error_code = $update->result->get_error_code();

			if ( ! in_array( $error_code, $rollback_error_codes, true ) ) {
				continue;
			}

			$plugin_file = $update->item->plugin ?? '';
			$plugin_data = [];
			if ( $plugin_file && file_exists( WP_PLUGIN_DIR . '/' . $plugin_file ) ) {
				$plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file, true, false );
			}

			$rollback_successful = ( $error_code === 'plugin_update_fatal_error_rollback_successful' );

			$context = [
				'plugin_slug'         => dirname( $plugin_file ),
				'plugin_name'         => $plugin_data['Name'] ?? $plugin_file,
				'plugin_prev_version' => $plugin_data['Version'] ?? '',
				'plugin_version'      => $update->item->new_version ?? '',
				'rollback_successful' => $rollback_successful ? '1' : '0',
				'error_message'       => $update->result->get_error_message(),
			];

			$this->warning_message( 'plugin_update_rolled_back', $context );
		}
	}

	/**
	 * Plugin is activated
	 * plugin_name is like admin-menu-tree-page-view/index.php
	 *
	 * @param string $plugin_name Plugin name.
	 * @param bool   $network_wide Network wide.
	 */
	public function on_activated_plugin( $plugin_name, $network_wide ) {
		$plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_name, true, false );

		$plugin_slug = dirname( $plugin_name );

		$context = array(
			'plugin_name'        => $plugin_data['Name'],
			'plugin_slug'        => $plugin_slug,
			'plugin_title'       => $plugin_data['Title'],
			'plugin_description' => $plugin_data['Description'],
			'plugin_author'      => $plugin_data['Author'],
			'plugin_version'     => $plugin_data['Version'],
			'plugin_url'         => $plugin_data['PluginURI'],
		);

		if ( ! empty( $plugin_data['GitHub Plugin URI'] ) ) {
			$context['plugin_github_url'] = $plugin_data['GitHub Plugin URI']; // @phpstan-ignore-line offsetAccess.notFound
		}

		$this->info_message( 'plugin_activated', $context );
	}

	/**
	 * Plugin is deactivated
	 * plugin_name is like admin-menu-tree-page-view/index.php
	 *
	 * @param string $plugin_name Plugin name.
	 */
	public function on_deactivated_plugin( $plugin_name ) {

		$plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_name, true, false );
		$plugin_slug = dirname( $plugin_name );

		$context = array(
			'plugin_name'        => $plugin_data['Name'],
			'plugin_slug'        => $plugin_slug,
			'plugin_title'       => $plugin_data['Title'],
			'plugin_description' => $plugin_data['Description'],
			'plugin_author'      => $plugin_data['Author'],
			'plugin_version'     => $plugin_data['Version'],
			'plugin_url'         => $plugin_data['PluginURI'],
		);

		if ( ! empty( $plugin_data['GitHub Plugin URI'] ) ) {
			$context['plugin_github_url'] = $plugin_data['GitHub Plugin URI']; // @phpstan-ignore-line offsetAccess.notFound
		}

		$this->info_message( 'plugin_deactivated', $context );
	}

	/**
	 * Get output for detailed log section
	 *
	 * @param object $row Log row.
	 */
	public function get_log_row_details_output( $row ) {
		$context     = $row->context;
		$message_key = $context['_message_key'];

		switch ( $message_key ) {
			case 'plugin_installed':
				return $this->get_plugin_installed_details_group( $context );
			case 'plugin_bulk_updated':
			case 'plugin_updated':
				return $this->get_plugin_updated_details_group( $context );
			case 'plugin_activated':
			case 'plugin_deactivated':
				return '';
		}

		return '';
	}

	/**
	 * Get action links for a log row.
	 *
	 * Shows "View plugin info" or "View changelog" thickbox links.
	 *
	 * @param object $row Log row object.
	 * @return array Array of action link arrays.
	 */
	/**
	 * Failure-path events store the underlying error in context (error_code,
	 * error_message, plus the raw upgrader package_result for rollbacks).
	 * Surface a "Show error message" link so users can open the modal and
	 * see what actually went wrong without hunting through the row dropdown.
	 *
	 * @param object $row Log row object.
	 * @return string|false
	 */
	public function event_has_more_details( $row ) {
		$context     = $row->context;
		$message_key = $context['_message_key'] ?? '';

		$failure_message_keys = array(
			'plugin_installed_failed',
			'plugin_update_failed',
			'plugin_bulk_updated_failed',
			'plugin_disabled_because_error',
		);

		if ( ! in_array( $message_key, $failure_message_keys, true ) ) {
			return false;
		}

		// Only show the link when there's actually an error payload to read.
		// Older failure events sometimes lack these keys, in which case the
		// modal wouldn't reveal anything useful.
		$has_error_payload = ! empty( $context['error_message'] )
			|| ! empty( $context['error_code'] )
			|| ! empty( $context['package_result_errors'] )
			|| ! empty( $context['deactivation_reason'] );

		if ( ! $has_error_payload ) {
			return false;
		}

		return __( 'Show error message', 'simple-history' );
	}

	/**
	 * Get action links for a log row.
	 *
	 * Returns up to two links: a per-plugin link appropriate for the message
	 * (changelog for updates, info thickbox for install/activate/deactivate,
	 * GitHub info for GitHub-hosted plugins) and an "All plugins" overview
	 * link for users who can manage plugins.
	 *
	 * The per-plugin destinations (plugin-install.php thickbox and the
	 * GitHub-info AJAX handler) all gate themselves with `install_plugins`
	 * and wp_die() without it. Cap-check here so we don't surface a link
	 * that lands the user on a permission-denied screen — on multisite that
	 * means only network admins see per-plugin links, while site admins
	 * still get the overview link below.
	 *
	 * @param object $row Log row object.
	 * @return array Array of action link arrays.
	 */
	public function get_action_links( $row ) {
		$context     = $row->context;
		$message_key = $context['_message_key'] ?? '';
		$plugin_slug = empty( $context['plugin_slug'] ) ? '' : $context['plugin_slug'];
		$is_network  = is_multisite();

		$action_links = [];

		if ( current_user_can( 'install_plugins' ) ) {
			if ( ! empty( $context['plugin_github_url'] ) ) {
				$action_links[] = [
					'url'    => wp_nonce_url(
						admin_url(
							sprintf(
								'admin-ajax.php?action=SimplePluginLogger_GetGitHubPluginInfo&getrepo&repo=%1$s&TB_iframe=true&width=640&height=550',
								rawurlencode( $context['plugin_github_url'] )
							)
						),
						'simple-history-github-plugin-info'
					),
					'label'  => _x( 'Plugin info', 'plugin logger: plugin info thickbox title view all info', 'simple-history' ),
					'action' => 'view',
				];
			} elseif ( $plugin_slug && in_array( $message_key, [ 'plugin_updated', 'plugin_bulk_updated' ], true ) ) {
				$changelog_path = "plugin-install.php?tab=plugin-information&plugin={$plugin_slug}&section=changelog&TB_iframe=true&width=772&height=550";

				$action_links[] = [
					'url'    => $is_network ? network_admin_url( $changelog_path ) : admin_url( $changelog_path ),
					'label'  => _x( 'Changelog', 'plugin logger: plugin info thickbox title', 'simple-history' ),
					'action' => 'view',
				];
			} elseif ( $plugin_slug && in_array( $message_key, [ 'plugin_installed', 'plugin_activated', 'plugin_deactivated' ], true ) ) {
				$plugin_info_path = "plugin-install.php?tab=plugin-information&plugin={$plugin_slug}&section=&TB_iframe=true&width=640&height=550";

				$action_links[] = [
					'url'    => $is_network ? network_admin_url( $plugin_info_path ) : admin_url( $plugin_info_path ),
					'label'  => _x( 'Plugin info', 'plugin logger: plugin info thickbox title view all info', 'simple-history' ),
					'action' => 'view',
				];
			}
		}

		// Overview cap matches the destination: network admin requires
		// `manage_network_plugins`; single-site requires `activate_plugins`.
		$overview_cap = $is_network ? 'manage_network_plugins' : 'activate_plugins';

		// phpcs:ignore WordPress.WP.Capabilities.Undetermined -- One of two literal caps picked above.
		if ( current_user_can( $overview_cap ) ) {
			$action_links[] = [
				'url'    => $is_network ? network_admin_url( 'plugins.php' ) : admin_url( 'plugins.php' ),
				'label'  => __( 'All plugins', 'simple-history' ),
				'action' => 'view',
			];
		}

		return $action_links;
	}

	/**
	 * Get Event_Details_Group for installed plugin details.
	 *
	 * @param array $context Log context.
	 * @return Event_Details_Group|string
	 */
	private function get_plugin_installed_details_group( $context ) {
		if ( ! isset( $context['plugin_description'] ) ) {
			return '';
		}

		// Description includes a link to author after <cite>; strip it and any HTML.
		$plugin_description = $context['plugin_description'];
		$cite_pos           = strpos( $plugin_description, '<cite>' );

		if ( $cite_pos !== false ) {
			$plugin_description = substr( $plugin_description, 0, $cite_pos );
		}

		$plugin_description = trim( wp_strip_all_tags( $plugin_description ) );

		// Author header is often wrapped in <a href="...">Name</a> by WP — flatten to plain text.
		$plugin_author = isset( $context['plugin_author'] )
			? trim( wp_strip_all_tags( $context['plugin_author'] ) )
			: '';

		// Translate install source codes to readable labels.
		$install_source       = $context['plugin_install_source'] ?? '';
		$install_source_label = '';

		if ( $install_source === 'web' ) {
			$install_source_label = __( 'WordPress Plugin Repository', 'simple-history' );
		} elseif ( $install_source === 'upload' ) {
			$install_source_label = __( 'Uploaded ZIP archive', 'simple-history' );
		} elseif ( $install_source !== '' ) {
			$install_source_label = $install_source;
		}

		// Source filename only meaningful for uploaded archives.
		$install_source_file = '';

		if ( $install_source === 'upload' && ! empty( $context['plugin_upload_name'] ) ) {
			$install_source_file = $context['plugin_upload_name'];
		}

		// Map of context-key => [ label, value ]. Empty values are skipped.
		$rows = array(
			'plugin_description'         => array( _x( 'Description', 'plugin logger - detailed output', 'simple-history' ), $plugin_description ),
			'plugin_install_source'      => array( _x( 'Source', 'plugin logger - detailed output install source', 'simple-history' ), $install_source_label ),
			'plugin_install_source_file' => array( _x( 'Source file name', 'plugin logger - detailed output install source', 'simple-history' ), $install_source_file ),
			'plugin_version'             => array( _x( 'Version', 'plugin logger - detailed output version', 'simple-history' ), $context['plugin_version'] ?? '' ),
			'plugin_author'              => array( _x( 'Author', 'plugin logger - detailed output author', 'simple-history' ), $plugin_author ),
			'plugin_url'                 => array( _x( 'URL', 'plugin logger - detailed output url', 'simple-history' ), $context['plugin_url'] ?? '' ),
		);

		// Backward-compatible filter: receives map of context-key => label.
		$arr_plugin_keys = apply_filters(
			'simple_history/plugin_logger/row_details_plugin_info_keys',
			wp_list_pluck( $rows, 0 )
		);

		$group = new Event_Details_Group();

		foreach ( $arr_plugin_keys as $key => $label ) {
			// Prefer our prepared value; fall back to raw context for keys added via filter.
			$value = $rows[ $key ][1] ?? ( $context[ $key ] ?? '' );

			if ( trim( (string) $value ) === '' ) {
				continue;
			}

			$group->add_item(
				( new Event_Details_Item( $key, $label ) )->set_new_value( $value )
			);
		}

		return $group;
	}

	/**
	 * Get Event_Details_Group for plugin update details.
	 *
	 * @param array $context Log context.
	 * @return Event_Details_Group|string
	 */
	private function get_plugin_updated_details_group( $context ) {
		$plugin_slug    = empty( $context['plugin_slug'] ) ? '' : $context['plugin_slug'];
		$plugin_version = empty( $context['plugin_version'] ) ? '' : $context['plugin_version'];

		$groups = [];

		/**
		 * Allow plugins (or Simple History itself) to add extra details to the plugin update details output.
		 *
		 * @param string $extra_details Extra HTML to output. Probably empty string.
		 */
		$extra_details = apply_filters( "simple_history/pluginlogger/plugin_updated_details/{$plugin_slug}", '' );

		/**
		 * Allow plugins (or Simple History itself) to add extra details to the plugin update details output.
		 *
		 * @param string $extra_details Extra HTML to output. Probably empty string.
		 */
		$extra_details = apply_filters( "simple_history/pluginlogger/plugin_updated_details/{$plugin_slug}/{$plugin_version}", $extra_details );

		// Extra details from filters (raw HTML).
		if ( ! empty( $extra_details ) ) {
			$groups[] = Event_Details_Group::create_raw( $extra_details );
		}

		$info_group = new Event_Details_Group();

		if ( isset( $context['plugin_was_active'] ) ) {
			$info_group->add_item(
				( new Event_Details_Item( null, _x( 'Status at update', 'plugin logger: plugin active status label', 'simple-history' ) ) )
					->set_new_value(
						$context['plugin_was_active'] === '1'
							? _x( 'Active', 'plugin logger: plugin was active at update', 'simple-history' )
							: _x( 'Inactive', 'plugin logger: plugin was inactive at update', 'simple-history' )
					)
			);
		}

		$plugin_update_type = $context['plugin_update_type'] ?? '';
		if ( $plugin_update_type === 'forced_security' ) {
			$info_group->add_item(
				( new Event_Details_Item( null, _x( 'Update method', 'plugin logger: update method label', 'simple-history' ) ) )
					->set_new_value( _x( 'Security auto-update', 'plugin logger: forced security update method', 'simple-history' ) )
			);
		}

		if ( ! empty( $context['plugin_upgrade_notice'] ) ) {
			$upgrade_notice = wp_strip_all_tags( $context['plugin_upgrade_notice'] );
			$upgrade_notice = wp_trim_words( $upgrade_notice, 30, '…' );
			$info_group->add_item(
				( new Event_Details_Item( null, _x( 'Update notice', 'plugin logger: update notice label', 'simple-history' ) ) )
					->set_new_value( $upgrade_notice )
			);
		}

		if ( ! empty( $info_group->items ) ) {
			$groups[] = $info_group;
		}

		if ( empty( $groups ) ) {
			return '';
		}

		return Event_Details_Container::create_from( $groups );
	}

	/**
	 * Add rollback context to event if rollback will occur.
	 *
	 * @param array  $context Context array.
	 * @param string $plugin_identifier Plugin main file path.
	 * @return array Modified context array.
	 */
	private function add_rollback_context( $context, $plugin_identifier ) {
		// Check if rollback will occur (WordPress 6.3+ feature).
		$package_result = $this->package_results[ $plugin_identifier ] ?? null;
		if ( $package_result && ! empty( $package_result['rollback_will_occur'] ) ) {
			$context['rollback_will_occur'] = true;
			if ( ! empty( $package_result['rollback_info'] ) ) {
				$context['rollback_backup_slug']   = $package_result['rollback_info']['backup_slug'];
				$context['rollback_error_code']    = $package_result['rollback_info']['error_code'];
				$context['rollback_error_message'] = $package_result['rollback_info']['error_message'];
			}
		}

		return $context;
	}

	/**
	 * Get the update type for a plugin (forced_security, user_enabled, or manual).
	 *
	 * WordPress.org can force security updates via the `autoupdate` flag in the API response,
	 * even when users have NOT enabled auto-updates for that plugin. This is used for critical
	 * security vulnerabilities where the WordPress.org security team decides all sites should
	 * be updated regardless of user preferences.
	 *
	 * How it works:
	 * - WordPress.org API returns `autoupdate => 1` for plugins needing forced security updates
	 * - This flag is checked against `auto_update_plugins` option (user preferences)
	 * - If API says force but user didn't enable → forced_security
	 * - If user enabled auto-updates → user_enabled
	 * - Otherwise → manual
	 *
	 * To test forced security updates:
	 * 1. Find a plugin with known forced update (e.g., WooCommerce 10.4.2 → 10.4.3)
	 * 2. Downgrade: wp plugin install woocommerce --version=10.4.2 --force
	 * 3. Clear cache: wp transient delete update_plugins --network
	 * 4. Trigger: wp eval 'wp_maybe_auto_update();'
	 * 5. Check log for "Security Update" badge
	 *
	 * @see https://gist.github.com/bonny/dceab0c8582f08075919e9f760380f50 Real API response with autoupdate=1.
	 * @see https://make.wordpress.org/plugins/2015/03/14/plugin-automatic-security-updates/
	 *
	 * @param string  $plugin_file Plugin main file path (e.g., 'woocommerce/woocommerce.php').
	 * @param ?object $update_plugins Optional update_plugins transient. Will be fetched if not provided.
	 * @return string Update type: 'forced_security', 'user_enabled', or 'manual'.
	 */
	private function get_plugin_update_type( $plugin_file, $update_plugins = null ) {
		if ( $update_plugins === null ) {
			$update_plugins = get_site_transient( 'update_plugins' );
		}

		// Check if WordPress.org API flagged this as a forced security update.
		$api_forced = false;
		if ( $update_plugins && isset( $update_plugins->response[ $plugin_file ] ) ) {
			$plugin_update_info = $update_plugins->response[ $plugin_file ];
			$api_forced         = ! empty( $plugin_update_info->autoupdate );
		}

		// Check if user has enabled auto-updates for this plugin.
		$auto_update_plugins = (array) get_site_option( 'auto_update_plugins', [] );
		$user_enabled        = in_array( $plugin_file, $auto_update_plugins, true );

		if ( $api_forced && ! $user_enabled ) {
			// WordPress.org forced this update, user did NOT enable auto-updates.
			return 'forced_security';
		}

		if ( $user_enabled ) {
			// User explicitly enabled auto-updates for this plugin.
			return 'user_enabled';
		}

		// Manual update (user clicked update button).
		return 'manual';
	}

	/**
	 * Add update type context to event.
	 *
	 * @param array   $context Context array.
	 * @param string  $plugin_file Plugin main file path.
	 * @param ?object $update_plugins Optional update_plugins transient.
	 * @return array Modified context array.
	 */
	private function add_update_type_context( $context, $plugin_file, $update_plugins = null ) {
		$plugin_update_type            = $this->get_plugin_update_type( $plugin_file, $update_plugins );
		$context['plugin_update_type'] = $plugin_update_type;

		return $context;
	}
}
