<?php

namespace Simple_History\Loggers;

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

/**
 * Logs WordPress menu edits
 */
class Menu_Logger extends Logger {
	/** @var string Logger slug */
	public $slug = 'SimpleMenuLogger';

	/** @var array<int,string> Menu names captured before deletion, keyed by term ID. */
	private $pre_delete_menu_names = [];

	/**
	 * Get array with information about this logger
	 *
	 * @return array
	 */
	public function get_info() {

		return array(
			'name'        => __( 'Menu Logger', 'simple-history' ),
			'description' => __( 'Logs menu edits', 'simple-history' ),
			'capability'  => 'edit_theme_options',
			'messages'    => array(
				'created_menu'          => __( 'Created menu "{menu_name}"', 'simple-history' ),
				'edited_menu'           => __( 'Edited menu "{menu_name}"', 'simple-history' ),
				'deleted_menu'          => __( 'Deleted menu "{menu_name}"', 'simple-history' ),
				'edited_menu_item'      => __( 'Edited a menu item', 'simple-history' ),
				'edited_menu_locations' => __( 'Updated menu locations', 'simple-history' ),
			),
			'labels'      => array(
				'search' => array(
					'label'     => _x( 'Menus', 'Menu logger: search', 'simple-history' ),
					'label_all' => _x( 'All menu activity', 'Menu updates logger: search', 'simple-history' ),
					'options'   => array(
						_x( 'Created menus', 'Menu updates logger: search', 'simple-history' ) => array(
							'created_menu',
						),
						_x( 'Edited menus', 'Menu updates logger: search', 'simple-history' ) => array(
							'edited_menu',
							'edited_menu_item',
							'edited_menu_locations',
						),
						_x( 'Deleted menus', 'Menu updates logger: search', 'simple-history' ) => array(
							'deleted_menu',
						),
					),
				),
			),
		);
	}

	/**
	 * Called when logger is loaded.
	 */
	public function loaded() {
		/*
		 * Fires after a navigation menu has been successfully deleted.
		 *
		 * @since 3.0.0
		 *
		 * @param int $term_id ID of the deleted menu.
		*/
		add_action( 'load-nav-menus.php', array( $this, 'on_load_nav_menus_page_detect_delete' ) );

		/*
		 * Fires after a navigation menu is successfully created.
		 *
		 * @since 3.0.0
		 *
		 * @param int   $term_id   ID of the new menu.
		 * @param array $menu_data An array of menu data.
		*/
		add_action( 'wp_create_nav_menu', array( $this, 'on_wp_create_nav_menu' ), 10, 2 );

		// This is fired when adding nav items in the editor, not at save, so not
		// good to log because user might not end up saving the changes
		// add_action("wp_update_nav_menu_item", array($this, "on_wp_update_nav_menu_item"), 10, 3 );
		// Fired before "wp_update_nav_menu" below, to remember menu layout before it's updated
		// so we can't detect changes.
		add_action( 'load-nav-menus.php', array( $this, 'on_load_nav_menus_page_detect_update' ) );

		// Detect menu location change in "manage locations".
		add_action( 'load-nav-menus.php', array( $this, 'on_load_nav_menus_page_detect_locations_update' ) );

		add_filter( 'simple_history/categories_logger/skip_taxonomies', array( $this, 'on_categories_logger_skip_taxonomy' ) );

		// WP-CLI / non-admin support.
		// Capture menu name before term deletion (wp_delete_nav_menu fires after, when name is gone).
		add_action( 'pre_delete_term', array( $this, 'on_pre_delete_term_capture_menu_name' ), 10, 2 );
		add_action( 'wp_delete_nav_menu', array( $this, 'on_wp_delete_nav_menu' ), 10, 1 );

		// Log menu item updates outside admin (WP-CLI, REST API).
		add_action( 'wp_update_nav_menu_item', array( $this, 'on_wp_update_nav_menu_item_non_admin' ), 10, 3 );

		// Detect nav_menu_locations changes via theme_mods (set_theme_mod path).
		add_action( 'updated_option', array( $this, 'on_updated_option_detect_nav_menu_locations' ), 10, 3 );
	}

