<?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_Group_Diff_Table_Formatter;
use Simple_History\Event_Details\Event_Details_Item;
use Simple_History\Helpers;
use Simple_History\Vendor\Jfcherng\Diff\DiffHelper;

/**
 * Logs changes to posts and pages, including custom post types.
 */
class Post_Logger extends Logger {
	/** @var string Logger slug */
	public $slug = 'SimplePostLogger';

	/**
	 * Array that will contain previous post data, before data is updated.
	 *
	 * Array format is
	 * [post_id] => [post_data, post_meta].
	 *               post_data = WP_Post object, post_meta = post meta array.
	 *
	 * @var array<int, array>
	 */
	protected $old_post_data = [];

	/**
	 * Revision ids created during this request, keyed by post id.
	 *
	 * Holds a revision until there is an event to put it on. Only used by the
	 * saves that log after the revision exists — see on_wp_put_post_revision()
	 * for which those are.
	 *
	 * @var array<int, int>
	 */
	protected $post_revision_ids = [];

	/**
	 * Event ids that have already been given a revision id.
	 *
	 * An event takes at most one. Without this, a later save that Simple History
	 * declines to log still produces a revision, and since last_insert_id still
	 * points at the previous event that revision would be appended to it as a
	 * second value.
	 *
	 * @var array<int, bool>
	 */
	protected $events_given_a_revision_id = [];

	/**
	 * Get array with information about this logger.
	 *
	 * @return array
	 */
	public function get_info() {
		return [
			'name'        => __( 'Post Logger', 'simple-history' ),
			'description' => __( 'Logs the creation and modification of posts and pages', 'simple-history' ),
			'capability'  => 'edit_pages',
			'messages'    => array(
				'post_created'               => __( 'Created {post_type} "{post_title}"', 'simple-history' ),
				'post_updated'               => __( 'Updated {post_type} "{post_title}"', 'simple-history' ),
				'post_restored'              => __( 'Restored {post_type} "{post_title}" from trash', 'simple-history' ),
				'post_deleted'               => __( 'Deleted {post_type} "{post_title}"', 'simple-history' ),
				'post_trashed'               => __( 'Moved {post_type} "{post_title}" to the trash', 'simple-history' ),

				'page_set_as_homepage'       => __( 'Set {post_type} "{post_title}" as the homepage', 'simple-history' ),
				'page_removed_as_homepage'   => __( 'Removed {post_type} "{post_title}" as the homepage', 'simple-history' ),
				'page_set_as_posts_page'     => __( 'Set {post_type} "{post_title}" as the posts page', 'simple-history' ),
				'page_removed_as_posts_page' => __( 'Removed {post_type} "{post_title}" as the posts page', 'simple-history' ),
			),
			'labels'      => array(
				'search' => array(
					'label'     => _x( 'Posts & Pages', 'Post logger: search', 'simple-history' ),
					'label_all' => _x( 'All posts & pages activity', 'Post logger: search', 'simple-history' ),
					'options'   => array(
						_x( 'Posts created', 'Post logger: search', 'simple-history' ) => array( 'post_created' ),
						_x( 'Posts updated', 'Post logger: search', 'simple-history' ) => array( 'post_updated' ),
						_x( 'Posts trashed', 'Post logger: search', 'simple-history' ) => array( 'post_trashed' ),
						_x( 'Posts deleted', 'Post logger: search', 'simple-history' ) => array( 'post_deleted' ),
						_x( 'Posts restored', 'Post logger: search', 'simple-history' ) => array( 'post_restored' ),
						_x( 'Pages set as homepage or posts page', 'Post logger: search', 'simple-history' ) => array(
							'page_set_as_homepage',
							'page_removed_as_homepage',
							'page_set_as_posts_page',
							'page_removed_as_posts_page',
						),
					),
				),
			),
		];
	}

	/**
	 * @inheritdoc
	 */
	public function loaded() {
		// Save old/prev post values before post is updated.
		add_action( 'admin_action_editpost', array( $this, 'on_admin_action_editpost_save_prev_post' ) );

		// Run quick edit changes old post save with prio 0 to run before WordPress core does its thing, which is at prio 1.
		add_action( 'wp_ajax_inline-save', array( $this, 'on_admin_action_editpost_save_prev_post' ), 0 );

		// Save prev post for bulk edit.
		// Bulk edit does not use ajax (like quick edit does). Instead it's a regular GET request to edit.php.
		// wp function bulk_edit_posts() takes care of making changes.
		add_action( 'admin_action_edit', array( $this, 'on_admin_action_edit_save_prev_post' ) );

		// Detect regular post edits.
		add_action( 'transition_post_status', array( $this, 'on_transition_post_status' ), 10, 3 );

		// Detect posts changing status from future to publish.
		add_action( 'transition_post_status', array( $this, 'on_transition_post_status_future' ), 10, 3 );

		add_action( 'delete_post', array( $this, 'on_delete_post' ) );
		add_action( 'untrash_post', array( $this, 'on_untrash_post' ) );

		$this->add_xml_rpc_hooks();

		// Add rest hooks late to increase chance of getting all registered post types.
		add_action( 'init', array( $this, 'add_rest_hooks' ), 99 );

		// WP-CLI post update path. on_transition_post_status bails for WP-CLI to avoid
		// double-logging; these two hooks handle the prev/new snapshot + log instead.
		add_action( 'pre_post_update', array( $this, 'on_pre_post_update' ), 10, 1 );
		add_action( 'wp_after_insert_post', array( $this, 'on_wp_after_insert_post' ), 10, 4 );

		add_action( 'update_option_page_on_front', array( $this, 'on_update_option_page_on_front' ), 10, 2 );
		add_action( 'update_option_page_for_posts', array( $this, 'on_update_option_page_for_posts' ), 10, 2 );

		add_filter( 'simple_history/rss_item_link', array( $this, 'filter_rss_item_link' ), 10, 2 );

		// Fires whenever core stores a revision. Where that lands relative to this
		// logger's own event depends on how the post was saved — see the handler.
		add_action( '_wp_put_post_revision', array( $this, 'on_wp_put_post_revision' ), 1, 2 );
	}

	/**
	 * Fired when a post is saved using save button and does have changes.
	 * Does not track autosave.
	 * This is done after simple history has logged the post change.
	 * So we need to update the context with the revision id.
	 *
	 * @param int      $revision_id The revision ID.
	 * @param int|null $post_id The post ID. Only passed by WordPress 6.4 and later.
	 */
	public function on_wp_put_post_revision( $revision_id, $post_id = null ) {
		// WordPress only started passing the post id with this action in 6.4, and
		// the plugin supports 6.3 — requiring an argument core does not send is a
		// fatal ArgumentCountError under PHP 8, on every post save that creates a
		// revision. Fall back to the revision's parent, which is what 6.4 passes.
		if ( $post_id === null ) {
			$post_id = wp_get_post_parent_id( $revision_id );
		}

		if ( ! $post_id ) {
			return;
		}

		$post_id = (int) $post_id;

		// Which side of the event this fires on depends on how the post was
		// saved, so both orderings have to work.
		//
		// The classic editor and Quick Edit log from transition_post_status,
		// which core fires from wp_insert_post() before the revision is saved —
		// on 6.4+ from wp_after_insert_post priority 9, and on 6.3 from
		// post_updated. The version differs, the ordering does not: the event
		// already exists, so the id is attached to it.
		//
		// Gutenberg and WP-CLI log after the revision, from rest_after_insert_*
		// and wp_after_insert_post priority 10. There is nothing to attach to
		// yet, so the id is held for maybe_log_post_change() to pick up when it
		// builds the context.
		$logged_event_is_for_this_post = $this->last_insert_id
			&& (int) ( $this->last_insert_context['post_id'] ?? 0 ) === $post_id;

		if ( $logged_event_is_for_this_post ) {
			// One per event. A later save that is not logged — an ignored post
			// type, an ok_to_log filter, the meta-box-loader bail — still makes a
			// revision, and last_insert_id would still be pointing here.
			if ( isset( $this->events_given_a_revision_id[ $this->last_insert_id ] ) ) {
				return;
			}

			$this->append_context(
				$this->last_insert_id,
				[
					'post_revision_id' => $revision_id,
				]
			);

			$this->events_given_a_revision_id[ $this->last_insert_id ] = true;

			return;
		}

		$this->post_revision_ids[ $post_id ] = (int) $revision_id;
	}

	/**
	 * Add hooks to catch updates via REST API, i.e. from the Gutenberg editor.
	 */
	public function add_rest_hooks() {
		/**
		 * Filter the post types we are logging information from.
		 *
		 * @param array $post_types Core, public and private post types.
		 * @return array $post_types Filtered post types.
		 *
		 * @since 2.37
		 */
		$post_types = apply_filters( 'simple_history/post_logger/post_types', get_post_types( array(), 'object' ) );

		// Add actions for each post type.
		foreach ( $post_types as $post_type ) {
			// class-wp-rest-posts-controller.php fires two actions in
			// the update_item() method: pre_insert and after_insert.

			// Rest pre insert is fired before an updated post is inserted into db.
			add_filter( "rest_pre_insert_{$post_type->name}", array( $this, 'on_rest_pre_insert' ), 10, 2 );

			// Rest insert happens after the post has been updated: "Fires after a single post is completely created or updated via the REST API.".
			add_action( "rest_after_insert_{$post_type->name}", array( $this, 'on_rest_after_insert' ), 10, 3 );

			// Rest delete is fired "immediately after a single post is deleted or trashed via the REST API".
			add_action( "rest_delete_{$post_type->name}", array( $this, 'on_rest_delete' ), 10, 3 );
		}
	}