	/**
	 * Add taxonomy "nav_menu" to list of categories to not log changes to,
	 * because menus are stored in this taxonomy,
	 * and the menu logger will log menu changes,
	 * so don't let categories logger log this
	 * or there will be duplicates.
	 *
	 * @param mixed $taxonomies_to_skip Array with taxonomies to skip.
	 * @return array
	 */
	public function on_categories_logger_skip_taxonomy( $taxonomies_to_skip ) {
		$taxonomies_to_skip[] = 'nav_menu';
		return $taxonomies_to_skip;
	}

	/**
	 * Capture menu name before the term is deleted (fired for ALL term deletions).
	 * Stores the name so on_wp_delete_nav_menu() can log it after the fact.
	 *
	 * @param int    $term     Term ID.
	 * @param string $taxonomy Taxonomy name.
	 */
	public function on_pre_delete_term_capture_menu_name( $term, $taxonomy ) {
		if ( $taxonomy !== 'nav_menu' ) {
			return;
		}

		$menu = wp_get_nav_menu_object( $term );

		if ( ! $menu || is_wp_error( $menu ) ) {
			return;
		}

		$this->pre_delete_menu_names[ (int) $term ] = $menu->name;
	}

	/**
	 * Log a menu deletion from a non-admin context (WP-CLI).
	 * Fires from wp_delete_nav_menu() after the menu is gone;
	 * the name was captured by on_pre_delete_term_capture_menu_name().
	 *
	 * @param int $term_id ID of the deleted menu term.
	 */
	public function on_wp_delete_nav_menu( $term_id ) {
		$term_id = (int) $term_id;

		// Admin path handled by on_load_nav_menus_page_detect_delete().
		if ( is_admin() ) {
			unset( $this->pre_delete_menu_names[ $term_id ] );
			return;
		}

		$menu_name = $this->pre_delete_menu_names[ $term_id ] ?? '';
		unset( $this->pre_delete_menu_names[ $term_id ] );

		$this->info_message(
			'deleted_menu',
			array(
				'menu_term_id' => $term_id,
				'menu_name'    => $menu_name,
			)
		);
	}

	/**
	 * Log menu item additions/updates outside wp-admin (WP-CLI, REST API).
	 * The admin form disables this hook because changes in the editor are not
	 * committed until "Save menu" is clicked. In WP-CLI/REST, they commit immediately.
	 *
	 * @param int   $menu_id          ID of the nav menu.
	 * @param int   $menu_item_db_id  ID of the menu item.
	 * @param array $args             Menu item arguments.
	 */
	public function on_wp_update_nav_menu_item_non_admin( $menu_id, $menu_item_db_id, $args ) {
		if ( is_admin() ) {
			return;
		}

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

		$menu = wp_get_nav_menu_object( $menu_id );

		$this->info_message(
			'edited_menu_item',
			array(
				'menu_id'         => $menu_id,
				'menu_name'       => $menu ? $menu->name : '',
				'menu_item_db_id' => $menu_item_db_id,
			)
		);
	}

	/**
	 * Detect nav_menu_locations changes via theme_mods option (set_theme_mod path).
	 * Used by WP-CLI (`wp menu location assign`) which calls set_theme_mod() directly.
	 * Admin form path is handled by on_load_nav_menus_page_detect_locations_update().
	 *
	 * @param string $option    Option name.
	 * @param mixed  $old_value Previous option value.
	 * @param mixed  $new_value New option value.
	 */
	public function on_updated_option_detect_nav_menu_locations( $option, $old_value, $new_value ) {
		if ( strpos( $option, 'theme_mods_' ) !== 0 ) {
			return;
		}

		if ( is_admin() ) {
			return;
		}

		$old_locations = isset( $old_value['nav_menu_locations'] ) ? (array) $old_value['nav_menu_locations'] : array();
		$new_locations = isset( $new_value['nav_menu_locations'] ) ? (array) $new_value['nav_menu_locations'] : array();

		if ( $old_locations === $new_locations ) {
			return;
		}

		$registered_menus = get_registered_nav_menus();
		$changes          = array();

		foreach ( $new_locations as $location_slug => $new_menu_id ) {
			$old_menu_id = isset( $old_locations[ $location_slug ] ) ? (int) $old_locations[ $location_slug ] : 0;
			$new_menu_id = (int) $new_menu_id;

			if ( $old_menu_id === $new_menu_id ) {
				continue;
			}

			$changes[] = array(
				'location' => $registered_menus[ $location_slug ] ?? $location_slug,
				'from'     => $this->get_menu_name_by_id( $old_menu_id ),
				'to'       => $this->get_menu_name_by_id( $new_menu_id ),
			);
		}

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

		$this->info_message(
			'edited_menu_locations',
			array(
				'menu_locations'    => Helpers::json_encode( $new_locations ),
				'locations_changed' => Helpers::json_encode( $changes ),
			)
		);
	}