	/**
	 * Fired after a single post is deleted or trashed via the REST API.
	 *
	 * @param \WP_Post          $post     The deleted or trashed post.
	 * @param \WP_REST_Response $response The response data.
	 * @param \WP_REST_Request  $request  The request sent to the API.

	 */
	public function on_rest_delete( $post, $response, $request ) {
		if ( ! $post instanceof \WP_Post ) {
			return;
		}

		if ( ! $this->ok_to_log_post_posttype( $post ) ) {
			return;
		}

		$this->info_message(
			'post_trashed',
			[
				'post_id'    => $post->ID,
				'post_type'  => $post->post_type,
				'post_title' => $post->post_title,
			]
		);
	}

	/**
	 * Filter "rest_pre_insert_{$this->post_type}" filters a post before it is inserted via the REST API.
	 * Fired from class-wp-rest-posts-controller.php.
	 *
	 * Here we can get the old post object.
	 *
	 * @param \stdClass        $prepared_post An object representing a single post prepared
	 *                                       for inserting or updating the database, i.e. the new updated post.
	 * @param \WP_REST_Request $request       Request object.
	 * @return \stdClass $prepared_post
	 */
	public function on_rest_pre_insert( $prepared_post, $request ) {
		// $prepared_post = stdClass Object with new and modified content.
		// changes are not saved to post in db yet, so get_post( $prepared_post->ID ) will get old contents.

		// Not all posts have ID, for example attachment uploaded in block editor does not.
		if ( empty( $prepared_post->ID ) ) {
			return $prepared_post;
		}

		$this->save_prev_post_data( $prepared_post->ID );

		return $prepared_post;
	}

	/**
	 * Fires after a single post is completely created or updated via the REST API.
	 *
	 * This is fired when a post is saved:
	 * - Using the Gutenberg block editor
	 * - ...possible more times...
	 *
	 * Here we get the updated post, after it is updated in the db.
	 *
	 * @param \WP_Post         $updated_post     Inserted or updated post object.
	 * @param \WP_REST_Request $request  Request object.
	 * @param bool             $creating True when creating a post, false when updating.
	 */
	public function on_rest_after_insert( $updated_post, $request, $creating ) {
		$updated_post = get_post( $updated_post->ID );
		$post_meta    = get_post_custom( $updated_post->ID );

		$old_post       = $this->old_post_data[ $updated_post->ID ]['post_data'] ?? null;
		$old_post_meta  = $this->old_post_data[ $updated_post->ID ]['post_meta'] ?? null;
		$old_post_terms = $this->old_post_data[ $updated_post->ID ]['post_terms'] ?? null;

		// If WordPress says this is a new post being created, and we don't have old post data,
		// assume it was transitioning from auto-draft status.
		// This ensures post creation is properly detected even when old data wasn't captured.
		$old_status = $old_post ? $old_post->post_status : null;
		if ( $creating && ! $old_status ) {
			$old_status = 'auto-draft';
		}

		$args = array(
			'new_post'       => $updated_post,
			'new_post_meta'  => $post_meta,
			'new_post_terms' => wp_get_object_terms( $updated_post->ID, get_object_taxonomies( $updated_post->post_type ) ),
			'old_post'       => $old_post,
			'old_post_meta'  => $old_post_meta,
			'old_post_terms' => $old_post_terms,
			'old_status'     => $old_status,
		);

		$this->maybe_log_post_change( $args );
	}

	/**
	 * Filters to XML RPC calls needs to be added early, admin_init is to late.
	 */
	public function add_xml_rpc_hooks() {
		add_action( 'xmlrpc_call_success_blogger_newPost', array( $this, 'on_xmlrpc_newPost' ), 10, 2 );
		add_action( 'xmlrpc_call_success_mw_newPost', array( $this, 'on_xmlrpc_newPost' ), 10, 2 );

		add_action( 'xmlrpc_call_success_blogger_editPost', array( $this, 'on_xmlrpc_editPost' ), 10, 2 );
		add_action( 'xmlrpc_call_success_mw_editPost', array( $this, 'on_xmlrpc_editPost' ), 10, 2 );

		add_action( 'xmlrpc_call_success_blogger_deletePost', array( $this, 'on_xmlrpc_deletePost' ), 10, 2 );
		add_action( 'xmlrpc_call_success_wp_deletePage', array( $this, 'on_xmlrpc_deletePost' ), 10, 2 );

		add_action( 'xmlrpc_call', array( $this, 'on_xmlrpc_call' ), 10, 1 );
	}