	/**
	 * Can't use action "wp_delete_nav_menu" because
	 * it's fired after menu is deleted, so we don't have the name in this action
	 */
	public function on_load_nav_menus_page_detect_delete() {
		// Check that needed vars are set.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( ! isset( $_REQUEST['menu'], $_REQUEST['action'] ) ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( $_REQUEST['action'] !== 'delete' ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$menu_id = sanitize_text_field( wp_unslash( $_REQUEST['menu'] ) );
		if ( ! is_nav_menu( $menu_id ) ) {
			return;
		}

		$menu = wp_get_nav_menu_object( $menu_id );

		$this->info_message(
			'deleted_menu',
			array(
				'menu_term_id' => $menu_id,
				'menu_name'    => $menu->name,
			)
		);
	}

	/**
	 * Detect menu being created
	 *
	 * @param int   $term_id ID of the new menu.
	 * @param array $menu_data An array of menu data.
	 */
	public function on_wp_create_nav_menu( $term_id, $menu_data ) {

		$menu = wp_get_nav_menu_object( $term_id );

		if ( ! $menu ) {
			return;
		}

		$this->info_message(
			'created_menu',
			array(
				'term_id'   => $term_id,
				'menu_name' => $menu->name,
			)
		);
	}

	/**
	 * Detect menu being saved
	 */
	public function on_load_nav_menus_page_detect_update() {
		/*
		This is the data to be saved
		$_REQUEST:
		Array
		(
			[action] => update
			[menu] => 25
			[menu-name] => Main menu edit
			[menu-item-title] => Array
				(
					[25243] => My new page edited
					[25244] => My new page
					[25245] => This is my new page. How does it look in the logs? <h1>Hej!</h1>
					[25264] => This page have revisions
					[25265] => Lorem ipsum dolor sit amet
				)
			[menu-locations] => Array
				(
					[primary] => 25
				)
		)
		*/

		// Check that needed vars are set.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( ! isset( $_REQUEST['menu'], $_REQUEST['action'], $_REQUEST['menu-name'] ) ) {
			return;
		}