	/**
	 * Detect when a post is deleted using a XML-RPC call.
	 * Fired from action "xmlrpc_call".
	 *
	 * @param string $method Method called.
	 */
	public function on_xmlrpc_call( $method ) {
		if ( $method !== 'wp.deletePost' ) {
			return;
		}

		// Parse the XML-RPC body to extract the post id from the method params.
		// Do not store the raw body or parsed message in the log: wp.deletePost
		// params are [blog_id, username, password, post_id], so persisting them
		// would leak the caller's credentials.
		// phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsRemoteFile
		$raw_post_data = file_get_contents( 'php://input' );
		$message       = new \IXR_Message( $raw_post_data );

		if ( ! $message->parse() ) {
			return;
		}

		// 4 params, where the last is the post id.
		if ( ! isset( $message->params[3] ) ) {
			return;
		}

		$post_ID = $message->params[3];

		$post = get_post( $post_ID );

		$context = array(
			'post_id'    => $post->ID,
			'post_type'  => get_post_type( $post ),
			'post_title' => get_the_title( $post ),
		);

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

	/**
	 * Fired when posts are saved in the admin area using bulk edit.
	 */
	public function on_admin_action_edit_save_prev_post() {
		global $pagenow;

		if ( $pagenow !== 'edit.php' ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$post_ids = array_map( 'intval', (array) ( $_GET['post'] ?? [] ) );

		foreach ( $post_ids as $one_post_id ) {
			$this->save_prev_post_data( $one_post_id );
		}
	}

	/**
	 * Save old info about a post that is going to be edited.
	 * Needed to later compare old data with new data, to detect differences.
	 *
	 * @param mixed $post_ID Post ID.
	 */
	protected function save_prev_post_data( $post_ID ) {
		$prev_post_data = get_post( $post_ID );

		if ( ! $prev_post_data instanceof \WP_Post ) {
			return;
		}

		$this->old_post_data[ $post_ID ] = [
			'post_data'  => $prev_post_data,
			'post_meta'  => get_post_custom( $post_ID ),
			'post_terms' => wp_get_object_terms( $post_ID, get_object_taxonomies( $prev_post_data->post_type ) ),
		];
	}

	/**
	 * Capture prev post state for WP-CLI updates.
	 *
	 * Admin path captures prev state in on_admin_action_editpost_save_prev_post()
	 * — pre_post_update would see new values there because custom fields are
	 * written via a separate AJAX call before the form submit.
	 *
	 * @param int $post_ID The post ID being updated.
	 */
	public function on_pre_post_update( $post_ID ) {
		if ( is_admin() ) {
			return;
		}

		if ( ! Helpers::is_wp_cli() ) {
			return;
		}

		$this->save_prev_post_data( $post_ID );
	}

	/**
	 * Log a post create or update made via WP-CLI. Mirrors on_rest_after_insert.
	 *
	 * @param int           $post_id     ID of the saved post.
	 * @param \WP_Post      $post        The saved post object.
	 * @param bool          $update      True when updating, false when creating.
	 * @param \WP_Post|null $post_before The post before the update, or null for new posts.
	 */
	public function on_wp_after_insert_post( $post_id, $post, $update, $post_before ) {
		if ( ! Helpers::is_wp_cli() ) {
			return;
		}

		if ( ! $post instanceof \WP_Post ) {
			return;
		}

		if ( ! $this->ok_to_log_post_posttype( $post ) ) {
			return;
		}

		if ( $update ) {
			$old_post       = $this->old_post_data[ $post->ID ]['post_data'] ?? null;
			$old_post_meta  = $this->old_post_data[ $post->ID ]['post_meta'] ?? null;
			$old_post_terms = $this->old_post_data[ $post->ID ]['post_terms'] ?? null;
			$old_status     = $old_post ? $old_post->post_status : null;
		} else {
			$old_post       = null;
			$old_post_meta  = null;
			$old_post_terms = null;
			// 'new' is WordPress's status transition placeholder when no prior post exists.
			$old_status = 'new';
		}

		$args = array(
			'new_post'       => $post,
			'new_post_meta'  => get_post_custom( $post->ID ),
			'new_post_terms' => wp_get_object_terms( $post->ID, get_object_taxonomies( $post->post_type ) ),
			'old_post'       => $old_post,
			'old_post_meta'  => $old_post_meta,
			'old_post_terms' => $old_post_terms,
			'old_status'     => $old_status,
		);

		$this->maybe_log_post_change( $args );
	}

	/**
	 * Get and store old info about a post that is going to be edited.
	 * Needed to later compare old data with new data, to detect differences.
	 * This function is called on edit screen but before post edits are saved.
	 *
	 * Can't use the regular filters like "pre_post_update" because custom fields are already written by then
	 * when editing via the classic admin form (custom fields are saved via AJAX before the form submit).
	 *
	 * This function is not fired when using the block editor — REST API hooks are used instead.
	 *
	 * @since 2.0.29
	 */
	public function on_admin_action_editpost_save_prev_post() {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing
		$post_ID = isset( $_POST['post_ID'] ) ? (int) $_POST['post_ID'] : 0;

		if ( $post_ID === 0 ) {
			return;
		}

		if ( ! current_user_can( 'edit_post', $post_ID ) ) {
			return;
		}

		$this->save_prev_post_data( $post_ID );
	}

	/**
	 * Fires after a post has been successfully deleted via the XML-RPC Blogger API.
	 *
	 * @since 2.0.21
	 *
	 * @param int   $post_ID ID of the deleted post.
	 * @param array $args    An array of arguments to delete the post.
	 */
	public function on_xmlrpc_deletePost( $post_ID, $args ) {
		$post = get_post( $post_ID );

		$context = array(
			'post_id'    => $post->ID,
			'post_type'  => get_post_type( $post ),
			'post_title' => get_the_title( $post ),
		);

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

	/**
	 * Fires after a post has been successfully updated via the XML-RPC API.
	 *
	 * @since 2.0.21
	 *
	 * @param int   $post_ID ID of the updated post.
	 * @param array $args    An array of arguments for the post to edit.
	 */
	public function on_xmlrpc_editPost( $post_ID, $args ) {
		$post = get_post( $post_ID );

		$context = array(
			'post_id'    => $post->ID,
			'post_type'  => get_post_type( $post ),
			'post_title' => get_the_title( $post ),
		);

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

	/**
	 * Fires after a new post has been successfully created via the XML-RPC API.
	 *
	 * @since 2.0.21
	 *
	 * @param int   $post_ID ID of the new post.
	 * @param array $args    An array of new post arguments.
	 */
	public function on_xmlrpc_newPost( $post_ID, $args ) {
		$post = get_post( $post_ID );

		$context = array(
			'post_id'    => $post->ID,
			'post_type'  => get_post_type( $post ),
			'post_title' => get_the_title( $post ),
		);

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

	/**
	 * Called when a post is restored from the trash
	 * @param int $post_id Post ID.
	 */
	public function on_untrash_post( $post_id ) {
		$post = get_post( $post_id );

		if ( ! $this->ok_to_log_post_posttype( $post ) ) {
			return;
		}

		$this->info_message(
			'post_restored',
			array(
				'post_id'    => $post_id,
				'post_type'  => get_post_type( $post ),
				'post_title' => get_the_title( $post ),
			)
		);
	}

	/**
	 * Fired immediately before a post is deleted from the database.
	 *
	 * @param int $post_id Post ID.
	 */
	public function on_delete_post( $post_id ) {
		$post = get_post( $post_id );

		if ( wp_is_post_revision( $post_id ) ) {
			return;
		}

		if ( $post->post_status === 'auto-draft' || $post->post_status === 'inherit' ) {
			return;
		}

		$ok_to_log = true;

		if ( ! $this->ok_to_log_post_posttype( $post ) ) {
			$ok_to_log = false;
		}

		/**
		 * Filter to control logging.
		 *
		 * @param bool $ok_to_log If this post deletion should be logged.
		 * @param int $post_id
		 *
		 * @return bool True to log, false to not log.
		 *
		 * @since 2.21
		 */
		$ok_to_log = apply_filters( 'simple_history/post_logger/post_deleted/ok_to_log', $ok_to_log, $post_id );

		if ( ! $ok_to_log ) {
			return;
		}

		/*
		Posts that have been in the trash for 30 days (default)
		are deleted using a cron job that is called with action hook "wp_scheduled_delete".
		We skip logging these because users are confused and think that the real post has been
		deleted.
		We detect this by checking $wp_current_filter for 'wp_scheduled_delete'
		[
			"wp_scheduled_delete",
			"delete_post",
			"simple_history\/log_argument\/context"
		]
		*/
		global $wp_current_filter;
		if ( isset( $wp_current_filter ) && is_array( $wp_current_filter ) && in_array( 'wp_scheduled_delete', $wp_current_filter, true ) ) {
			return;
		}

		$this->info_message(
			'post_deleted',
			array(
				'post_id'    => $post_id,
				'post_type'  => get_post_type( $post ),
				'post_title' => get_the_title( $post ),
			)
		);
	}

	/**
	 * Get an array of post types that should not be logged by this logger.
	 *
	 * @return Array with post type slugs to skip.
	 */
	public function get_skip_posttypes() {
		$skip_posttypes = array(
			// Don't log nav_menu_updates.
			'nav_menu_item',
			// Don't log jetpack migration-things.
			// https://wordpress.org/support/topic/updated-jetpack_migration-sidebars_widgets/.
			'jetpack_migration',
			'jp_sitemap',
			'jp_img_sitemap',
			'jp_sitemap_master',
			'attachment',
			// SecuPress logs.
			'secupress_log_action',
		);

		/**
		 * Filter to log what post types not to log
		 *
		 * @since 2.18
		 */
		$skip_posttypes = apply_filters( 'simple_history/post_logger/skip_posttypes', $skip_posttypes );

		return $skip_posttypes;
	}

	/**
	 * Check if post type is ok to log by logger
	 *
	 * @param \WP_Post|int $post Post the check.
	 *
	 * @return bool
	 */
	public function ok_to_log_post_posttype( $post ) {
		$ok_to_log      = true;
		$skip_posttypes = $this->get_skip_posttypes();

		if ( in_array( get_post_type( $post ), $skip_posttypes, true ) ) {
			$ok_to_log = false;
		}

		return $ok_to_log;
	}

	/**
	 * Maybe log a post creation, modification or deletion.
	 *
	 * Called from:
	 * - on_transition_post_status
	 * - on_rest_after_insert
	 *
	 * Todo:
	 * - support password protect.
	 * - post_password is set
	 *
	 * @param array $args Array with old and new post data.
	 */
	public function maybe_log_post_change( $args ) {
		$default_args = array(
			'new_post',
			'new_post_meta',
			'old_post',
			'old_post_meta',
			// Old status is included because that's the value we get in filter
			// "transition_post_status", when a previous post may not exist.
			'old_status',
		);

		$args = wp_parse_args( $args, $default_args );

		// Bail if needed args not set.
		if ( ! isset( $args['new_post'] ) || ! isset( $args['new_post_meta'] ) ) {
			return;
		}

		$new_status    = $args['new_post']->post_status ?? null;
		$post          = $args['new_post'];
		$new_post_data = array(
			'post_data'  => $post,
			'post_meta'  => $args['new_post_meta'],
			'post_terms' => $args['new_post_terms'],
		);

		// Set old status to status from old post with fallback to old_status variable.
		$old_status = $args['old_post']->post_status ?? null;
		$old_status = ! isset( $old_status ) && isset( $args['old_status'] ) ? $args['old_status'] : $old_status;

		$old_post      = $args['old_post'] ?? null;
		$old_post_meta = $args['old_post_meta'] ?? null;
		$old_post_data = array(
			'post_data'  => $old_post,
			'post_meta'  => $old_post_meta,
			'post_terms' => $args['old_post_terms'] ?? null,
		);

		// Default to log.
		$ok_to_log = true;

		// Calls from the WordPress ios app/jetpack comes from non-admin-area
		// i.e. is_admin() is false
		// so don't log when outside admin area.
		if ( ! is_admin() ) {
			$ok_to_log = false;
		}

		$is_autosave      = defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE;
		$isXmlRpcRequest  = defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST;
		$isRestApiRequest = Helpers::is_rest_request();

		// Except when calls are from/for Jetpack/WordPress apps.
		// seems to be jetpack/app request when $_GET["for"] == "jetpack.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( $isXmlRpcRequest && isset( $_GET['for'] ) && $_GET['for'] === 'jetpack' ) {
			$ok_to_log = true;
		}

		// Also accept calls from REST API.
		// "REST_API_REQUEST" is used by Jetpack I believe.
		if ( $isRestApiRequest ) {
			$ok_to_log = true;
		}

		// Accept calls from WP-CLI.
		if ( Helpers::is_wp_cli() ) {
			$ok_to_log = true;
		}

		// When a post is transitioned from future to publish, it's done by a cron job,
		// and is_admin() is false. It's called from filter "publish_future_post".
		// Logging is done from another function, we just make double sure to not log it here.
		if ( did_action( 'publish_future_post' ) ) {
			$ok_to_log = false;
		}

		// Don't log revisions.
		if ( wp_is_post_revision( $post ) ) {
			$ok_to_log = false;
		}

		// Don't log Gutenberg saving meta boxes.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( isset( $_GET['meta-box-loader'] ) && sanitize_text_field( wp_unslash( $_GET['meta-box-loader'] ) ) ) {
			$ok_to_log = false;
		}

		if ( ! $this->ok_to_log_post_posttype( $post ) ) {
			$ok_to_log = false;
		}

		/**
		 * Filter to control logging.
		 *
		 * @param bool $ok_to_log
		 * @param string|null $new_status
		 * @param string|null $old_status
		 * @param \WP_Post $post
		 *
		 * @return bool True to log, false to not log.
		 *
		 * @since 2.21
		 */
		$ok_to_log = apply_filters(
			'simple_history/post_logger/post_updated/ok_to_log',
			$ok_to_log,
			$new_status,
			$old_status,
			$post
		);

		if ( ! $ok_to_log ) {
			return;
		}

		/*
		From new to auto-draft <- ignore
		From new to inherit <- ignore
		From auto-draft to draft <- page/post created
		From draft to draft
		From draft to pending
		From pending to publish
		From pending to trash
		From something to publish = post published
		if not from & to = same, then user has changed something
		From draft to publish in future: status = "future"
		*/
		$context = array(
			'post_id'    => $post->ID,
			'post_type'  => get_post_type( $post ),
			'post_title' => get_the_title( $post ),
		);

		// A revision saved earlier in this request belongs to this event. Consumed
		// rather than only read, so a second change to the same post within one
		// request cannot inherit the first one's revision.
		if ( isset( $this->post_revision_ids[ $post->ID ] ) ) {
			$context['post_revision_id'] = $this->post_revision_ids[ $post->ID ];

			unset( $this->post_revision_ids[ $post->ID ] );
		}

		// Check if this is a post being created.
		// This includes manual creation (auto-draft -> draft/publish), auto-save
		// creation (auto-draft -> draft), and WP-CLI / direct wp_insert_post()
		// creation which transitions from 'new' to the final status.
		$is_post_created = ( $old_status === 'auto-draft' || $old_status === 'new' ) && ( $new_status !== 'auto-draft' && $new_status !== 'inherit' );

		if ( $is_post_created ) {
			// Post created.
			// Add context to indicate if this was auto-created by WordPress (auto-save)
			// vs manually created by user clicking Save/Publish.
			if ( $new_status === 'draft' && $is_autosave ) {
				$context['post_auto_created'] = true;
			}

			// Capture initial post content so there's no information gap in the audit trail.
			// This is especially important for autosaved posts where the initial content
			// would otherwise be lost (first update would only show diff from autosave state).
			$context['post_new_post_content'] = $post->post_content;
			$context['post_new_post_excerpt'] = $post->post_excerpt;
			$context['post_prev_status']      = $old_status;
			$context['post_new_status']       = $new_status;

			$referer_post = $this->get_referer_post();
			if ( $referer_post ) {
				$context['post_created_from_post_id']    = $referer_post->ID;
				$context['post_created_from_post_title'] = get_the_title( $referer_post );
			}

			$this->info_message( 'post_created', $context );
		} elseif ( $new_status === 'auto-draft' || ( $old_status === 'new' && $new_status === 'inherit' ) ) {
			// Post was automagically saved by WordPress but not yet created (still auto-draft).
			return;
		} elseif ( $new_status === 'trash' ) {
			// Post trashed.
			$this->info_message( 'post_trashed', $context );
		} else {
			// Existing post was updated.

			// Also add diff between previous saved data and new data.
			// Now we have both old and new post data, including custom fields, in the same format
			// So let's compare!
			$context = $this->add_post_data_diff_to_context( $context, $old_post_data, $new_post_data );

			$context['_occasionsID'] = self::class . '/' . __FUNCTION__ . "/post_updated/{$post->ID}";

			/**
			 * Modify the context saved.
			 *
			 * @param array $context
			 * @param WP_Post $post
			 */
			$context = apply_filters( 'simple_history/post_logger/post_updated/context', $context, $post );

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

	/**
	 * When a post is transitioned from future to publish, it's done by a cron job,
	 * and is_admin() is false. It's called from filter "publish_future_post" however, so we can check for that.
	 *
	 * @param string   $new_status New status.
	 * @param string   $old_status Old status.
	 * @param \WP_Post $post Post object.
	 */
	public function on_transition_post_status_future( $new_status, $old_status, $post ) {
		if ( ! did_action( 'publish_future_post' ) ) {
			return;
		}

		$this->info_message(
			'post_updated',
			[
				'post_id'          => $post->ID,
				'post_type'        => get_post_type( $post ),
				'post_title'       => get_the_title( $post ),
				'post_prev_status' => $old_status,
				'post_new_status'  => $new_status,
			]
		);
	}

	/**
	 * Fired when a post has changed status in the classical editor.
	 *
	 * It is also fired when saving from the Gutenberg editor,
	 * but it seems something is different because
	 * we can't get previously custom fields here (we only get latest values instead).
	 *
	 * Only run in certain cases,
	 * because when always enabled it catches a lots of edits made by plugins during cron jobs etc,
	 * which by definition is not wrong, but perhaps not wanted/annoying.
	 *
	 * @param string   $new_status One of auto-draft, inherit, draft, pending, publish, future.
	 * @param string   $old_status Same as above.
	 * @param \WP_Post $post New updated post.
	 */
	public function on_transition_post_status( $new_status, $old_status, $post ) {
		$isRestApiRequest       = Helpers::is_rest_request();
		$isAutosave             = defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE;
		$isAutosaveCreatingPost = $isAutosave && $old_status === 'auto-draft' && $new_status === 'draft';

		// Bail if this is a REST API request, EXCEPT for autosaves that create posts.
		// Autosaves from Gutenberg use the REST API but we want to log when they
		// transition from auto-draft to draft (which represents post creation).
		if ( $isRestApiRequest && ! $isAutosaveCreatingPost ) {
			return;
		}

		// Bail for WP-CLI — handled by on_wp_after_insert_post to avoid double-logging.
		if ( Helpers::is_wp_cli() ) {
			return;
		}

		// Bail if post is not a post.
		if ( ! is_a( $post, 'WP_Post' ) ) {
			return;
		}

		$old_post       = $this->old_post_data[ $post->ID ]['post_data'] ?? null;
		$old_post_meta  = $this->old_post_data[ $post->ID ]['post_meta'] ?? null;
		$old_post_terms = $this->old_post_data[ $post->ID ]['post_terms'] ?? null;

		$args = array(
			'new_post'       => $post,
			'new_post_meta'  => get_post_custom( $post->ID ),
			'new_post_terms' => wp_get_object_terms( $post->ID, get_object_taxonomies( $post->post_type ) ),
			'old_post'       => $old_post,
			'old_post_meta'  => $old_post_meta,
			'old_post_terms' => $old_post_terms,
			'old_status'     => $old_status,
		);

		$this->maybe_log_post_change( $args );
	}

	/**
	 * Adds diff data to the context array. Is called just before the event is logged.
	 *
	 * Since 2.0.29
	 *
	 * To detect
	 *  - categories
	 *  - tags
	 *
	 * @param array $context Array with context.
	 * @param array $old_post_data Old/prev post data.
	 * @param array $new_post_data New post data.
	 * @return array $context with diff data added.
	 */
	public function add_post_data_diff_to_context( $context, $old_post_data, $new_post_data ) {
		$old_data = $old_post_data['post_data'];
		$new_data = $new_post_data['post_data'];

		// Will contain the differences.
		$post_data_diff = array();

		$arr_keys_to_diff = array(
			'post_title',
			'post_name',
			'post_content',
			'post_status',
			'menu_order',
			'post_date',
			'post_excerpt',
			'comment_status',
			'ping_status',
			'post_parent', // only id, need to get context for that, like name of parent at least?
			'post_author', // only id, need to get more info for user.
		);

		$arr_keys_to_diff = $this->add_keys_to_diff( $arr_keys_to_diff );

		foreach ( $arr_keys_to_diff as $key ) {
			if ( ! isset( $old_data->$key ) || ! isset( $new_data->$key ) ) {
				continue;
			}

			$post_data_diff = $this->add_diff( $post_data_diff, $key, $old_data->$key, $new_data->$key );
		}

		// If changes where detected.
		// Save at least 2 values for each detected value change, i.e. the old value and the new value.
		foreach ( $post_data_diff as $diff_key => $diff_values ) {
			// For post_content, try compact JSON diff storage if the library is available.
			if (
				$diff_key === 'post_content'
				&& class_exists( DiffHelper::class )
			) {
				try {
					// Normalize whitespace to match WP's text_diff behavior.
					$old_normalized = normalize_whitespace( $diff_values['old'] );
					$new_normalized = normalize_whitespace( $diff_values['new'] );

					$json_diff = DiffHelper::calculate(
						$old_normalized,
						$new_normalized,
						'JsonHtml',
						[
							'context'           => 1,
							'ignoreLineEndings' => true,
							'ignoreWhitespace'  => true,
						],
						[
							'detailLevel'       => 'word',
							'outputTagAsString' => true,
						]
					);

					$full_size = strlen( $old_normalized ) + strlen( $new_normalized );
					$diff_size = strlen( $json_diff );

					// Use compact diff only if it's actually smaller than storing full content.
					if ( $diff_size < $full_size ) {
						$context['post_content_diff']        = $json_diff;
						$context['post_content_diff_format'] = 'jfcherng_json_html_v1';
					} else {
						$context[ "post_prev_{$diff_key}" ]  = $diff_values['old'];
						$context[ "post_new_{$diff_key}" ]   = $diff_values['new'];
						$context['post_content_diff_format'] = 'full_content_v1';
					}
				} catch ( \Exception $e ) {
					// Fallback to full content storage on any error.
					$context[ "post_prev_{$diff_key}" ]  = $diff_values['old'];
					$context[ "post_new_{$diff_key}" ]   = $diff_values['new'];
					$context['post_content_diff_format'] = 'full_content_v1';
				}

				continue;
			}

				$context[ "post_prev_{$diff_key}" ] = $diff_values['old'];
				$context[ "post_new_{$diff_key}" ]  = $diff_values['new'];

				// If post_author then get more author info,
				// because just a user ID does not get us far.
			if ( $diff_key !== 'post_author' ) {
				continue;
			}

			$old_author_user = get_userdata( (int) $diff_values['old'] );
			$new_author_user = get_userdata( (int) $diff_values['new'] );

			if ( ! is_a( $old_author_user, 'WP_User' ) || ! is_a( $new_author_user, 'WP_User' ) ) {
				continue;
			}

			$context[ "post_prev_{$diff_key}/user_login" ]   = $old_author_user->user_login;
			$context[ "post_prev_{$diff_key}/user_email" ]   = $old_author_user->user_email;
			$context[ "post_prev_{$diff_key}/display_name" ] = $old_author_user->display_name;

			$context[ "post_new_{$diff_key}/user_login" ]   = $new_author_user->user_login;
			$context[ "post_new_{$diff_key}/user_email" ]   = $new_author_user->user_email;
			$context[ "post_new_{$diff_key}/display_name" ] = $new_author_user->display_name;
		}

		// Compare custom fields.
		// Array with custom field keys to ignore because changed every time or very internal.
		$arr_meta_keys_to_ignore = array(
			'_edit_lock',
			'_edit_last',
			'_post_restored_from',
			'_wp_page_template',
			'_thumbnail_id',

			// _encloseme is added to a post when it's published. The wp-cron process should get scheduled shortly thereafter to process the post to look for enclosures.
			// https://wordpress.stackexchange.com/questions/20904/the-encloseme-meta-key-conundrum
			'_encloseme',
		);

		/**
		 * Filters the array with custom field keys to ignore.
		 *
		 * @param  array $arr_meta_keys_to_ignore Array with custom field keys to ignore.
		 * @param  array $context                 Array with context.
		 * @return array                          Filtered array with custom field keys to ignore.
		 *
		 * @since 5.8.2
		 */
		$arr_meta_keys_to_ignore = apply_filters( 'simple_history/post_logger/meta_keys_to_ignore', $arr_meta_keys_to_ignore, $context );

		$meta_changes = array(
			'added'   => array(),
			'removed' => array(),
			'changed' => array(),
		);

		$old_meta = isset( $old_post_data['post_meta'] ) ? (array) $old_post_data['post_meta'] : array();
		$new_meta = isset( $new_post_data['post_meta'] ) ? (array) $new_post_data['post_meta'] : array();

		// Add post featured thumb data.
		$context = $this->add_post_thumb_diff( $context, $old_meta, $new_meta );

		// Detect page template changes.
		// Page template is stored in _wp_page_template.
		if ( isset( $old_meta['_wp_page_template'][0] ) && isset( $new_meta['_wp_page_template'][0] ) && $old_meta['_wp_page_template'][0] !== $new_meta['_wp_page_template'][0] ) {
			// Prev page template is different from new page template,
			// store template php file name.
			$context['post_prev_page_template'] = $old_meta['_wp_page_template'][0];
			$context['post_new_page_template']  = $new_meta['_wp_page_template'][0];
			$theme_templates                    = (array) $this->get_theme_templates();

			if ( isset( $theme_templates[ $context['post_prev_page_template'] ] ) ) {
					$context['post_prev_page_template_name'] = $theme_templates[ $context['post_prev_page_template'] ];
			}

			if ( isset( $theme_templates[ $context['post_new_page_template'] ] ) ) {
					$context['post_new_page_template_name'] = $theme_templates[ $context['post_new_page_template'] ];
			}
		}

		// Remove fields that we have checked already and other that should be ignored.
		foreach ( $arr_meta_keys_to_ignore as $key_to_ignore ) {
			unset( $old_meta[ $key_to_ignore ] );
			unset( $new_meta[ $key_to_ignore ] );
		}

		// Look for added custom fields/meta.
		foreach ( $new_meta as $meta_key => $meta_value ) {
			if ( isset( $old_meta[ $meta_key ] ) ) {
				continue;
			}

			$meta_changes['added'][ $meta_key ] = true;
		}

		// Look for changed custom fields/meta.
		foreach ( $old_meta as $meta_key => $meta_value ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
			if ( ! isset( $new_meta[ $meta_key ] ) || json_encode( $old_meta[ $meta_key ] ) === json_encode( $new_meta[ $meta_key ] ) ) {
				continue;
			}

			$meta_changes['changed'][ $meta_key ] = true;
		}

		// Look for removed custom fields/meta.
		foreach ( $old_meta as $meta_key => $meta_value ) {
			if ( isset( $new_meta[ $meta_key ] ) ) {
				continue;
			}

			$meta_changes['removed'][ $meta_key ] = true;
		}

		if ( $meta_changes['added'] ) {
			$context['post_meta_added'] = count( $meta_changes['added'] );
		}

		if ( $meta_changes['removed'] ) {
			$context['post_meta_removed'] = count( $meta_changes['removed'] );
		}

		if ( $meta_changes['changed'] ) {
			$context['post_meta_changed'] = count( $meta_changes['changed'] );
		}

		// Check for changes in post visibility and post password usage and store in context.
		// publish = public
		// publish + post_password = password protected
		// private = post private.
		$old_post_has_password = ! empty( $old_data->post_password );
		$old_post_password     = $old_post_has_password ? $old_data->post_password : null;
		$old_post_status       = $old_data->post_status ?? null;

		$new_post_has_password = ! empty( $new_data->post_password );
		$new_post_password     = $new_post_has_password ? $new_data->post_password : null;
		$new_post_status       = $new_data->post_status ?? null;

		if ( $old_post_has_password === false && $new_post_status === 'publish' && $new_post_has_password ) {
			// If updated post is published and password is set and old post did not have password set
			// = post changed to be password protected.
			$context['post_password_protected'] = true;
		} elseif (
			$old_post_has_password &&
			$old_post_status === 'publish' &&
			$new_post_has_password === false &&
			$new_post_status === 'publish'
		) {
			// Old post is publish and had password protection and new post is publish but no password
			// = post changed to be un-password protected.
			$context['post_password_unprotected'] = true;
		} elseif ( $old_post_has_password && $new_post_has_password && $old_post_password !== $new_post_password ) {
			// If old post had password and new post has password, but passwords are note same
			// = post has changed password.
			$context['post_password_changed'] = true;
		} elseif ( $new_post_status === 'private' && $old_post_status !== 'private' ) {
			// If new status is private and old is not
			// = post is changed to be private.
			$context['post_private'] = true;
			// Also check if password was set before.
			if ( $old_post_has_password ) {
				$context['post_password_unprotected'] = true;
			}
		}

		// Todo: detect sticky.
		// Sticky is stored in option:
		// $sticky_posts = get_option('sticky_posts');.

		// Check for changes in post terms.
		$old_post_terms = $old_post_data['post_terms'] ?? [];
		$new_post_terms = $new_post_data['post_terms'] ?? [];

		// Keys to keep for each term: term_id, name, slug, term_taxonomy_id, taxonomy.
		$term_keys_to_keep = [
			'term_id',
			'name',
			'slug',
			'term_taxonomy_id',
			'taxonomy',
		];

		$old_post_terms = array_map(
			function ( $term ) use ( $term_keys_to_keep ) {
				return array_intersect_key( (array) $term, array_flip( $term_keys_to_keep ) );
			},
			$old_post_terms
		);

		$new_post_terms = array_map(
			function ( $term ) use ( $term_keys_to_keep ) {
				return array_intersect_key( (array) $term, array_flip( $term_keys_to_keep ) );
			},
			$new_post_terms
		);

		// Detect added and removed terms.
		$term_changes = [
			// Added = exists in new but not in old.
			'added'   => [],
			// Removed = exists in old but not in new.
			'removed' => [],
		];

		$term_changes['added']   = array_values( array_udiff( $new_post_terms, $old_post_terms, [ $this, 'compare_terms' ] ) );
		$term_changes['removed'] = array_values( array_udiff( $old_post_terms, $new_post_terms, [ $this, 'compare_terms' ] ) );

		// Add old and new terms to context.
		$context['post_terms_added']   = $term_changes['added'];
		$context['post_terms_removed'] = $term_changes['removed'];

		/**
		 * Filter to control context sent to the diff output.
		 *
		 * @param array $context Array with context.
		 * @param array $old_data Old/prev post data.
		 * @param array $new_data New post data.
		 * @param array $old_meta Old/prev post meta data.
		 * @param array $new_meta New post meta data.
		 *
		 * @return array $context Array with diff data added.
		 *
		 * @since 2.36.0
		 */
		return apply_filters( 'simple_history/post_logger/context', $context, $old_data, $new_data, $old_meta, $new_meta );
	}

	/**
	 * Compare function for terms to check terms by id.
	 *
	 * @param array $a Term A.
	 * @param array $b Term B.
	 */
	private function compare_terms( $a, $b ) {
		return $a['term_id'] <=> $b['term_id'];
	}

	/**
	 * Return the current theme templates.
	 * Template will return untranslated.
	 * Uses the same approach as in class-wp-theme.php to get templates.
	 *
	 * @since 2.0.29
	 */
	public function get_theme_templates() {
		$theme          = wp_get_theme();
		$page_templates = array();

		$files = (array) $theme->get_files( 'php', 1 );

		foreach ( $files as $file => $full_path ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown
			if ( ! preg_match( '|Template Name:(.*)$|mi', file_get_contents( $full_path ), $header ) ) {
				continue;
			}
			$page_templates[ $file ] = _cleanup_header_comment( $header[1] );
		}

		return $page_templates;
	}

	/**
	 * Add diff to array if old and new values are different
	 *
	 * Since 2.0.29
	 *
	 * @param array  $post_data_diff Post data diff.
	 * @param string $key Key.
	 * @param mixed  $old_value Old value.
	 * @param mixed  $new_value New value.
	 * @return array
	 */
	public function add_diff( $post_data_diff, $key, $old_value, $new_value ) {
		// phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual -- Loose comparison intentional to avoid false diffs when types differ.
		if ( $old_value != $new_value ) {
			$post_data_diff[ $key ] = array(
				'old' => $old_value,
				'new' => $new_value,
			);
		}

		return $post_data_diff;
	}

	/**
	 * Modify plain output to include link to post.
	 *
	 * @param object $row Row data.
	 */
	public function get_log_row_plain_text_output( $row ) {
		$context = $row->context;
		$post_id = $context['post_id'] ?? 0;

		// Default to original log message.
		$message = $row->message;

		// Check if post still is available.
		// It will return a WP_Post Object if post still is in system.
		// If post is deleted from trash (not just moved there), then null is returned.
		$post              = get_post( $post_id );
		$post_is_available = is_a( $post, 'WP_Post' );

		$message_key = $context['_message_key'] ?? null;

		// Try to get singular name.
		$post_type     = $context['post_type'] ?? '';
		$post_type_obj = get_post_type_object( $post_type );
		if ( ! is_null( $post_type_obj ) && ! empty( $post_type_obj->labels->singular_name ) ) {
			$context['post_type'] = strtolower( $post_type_obj->labels->singular_name );
		}

		// Only try to get edit link if post is available. This _may_ fix some issues with edit links in
		// for example old versions of WPML.
		$context['edit_link'] = $post_is_available ? get_edit_post_link( $post_id ) : null;

		// If post is not available any longer then we can't link to it, so keep plain message then.
		// Also keep plain format if user is not allowed to edit post (edit link is empty).
		if ( $post_is_available && $context['edit_link'] ) {
			if ( $message_key === 'post_updated' ) {
				$message = __( 'Updated {post_type} <a href="{edit_link}">"{post_title}"</a>', 'simple-history' );
			} elseif ( $message_key === 'post_deleted' ) {
				$message = __( 'Deleted {post_type} "{post_title}"', 'simple-history' );
			} elseif ( $message_key === 'post_created' ) {
				$message = __( 'Created {post_type} <a href="{edit_link}">"{post_title}"</a>', 'simple-history' );
			} elseif ( $message_key === 'post_trashed' ) {
				// While in trash we can still get actions to delete or restore if we follow the edit link.
				$message = __(
					'Moved {post_type} <a href="{edit_link}">"{post_title}"</a> to the trash',
					'simple-history'
				);
			} elseif ( $message_key === 'page_set_as_homepage' ) {
				if ( ! empty( $context['old_post_title'] ) ) {
					$message = __( 'Set {post_type} <a href="{edit_link}">"{post_title}"</a> as the homepage, replacing "{old_post_title}"', 'simple-history' );
				} else {
					$message = __( 'Set {post_type} <a href="{edit_link}">"{post_title}"</a> as the homepage', 'simple-history' );
				}
			} elseif ( $message_key === 'page_removed_as_homepage' ) {
				$message = __( 'Removed {post_type} <a href="{edit_link}">"{post_title}"</a> as the homepage', 'simple-history' );
			} elseif ( $message_key === 'page_set_as_posts_page' ) {
				if ( ! empty( $context['old_post_title'] ) ) {
					$message = __( 'Set {post_type} <a href="{edit_link}">"{post_title}"</a> as the posts page, replacing "{old_post_title}"', 'simple-history' );
				} else {
					$message = __( 'Set {post_type} <a href="{edit_link}">"{post_title}"</a> as the posts page', 'simple-history' );
				}
			} elseif ( $message_key === 'page_removed_as_posts_page' ) {
				$message = __( 'Removed {post_type} <a href="{edit_link}">"{post_title}"</a> as the posts page', 'simple-history' );
			}
		}

		// For page role messages without edit link, add "replacing" info to the plain message.
		if ( ! empty( $context['old_post_title'] ) ) {
			if ( $message_key === 'page_set_as_homepage' && strpos( $message, 'old_post_title' ) === false ) {
				$message = __( 'Set {post_type} "{post_title}" as the homepage, replacing "{old_post_title}"', 'simple-history' );
			} elseif ( $message_key === 'page_set_as_posts_page' && strpos( $message, 'old_post_title' ) === false ) {
				$message = __( 'Set {post_type} "{post_title}" as the posts page, replacing "{old_post_title}"', 'simple-history' );
			}
		}

		$context['post_type']      = isset( $context['post_type'] ) ? esc_html( $context['post_type'] ) : '';
		$context['post_title']     = isset( $context['post_title'] ) ? esc_html( $context['post_title'] ) : '';
		$context['old_post_title'] = isset( $context['old_post_title'] ) ? esc_html( $context['old_post_title'] ) : '';

		return helpers::interpolate( $message, $context, $row );
	}

	/**
	 * Get structured action links for a post event.
	 *
	 * Returns View, Edit, Preview, and Revisions links based on
	 * message key, post availability, status, and user capabilities.
	 *
	 * @since 5.24.0
	 *
	 * @param object $row Log row object.
	 * @return array Array of action link arrays.
	 */
	public function get_action_links( $row ) {
		$context     = $row->context;
		$post_id     = (int) ( $context['post_id'] ?? 0 );
		$post_type   = $context['post_type'] ?? '';
		$message_key = $context['_message_key'] ?? null;

		$action_links = [];

		// Skip the DB lookup for post_deleted (the post is gone) and for
		// events without a post id. The overview block below still runs
		// from $context['post_type'].
		$post = $post_id && $message_key !== 'post_deleted' ? get_post( $post_id ) : null;

		$effective_post_type_obj = null;

		if ( $post instanceof \WP_Post ) {
			$effective_post_type_obj = get_post_type_object( $post->post_type );
			$type_label              = $effective_post_type_obj ? strtolower( $effective_post_type_obj->labels->singular_name ) : $post->post_type;
			$post_status             = get_post_status( $post );

			$is_published = $post_status === 'publish';
			$is_viewable  = in_array( $post_status, [ 'draft', 'pending', 'future' ], true );
			$has_edit_cap = current_user_can( 'edit_post', $post_id );

			if ( $has_edit_cap ) {
				$edit_link = get_edit_post_link( $post_id, 'raw' );
				if ( $edit_link ) {
					$action_links[] = [
						'url'    => $edit_link,
						/* translators: %s: post type label, e.g. "page" or "post". */
						'label'  => sprintf( __( 'Edit %s', 'simple-history' ), $type_label ),
						'action' => 'edit',
					];
				}
			}

			if ( $is_published ) {
				$permalink = get_permalink( $post_id );
				if ( $permalink ) {
					$action_links[] = [
						'url'    => $permalink,
						/* translators: %s: post type label, e.g. "page" or "post". */
						'label'  => sprintf( __( 'View %s', 'simple-history' ), $type_label ),
						'action' => 'view',
					];
				}
			}

			if ( $is_viewable && $has_edit_cap ) {
				$preview_link = get_preview_post_link( $post_id );
				if ( $preview_link ) {
					$action_links[] = [
						'url'    => $preview_link,
						/* translators: %s: post type label, e.g. "page" or "post". */
						'label'  => sprintf( __( 'Preview %s', 'simple-history' ), $type_label ),
						'action' => 'preview',
					];
				}
			}

			// `wp_revisions_enabled()` is a constant-time check (post-type
			// support + WP_POST_REVISIONS); skipping it would issue a WP_Query
			// per post_updated event on sites that have revisions disabled.
			if ( $message_key === 'post_updated' && wp_revisions_enabled( $post ) ) {
				$revisions = wp_get_post_revisions( $post_id, [ 'numberposts' => 1 ] );
				if ( ! empty( $revisions ) ) {
					$latest_revision = reset( $revisions );
					$action_links[]  = [
						'url'    => admin_url( 'revision.php?revision=' . $latest_revision->ID ),
						'label'  => __( 'Revisions', 'simple-history' ),
						'action' => 'revisions',
					];
				}
			}
		}

		// Overview link still renders for post_deleted (per-post block is
		// gone there); fall back to $context['post_type'] when $post is null.
		$overview_obj = $effective_post_type_obj;
		if ( ! $overview_obj && $post_type ) {
			$overview_obj = get_post_type_object( $post_type );
		}

		// phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability comes from registered post type's cap mapping.
		if ( $overview_obj && current_user_can( $overview_obj->cap->edit_posts ) ) {
			$action_links[] = [
				'url'    => admin_url( 'edit.php?post_type=' . rawurlencode( $overview_obj->name ) ),
				'label'  => sprintf(
					/* translators: %s: post type plural label, e.g. "posts" or "pages". */
					__( 'All %s', 'simple-history' ),
					strtolower( $overview_obj->labels->name )
				),
				'action' => 'view',
			];
		}

		return $action_links;
	}

	/**
	 * Get details output for row.
	 *
	 * @param object $row Row data.
	 */
	public function get_log_row_details_output( $row ) {
		$context     = $row->context;
		$message_key = $context['_message_key'];

		$out = '';

		if ( $message_key === 'post_updated' ) {
			// Check for keys like "post_prev_post_title" and "post_new_post_title".
			$diff_table_output = '';
			$has_diff_values   = false;

			$inline_group = new Event_Details_Group();

			foreach ( $context as $key => $val ) {

				// Skip some context keys.
				$keys_to_skip = [];

				// Skip post author because we manually output the change already.
				$keys_to_skip = [ 'post_author/user_login', 'post_author/user_email', 'post_author/display_name' ];

				if ( strpos( $key, 'post_prev_' ) === false ) {
					continue;
				}

				// Old value exists, new value must also exist for diff to be calculates.
				$key_to_diff = substr( $key, strlen( 'post_prev_' ) );

				$key_for_new_val = "post_new_{$key_to_diff}";

				// Skip some keys.
				if ( in_array( $key_to_diff, $keys_to_skip, true ) ) {
					continue;
				}

				if ( ! isset( $context[ $key_for_new_val ] ) ) {
					continue;
				}

				$post_old_value = $context[ $key ];
				$post_new_value = $context[ $key_for_new_val ];
				// phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- Loose comparison intentional to avoid false diffs when types differ.
				if ( $post_old_value == $post_new_value ) {
					continue;
				}

				// Different diffs for different keys.
				if ( $key_to_diff === 'post_title' ) {
					$has_diff_values = true;
					$label           = __( 'Title', 'simple-history' );

					$diff_table_output .= sprintf(
						'<tr><td>%1$s</td><td>%2$s</td></tr>',
						$this->label_for( $key_to_diff, $label, $context ),
						helpers::text_diff( $post_old_value, $post_new_value )
					);
				} elseif ( $key_to_diff === 'post_content' ) {
					// Skip if compact JSON diff exists — it's rendered separately below.
					if ( isset( $context['post_content_diff'] ) ) {
						continue;
					}

					$has_diff_values = true;
					$label           = __( 'Content', 'simple-history' );
					$key_text_diff   = helpers::text_diff( $post_old_value, $post_new_value );

					if ( $key_text_diff ) {
						$diff_table_output .= sprintf(
							'<tr><td>%1$s</td><td>%2$s</td></tr>',
							$this->label_for( $key_to_diff, $label, $context ),
							$key_text_diff
						);
					}
				} elseif ( $key_to_diff === 'post_status' ) {
					$inline_group->add_item(
						( new Event_Details_Item( null, __( 'Status', 'simple-history' ) ) )
							->set_values( $post_new_value, $post_old_value )
					);
				} elseif ( $key_to_diff === 'post_date' ) {
					$inline_group->add_item(
						( new Event_Details_Item( null, __( 'Publish date', 'simple-history' ) ) )
							->set_values( $post_new_value, $post_old_value )
					);
				} elseif ( $key_to_diff === 'post_name' ) {
					$has_diff_values = true;
					$label           = __( 'Permalink', 'simple-history' );

					$diff_table_output .= sprintf(
						'<tr>
							<td>%1$s</td>
							<td>%2$s</td>
						</tr>',
						$this->label_for( $key_to_diff, $label, $context ),
						helpers::text_diff( $post_old_value, $post_new_value )
					);
				} elseif ( $key_to_diff === 'comment_status' ) {
					$inline_group->add_item(
						( new Event_Details_Item( null, __( 'Comment status', 'simple-history' ) ) )
							->set_values( $post_new_value, $post_old_value )
					);
				} elseif ( $key_to_diff === 'post_author' ) {
					// wp post edit screen uses display_name so we should use it too.
					if (
						isset( $context['post_prev_post_author/display_name'] ) &&
						isset( $context['post_new_post_author/display_name'] )
					) {
						$prev_display = sprintf(
							'%1$s (%2$s)',
							$context['post_prev_post_author/display_name'],
							$context['post_prev_post_author/user_email'] ?? ''
						);
						$new_display  = sprintf(
							'%1$s (%2$s)',
							$context['post_new_post_author/display_name'],
							$context['post_new_post_author/user_email'] ?? ''
						);

						$inline_group->add_item(
							( new Event_Details_Item( null, __( 'Author', 'simple-history' ) ) )
								->set_values( $new_display, $prev_display )
						);
					}
				} elseif ( $key_to_diff === 'page_template' ) {
					// page template filename.
					$prev_page_template = $context['post_prev_page_template'];
					$new_page_template  = $context['post_new_page_template'];

					// page template name, should exist, but I guess someone could have deleted a template
					// and after that change the template for a post.
					$prev_page_template_name = $context['post_prev_page_template_name'] ?? '';
					$new_page_template_name  = $context['post_new_page_template_name'] ?? '';

					// If prev or new template is "default" then use that as name.
					if ( $prev_page_template === 'default' && ! $prev_page_template_name ) {
						$prev_page_template_name = $prev_page_template;
					} elseif ( $new_page_template === 'default' && ! $new_page_template_name ) {
						$new_page_template_name = $new_page_template;
					}

					$prev_display = $prev_page_template_name
						? sprintf( '%1$s (%2$s)', $prev_page_template_name, $prev_page_template )
						: $prev_page_template;
					$new_display  = $new_page_template_name
						? sprintf( '%1$s (%2$s)', $new_page_template_name, $new_page_template )
						: $new_page_template;

					$inline_group->add_item(
						( new Event_Details_Item( null, __( 'Template', 'simple-history' ) ) )
							->set_values( $new_display, $prev_display )
					);
				} else {
					$has_diff_values = true;

					$diff_table_output .= $this->extra_diff_record(
						$this->label_for( $key_to_diff, $key_to_diff, $context ),
						$post_old_value,
						$post_new_value
					);
				}
			}

			if (
				isset( $context['post_meta_added'] ) ||
				isset( $context['post_meta_removed'] ) ||
				isset( $context['post_meta_changed'] )
			) {
				$meta_changed_out = '';
				$has_diff_values  = true;

				if ( isset( $context['post_meta_added'] ) ) {
					$meta_changed_out .=
						"<span class='SimpleHistoryLogitem__inlineDivided'>" .
						(int) $context['post_meta_added'] .
						' added</span> ';
				}

				if ( isset( $context['post_meta_removed'] ) ) {
					$meta_changed_out .=
						"<span class='SimpleHistoryLogitem__inlineDivided'>" .
						(int) $context['post_meta_removed'] .
						' removed</span> ';
				}

				if ( isset( $context['post_meta_changed'] ) ) {
					$meta_changed_out .=
						"<span class='SimpleHistoryLogitem__inlineDivided'>" .
						(int) $context['post_meta_changed'] .
						' changed</span> ';
				}

				$diff_table_output .= sprintf(
					'<tr>
						<td>%1$s</td>
						<td>%2$s</td>
					</tr>',
					esc_html( __( 'Custom fields', 'simple-history' ) ),
					$meta_changed_out
				);
			}

			// Changed terms.
			$diff_table_output .= $this->get_log_row_details_output_for_post_terms( $context, 'added' );
			$diff_table_output .= $this->get_log_row_details_output_for_post_terms( $context, 'removed' );

			// Changed post thumb/featured image.
			// post_prev_thumb, int of prev thumb, empty if not prev thumb.
			// post_new_thumb, int of new thumb, empty if no new thumb.
			$diff_table_output .= $this->get_log_row_details_output_for_post_thumb( $context );

			// Render compact JSON diff for post_content if available.
			if ( isset( $context['post_content_diff'] ) ) {
				$json_diff_html = Helpers::render_json_diff_to_html( $context['post_content_diff'] );

				if ( $json_diff_html !== '' ) {
					$has_diff_values    = true;
					$diff_table_output .= sprintf(
						'<tr><td>%1$s</td><td>%2$s</td></tr>',
						esc_html( __( 'Content', 'simple-history' ) ),
						$json_diff_html
					);
				}
			}

			/**
			 * Modify the formatted diff output of a saved/modified post
			 *
			 * @param string $diff_table_output
			 * @param array $context
			 * @return string
			 */
			$diff_table_output = apply_filters(
				'simple_history/post_logger/post_updated/diff_table_output',
				$diff_table_output,
				$context
			);

			if ( $has_diff_values || $diff_table_output ) {
				$diff_table_output =
					'<table class="SimpleHistoryLogitem__keyValueTable">' . $diff_table_output . '</table>';
			}

			$groups = [];

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

			if ( $diff_table_output !== '' ) {
				$groups[] = Event_Details_Group::create_raw( $diff_table_output );
			}

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

			return Event_Details_Container::create_from( $groups );
		} elseif ( $message_key === 'post_created' ) {
			// Show initial post content for created posts using Event_Details classes.
			// The Event Details system will automatically read values from context.
			// Using diff table formatter for consistency with post_updated display.
			$event_details_group = new Event_Details_Group();
			$event_details_group->set_formatter( new Event_Details_Group_Diff_Table_Formatter() );
			$event_details_group->add_items(
				[
					new Event_Details_Item(
						'post_new_post_content',
						__( 'Content', 'simple-history' )
					),
					new Event_Details_Item(
						'post_new_post_excerpt',
						__( 'Excerpt', 'simple-history' )
					),
					new Event_Details_Item(
						'post_new_status',
						__( 'Status', 'simple-history' )
					),
				]
			);

			return $event_details_group;
		}

		return $out;
	}

	/**
	 * Modify the label for a key.
	 *
	 * @param string $key Key.
	 * @param string $label Label.
	 * @param array  $context Context.
	 * @return string
	 */
	protected function label_for( $key, $label, $context ) {
		/**
		 * Filters the label for a key.
		 *
		 * @param string $label Label.
		 * @param string $key Key.
		 * @param array  $context Context.
		 * @return string
		 */
		return apply_filters( 'simple_history/post_logger/label_for_key', $label, $key, $context );
	}

	/**
	 * Get extra diff record.
	 *
	 * @param string $key Key.
	 * @param string $old_value Old value.
	 * @param string $new_value New value.
	 * @return string
	 */
	public function extra_diff_record( $key, $old_value, $new_value ) {
		return sprintf( '<tr><td>%1$s</td><td>%2$s</td></tr>', $key, helpers::text_diff( $old_value, $new_value ) );
	}

	/**
	 * Modify RSS links to they go directly to the correct post in WP admin.
	 *
	 * @since 2.0.23
	 * @param string $link Link.
	 * @param object $row Row.
	 */
	public function filter_rss_item_link( $link, $row ) {
		if ( $row->logger !== $this->get_slug() ) {
			return $link;
		}

		if ( isset( $row->context['post_id'] ) ) {
			$link = add_query_arg(
				array(
					'action' => 'edit',
					'post'   => $row->context['post_id'],
				),
				admin_url( 'post.php' )
			);
		}

		return $link;
	}

	/**
	 * Add diff for post thumb/post featured image.
	 *
	 * @param array $context Context.
	 * @param array $old_meta Old meta.
	 * @param array $new_meta New meta.
	 * @return array Maybe modified context.
	 */
	public function add_post_thumb_diff( $context, $old_meta, $new_meta ) {
		$prev_post_thumb_id = null;
		$new_post_thumb_id  = null;

		// If it was changed from one image to another.
		if ( isset( $old_meta['_thumbnail_id'][0] ) && isset( $new_meta['_thumbnail_id'][0] ) ) {
			if ( $old_meta['_thumbnail_id'][0] !== $new_meta['_thumbnail_id'][0] ) {
				$prev_post_thumb_id = $old_meta['_thumbnail_id'][0];
				$new_post_thumb_id  = $new_meta['_thumbnail_id'][0];
			}
		} elseif ( isset( $old_meta['_thumbnail_id'][0] ) ) {
			// Featured image id did not exist on both new and old data. But on any?
			$prev_post_thumb_id = $old_meta['_thumbnail_id'][0];
		} elseif ( isset( $new_meta['_thumbnail_id'][0] ) ) {
				$new_post_thumb_id = $new_meta['_thumbnail_id'][0];
		}

		if ( $prev_post_thumb_id ) {
			$context['post_prev_thumb_id']    = $prev_post_thumb_id;
			$context['post_prev_thumb_title'] = get_the_title( $prev_post_thumb_id );
		}

		if ( $new_post_thumb_id ) {
			$context['post_new_thumb_id']    = $new_post_thumb_id;
			$context['post_new_thumb_title'] = get_the_title( $new_post_thumb_id );
		}

		return $context;
	}

	/**
	 * Add keys to diff.
	 *
	 * @param array $arr_keys_to_diff Array with keys to diff.
	 * @return array
	 */
	protected function add_keys_to_diff( $arr_keys_to_diff ) {
		/**
		 * Filters the keys to diff.
		 *
		 * @param array $arr_keys_to_diff Array with keys to diff.
		 * @return array
		 */
		return apply_filters( 'simple_history/post_logger/keys_to_diff', $arr_keys_to_diff );
	}

	/**
	 * Get the HTML output for context that contains modified post meta.
	 *
	 * @param array  $context Context that may contains prev- and new thumb ids.
	 * @param string $type Type of meta change, "added" or "removed".
	 * @return string HTML to be used in keyvale table.
	 */
	private function get_log_row_details_output_for_post_terms( $context = [], $type = 'added' ) {
		// Bail if type is not added or removed.
		if ( ! in_array( $type, [ 'added', 'removed' ], true ) ) {
			return '';
		}

		$post_terms = json_decode( $context[ "post_terms_{$type}" ] ?? '' ) ?? null;

		// Bail if no terms.
		if ( $post_terms === null || sizeof( $post_terms ) === 0 ) {
			return '';
		}

		if ( $type === 'added' ) {
			$label = _n(
				'Added term',
				'Added terms',
				sizeof( $post_terms ),
				'simple-history'
			);
		} elseif ( $type === 'removed' ) {
			$label = _n(
				'Removed term',
				'Removed terms',
				sizeof( $post_terms ),
				'simple-history'
			);
		}

		$terms_values = [];
		foreach ( $post_terms as $term ) {
			$taxonomy_name  = get_taxonomy( $term->taxonomy )->labels->singular_name ?? '';
			$terms_values[] = sprintf(
				'%1$s (%2$s)',
				$term->name,
				$taxonomy_name,
			);
		}

		$term_added_values_as_comma_separated_list = wp_sprintf(
			'%l',
			$terms_values
		);

		return sprintf(
			'<tr>
				<td>%1$s</td>
				<td>%2$s</td>
			</tr>',
			esc_html( $label ),
			esc_html( $term_added_values_as_comma_separated_list ),
		);
	}

	/**
	 * Get the HTML output for context that contains a modified post thumb.
	 *
	 * @param array $context Context that may contains prev- and new thumb ids.
	 * @return string HTML to be used in keyvale table.
	 */
	private function get_log_row_details_output_for_post_thumb( $context = null ) {
		$out = '';

		if ( ! empty( $context['post_prev_thumb_id'] ) || ! empty( $context['post_new_thumb_id'] ) ) {
			// Check if images still exists and if so get their thumbnails.
			$prev_thumb_id         = empty( $context['post_prev_thumb_id'] ) ? null : $context['post_prev_thumb_id'];
			$new_thumb_id          = empty( $context['post_new_thumb_id'] ) ? null : $context['post_new_thumb_id'];
			$post_new_thumb_title  = empty( $context['post_new_thumb_title'] ) ? null : $context['post_new_thumb_title'];
			$post_prev_thumb_title = empty( $context['post_prev_thumb_title'] )
				? null
				: $context['post_prev_thumb_title'];

			$prev_attached_file = get_attached_file( $prev_thumb_id );
			$prev_thumb_src     = wp_get_attachment_image_src( $prev_thumb_id, 'small' );

			$new_attached_file = get_attached_file( $new_thumb_id );
			$new_thumb_src     = wp_get_attachment_image_src( $new_thumb_id, 'small' );

			if ( file_exists( $prev_attached_file ) && $prev_thumb_src ) {
				$prev_thumb_html = sprintf(
					'
						<div>%2$s</div>
						<div class="SimpleHistoryLogitemThumbnail">
							<img src="%1$s" alt="">
						</div>
					',
					$prev_thumb_src[0],
					esc_html( $post_prev_thumb_title )
				);
			} else {
				// Fallback if image does not exist.
				$prev_thumb_html = sprintf( '<div>%1$s</div>', esc_html( $post_prev_thumb_title ) );
			}

			$new_thumb_html = '';
			if ( file_exists( $new_attached_file ) && $new_thumb_src ) {
				$new_thumb_html = sprintf(
					'
						<div>%2$s</div>
						<div class="SimpleHistoryLogitemThumbnail">
							<img src="%1$s" alt="">
						</div>
					',
					$new_thumb_src[0],
					esc_html( $post_new_thumb_title )
				);
			} else {
				// Fallback if image does not exist.
				$new_thumb_html = sprintf( '<div>%1$s</div>', esc_html( $post_new_thumb_title ) );
			}

			$out .= sprintf(
				'<tr>
					<td>%1$s</td>
					<td>

						<div class="SimpleHistory__diff__contents SimpleHistory__diff__contents--noContentsCrop" tabindex="0">
						    <div class="SimpleHistory__diff__contentsInner">
						        <table class="diff SimpleHistory__diff">
						            <tr>
						                <td class="diff-deletedline">
						                    %2$s
						                </td>
						                <td>&nbsp;</td>
						                <td class="diff-addedline">
						                    %3$s
						                </td>
						            </tr>
						        </table>
						    </div>
						</div>

					</td>
				</tr>',
				esc_html( __( 'Featured image', 'simple-history' ) ), // 1
				$prev_thumb_html, // 2
				$new_thumb_html // 3
			);
		}

		return $out;
	}

	/**
	 * Fired when the "page_on_front" option is updated.
	 *
	 * Only logs during REST API requests (block editor).
	 * Traditional admin changes are handled by the Options Logger,
	 * and Customizer changes by the Theme Logger.
	 *
	 * @param mixed $old_value Previous value.
	 * @param mixed $new_value New value.
	 */
	public function on_update_option_page_on_front( $old_value, $new_value ) {
		$this->log_page_role_change( $old_value, $new_value, 'homepage' );
	}

	/**
	 * Fired when the "page_for_posts" option is updated.
	 *
	 * Only logs during REST API requests (block editor).
	 * Traditional admin changes are handled by the Options Logger,
	 * and Customizer changes by the Theme Logger.
	 *
	 * @param mixed $old_value Previous value.
	 * @param mixed $new_value New value.
	 */
	public function on_update_option_page_for_posts( $old_value, $new_value ) {
		$this->log_page_role_change( $old_value, $new_value, 'posts_page' );
	}

	/**
	 * Log when a page is set as or removed as the homepage or posts page.
	 *
	 * @param mixed  $old_value Previous option value (page ID or 0).
	 * @param mixed  $new_value New option value (page ID or 0).
	 * @param string $role Either 'homepage' or 'posts_page'.
	 */
	private function log_page_role_change( $old_value, $new_value, $role ) {
		// Only log during REST API requests (block editor).
		if ( ! Helpers::is_rest_request() ) {
			return;
		}

		$old_value = (int) $old_value;
		$new_value = (int) $new_value;

		// No change.
		if ( $old_value === $new_value ) {
			return;
		}

		$set_message_key     = $role === 'homepage' ? 'page_set_as_homepage' : 'page_set_as_posts_page';
		$removed_message_key = $role === 'homepage' ? 'page_removed_as_homepage' : 'page_removed_as_posts_page';

		// A page was set.
		if ( ! empty( $new_value ) ) {
			$new_post = get_post( $new_value );

			if ( ! $new_post instanceof \WP_Post ) {
				return;
			}

			$context = array(
				'post_id'    => $new_post->ID,
				'post_type'  => get_post_type( $new_post ),
				'post_title' => get_the_title( $new_post ),
			);

			// If changing from one page to another, include the old page info.
			if ( ! empty( $old_value ) ) {
				$old_post = get_post( $old_value );
				if ( $old_post instanceof \WP_Post ) {
					$context['old_post_id']    = $old_post->ID;
					$context['old_post_title'] = get_the_title( $old_post );
				}
			}

			$this->info_message( $set_message_key, $context );
		} elseif ( ! empty( $old_value ) ) {
			// Page was removed (set to 0).
			$old_post = get_post( $old_value );

			if ( ! $old_post instanceof \WP_Post ) {
				return;
			}

			$context = array(
				'post_id'    => $old_post->ID,
				'post_type'  => get_post_type( $old_post ),
				'post_title' => get_the_title( $old_post ),
			);

			$this->info_message( $removed_message_key, $context );
		}
	}

	/**
	 * Get the post from the HTTP referer if it points to a post editor.
	 *
	 * Useful for detecting when a post is created from within another post's
	 * editor, e.g. via the Gutenberg link component.
	 *
	 * @return \WP_Post|null Post object or null if referer doesn't point to a valid post editor.
	 */
	private function get_referer_post() {
		$http_referer = wp_get_referer();
		if ( ! $http_referer ) {
			return null;
		}

		$referer_query = wp_parse_url( $http_referer, PHP_URL_QUERY );
		if ( ! $referer_query ) {
			return null;
		}

		$referer_args = [];
		wp_parse_str( $referer_query, $referer_args );

		if (
			empty( $referer_args['post'] )
			|| empty( $referer_args['action'] )
			|| $referer_args['action'] !== 'edit'
		) {
			return null;
		}

		$post = get_post( (int) $referer_args['post'] );

		return $post instanceof \WP_Post ? $post : null;
	}
}