		// Only go on for update action.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( $_REQUEST['action'] !== 'update' ) {
			return;
		}

		// Make sure we got the id of a menu.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$menu_id = sanitize_text_field( wp_unslash( $_REQUEST['menu'] ) );
		if ( ! is_nav_menu( $menu_id ) ) {
			return;
		}

		// Snapshot the menu's current name before update so we can detect renames.
		$old_menu          = wp_get_nav_menu_object( $menu_id );
		$old_menu_name_raw = $old_menu instanceof \WP_Term ? (string) $old_menu->name : '';

		// Get saved menu. May be empty if this is the first time we save the menu.
		$arr_prev_menu_items = wp_get_nav_menu_items( $menu_id );

		// Build map of old items keyed by db_id.
		$old_items_map = array();
		if ( is_array( $arr_prev_menu_items ) ) {
			foreach ( $arr_prev_menu_items as $item ) {
				$old_items_map[ $item->db_id ] = $item;
			}
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$new_ids = array_values( isset( $_POST['menu-item-db-id'] ) ? (array) wp_unslash( $_POST['menu-item-db-id'] ) : array() );
		$old_ids = array_keys( $old_items_map );

		// Detect added, removed, renamed, moved items.
		$arr_removed = array_diff( $old_ids, $new_ids );
		$arr_added   = array_diff( $new_ids, $old_ids );

		$menu_changes = array(
			'added'         => array(),
			'removed'       => array(),
			'renamed'       => array(),
			'moved'         => array(),
			'order_changed' => false,
		);

		// phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
		$post_titles      = isset( $_POST['menu-item-title'] ) ? (array) wp_unslash( $_POST['menu-item-title'] ) : array();
		$post_parents     = isset( $_POST['menu-item-parent-id'] ) ? (array) wp_unslash( $_POST['menu-item-parent-id'] ) : array();
		$post_types       = isset( $_POST['menu-item-type'] ) ? (array) wp_unslash( $_POST['menu-item-type'] ) : array();
		$post_type_labels = isset( $_POST['menu-item-type-label'] ) ? (array) wp_unslash( $_POST['menu-item-type-label'] ) : array();
		$post_objects     = isset( $_POST['menu-item-object'] ) ? (array) wp_unslash( $_POST['menu-item-object'] ) : array();
		$post_urls        = isset( $_POST['menu-item-url'] ) ? (array) wp_unslash( $_POST['menu-item-url'] ) : array();
		// phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated

		// Added items.
		foreach ( $arr_added as $added_id ) {
			$type_label = isset( $post_type_labels[ $added_id ] ) ? sanitize_text_field( $post_type_labels[ $added_id ] ) : '';

			// Type label is often empty for newly added items, resolve from type/object.
			if ( $type_label === '' ) {
				$type_label = $this->get_menu_item_type_label(
					$post_types[ $added_id ] ?? '',
					$post_objects[ $added_id ] ?? ''
				);
			}

			$change = array(
				'id'    => (int) $added_id,
				'title' => isset( $post_titles[ $added_id ] ) ? sanitize_text_field( $post_titles[ $added_id ] ) : '',
				'type'  => $type_label,
			);

			if ( isset( $post_types[ $added_id ] ) && $post_types[ $added_id ] === 'custom' && ! empty( $post_urls[ $added_id ] ) ) {
				$change['url'] = esc_url_raw( $post_urls[ $added_id ] );
			}

			$menu_changes['added'][] = $change;
		}

		// Removed items.
		foreach ( $arr_removed as $removed_id ) {
			if ( ! isset( $old_items_map[ $removed_id ] ) ) {
				continue;
			}

			$old_item                  = $old_items_map[ $removed_id ];
			$menu_changes['removed'][] = array(
				'id'    => (int) $removed_id,
				'title' => $old_item->title,
				'type'  => $old_item->type_label,
			);
		}

		// Renamed and moved items (items present in both old and new).
		$existing_ids = array_intersect( $new_ids, $old_ids );
		foreach ( $existing_ids as $item_id ) {
			if ( ! isset( $old_items_map[ $item_id ] ) ) {
				continue;
			}

			$old_item = $old_items_map[ $item_id ];

			// Two raw shapes for the old label:
			// - post_title: the value stored in the DB (empty when the item
			// has no custom label and inherits the linked object's title).
			// - $old_item->title: the resolved label as set by
			// wp_setup_nav_menu_item() — falls back to the linked object's
			// title when post_title is empty.
			// The form re-submits whatever label the user sees in the input,
			// which matches one of these two. Treat a match against either as
			// "no rename" so we don't flag items with HTML in the label
			// (e.g. "Books <span>& authors</span>") or items inheriting their
			// label from a linked page on every save.
			$old_post_title    = isset( $old_item->post_title ) ? (string) $old_item->post_title : '';
			$old_display_title = isset( $old_item->title ) ? (string) $old_item->title : '';
			$new_title_raw     = isset( $post_titles[ $item_id ] ) ? (string) $post_titles[ $item_id ] : '';
			$new_title         = sanitize_text_field( $new_title_raw );

			$is_rename = $new_title_raw !== ''
				&& $new_title_raw !== $old_post_title
				&& $new_title_raw !== $old_display_title;

			if ( $is_rename ) {
				$menu_changes['renamed'][] = array(
					'id'   => (int) $item_id,
					'from' => $old_display_title !== '' ? $old_display_title : $old_post_title,
					'to'   => $new_title_raw,
				);
			}

			// Detect move (parent change).
			$new_parent = isset( $post_parents[ $item_id ] ) ? (int) $post_parents[ $item_id ] : 0;
			$old_parent = (int) $old_item->menu_item_parent;

			if ( $new_parent === $old_parent ) {
				continue;
			}

			$title = $new_title !== '' ? $new_title : $old_item->title;

			// Resolve parent titles for readability.
			$from_label = $this->get_menu_item_parent_label( $old_parent, $old_items_map, $post_titles );
			$to_label   = $this->get_menu_item_parent_label( $new_parent, $old_items_map, $post_titles );

			$menu_changes['moved'][] = array(
				'id'    => (int) $item_id,
				'title' => $title,
				'from'  => $from_label,
				'to'    => $to_label,
			);
		}

		// Detect order changes by comparing old and new item sequences
		// for items that existed in both (excluding added/removed).
		// Cast to int for consistent comparison since $_POST values are strings.
		$old_order = array_map( 'intval', array_values( array_intersect( $old_ids, $new_ids ) ) );
		$new_order = array_map( 'intval', array_values( array_intersect( $new_ids, $old_ids ) ) );

		if ( $old_order !== $new_order ) {
			$menu_changes['order_changed'] = true;
		}

		// Detect location changes from "Display location" checkboxes.
		// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
		$post_locations    = isset( $_POST['menu-locations'] ) ? (array) wp_unslash( $_POST['menu-locations'] ) : array();
		$locations_changed = $this->detect_location_changes( $post_locations, (int) $menu_id );

		// Detect "auto add pages" setting change.
		// phpcs:ignore WordPress.Security.NonceVerification.Missing
		$new_auto_add     = ! empty( $_POST['auto-add-pages'] );
		$auto_add_options = get_option( 'nav_menu_options', array() );
		$old_auto_add     = isset( $auto_add_options['auto_add'] ) && is_array( $auto_add_options['auto_add'] ) && in_array( (int) $menu_id, $auto_add_options['auto_add'], true );

		// Remove empty change types.
		$menu_changes = array_filter( $menu_changes );

		// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
		$new_menu_name = sanitize_text_field( wp_unslash( $_POST['menu-name'] ) );

		$context = array(
			'menu_id'            => $menu_id,
			'menu_name'          => $new_menu_name,
			'menu_items_added'   => count( $arr_added ),
			'menu_items_removed' => count( $arr_removed ),
			'menu_changes'       => Helpers::json_encode( $menu_changes ),
		);

		// Detect rename of the menu itself (the "Menu Name" field).
		if ( $old_menu_name_raw !== '' && $new_menu_name !== '' && $new_menu_name !== $old_menu_name_raw ) {
			$context['menu_name_prev'] = $old_menu_name_raw;
		}

		if ( ! empty( $locations_changed ) ) {
			$context['locations_changed'] = Helpers::json_encode( $locations_changed );
		}

		if ( $new_auto_add !== $old_auto_add ) {
			$context['auto_add_pages_new']  = $new_auto_add ? '1' : '0';
			$context['auto_add_pages_prev'] = $old_auto_add ? '1' : '0';
		}

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

	/**
	 * Action links for menu events — jump to the menu editor.
	 *
	 * @param object $row Log row.
	 * @return array
	 */
	public function get_action_links( $row ) {
		if ( ! current_user_can( 'edit_theme_options' ) ) {
			return array();
		}

		$context     = $row->context;
		$message_key = $context['_message_key'] ?? '';

		// "Manage locations" link for location-only events.
		if ( $message_key === 'edited_menu_locations' ) {
			return array(
				array(
					'url'    => add_query_arg(
						array( 'action' => 'locations' ),
						admin_url( 'nav-menus.php' )
					),
					'label'  => __( 'Manage menu locations', 'simple-history' ),
					'action' => 'edit',
				),
			);
		}

		// "Edit menu" link for events that target a specific menu.
		if ( ! in_array( $message_key, array( 'edited_menu', 'created_menu' ), true ) ) {
			return array();
		}

		// Resolve menu term id from context. edited_menu uses "menu_id";
		// created_menu uses "term_id".
		$menu_id = 0;
		if ( ! empty( $context['menu_id'] ) ) {
			$menu_id = (int) $context['menu_id'];
		} elseif ( ! empty( $context['term_id'] ) ) {
			$menu_id = (int) $context['term_id'];
		}

		if ( $menu_id <= 0 || ! is_nav_menu( $menu_id ) ) {
			return array();
		}

		return array(
			array(
				'url'    => add_query_arg(
					array(
						'action' => 'edit',
						'menu'   => $menu_id,
					),
					admin_url( 'nav-menus.php' )
				),
				'label'  => __( 'Edit menu', 'simple-history' ),
				'action' => 'edit',
			),
		);
	}

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

		if ( $message_key === 'edited_menu' ) {
			return $this->get_edited_menu_details( $context );
		}

		if ( $message_key === 'edited_menu_locations' ) {
			return $this->get_edited_locations_details( $context );
		}

		return '';
	}

	/**
	 * Get details output for edited_menu events.
	 *
	 * @param array $context Log row context.
	 * @return Event_Details_Group|string
	 */
	private function get_edited_menu_details( $context ) {
		// New format with detailed changes.
		if ( ! empty( $context['menu_changes'] ) ) {
			$changes = json_decode( $context['menu_changes'], true );

			if ( ! is_array( $changes ) ) {
				return '';
			}

			$group = new Event_Details_Group();

			// Menu rename (the menu itself, not its items). menu_name_prev is
			// only set in context when the name actually changed.
			if ( ! empty( $context['menu_name_prev'] ) ) {
				$group->add_item(
					new Event_Details_Item(
						array( 'menu_name', 'menu_name_prev' ),
						__( 'Menu name', 'simple-history' )
					)
				);
			}

			if ( ! empty( $changes['added'] ) ) {
				$descriptions = array();
				foreach ( $changes['added'] as $item ) {
					$descriptions[] = $this->format_menu_item_description( $item );
				}

				$group->add_item(
					( new Event_Details_Item( null, __( 'Added', 'simple-history' ) ) )
						->set_new_value( implode( _x( ', ', 'Menu item list separator', 'simple-history' ), $descriptions ) )
				);
			}

			if ( ! empty( $changes['removed'] ) ) {
				$descriptions = array();
				foreach ( $changes['removed'] as $item ) {
					$descriptions[] = $this->format_menu_item_description( $item );
				}

				$group->add_item(
					( new Event_Details_Item( null, __( 'Removed', 'simple-history' ) ) )
						->set_new_value( implode( _x( ', ', 'Menu item list separator', 'simple-history' ), $descriptions ) )
				);
			}

			if ( ! empty( $changes['renamed'] ) ) {
				foreach ( $changes['renamed'] as $item ) {
					$group->add_item(
						( new Event_Details_Item( null, __( 'Renamed', 'simple-history' ) ) )
							->set_new_value( $item['to'] )
							->set_prev_value( $item['from'] )
					);
				}
			}

			if ( ! empty( $changes['moved'] ) ) {
				foreach ( $changes['moved'] as $item ) {
					$group->add_item(
						( new Event_Details_Item( null, __( 'Moved', 'simple-history' ) ) )
							->set_new_value(
								sprintf(
									// translators: 1: menu item title, 2: parent location.
									__( '%1$s: %2$s', 'simple-history' ),
									$item['title'],
									$item['to']
								)
							)
							->set_prev_value(
								sprintf(
									// translators: 1: menu item title, 2: parent location.
									__( '%1$s: %2$s', 'simple-history' ),
									$item['title'],
									$item['from']
								)
							)
					);
				}
			}

			if ( ! empty( $changes['order_changed'] ) ) {
				$group->add_item(
					( new Event_Details_Item( null, __( 'Order', 'simple-history' ) ) )
						->set_new_value( __( 'Changed menu item order', 'simple-history' ) )
				);
			}

			// Location changes from "Display location" checkboxes.
			$this->add_location_items_to_group( $group, $context );

			// Auto add pages setting change.
			if ( isset( $context['auto_add_pages_new'] ) ) {
				$enabled_label  = __( 'Enabled', 'simple-history' );
				$disabled_label = __( 'Disabled', 'simple-history' );

				$group->add_item(
					( new Event_Details_Item( null, __( 'Auto add pages', 'simple-history' ) ) )
						->set_new_value( $context['auto_add_pages_new'] === '1' ? $enabled_label : $disabled_label )
						->set_prev_value( $context['auto_add_pages_prev'] === '1' ? $enabled_label : $disabled_label )
				);
			}

			if ( empty( $group->items ) ) {
				return '';
			}

			return $group;
		}

		// Legacy format: count-based display for old log entries.
		if ( ! empty( $context['menu_items_added'] ) || ! empty( $context['menu_items_removed'] ) ) {
			$output  = '<p>';
			$output .= '<span class="SimpleHistoryLogitem__inlineDivided">';
			$output .= sprintf(
				// translators: Number of menu items added.
				_nx( '%1$s menu item added', '%1$s menu items added', $context['menu_items_added'], 'menu logger', 'simple-history' ),
				esc_attr( $context['menu_items_added'] )
			);
			$output .= '</span> ';
			$output .= '<span class="SimpleHistoryLogitem__inlineDivided">';
			$output .= sprintf(
				// translators: Number of menu items removed.
				_nx( '%1$s menu item removed', '%1$s menu items removed', $context['menu_items_removed'], 'menu logger', 'simple-history' ),
				esc_attr( $context['menu_items_removed'] )
			);
			$output .= '</span> ';
			$output .= '</p>';

			return $output;
		}

		return '';
	}

	/**
	 * Get details output for edited_menu_locations events.
	 *
	 * @param array $context Log row context.
	 * @return Event_Details_Group|string
	 */
	private function get_edited_locations_details( $context ) {
		$group = new Event_Details_Group();

		$this->add_location_items_to_group( $group, $context );

		if ( empty( $group->items ) ) {
			return '';
		}

		return $group;
	}

	/**
	 * Add location change items to an Event_Details_Group.
	 *
	 * @param Event_Details_Group $group   Group to add items to.
	 * @param array               $context Log row context.
	 */
	private function add_location_items_to_group( $group, $context ) {
		if ( empty( $context['locations_changed'] ) ) {
			return;
		}

		$changes = json_decode( $context['locations_changed'], true );

		if ( ! is_array( $changes ) || empty( $changes ) ) {
			return;
		}

		foreach ( $changes as $change ) {
			$group->add_item(
				( new Event_Details_Item( null, $change['location'] ) )
					->set_new_value( $change['to'] )
					->set_prev_value( $change['from'] )
			);
		}
	}

	/**
	 * Log updates to theme menu locations
	 */
	public function on_load_nav_menus_page_detect_locations_update() {

		// Check that needed vars are set.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( ! isset( $_REQUEST['menu'], $_REQUEST['action'] ) ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( $_REQUEST['action'] !== 'locations' ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$new_locations = (array) wp_unslash( $_POST['menu-locations'] );

		$locations_changed = $this->detect_location_changes( $new_locations );

		// Only log if something actually changed.
		if ( empty( $locations_changed ) ) {
			return;
		}

		$this->info_message(
			'edited_menu_locations',
			array(
				'menu_locations'    => Helpers::json_encode( $new_locations ),
				'locations_changed' => Helpers::json_encode( $locations_changed ),
			)
		);
	}

	/**
	 * Get human-readable menu name by ID, or "None" if empty/invalid.
	 *
	 * @param int $menu_id Menu term ID.
	 * @return string Menu name or "None".
	 */
	private function get_menu_name_by_id( $menu_id ) {
		if ( empty( $menu_id ) ) {
			return __( 'None', 'simple-history' );
		}

		$menu = wp_get_nav_menu_object( $menu_id );

		if ( $menu && ! is_wp_error( $menu ) ) {
			return $menu->name;
		}

		return __( 'None', 'simple-history' );
	}

	/**
	 * Compare new menu locations with current ones and return changes.
	 *
	 * @param array $new_locations New menu locations from POST data.
	 * @param int   $menu_id      Optional menu ID being edited, to detect removed locations.
	 * @return array Array of location changes with location/from/to keys.
	 */
	private function detect_location_changes( $new_locations, $menu_id = 0 ) {
		$old_locations    = get_nav_menu_locations();
		$registered_menus = get_registered_nav_menus();
		$changes          = array();

		// Detect added or changed locations.
		foreach ( $new_locations as $location_slug => $new_menu_id ) {
			$old_menu_id = isset( $old_locations[ $location_slug ] ) ? (int) $old_locations[ $location_slug ] : 0;
			$new_menu_id = (int) $new_menu_id;

			if ( $old_menu_id === $new_menu_id ) {
				continue;
			}

			$changes[] = array(
				'location' => $registered_menus[ $location_slug ] ?? $location_slug,
				'from'     => $this->get_menu_name_by_id( $old_menu_id ),
				'to'       => $this->get_menu_name_by_id( $new_menu_id ),
			);
		}

		// Detect removed locations: old locations assigned to this menu
		// that are no longer in the POST data (checkbox was unchecked).
		if ( $menu_id > 0 ) {
			foreach ( $old_locations as $location_slug => $old_menu_id ) {
				if ( (int) $old_menu_id !== $menu_id ) {
					continue;
				}

				if ( isset( $new_locations[ $location_slug ] ) ) {
					continue;
				}

				$changes[] = array(
					'location' => $registered_menus[ $location_slug ] ?? $location_slug,
					'from'     => $this->get_menu_name_by_id( $old_menu_id ),
					'to'       => $this->get_menu_name_by_id( 0 ),
				);
			}
		}

		return $changes;
	}

	/**
	 * Get label for a menu item's parent, resolving ID to title.
	 *
	 * @param int   $parent_id     Parent menu item db_id (0 = top level).
	 * @param array $old_items_map Map of old menu items keyed by db_id.
	 * @param array $post_titles   Array of new titles from $_POST keyed by db_id.
	 * @return string Human-readable parent label.
	 */
	private function get_menu_item_parent_label( $parent_id, $old_items_map, $post_titles ) {
		if ( empty( $parent_id ) ) {
			return __( 'top level', 'simple-history' );
		}

		$parent_title = '';

		// Try new title from POST first (may have been renamed).
		if ( isset( $post_titles[ $parent_id ] ) && $post_titles[ $parent_id ] !== '' ) {
			$parent_title = sanitize_text_field( $post_titles[ $parent_id ] );
		} elseif ( isset( $old_items_map[ $parent_id ] ) ) {
			$parent_title = $old_items_map[ $parent_id ]->title;
		}

		if ( $parent_title === '' ) {
			return __( 'top level', 'simple-history' );
		}

		return sprintf(
			// translators: %s is the parent menu item title.
			__( 'under "%s"', 'simple-history' ),
			$parent_title
		);
	}

	/**
	 * Get a human-readable type label for a menu item from its type and object slug.
	 *
	 * @param string $type        Menu item type (post_type, taxonomy, custom).
	 * @param string $object_slug Menu item object slug (page, post, category, etc.).
	 * @return string Human-readable label.
	 */
	private function get_menu_item_type_label( $type, $object_slug ) {
		if ( $type === 'post_type' && $object_slug !== '' ) {
			$post_type_obj = get_post_type_object( $object_slug );

			if ( $post_type_obj ) {
				return $post_type_obj->labels->singular_name;
			}
		}

		if ( $type === 'taxonomy' && $object_slug !== '' ) {
			$taxonomy_obj = get_taxonomy( $object_slug );

			if ( $taxonomy_obj ) {
				return $taxonomy_obj->labels->singular_name;
			}
		}

		if ( $type === 'custom' ) {
			return __( 'Custom Link', 'simple-history' );
		}

		return '';
	}

	/**
	 * Format a menu item for display in added/removed lists.
	 *
	 * @param array $item Item data with title, type, and optionally url.
	 * @return string Formatted description.
	 */
	private function format_menu_item_description( $item ) {
		if ( ! empty( $item['type'] ) && ! empty( $item['url'] ) ) {
			return sprintf(
				// translators: 1: menu item title, 2: item type, 3: URL.
				__( '"%1$s" (%2$s: %3$s)', 'simple-history' ),
				$item['title'],
				$item['type'],
				$item['url']
			);
		}

		if ( ! empty( $item['type'] ) ) {
			return sprintf(
				// translators: 1: menu item title, 2: item type (e.g. Page, Post, Category).
				__( '"%1$s" (%2$s)', 'simple-history' ),
				$item['title'],
				$item['type']
			);
		}

		return sprintf(
			// translators: %s: menu item title.
			__( '"%s"', 'simple-history' ),
			$item['title']
		);
	}
}
