<?php

namespace AdminMenuOrganizer;

use WP_PowerSuite\Core\AbstractModule;
class Module extends AbstractModule {

	private const MODULE_ID = 'admin-menu-organizer';
	private const MODULE_VERSION = '1.4.10';
	public const MENU_CONFIG_OPTION = 'wp_powersuite_admin_menu_organizer_config';
	private const MAX_MENU_CONFIG_BYTES = 1048576;
	private const MAX_MENU_CONFIG_ITEMS = 500;
	private const MENU_CONFIG_MIGRATED_FLAG = 'wp_powersuite_admin_menu_config_migrated';

	private ?array $settings_cache = null;

	/** @var array{user_id: int, user_roles: string[]}|null */
	private ?array $hide_rules_user_context = null;

	/**
	 * Snapshot of $menu/$submenu taken before applyMenuCustomizations runs.
	 * Editor merge must use this to avoid reading already-customized icons/order.
	 *
	 * @var array{menu: array<int, array>, submenu: array<string, array>}|null
	 */
	private ?array $pristine_menu_snapshot = null;


	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( 'Admin Menu Organizer', 'wp-powersuite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'Reorder, rename, or hide sidebar menu items so the dashboard fits how you work.', 'wp-powersuite' );
	}

	public function getContext(): string|array {
		return [ 'admin', 'ajax' ];
	}

	public function getDependencies(): array {
		return [];
	}

	private function isLicenseActive(): bool {
		return function_exists( 'wp_powersuite_is_license_valid' ) && wp_powersuite_is_license_valid();
	}

	protected function registerHooks(): void {
		if ( ! $this->isLicenseActive() ) {
			return;
		}

		add_action( $this->adminMenuHook(), [ $this, 'addAdminMenu' ], 20 );
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueueScripts' ] );

		// Must register here — this module boots on admin_menu (priority 0), after admin_init has already run.
		$this->registerMenuApplicationHooks();

		add_action( 'wp_ajax_wpps_save_menu_config', [ $this, 'handleSaveMenuConfig' ] );
		add_action( 'wp_ajax_wpps_get_menu_config', [ $this, 'handleGetMenuConfig' ] );
		add_action( 'wp_ajax_wpps_restore_menu_config', [ $this, 'handleRestoreMenuConfig' ] );
		add_action( 'wp_ajax_wpps_search_users', [ $this, 'handleSearchUsers' ] );
	}


	public function addAdminMenu(): void {
		$this->registerPowerSuiteSubmenu(
			__( 'Admin Menu Organizer', 'wp-powersuite' ),
			__( 'Menu Organizer', 'wp-powersuite' ),
			'powersuite-admin-menu-organizer',
			[ $this, 'renderAdminPage' ]
		);
	}

	public function renderAdminPage(): void {
		if ( ! $this->canManagePlugin() ) {
			wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'wp-powersuite' ) );
		}

		$saved_menu_config = get_option( self::MENU_CONFIG_OPTION, null );
		if ( ! is_array( $saved_menu_config ) ) {
			$settings          = $this->getSettings();
			$saved_menu_config = $settings['menu_config'] ?? null;
		}

		$menu_config = $this->getEditorMenuConfig( is_array( $saved_menu_config ) ? $saved_menu_config : null );
		$users_map   = $this->buildUsersMapFromConfig( $menu_config );

		include __DIR__ . '/admin.php';
	}

	public function enqueueScripts( string $hook ): void {
		if ( $hook !== 'wp-powersuite_page_powersuite-admin-menu-organizer' ) {
			return;
		}

		wp_enqueue_script( 'jquery-ui-sortable' );
		wp_enqueue_style( 'wp-powersuite-admin-menu-organizer', $this->getAssetUrl( 'admin-menu-organizer.css' ), [], self::MODULE_VERSION );
		wp_enqueue_script( 'wp-powersuite-admin-menu-organizer', $this->getAssetUrl( 'admin-menu-organizer.js' ), [ 'jquery', 'jquery-ui-sortable' ], self::MODULE_VERSION, true );

		wp_localize_script( 'wp-powersuite-admin-menu-organizer', 'wppsMenuOrganizer', [
			'ajaxUrl' => admin_url( 'admin-ajax.php' ),
			'nonce'   => wp_create_nonce( 'wpps_menu_organizer' ),
			'strings' => [
				'saving'        => __( 'Saving...', 'wp-powersuite' ),
				'saved'         => __( 'Menu configuration saved successfully!', 'wp-powersuite' ),
				'error'         => __( 'An error occurred. Please try again.', 'wp-powersuite' ),
				'confirmRestore' => __( 'Are you sure you want to restore the default menu? This will remove all customizations.', 'wp-powersuite' ),
				'restoring'     => __( 'Restoring...', 'wp-powersuite' ),
				'restored'      => __( 'Menu restored to defaults successfully!', 'wp-powersuite' ),
				'refreshing'    => __( 'Refreshing...', 'wp-powersuite' ),
				'allSelected'   => __( 'All matching users already selected.', 'wp-powersuite' ),
				'hideModeToggle' => __( 'Collapse under "Show All"', 'wp-powersuite' ),
				'hideModeRemove' => __( 'Remove from menu', 'wp-powersuite' ),
				'confirmDelete'  => __( 'Are you sure you want to delete this menu item?', 'wp-powersuite' ),
				'confirmDeleteSub' => __( 'Are you sure you want to delete this submenu item?', 'wp-powersuite' ),
				'requiredFields'   => __( 'Title and URL/Menu Slug are required.', 'wp-powersuite' ),
				'selectRole'       => __( 'Please select at least one role when "Specific Role(s)" is selected.', 'wp-powersuite' ),
				'duplicateSlug'    => __( 'A menu item with this slug already exists. Please use a unique slug.', 'wp-powersuite' ),
			],
		] );
	}

	/**
	 * Register hooks that reshape the live admin menu (only when saved config exists).
	 */
	private function registerMenuApplicationHooks(): void {
		if ( ! is_admin() ) {
			return;
		}

		$settings    = $this->getSettings();
		$menu_config = $settings['menu_config'] ?? null;
		if ( empty( $menu_config ) || ! is_array( $menu_config ) ) {
			return;
		}

		add_action( 'admin_menu', [ $this, 'registerCustomMenuPages' ], 98 );
		/*
		 * Snapshot + apply MUST run after core privilege filtering in wp-admin/includes/menu.php.
		 * admin_menu is too early — items like Links (link manager disabled) are still present then,
		 * so the editor showed ghost rows that never appear in the sidebar.
		 * add_menu_classes is the last filter before the menu is rendered.
		 */
		add_filter( 'add_menu_classes', [ $this, 'filterFinalAdminMenu' ], 0 );
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueueAppliedMenuAssets' ] );
		add_action( 'admin_head', [ $this, 'printAdminMenuOrganizerCss' ] );
		add_action( 'current_screen', [ $this, 'maybeRestrictScreenAccess' ], 1 );
	}

	/**
	 * Enqueue assets for applied menu customizations (open-in-new-tab, separator styling).
	 */
	public function enqueueAppliedMenuAssets( string $hook_suffix ): void {
		unset( $hook_suffix );

		$settings    = $this->getSettings();
		$menu_config = $settings['menu_config'] ?? null;
		if ( empty( $menu_config ) || ! is_array( $menu_config ) ) {
			return;
		}

		$asset_state = $this->collectAppliedMenuAssetState( $menu_config );

		// Always load applied CSS when a config exists (separators, soft-hide, badge flyout fixes).
		wp_enqueue_style(
			'wp-powersuite-admin-menu-organizer-applied',
			$this->getAssetUrl( 'admin-menu-applied.css' ),
			[],
			self::MODULE_VERSION
		);

		if ( $asset_state['has_soft_hidden'] ) {
			wp_enqueue_script(
				'wp-powersuite-admin-menu-organizer-toggle-hidden',
				$this->getAssetUrl( 'toggle-hidden-menu.js' ),
				[ 'jquery' ],
				self::MODULE_VERSION,
				true
			);
			wp_localize_script(
				'wp-powersuite-admin-menu-organizer-toggle-hidden',
				'wppsMenuOrganizerToggle',
				[
					'showAll'  => __( 'Show All', 'wp-powersuite' ),
					'showLess' => __( 'Show Less', 'wp-powersuite' ),
				]
			);
		}

		$needs_menu_links_script = ! empty( $asset_state['custom_links'] ) || ! empty( $asset_state['open_new_tab_slugs'] );
		if ( ! $needs_menu_links_script ) {
			return;
		}

		wp_enqueue_script(
			'wp-powersuite-admin-menu-organizer-applied',
			$this->getAssetUrl( 'admin-menu-applied.js' ),
			[ 'jquery' ],
			self::MODULE_VERSION,
			true
		);

		wp_localize_script(
			'wp-powersuite-admin-menu-organizer-applied',
			'wppsMenuOrganizerApplied',
			[
				'customLinks'     => $asset_state['custom_links'],
				'openNewTabSlugs' => $asset_state['open_new_tab_slugs'],
				'adminPageBase'   => admin_url( 'admin.php?page=' ),
			]
		);
	}

	/**
	 * Single tree walk for applied-menu CSS/JS flags (avoids multiple config scans).
	 *
	 * @param array<int, array<string, mixed>> $menu_config
	 * @return array{
	 *   has_separators: bool,
	 *   has_custom_items: bool,
	 *   has_soft_hidden: bool,
	 *   custom_links: array<string, array{url: string, openNewTab: bool}>,
	 *   open_new_tab_slugs: list<string>
	 * }
	 */
	private function collectAppliedMenuAssetState( array $menu_config ): array {
		$state = [
			'has_separators'     => false,
			'has_custom_items'   => false,
			'has_soft_hidden'    => false,
			'custom_links'       => [],
			'open_new_tab_slugs' => [],
		];

		$this->walkMenuConfigForAppliedAssetState( $menu_config, $state );

		$state['open_new_tab_slugs'] = array_values( array_unique( $state['open_new_tab_slugs'] ) );

		return $state;
	}

	/**
	 * @param array<int, array<string, mixed>> $items
	 * @param array<string, mixed>              $state
	 */
	private function walkMenuConfigForAppliedAssetState( array $items, array &$state ): void {
		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			if ( ! empty( $item['is_separator'] ) && empty( $item['hidden'] ) ) {
				$state['has_separators'] = true;
			}
			if ( ! empty( $item['is_custom'] ) && empty( $item['hidden'] ) ) {
				$state['has_custom_items'] = true;
			}
			if ( $this->isHiddenForCurrentUser( $item ) && $this->usesSoftHideBehavior( $item ) ) {
				$state['has_soft_hidden'] = true;
			}

			if ( ! empty( $item['is_custom'] ) ) {
				$slug = $this->getItemSlug( $item );
				$url  = $this->getCustomMenuTargetUrl( $item );
				if ( $slug !== '' && $url !== '' ) {
					$state['custom_links'][ $slug ] = [
						'url'        => $url,
						'openNewTab' => ! empty( $item['open_new_tab'] ),
					];
				}
			}

			if ( ! empty( $item['open_new_tab'] ) ) {
				$slug = $this->getItemSlug( $item );
				if ( $slug !== '' && ! isset( $state['custom_links'][ $slug ] ) ) {
					$state['open_new_tab_slugs'][] = $slug;
				}
			}

			if ( ! empty( $item['submenu'] ) && is_array( $item['submenu'] ) ) {
				$this->walkMenuConfigForAppliedAssetState( $item['submenu'], $state );
			}
		}
	}

	/**
	 * Final pass: capture privilege-filtered menu, then apply saved organizer config.
	 *
	 * @param array<int|string, array<int, mixed>> $menu Menu rows (already class-annotated by core).
	 * @return array<int|string, array<int, mixed>>
	 */
	public function filterFinalAdminMenu( $menu ) {
		global $submenu;

		if ( ! is_array( $menu ) || $menu === [] ) {
			return is_array( $menu ) ? $menu : [];
		}

		// Always snapshot the filtered menu (what the sidebar can actually show).
		if ( $this->pristine_menu_snapshot === null ) {
			$this->pristine_menu_snapshot = [
				'menu'    => $menu,
				'submenu' => is_array( $submenu ) ? $submenu : [],
			];
		}

		if ( get_current_user_id() === 0 || wp_doing_cron() ) {
			return $menu;
		}

		if ( wp_doing_ajax() ) {
			$action = isset( $_REQUEST['action'] ) ? sanitize_key( (string) $_REQUEST['action'] ) : '';
			// Organizer AJAX must keep filtered pristine globals for editor merge.
			if ( in_array( $action, [ 'heartbeat', 'wp-compression-test', 'wpps_get_menu_config', 'wpps_save_menu_config', 'wpps_restore_menu_config', 'wpps_search_users' ], true ) ) {
				return $menu;
			}
		}

		$settings    = $this->getSettings();
		$menu_config = $settings['menu_config'] ?? null;
		if ( empty( $menu_config ) || ! is_array( $menu_config ) ) {
			return $menu;
		}

		// applyMenuCustomizations() reads/writes the global $menu.
		$GLOBALS['menu'] = $menu;
		$this->applyMenuCustomizations();

		return is_array( $GLOBALS['menu'] ) ? $GLOBALS['menu'] : $menu;
	}

	/**
	 * Reshape global $menu/$submenu from saved organizer config.
	 * Called only from filterFinalAdminMenu after privilege filtering.
	 */
	public function applyMenuCustomizations(): void {
		$settings    = $this->getSettings();
		$menu_config = $settings['menu_config'] ?? null;

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

		global $menu, $submenu;

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

		$menu_lookup = [];
		$menu_lookup_legacy = [];
		$config_slugs = [];
		
		foreach ( $menu as $key => $item ) {
			$slug = $item[2] ?? '';
			if ( $slug ) {
				$menu_lookup[ $slug ] = $key;
				$legacy = $this->legacyCompactSlug( (string) $slug );
				if ( $legacy !== '' && $legacy !== $slug && ! isset( $menu_lookup_legacy[ $legacy ] ) ) {
					$menu_lookup_legacy[ $legacy ] = $slug;
				}
			}
		}

		foreach ( $menu_config as $item ) {
			if ( ! empty( $item['is_separator'] ) ) {
				continue;
			}
			$slug = $this->getItemSlug( $item );
			if ( $slug !== '' ) {
				$config_slugs[ $slug ] = $item;
			}
		}

		$new_menu           = [];
		$new_submenu        = [];
		$applied_top_slugs  = [];
		$menu_order         = 0;

		// Apply saved order first (config array order, not stale position integers).
		foreach ( $menu_config as $item ) {
			$is_separator  = ! empty( $item['is_separator'] );
			$menu_slug     = $item['menu_slug'] ?? '';
			$resolved_slug = $this->getItemSlug( $item );
			$parent_slug   = ! empty( $item['parent_slug'] ) ? $this->sanitizeMenuSlug( (string) $item['parent_slug'] ) : '';

			if ( $this->shouldRemoveMenuItemForCurrentUser( $item ) ) {
				continue;
			}

			if ( $is_separator ) {
				// Match core separator shape so WP styles it as a line, not a broken menu row.
				$separator_slug = ! empty( $menu_slug ) ? $this->sanitizeMenuSlug( (string) $menu_slug ) : 'separator' . $menu_order;
				if ( strpos( $separator_slug, 'separator' ) !== 0 ) {
					$separator_slug = 'separator-' . $separator_slug;
				}
				$new_menu[ $menu_order++ ] = [
					'',
					'read',
					$separator_slug,
					'',
					// Extra class renders user separators as a visible line
					// (native WP separators are just an invisible gap).
					'wp-menu-separator wpps-user-separator',
				];
				continue;
			}

			if ( $parent_slug !== '' && $parent_slug !== $resolved_slug ) {
				continue;
			}

			if ( ! empty( $item['is_custom'] ) && ! $this->userCanSeeCustomItem( $item ) ) {
				continue;
			}

			if ( $resolved_slug === '' || isset( $applied_top_slugs[ $resolved_slug ] ) ) {
				continue;
			}

			$lookup_slug = null;
			if ( isset( $menu_lookup[ $resolved_slug ] ) ) {
				$lookup_slug = $resolved_slug;
			} elseif ( $menu_slug !== '' && isset( $menu_lookup[ $menu_slug ] ) ) {
				$lookup_slug = $menu_slug;
			} elseif ( isset( $menu_lookup_legacy[ $resolved_slug ] ) ) {
				// Heal configs corrupted by older slug sanitizers that stripped "/".
				$lookup_slug = $menu_lookup_legacy[ $resolved_slug ];
			} elseif ( $menu_slug !== '' && isset( $menu_lookup_legacy[ $menu_slug ] ) ) {
				$lookup_slug = $menu_lookup_legacy[ $menu_slug ];
			}

			if ( $lookup_slug === null ) {
				continue;
			}

			$original_key = $menu_lookup[ $lookup_slug ];
			$menu_item    = $menu[ $original_key ];

			if ( ! empty( $item['title'] ) ) {
				$menu_item[0] = $this->mergeMenuTitleBubbles( $menu_item[0] ?? '', $item['title'] );
			}

			$menu_item[6] = $this->resolveAppliedMenuIcon(
				(string) ( $item['icon'] ?? '' ),
				(string) ( $menu_item[6] ?? '' )
			);

			$new_menu[ $menu_order++ ]        = $menu_item;
			$applied_top_slugs[ $resolved_slug ] = true;
			if ( $lookup_slug !== $resolved_slug ) {
				$applied_top_slugs[ $lookup_slug ] = true;
			}

			$submenu_parent_key = $lookup_slug;
			$this->applyParentSubmenuWithRelocations(
				$submenu_parent_key,
				$item,
				$menu_config,
				$submenu,
				$new_submenu,
				$menu_lookup,
				$menu
			);
		}

		// Append WP items not yet applied (new plugins, or slug-mismatch survivors).
		foreach ( $menu as $key => $menu_item ) {
			$slug         = $menu_item[2] ?? '';
			$is_separator = empty( $slug ) || $slug === 'separator' || strpos( $slug, 'separator' ) === 0;

			if ( $is_separator ) {
				continue;
			}

			if ( isset( $applied_top_slugs[ $slug ] ) ) {
				continue;
			}

			$config_item = $config_slugs[ $slug ] ?? null;
			if ( $config_item && $this->shouldRemoveMenuItemForCurrentUser( $config_item ) ) {
				continue;
			}

			if ( $config_item ) {
				$item_parent = ! empty( $config_item['parent_slug'] ) ? $this->sanitizeMenuSlug( (string) $config_item['parent_slug'] ) : '';
				if ( $item_parent !== '' && $item_parent !== $slug && $item_parent !== $this->getItemSlug( $config_item ) ) {
					// Relocated under another parent — keep out of top-level.
					continue;
				}
				// Config row existed but first pass missed lookup (legacy corrupted slug).
				// Keep the live WP item rather than dropping it from the sidebar.
				if ( ! empty( $config_item['title'] ) ) {
					$menu_item[0] = $this->mergeMenuTitleBubbles( $menu_item[0] ?? '', $config_item['title'] );
				}
				$menu_item[6] = $this->resolveAppliedMenuIcon(
					(string) ( $config_item['icon'] ?? '' ),
					(string) ( $menu_item[6] ?? '' )
				);
			}

			$new_menu[ $menu_order++ ]   = $menu_item;
			$applied_top_slugs[ $slug ] = true;

			if ( isset( $submenu[ $slug ] ) && ! isset( $new_submenu[ $slug ] ) ) {
				$new_submenu[ $slug ] = $submenu[ $slug ];
			}
		}

		// Pull soft-hidden top-level items out of their original positions and
		// place them under a trailing "Show All" toggle (no count in the label).
		$soft_hidden_rows = [];
		$visible_menu     = [];
		foreach ( $new_menu as $menu_row ) {
			$slug = (string) ( $menu_row[2] ?? '' );
			if ( $slug !== '' && isset( $config_slugs[ $slug ] ) ) {
				$config_item = $config_slugs[ $slug ];
				if ( $this->isHiddenForCurrentUser( $config_item ) && $this->usesSoftHideBehavior( $config_item ) ) {
					$soft_hidden_rows[] = $this->applySoftHideMenuClasses( $menu_row );
					continue;
				}
			}
			$visible_menu[] = $menu_row;
		}

		if ( $soft_hidden_rows !== [] ) {
			$visible_menu[] = [
				__( 'Show All', 'wp-powersuite' ),
				'read',
				'#wpps-show-all-menu',
				'',
				'wpps-show-all-toggle menu-top',
				'wpps-show-all-menu',
				'dashicons-arrow-down-alt2',
			];
			foreach ( $soft_hidden_rows as $soft_row ) {
				$visible_menu[] = $soft_row;
			}
		}

		$new_menu = $visible_menu;

		// Last line of defense: never output two top-level rows with the same slug
		// (legacy corrupted configs could alias one item under two spellings).
		$deduped_menu = [];
		$seen_slugs   = [];
		foreach ( $new_menu as $menu_row ) {
			$row_slug = (string) ( $menu_row[2] ?? '' );
			if ( $row_slug !== '' && strpos( $row_slug, 'separator' ) !== 0 ) {
				if ( isset( $seen_slugs[ $row_slug ] ) ) {
					continue;
				}
				$seen_slugs[ $row_slug ] = true;
			}
			$deduped_menu[] = $menu_row;
		}

		$menu = $deduped_menu;

		foreach ( $new_submenu as $parent_slug => $submenu_items ) {
			$submenu[ $parent_slug ] = $this->pruneSelfReferencingSubmenuDuplicates( $parent_slug, $submenu_items );
		}
	}

	/**
	 * Prefer a valid saved icon; never overwrite a real WP icon with corrupted generic/empty.
	 */
	private function resolveAppliedMenuIcon( string $saved_icon, string $original_icon ): string {
		$saved_icon    = trim( $saved_icon );
		$original_icon = trim( $original_icon );

		if ( $saved_icon === '' ) {
			return $original_icon;
		}

		if (
			$saved_icon === 'dashicons-admin-generic'
			&& $original_icon !== ''
			&& $original_icon !== 'dashicons-admin-generic'
		) {
			return $original_icon;
		}

		return $saved_icon;
	}

	/**
	 * WordPress adds a first submenu row matching the parent slug after add_menu_page().
	 * Remove that duplicate when the parent has other submenu entries (e.g. moved custom items).
	 *
	 * @param array<int, array<int, string>> $submenu_items
	 * @return array<int, array<int, string>>
	 */
	private function pruneSelfReferencingSubmenuDuplicates( string $parent_slug, array $submenu_items ): array {
		if ( count( $submenu_items ) < 2 || ! $this->isWppsCustomMenuSlug( $parent_slug ) ) {
			return $submenu_items;
		}

		$pruned = [];
		foreach ( $submenu_items as $row ) {
			if ( ( $row[2] ?? '' ) === $parent_slug ) {
				continue;
			}
			$pruned[] = $row;
		}

		return ! empty( $pruned ) ? array_values( $pruned ) : $submenu_items;
	}

	private function isWppsCustomMenuSlug( string $slug ): bool {
		return str_starts_with( $slug, 'wpps-link-' ) || str_starts_with( $slug, 'wpps-custom-' );
	}


	public function handleGetMenuConfig(): void {
		check_ajax_referer( 'wpps_menu_organizer', 'nonce' );

		if ( ! $this->canManagePlugin() ) {
			wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'wp-powersuite' ) ] );
		}

		// admin-ajax.php never builds $menu/$submenu — the editor list can only be
		// rebuilt on a full admin page load. Never require wp-admin/menu.php here:
		// it re-fires every plugin's admin_menu mid-AJAX and can wp_die().
		global $menu;
		if ( ! is_array( $menu ) || $menu === [] ) {
			wp_send_json_success( [ 'reload' => true ] );
		}

		$settings    = $this->getSettings();
		$menu_config = $settings['menu_config'] ?? null;

		if ( empty( $menu_config ) || ! is_array( $menu_config ) ) {
			$menu_config = $this->prepareMenuConfigForEditorClient( $this->getCurrentMenu() );
		} else {
			$menu_config = $this->getEditorMenuConfig( $menu_config );
		}

		$users_map = $this->buildUsersMapFromConfig( $menu_config );
		wp_send_json_success( [ 'menu_config' => $menu_config, 'users_map' => $users_map ] );
	}

	private function buildUsersMapFromConfig( array $config ): object {
		$user_ids = [];
		foreach ( $config as $item ) {
			$hide_rules = $item['hide_rules'] ?? null;
			if ( ! empty( $hide_rules['users'] ) && is_array( $hide_rules['users'] ) ) {
				$user_ids = array_merge( $user_ids, $hide_rules['users'] );
			}
			$this->collectHideRuleUserIdsFromSubmenu( $item['submenu'] ?? [], $user_ids );
		}
		$user_ids = array_unique( array_filter( array_map( 'absint', $user_ids ) ) );
		$map      = new \stdClass();
		if ( ! empty( $user_ids ) ) {
			$users = get_users( [ 'include' => $user_ids, 'fields' => [ 'ID', 'user_login', 'display_name' ] ] );
			foreach ( $users as $u ) {
				$map->{$u->ID} = [
					'display_name' => $u->display_name,
					'user_login'   => $u->user_login,
				];
			}
		}
		return $map;
	}

	public function handleSaveMenuConfig(): void {
		check_ajax_referer( 'wpps_menu_organizer', 'nonce' );

		if ( ! $this->canManagePlugin() ) {
			wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'wp-powersuite' ) ] );
		}

		$raw_config = isset( $_POST['menu_config'] ) ? wp_unslash( $_POST['menu_config'] ) : '';
		if ( ! is_string( $raw_config ) || $raw_config === '' ) {
			wp_send_json_error( [ 'message' => __( 'Invalid menu configuration.', 'wp-powersuite' ) ] );
		}

		if ( strlen( $raw_config ) > self::MAX_MENU_CONFIG_BYTES ) {
			wp_send_json_error( [ 'message' => __( 'Menu configuration is too large to save.', 'wp-powersuite' ) ] );
		}

		$menu_config = json_decode( $raw_config, true );

		if ( ! is_array( $menu_config ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid menu configuration.', 'wp-powersuite' ) ] );
		}

		if ( $this->countMenuConfigItems( $menu_config ) > self::MAX_MENU_CONFIG_ITEMS ) {
			wp_send_json_error( [ 'message' => __( 'Menu configuration has too many items.', 'wp-powersuite' ) ] );
		}

		$menu_config = $this->sanitizeMenuConfig( $menu_config );

		try {
			$this->saveMenuConfig( $menu_config );
			wp_send_json_success( [ 'message' => __( 'Menu configuration saved successfully!', 'wp-powersuite' ) ] );
		} catch ( \Exception $e ) {
			$this->logHandlerException( 'WPPS Admin Menu Organizer save error', $e );
			wp_send_json_error( [ 'message' => __( 'Could not save configuration. Please try again.', 'wp-powersuite' ) ] );
		}
	}

	public function handleRestoreMenuConfig(): void {
		check_ajax_referer( 'wpps_menu_organizer', 'nonce' );

		if ( ! $this->canManagePlugin() ) {
			wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'wp-powersuite' ) ] );
		}

		try {
			$this->deleteMenuConfig();
			wp_send_json_success( [ 'message' => __( 'Menu restored to defaults successfully!', 'wp-powersuite' ) ] );
		} catch ( \Exception $e ) {
			$this->logHandlerException( 'WPPS Admin Menu Organizer restore error', $e );
			wp_send_json_error( [ 'message' => __( 'Could not restore configuration. Please try again.', 'wp-powersuite' ) ] );
		}
	}

	private function getCurrentMenu(): array {
		global $menu, $submenu;

		$restore_menu    = $menu;
		$restore_submenu = $submenu;

		if ( $this->pristine_menu_snapshot !== null ) {
			$menu    = $this->pristine_menu_snapshot['menu'];
			$submenu = $this->pristine_menu_snapshot['submenu'];
		}

		try {
			return $this->buildCurrentMenuFromGlobals();
		} finally {
			$menu    = $restore_menu;
			$submenu = $restore_submenu;
		}
	}

	/**
	 * @return array<int, array<string, mixed>>
	 */
	private function buildCurrentMenuFromGlobals(): array {
		global $menu, $submenu;

		if ( ! is_array( $menu ) || empty( $menu ) ) {
			return [];
		}

		$menu_structure = [];
		$seen_slugs     = [];
		$role_names     = wp_roles()->get_names();
		$role_objects   = [];
		foreach ( array_keys( $role_names ) as $role_key ) {
			$role = get_role( $role_key );
			if ( $role ) {
				$role_objects[ $role_key ] = $role;
			}
		}
		$eligible_roles_cache = [];

		foreach ( $menu as $menu_key => $menu_item ) {
			if ( ! is_array( $menu_item ) ) {
				continue;
			}

			$menu_slug    = isset( $menu_item[2] ) ? (string) $menu_item[2] : '';
			$is_separator = $this->isWordPressSeparatorMenuItem( $menu_item, $menu_slug );

			// Synthetic soft-hide toggle — never treat as a real organizer item.
			if ( $menu_slug === '#wpps-show-all-menu' || $menu_slug === 'wpps-show-all-menu' ) {
				continue;
			}

			// Native WP separators stay out of the editor. User-added separators come from saved config only.
			if ( $is_separator ) {
				continue;
			}

			$capability = isset( $menu_item[1] ) ? (string) $menu_item[1] : 'read';
			// Match sidebar: hide items the current user cannot access (e.g. Links when link manager is off).
			if ( $capability !== '' && ! current_user_can( $capability ) ) {
				continue;
			}

			if ( $menu_slug !== '' && isset( $seen_slugs[ $menu_slug ] ) ) {
				continue;
			}

			$title = $this->extractMenuItemTitle( $menu_item );
			if ( $title === '' && $menu_slug === '' ) {
				continue;
			}

			if ( ! isset( $eligible_roles_cache[ $capability ] ) ) {
				$eligible_roles_cache[ $capability ] = $this->getEligibleRolesForCapability( $capability, $role_names, $role_objects );
			}
			$eligible_roles = $eligible_roles_cache[ $capability ];
			
			$item_data = [
				'menu_key'      => $menu_key,
				'title'         => $title !== '' ? $title : $this->titleFromMenuSlug( $menu_slug ),
				'capability'    => $capability,
				'menu_slug'     => $menu_slug,
				'page_title'    => $menu_item[3] ?? '',
				'icon'          => isset( $menu_item[6] ) ? (string) $menu_item[6] : '',
				'position'      => $menu_key,
				'parent_slug'   => '',
				'hidden'        => false,
				'is_separator'  => false,
				'eligible_roles' => $eligible_roles,
				'submenu'       => [],
			];

			$visited_submenu_slugs = [];
			$item_data['submenu']  = $this->buildSubmenuConfigFromGlobals(
				$menu_slug,
				$role_names,
				$role_objects,
				$eligible_roles_cache,
				$visited_submenu_slugs
			);

			if ( $menu_slug !== '' ) {
				$seen_slugs[ $menu_slug ] = true;
			}
			$menu_structure[] = $item_data;
		}

		return $menu_structure;
	}

	/**
	 * @param array<int, mixed> $menu_item
	 */
	private function isWordPressSeparatorMenuItem( array $menu_item, string $menu_slug ): bool {
		if ( $menu_slug === '' || $menu_slug === 'separator' || strpos( $menu_slug, 'separator' ) === 0 ) {
			return true;
		}

		$class = isset( $menu_item[4] ) && is_string( $menu_item[4] ) ? $menu_item[4] : '';

		return $class !== '' && str_contains( $class, 'wp-menu-separator' );
	}

	/**
	 * @param array<int, mixed> $menu_item
	 */
	private function extractMenuItemTitle( array $menu_item ): string {
		$title = isset( $menu_item[0] ) ? (string) $menu_item[0] : '';
		$title = $this->stripMenuTitleBubbles( $title );
		$title = wp_strip_all_tags( $title );
		$title = preg_replace( '/\s*0\s+Comments\s+in\s+moderation\s*/i', '', $title ) ?? $title;
		$title = preg_replace( '/\s*\d+\s+Comments\s+in\s+moderation\s*/i', '', $title ) ?? $title;
		$title = trim( html_entity_decode( $title, ENT_QUOTES, get_bloginfo( 'charset' ) ?: 'UTF-8' ) );

		if ( $title === '' && ! empty( $menu_item[3] ) ) {
			$title = trim( wp_strip_all_tags( (string) $menu_item[3] ) );
		}

		return $title;
	}

	/**
	 * Remove nested update/moderation bubble markup from a menu title.
	 */
	private function stripMenuTitleBubbles( string $title ): string {
		foreach ( $this->extractMenuTitleBubbleHtml( $title ) as $bubble ) {
			$title = str_replace( $bubble, '', $title );
		}

		return $title;
	}

	/**
	 * Extract full bubble HTML fragments, including nested spans.
	 *
	 * @return array<int, string>
	 */
	private function extractMenuTitleBubbleHtml( string $title ): array {
		$bubbles = [];
		if ( ! preg_match_all( '/<span\b[^>]*>/i', $title, $opens, PREG_OFFSET_CAPTURE ) ) {
			return $bubbles;
		}

		foreach ( $opens[0] as $open ) {
			$open_html = $open[0];
			$start     = (int) $open[1];
			if ( ! preg_match( '/\b(awaiting-mod|update-plugins|update-count|menu-counter)\b/i', $open_html ) ) {
				continue;
			}

			$pos   = $start + strlen( $open_html );
			$depth = 1;
			$len   = strlen( $title );
			while ( $pos < $len && $depth > 0 ) {
				$next_open  = stripos( $title, '<span', $pos );
				$next_close = stripos( $title, '</span>', $pos );
				if ( $next_close === false ) {
					break;
				}
				if ( $next_open !== false && $next_open < $next_close ) {
					++$depth;
					$pos = $next_open + 5;
					continue;
				}
				--$depth;
				$pos = $next_close + 7;
				if ( $depth === 0 ) {
					$bubbles[] = substr( $title, $start, $pos - $start );
				}
			}
		}

		return $bubbles;
	}

	private function titleFromMenuSlug( string $menu_slug ): string {
		$slug = $menu_slug;
		if ( str_contains( $slug, 'page=' ) ) {
			$parts = [];
			parse_str( (string) wp_parse_url( $slug, PHP_URL_QUERY ), $parts );
			if ( ! empty( $parts['page'] ) ) {
				$slug = (string) $parts['page'];
			}
		}
		$slug = preg_replace( '/\.(php|html)$/i', '', $slug ) ?? $slug;
		$slug = str_replace( [ '-', '_', '/' ], ' ', $slug );

		return ucwords( trim( $slug ) );
	}

	/**
	 * Keep editor JSON small/safe — huge SVG data-URIs break inline <script> payloads.
	 *
	 * @param array<int, array<string, mixed>> $menu_config
	 * @return array<int, array<string, mixed>>
	 */
	private function prepareMenuConfigForEditorClient( array $menu_config ): array {
		foreach ( $menu_config as $index => $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$icon = isset( $item['icon'] ) ? (string) $item['icon'] : '';
			if ( $icon !== '' && preg_match( '/^data:image\/svg\+xml/i', $icon ) && strlen( $icon ) > 256 ) {
				// Omit huge data-URIs from editor JSON (breaks inline script). Empty icon = keep WP original on apply.
				$menu_config[ $index ]['icon']        = '';
				$menu_config[ $index ]['icon_is_svg'] = true;
			}
			if ( ! empty( $item['submenu'] ) && is_array( $item['submenu'] ) ) {
				$menu_config[ $index ]['submenu'] = $this->prepareMenuConfigForEditorClient( $item['submenu'] );
			}
		}

		return $menu_config;
	}

	/**
	 * Merge saved organizer config with the pristine admin menu (includes nested submenu branches).
	 *
	 * @param array<int, array<string, mixed>>|null $saved_menu_config
	 * @return array<int, array<string, mixed>>
	 */
	public function getEditorMenuConfig( ?array $saved_menu_config ): array {
		$current_menu_for_js = $this->getCurrentMenu();

		if ( empty( $saved_menu_config ) || ! is_array( $saved_menu_config ) ) {
			return $this->prepareMenuConfigForEditorClient( $current_menu_for_js );
		}

		$menu_config = $saved_menu_config;

		$saved_config_lookup = [];
		foreach ( $saved_menu_config as $saved_item ) {
			$saved_slug = $this->getItemSlug( $saved_item );
			if ( $saved_slug !== '' ) {
				$saved_config_lookup[ $saved_slug ] = $saved_item;
			}
		}

		$current_menu_lookup = [];
		foreach ( $current_menu_for_js as $current_item ) {
			$current_slug = $this->getItemSlug( $current_item );
			if ( $current_slug !== '' ) {
				$current_menu_lookup[ $current_slug ] = $current_item;
			}
		}

		$relocated_by_parent = $this->getRelocatedChildSlugsByParent( $saved_menu_config );

		// Map legacy-compact slugs (stripped "/") back to live WP slugs.
		$legacy_to_current = [];
		foreach ( $current_menu_lookup as $current_slug => $current_item ) {
			$legacy = $this->legacyCompactSlug( (string) $current_slug );
			if ( $legacy !== '' && $legacy !== $current_slug && ! isset( $legacy_to_current[ $legacy ] ) ) {
				$legacy_to_current[ $legacy ] = $current_slug;
			}
		}

		foreach ( $menu_config as $index => $saved_item ) {
			$saved_slug = $this->getItemSlug( $saved_item );
			$raw_slug   = $this->sanitizeMenuSlug( (string) ( $saved_item['menu_slug'] ?? '' ) );

			if ( $saved_slug !== '' && ! isset( $current_menu_lookup[ $saved_slug ] ) && isset( $legacy_to_current[ $saved_slug ] ) ) {
				$healed_slug = $legacy_to_current[ $saved_slug ];
				$menu_config[ $index ]['menu_slug'] = $healed_slug;
				$saved_slug = $healed_slug;
				$saved_item = $menu_config[ $index ];
				$saved_config_lookup[ $healed_slug ] = $saved_item;
			}

			if ( $saved_slug === '' || ! isset( $current_menu_lookup[ $saved_slug ] ) ) {
				continue;
			}

			$current_item = $current_menu_lookup[ $saved_slug ];
			if ( isset( $current_item['eligible_roles'] ) ) {
				$menu_config[ $index ]['eligible_roles'] = $current_item['eligible_roles'];
			}

			// Always prefer pristine slug spelling from live WP menu.
			if ( empty( $saved_item['is_custom'] ) && empty( $saved_item['is_separator'] ) ) {
				$live_slug = (string) ( $current_item['menu_slug'] ?? '' );
				if ( $live_slug !== '' && $live_slug !== (string) ( $saved_item['menu_slug'] ?? '' ) ) {
					$menu_config[ $index ]['menu_slug'] = $live_slug;
				}
			}

			// Heal blank titles from pristine menu (corrupted saves / restore AJAX).
			if ( empty( $saved_item['is_separator'] ) ) {
				$saved_title   = trim( wp_strip_all_tags( (string) ( $saved_item['title'] ?? '' ) ) );
				$current_title = trim( (string) ( $current_item['title'] ?? '' ) );
				if ( $saved_title === '' && $current_title !== '' ) {
					$menu_config[ $index ]['title'] = $current_title;
				}
			}

			// Heal icons from pristine menu when saved config was corrupted by older sanitizers.
			if ( empty( $saved_item['is_custom'] ) && isset( $current_item['icon'] ) ) {
				$saved_icon   = (string) ( $saved_item['icon'] ?? '' );
				$current_icon = (string) ( $current_item['icon'] ?? '' );
				$menu_config[ $index ]['icon'] = $this->resolveAppliedMenuIcon( $saved_icon, $current_icon );
			}

			$exclude_relocated = $relocated_by_parent[ $saved_slug ] ?? [];
			if ( $raw_slug !== '' && $raw_slug !== $saved_slug && isset( $relocated_by_parent[ $raw_slug ] ) ) {
				$exclude_relocated = array_merge( $exclude_relocated, $relocated_by_parent[ $raw_slug ] );
			}

			if (
				isset( $current_item['submenu'], $saved_item['submenu'] )
				&& is_array( $current_item['submenu'] )
				&& is_array( $saved_item['submenu'] )
			) {
				$menu_config[ $index ]['submenu'] = $this->mergeSavedSubmenuWithCurrent(
					$saved_item['submenu'],
					$current_item['submenu'],
					$exclude_relocated
				);
			} elseif ( ! empty( $current_item['submenu'] ) && is_array( $current_item['submenu'] ) ) {
				$menu_config[ $index ]['submenu'] = $this->mergeSavedSubmenuWithCurrent(
					$saved_item['submenu'] ?? [],
					$current_item['submenu'],
					$exclude_relocated
				);
			}
		}

		foreach ( $current_menu_for_js as $current_item ) {
			$current_slug = $this->getItemSlug( $current_item );
			if ( $current_slug === '' ) {
				continue;
			}
			if ( isset( $saved_config_lookup[ $current_slug ] ) ) {
				continue;
			}
			$legacy = $this->legacyCompactSlug( $current_slug );
			if ( $legacy !== '' && isset( $saved_config_lookup[ $legacy ] ) ) {
				continue;
			}
			$menu_config[] = $current_item;
		}

		// Remove native WP separators that older builds incorrectly stored as organizer rows.
		$menu_config = array_values(
			array_filter(
				$menu_config,
				function ( $item ) {
					if ( ! is_array( $item ) || empty( $item['is_separator'] ) ) {
						return true;
					}
					$slug = (string) ( $item['menu_slug'] ?? '' );

					return ! $this->isNativeWordPressSeparatorSlug( $slug );
				}
			)
		);

		// Drop saved rows the current user cannot see / that are not in the live filtered menu
		// (except custom items and user separators), and de-dupe by slug.
		$live_slugs = [];
		foreach ( $current_menu_for_js as $live_item ) {
			$live_slug = $this->getItemSlug( $live_item );
			if ( $live_slug !== '' ) {
				$live_slugs[ $live_slug ] = true;
			}
		}

		$deduped = [];
		$seen    = [];
		foreach ( $menu_config as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$slug = $this->getItemSlug( $item );
			if ( $slug !== '' && isset( $seen[ $slug ] ) ) {
				continue;
			}
			$is_custom_or_sep = ! empty( $item['is_custom'] ) || ! empty( $item['is_separator'] );
			if ( ! $is_custom_or_sep && $slug !== '' && ! isset( $live_slugs[ $slug ] ) ) {
				// Ghost row (e.g. Links) — present in old saves, not in filtered sidebar.
				continue;
			}
			if ( $slug !== '' ) {
				$seen[ $slug ] = true;
			}
			$deduped[] = $item;
		}

		return $this->prepareMenuConfigForEditorClient( $this->normalizeMenuConfigPositions( $deduped ) );
	}

	private function isNativeWordPressSeparatorSlug( string $slug ): bool {
		return $slug === 'separator'
			|| $slug === 'separator-last'
			|| (bool) preg_match( '/^separator\d+$/', $slug );
	}

	/**
	 * @param array<int, array<string, mixed>> $saved_submenu
	 * @param array<int, array<string, mixed>> $current_submenu
	 * @return array<int, array<string, mixed>>
	 */
	/**
	 * Top-level config rows with parent_slug (Move under) — keyed by parent slug.
	 *
	 * @param array<int, array<string, mixed>> $menu_config
	 * @return array<string, array<string, true>>
	 */
	private function getRelocatedChildSlugsByParent( array $menu_config ): array {
		$by_parent = [];

		foreach ( $menu_config as $item ) {
			if ( ! empty( $item['is_separator'] ) ) {
				continue;
			}

			$parent = ! empty( $item['parent_slug'] ) ? $this->sanitizeMenuSlug( (string) $item['parent_slug'] ) : '';
			$slug   = $this->getItemSlug( $item );
			if ( $parent === '' || $slug === '' || $parent === $slug ) {
				continue;
			}

			if ( ! isset( $by_parent[ $parent ] ) ) {
				$by_parent[ $parent ] = [];
			}
			$by_parent[ $parent ][ $slug ] = true;
		}

		// Alias parent keys by both resolved and raw menu_slug so exclude lookups never miss.
		foreach ( $menu_config as $parent_item ) {
			if ( ! empty( $parent_item['parent_slug'] ) || ! empty( $parent_item['is_separator'] ) ) {
				continue;
			}
			$resolved = $this->getItemSlug( $parent_item );
			$raw      = $this->sanitizeMenuSlug( (string) ( $parent_item['menu_slug'] ?? '' ) );
			if ( $resolved === '' || $raw === '' || $resolved === $raw ) {
				continue;
			}
			$merged = array_merge( $by_parent[ $resolved ] ?? [], $by_parent[ $raw ] ?? [] );
			if ( $merged !== [] ) {
				$by_parent[ $resolved ] = $merged;
				$by_parent[ $raw ]      = $merged;
			}
		}

		return $by_parent;
	}

	/**
	 * @param array<int, array<string, mixed>> $submenu
	 * @param array<string, true>              $exclude_slugs
	 * @return array<int, array<string, mixed>>
	 */
	private function filterSubmenuExcludingSlugs( array $submenu, array $exclude_slugs ): array {
		if ( empty( $exclude_slugs ) ) {
			return $submenu;
		}

		$filtered = [];
		foreach ( $submenu as $sub ) {
			$slug = $this->getItemSlug( $sub );
			if ( $slug !== '' && isset( $exclude_slugs[ $slug ] ) ) {
				continue;
			}
			$filtered[] = $sub;
		}

		return $filtered;
	}

	/**
	 * @param array<int, array<string, mixed>> $saved_submenu
	 * @param array<int, array<string, mixed>> $current_submenu
	 * @param array<string, true>              $exclude_slugs Slugs shown via parent_slug, not native submenu merge.
	 * @return array<int, array<string, mixed>>
	 */
	private function mergeSavedSubmenuWithCurrent( array $saved_submenu, array $current_submenu, array $exclude_slugs = [] ): array {
		$current_submenu = $this->filterSubmenuExcludingSlugs( $current_submenu, $exclude_slugs );

		$current_lookup = [];
		foreach ( $current_submenu as $current_sub ) {
			$slug = $this->getItemSlug( $current_sub );
			if ( $slug !== '' ) {
				$current_lookup[ $slug ] = $current_sub;
			}
		}

		$merged = [];
		foreach ( $saved_submenu as $saved_sub ) {
			$slug = $this->getItemSlug( $saved_sub );
			if ( $slug !== '' && isset( $current_lookup[ $slug ] ) ) {
				$current_sub = $current_lookup[ $slug ];
				if ( isset( $current_sub['eligible_roles'] ) ) {
					$saved_sub['eligible_roles'] = $current_sub['eligible_roles'];
				}
				if (
					isset( $current_sub['submenu'], $saved_sub['submenu'] )
					&& is_array( $current_sub['submenu'] )
					&& is_array( $saved_sub['submenu'] )
				) {
					$saved_sub['submenu'] = $this->mergeSavedSubmenuWithCurrent(
						$saved_sub['submenu'],
						$current_sub['submenu']
					);
				} elseif ( ! empty( $current_sub['submenu'] ) && is_array( $current_sub['submenu'] ) ) {
					$saved_sub['submenu'] = $this->mergeSavedSubmenuWithCurrent(
						$saved_sub['submenu'] ?? [],
						$current_sub['submenu']
					);
				} elseif ( empty( $saved_sub['submenu'] ) && ! empty( $current_sub['submenu'] ) ) {
					$saved_sub['submenu'] = $current_sub['submenu'];
				}
			}

			$merged[] = $saved_sub;
		}

		$saved_slugs = [];
		foreach ( $merged as $sub ) {
			$slug = $this->getItemSlug( $sub );
			if ( $slug !== '' ) {
				$saved_slugs[ $slug ] = true;
			}
		}

		foreach ( $current_submenu as $current_sub ) {
			$slug = $this->getItemSlug( $current_sub );
			if ( $slug !== '' && ! isset( $saved_slugs[ $slug ] ) ) {
				$merged[] = $current_sub;
			}
		}

		return $merged;
	}

	/**
	 * @param array<string, string>              $role_names
	 * @param array<string, \WP_Role>            $role_objects
	 * @param array<string, array<string, string>> $eligible_roles_cache
	 * @return array<int, array<string, mixed>>
	 */
	/**
	 * Whether $submenu[ $child_slug ] should be loaded (avoids self/cyclic WP menu registration).
	 *
	 * @param array<string, true> $visited_slugs
	 */
	private function shouldLoadNestedSubmenuBranch( string $child_slug, string $parent_slug, array $visited_slugs ): bool {
		if ( $child_slug === '' || $child_slug === $parent_slug ) {
			return false;
		}

		return ! isset( $visited_slugs[ $child_slug ] );
	}

	private function buildSubmenuConfigFromGlobals(
		string $parent_slug,
		array $role_names,
		array $role_objects,
		array &$eligible_roles_cache,
		array &$visited_slugs = []
	): array {
		global $submenu;

		if ( $parent_slug === '' || isset( $visited_slugs[ $parent_slug ] ) ) {
			return [];
		}

		if ( ! isset( $submenu[ $parent_slug ] ) || ! is_array( $submenu[ $parent_slug ] ) ) {
			return [];
		}

		$visited_slugs[ $parent_slug ] = true;

		$items = [];
		foreach ( $submenu[ $parent_slug ] as $submenu_key => $submenu_item ) {
			$sub_title      = $this->stripMenuTitleHtml( (string) ( $submenu_item[0] ?? '' ) );
			$sub_capability = $submenu_item[1] ?? 'read';
			if ( ! isset( $eligible_roles_cache[ $sub_capability ] ) ) {
				$eligible_roles_cache[ $sub_capability ] = $this->getEligibleRolesForCapability(
					$sub_capability,
					$role_names,
					$role_objects
				);
			}

			$sub_slug = $submenu_item[2] ?? '';
			$entry    = [
				'submenu_key'    => $submenu_key,
				'title'          => $sub_title,
				'capability'     => $sub_capability,
				'menu_slug'      => $sub_slug,
				'page_title'     => $submenu_item[3] ?? '',
				'hidden'         => false,
				'eligible_roles' => $eligible_roles_cache[ $sub_capability ],
				'submenu'        => [],
			];

			if ( $this->shouldLoadNestedSubmenuBranch( $sub_slug, $parent_slug, $visited_slugs ) ) {
				$entry['submenu'] = $this->buildSubmenuConfigFromGlobals(
					$sub_slug,
					$role_names,
					$role_objects,
					$eligible_roles_cache,
					$visited_slugs
				);
			}

			$items[] = $entry;
		}

		return $items;
	}

	private function stripMenuTitleHtml( string $title ): string {
		$title = preg_replace( '/<span[^>]*class=["\'][^"\']*awaiting-mod[^"\']*["\'][^>]*>.*?<\/span>/i', '', $title );
		$title = preg_replace( '/<span[^>]*class=["\'][^"\']*update-plugins[^"\']*["\'][^>]*>.*?<\/span>/i', '', $title );
		$title = strip_tags( $title );
		$title = preg_replace( '/\s*0\s+Comments\s+in\s+moderation\s*/i', '', $title );
		$title = preg_replace( '/\s*\d+\s+Comments\s+in\s+moderation\s*/i', '', $title );

		return trim( $title );
	}

	/**
	 * @param array<int, array<string, mixed>> $submenu_items
	 * @param list<int>                        $user_ids
	 */
	private function collectHideRuleUserIdsFromSubmenu( array $submenu_items, array &$user_ids ): void {
		foreach ( $submenu_items as $sub ) {
			$sub_rules = $sub['hide_rules'] ?? null;
			if ( ! empty( $sub_rules['users'] ) && is_array( $sub_rules['users'] ) ) {
				$user_ids = array_merge( $user_ids, $sub_rules['users'] );
			}
			if ( ! empty( $sub['submenu'] ) && is_array( $sub['submenu'] ) ) {
				$this->collectHideRuleUserIdsFromSubmenu( $sub['submenu'], $user_ids );
			}
		}
	}

	/**
	 * Build a parent's submenu including relocated top-level items, respecting submenu_child_order.
	 *
	 * @param array<int, array<string, mixed>> $menu_config
	 * @param array<string, array<int, array>> $original_submenu
	 * @param array<string, array<int, array>> $new_submenu
	 * @param array<string, int>               $menu_lookup
	 * @param array<int, array>                $menu
	 */
	private function applyParentSubmenuWithRelocations(
		string $wp_parent_slug,
		array $parent_item,
		array $menu_config,
		array $original_submenu,
		array &$new_submenu,
		array $menu_lookup,
		array $menu
	): void {
		$parent_resolved = $this->getItemSlug( $parent_item );
		$relocated       = [];

		foreach ( $menu_config as $item ) {
			if ( ! empty( $item['is_separator'] ) ) {
				continue;
			}
			$item_parent = ! empty( $item['parent_slug'] ) ? $this->sanitizeMenuSlug( (string) $item['parent_slug'] ) : '';
			if ( $item_parent === '' ) {
				continue;
			}
			if ( $item_parent !== $wp_parent_slug && $item_parent !== $parent_resolved ) {
				continue;
			}
			if ( $this->shouldRemoveMenuItemForCurrentUser( $item ) ) {
				continue;
			}
			$child_slug = $this->getItemSlug( $item );
			if ( $child_slug === '' || $child_slug === $wp_parent_slug ) {
				continue;
			}
			$relocated[ $child_slug ] = $item;
		}

		$child_order = [];
		if ( ! empty( $parent_item['submenu_child_order'] ) && is_array( $parent_item['submenu_child_order'] ) ) {
			foreach ( $parent_item['submenu_child_order'] as $ordered_slug ) {
				$clean = $this->sanitizeMenuSlug( (string) $ordered_slug );
				if ( $clean !== '' ) {
					$child_order[] = $clean;
				}
			}
		}

		if ( $child_order === [] ) {
			if ( ! empty( $parent_item['submenu'] ) && is_array( $parent_item['submenu'] ) ) {
				$visited_apply_slugs = [];
				$this->applyConfiguredSubmenu( $wp_parent_slug, $parent_item['submenu'], $original_submenu, $new_submenu, $visited_apply_slugs );
			} elseif ( isset( $original_submenu[ $wp_parent_slug ] ) ) {
				$new_submenu[ $wp_parent_slug ] = $original_submenu[ $wp_parent_slug ];
				$visited_copy_slugs             = [];
				$this->copyNestedSubmenuBranches( $wp_parent_slug, $original_submenu, $new_submenu, $visited_copy_slugs );
			}

			foreach ( $relocated as $relocated_item ) {
				$this->appendRelocatedSubmenuItem(
					$wp_parent_slug,
					$relocated_item,
					$original_submenu,
					$new_submenu,
					$menu_lookup,
					$menu
				);
			}

			return;
		}

		$new_submenu[ $wp_parent_slug ] = [];
		$used_slugs                     = [];
		$visited_apply_slugs            = [ $wp_parent_slug => true ];
		$native_by_slug                 = [];

		if ( ! empty( $parent_item['submenu'] ) && is_array( $parent_item['submenu'] ) ) {
			foreach ( $parent_item['submenu'] as $sub_item ) {
				$sub_slug = $this->getItemSlug( $sub_item );
				if ( $sub_slug !== '' && ! isset( $relocated[ $sub_slug ] ) ) {
					$native_by_slug[ $sub_slug ] = $sub_item;
				}
			}
		}

		foreach ( $child_order as $slug ) {
			if ( isset( $used_slugs[ $slug ] ) ) {
				continue;
			}
			if ( isset( $relocated[ $slug ] ) ) {
				$this->appendRelocatedSubmenuItem(
					$wp_parent_slug,
					$relocated[ $slug ],
					$original_submenu,
					$new_submenu,
					$menu_lookup,
					$menu
				);
				$used_slugs[ $slug ] = true;
				continue;
			}
			if ( isset( $native_by_slug[ $slug ] ) ) {
				$this->appendConfiguredSubmenuItem(
					$wp_parent_slug,
					$native_by_slug[ $slug ],
					$original_submenu,
					$new_submenu,
					$visited_apply_slugs
				);
				$used_slugs[ $slug ] = true;
			}
		}

		foreach ( $native_by_slug as $slug => $sub_item ) {
			if ( isset( $used_slugs[ $slug ] ) ) {
				continue;
			}
			$this->appendConfiguredSubmenuItem(
				$wp_parent_slug,
				$sub_item,
				$original_submenu,
				$new_submenu,
				$visited_apply_slugs
			);
			$used_slugs[ $slug ] = true;
		}

		foreach ( $relocated as $slug => $relocated_item ) {
			if ( isset( $used_slugs[ $slug ] ) ) {
				continue;
			}
			$this->appendRelocatedSubmenuItem(
				$wp_parent_slug,
				$relocated_item,
				$original_submenu,
				$new_submenu,
				$menu_lookup,
				$menu
			);
		}

		if ( isset( $original_submenu[ $wp_parent_slug ] ) ) {
			foreach ( $original_submenu[ $wp_parent_slug ] as $submenu_item ) {
				$orig_slug = $submenu_item[2] ?? '';
				if ( $orig_slug === '' || isset( $used_slugs[ $orig_slug ] ) || isset( $relocated[ $orig_slug ] ) ) {
					continue;
				}
				$this->appendSubmenuRow( $new_submenu, $wp_parent_slug, $submenu_item );
				if ( $this->shouldLoadNestedSubmenuBranch( $orig_slug, $wp_parent_slug, $visited_apply_slugs ) ) {
					$this->copyNestedSubmenuBranches( $orig_slug, $original_submenu, $new_submenu, $visited_apply_slugs );
				}
			}
		}
	}

	/**
	 * @param array<string, array<int, array>> $original_submenu
	 * @param array<string, array<int, array>> $new_submenu
	 * @param array<string, true>              $visited_slugs
	 */
	private function appendConfiguredSubmenuItem(
		string $wp_parent_slug,
		array $sub_item,
		array $original_submenu,
		array &$new_submenu,
		array &$visited_slugs
	): void {
		if ( $this->shouldRemoveMenuItemForCurrentUser( $sub_item ) ) {
			return;
		}

		$submenu_slug = $sub_item['menu_slug'] ?? '';
		if ( $submenu_slug === '' ) {
			return;
		}

		if ( ! empty( $sub_item['is_custom'] ) ) {
			$sub_slug = $this->resolveCustomMenuSlug( $sub_item );
			$row      = [
				trim( wp_strip_all_tags( $sub_item['title'] ?? '' ) ),
				$sub_item['capability'] ?? 'read',
				$sub_slug,
				$sub_item['page_title'] ?? $sub_item['title'] ?? '',
			];
			$this->appendSubmenuRow(
				$new_submenu,
				$wp_parent_slug,
				$this->maybeApplySoftHideSubmenuClasses( $row, $sub_item )
			);
			if ( ! empty( $sub_item['submenu'] ) && is_array( $sub_item['submenu'] ) && $this->shouldLoadNestedSubmenuBranch( $sub_slug, $wp_parent_slug, $visited_slugs ) ) {
				$this->applyConfiguredSubmenu( $sub_slug, $sub_item['submenu'], $original_submenu, $new_submenu, $visited_slugs );
			}
			return;
		}

		if ( ! isset( $original_submenu[ $wp_parent_slug ] ) ) {
			return;
		}

		foreach ( $original_submenu[ $wp_parent_slug ] as $submenu_item ) {
			if ( ( $submenu_item[2] ?? '' ) !== $submenu_slug ) {
				continue;
			}
			if ( ! empty( $sub_item['title'] ) ) {
				$submenu_item[0] = $this->mergeMenuTitleBubbles( $submenu_item[0] ?? '', $sub_item['title'] );
			}
			$this->appendSubmenuRow(
				$new_submenu,
				$wp_parent_slug,
				$this->maybeApplySoftHideSubmenuClasses( $submenu_item, $sub_item )
			);
			if ( ! empty( $sub_item['submenu'] ) && is_array( $sub_item['submenu'] ) && $this->shouldLoadNestedSubmenuBranch( $submenu_slug, $wp_parent_slug, $visited_slugs ) ) {
				$this->applyConfiguredSubmenu( $submenu_slug, $sub_item['submenu'], $original_submenu, $new_submenu, $visited_slugs );
			} elseif ( $this->shouldLoadNestedSubmenuBranch( $submenu_slug, $wp_parent_slug, $visited_slugs ) ) {
				$this->copyNestedSubmenuBranches( $submenu_slug, $original_submenu, $new_submenu, $visited_slugs );
			}
			break;
		}
	}

	/**
	 * @param array<string, array<int, array>> $original_submenu
	 * @param array<string, array<int, array>> $new_submenu
	 * @param array<string, int>               $menu_lookup
	 * @param array<int, array>                $menu
	 */
	private function appendRelocatedSubmenuItem(
		string $parent_slug,
		array $item,
		array $original_submenu,
		array &$new_submenu,
		array $menu_lookup,
		array $menu
	): void {
		$resolved_slug = $this->getItemSlug( $item );
		$menu_slug     = (string) ( $item['menu_slug'] ?? '' );
		if ( $resolved_slug === '' ) {
			return;
		}

		if ( ! empty( $item['is_custom'] ) ) {
			if ( ! $this->userCanSeeCustomItem( $item ) ) {
				return;
			}
			$row = [
				trim( wp_strip_all_tags( $item['title'] ?? '' ) ),
				$item['capability'] ?? 'read',
				$resolved_slug,
				$item['page_title'] ?? $item['title'] ?? '',
			];
			$this->appendSubmenuRow(
				$new_submenu,
				$parent_slug,
				$this->maybeApplySoftHideSubmenuClasses( $row, $item )
			);
			return;
		}

		$lookup_slug = isset( $menu_lookup[ $menu_slug ] ) ? $menu_slug : ( isset( $menu_lookup[ $resolved_slug ] ) ? $resolved_slug : null );
		if ( $lookup_slug === null ) {
			return;
		}

		$menu_item = $menu[ $menu_lookup[ $lookup_slug ] ];
		if ( ! empty( $item['title'] ) ) {
			$menu_item[0] = $this->mergeMenuTitleBubbles( $menu_item[0] ?? '', $item['title'] );
		}

		$row = [
			$menu_item[0],
			$menu_item[1],
			$lookup_slug,
			$menu_item[3] ?? $menu_item[0],
		];
		$this->appendSubmenuRow(
			$new_submenu,
			$parent_slug,
			$this->maybeApplySoftHideSubmenuClasses( $row, $item )
		);

		// WordPress only renders two menu levels. Flatten this item's former
		// children into the same parent submenu so they stay visible/reachable.
		$this->appendFlattenedRelocatedChildren(
			$parent_slug,
			$item,
			$lookup_slug,
			$original_submenu,
			$new_submenu
		);
	}

	/**
	 * Append a relocated item's former submenu rows as siblings under $parent_slug.
	 *
	 * @param array<string, mixed>                $item
	 * @param array<string, array<int, array>>     $original_submenu
	 * @param array<string, array<int, array>>     $new_submenu
	 */
	private function appendFlattenedRelocatedChildren(
		string $parent_slug,
		array $item,
		string $lookup_slug,
		array $original_submenu,
		array &$new_submenu
	): void {
		$original_children = $original_submenu[ $lookup_slug ] ?? [];
		$config_subs       = ( ! empty( $item['submenu'] ) && is_array( $item['submenu'] ) ) ? $item['submenu'] : [];

		if ( $config_subs !== [] ) {
			foreach ( $config_subs as $sub_item ) {
				if ( ! is_array( $sub_item ) || $this->shouldRemoveMenuItemForCurrentUser( $sub_item ) ) {
					continue;
				}

				if ( ! empty( $sub_item['is_custom'] ) ) {
					if ( ! $this->userCanSeeCustomItem( $sub_item ) ) {
						continue;
					}
					$sub_slug = $this->resolveCustomMenuSlug( $sub_item );
					$custom   = [
						trim( wp_strip_all_tags( (string) ( $sub_item['title'] ?? '' ) ) ),
						$sub_item['capability'] ?? 'read',
						$sub_slug,
						$sub_item['page_title'] ?? $sub_item['title'] ?? '',
					];
					$this->appendSubmenuRow(
						$new_submenu,
						$parent_slug,
						$this->markFlattenedChildRow(
							$this->maybeApplySoftHideSubmenuClasses( $custom, $sub_item )
						)
					);
					continue;
				}

				$sub_slug = (string) ( $sub_item['menu_slug'] ?? '' );
				if ( $sub_slug === '' ) {
					continue;
				}

				$child_row = null;
				foreach ( $original_children as $orig ) {
					if ( (string) ( $orig[2] ?? '' ) === $sub_slug ) {
						$child_row = $orig;
						break;
					}
				}
				if ( $child_row === null ) {
					continue;
				}
				if ( ! empty( $sub_item['title'] ) ) {
					$child_row[0] = $this->mergeMenuTitleBubbles( (string) ( $child_row[0] ?? '' ), (string) $sub_item['title'] );
				}
				$this->appendSubmenuRow(
					$new_submenu,
					$parent_slug,
					$this->markFlattenedChildRow(
						$this->maybeApplySoftHideSubmenuClasses( $child_row, $sub_item )
					)
				);
			}

			return;
		}

		foreach ( $original_children as $child_row ) {
			if ( ! is_array( $child_row ) ) {
				continue;
			}
			$this->appendSubmenuRow(
				$new_submenu,
				$parent_slug,
				$this->markFlattenedChildRow( $child_row )
			);
		}
	}

	/**
	 * Indent / prefix a former nested child that was flattened into a parent submenu.
	 *
	 * @param array<int, mixed> $row
	 * @return array<int, mixed>
	 */
	private function markFlattenedChildRow( array $row ): array {
		$row[0] = $this->prefixFlattenedChildTitle( (string) ( $row[0] ?? '' ) );
		$row[4] = trim( (string) ( $row[4] ?? '' ) . ' wpps-flattened-child' );

		return $row;
	}

	private function prefixFlattenedChildTitle( string $title ): string {
		$bubbles = $this->extractMenuTitleBubbleHtml( $title );
		$plain   = trim( wp_strip_all_tags( $this->stripMenuTitleBubbles( $title ) ) );
		if ( $plain === '' ) {
			return $title;
		}

		// Avoid double-prefixing on re-apply / reloads.
		if ( preg_match( '/^[-–—]\s/u', $plain ) === 1 ) {
			return $title;
		}

		$result = '— ' . $plain;
		foreach ( $bubbles as $bubble ) {
			$result .= ' ' . $bubble;
		}

		return $result;
	}

	/**
	 * @param array<int, array<string, mixed>>     $config_items
	 * @param array<string, array<int, array>>     $original_submenu
	 * @param array<string, array<int, array>>     $new_submenu
	 */
	private function applyConfiguredSubmenu(
		string $wp_parent_slug,
		array $config_items,
		array $original_submenu,
		array &$new_submenu,
		array &$visited_slugs = []
	): void {
		if ( $wp_parent_slug === '' || isset( $visited_slugs[ $wp_parent_slug ] ) ) {
			return;
		}

		$visited_slugs[ $wp_parent_slug ] = true;

		$new_submenu[ $wp_parent_slug ] = [];
		$config_sub_slugs               = [];

		foreach ( $config_items as $sub_item ) {
			if ( $this->shouldRemoveMenuItemForCurrentUser( $sub_item ) ) {
				continue;
			}

			$submenu_slug = $sub_item['menu_slug'] ?? '';
			if ( $submenu_slug === '' ) {
				continue;
			}

			if ( ! empty( $sub_item['is_custom'] ) ) {
				$sub_slug = $this->resolveCustomMenuSlug( $sub_item );
				$row      = [
					trim( wp_strip_all_tags( $sub_item['title'] ?? '' ) ),
					$sub_item['capability'] ?? 'read',
					$sub_slug,
					$sub_item['page_title'] ?? $sub_item['title'] ?? '',
				];
				$this->appendSubmenuRow(
					$new_submenu,
					$wp_parent_slug,
					$this->maybeApplySoftHideSubmenuClasses( $row, $sub_item )
				);
				$config_sub_slugs[] = $sub_slug;

				if ( ! empty( $sub_item['submenu'] ) && is_array( $sub_item['submenu'] ) && $this->shouldLoadNestedSubmenuBranch( $sub_slug, $wp_parent_slug, $visited_slugs ) ) {
					$this->applyConfiguredSubmenu( $sub_slug, $sub_item['submenu'], $original_submenu, $new_submenu, $visited_slugs );
				}
				continue;
			}

			if ( isset( $original_submenu[ $wp_parent_slug ] ) ) {
				foreach ( $original_submenu[ $wp_parent_slug ] as $submenu_item ) {
					if ( ( $submenu_item[2] ?? '' ) !== $submenu_slug ) {
						continue;
					}

					if ( ! empty( $sub_item['title'] ) ) {
						$submenu_item[0] = $this->mergeMenuTitleBubbles( $submenu_item[0] ?? '', $sub_item['title'] );
					}
					$this->appendSubmenuRow(
						$new_submenu,
						$wp_parent_slug,
						$this->maybeApplySoftHideSubmenuClasses( $submenu_item, $sub_item )
					);
					$config_sub_slugs[] = $submenu_slug;

					if ( ! empty( $sub_item['submenu'] ) && is_array( $sub_item['submenu'] ) && $this->shouldLoadNestedSubmenuBranch( $submenu_slug, $wp_parent_slug, $visited_slugs ) ) {
						$this->applyConfiguredSubmenu( $submenu_slug, $sub_item['submenu'], $original_submenu, $new_submenu, $visited_slugs );
					}
					break;
				}
			}
		}

		if ( isset( $original_submenu[ $wp_parent_slug ] ) ) {
			foreach ( $original_submenu[ $wp_parent_slug ] as $submenu_item ) {
				$orig_slug = $submenu_item[2] ?? '';
				if ( $orig_slug !== '' && ! in_array( $orig_slug, $config_sub_slugs, true ) ) {
					$this->appendSubmenuRow( $new_submenu, $wp_parent_slug, $submenu_item );
					if ( $this->shouldLoadNestedSubmenuBranch( $orig_slug, $wp_parent_slug, $visited_slugs ) ) {
						$this->copyNestedSubmenuBranches( $orig_slug, $original_submenu, $new_submenu, $visited_slugs );
					}
				}
			}
		}
	}

	/**
	 * Preserve nested $submenu branches keyed by child slugs (plugin flyout menus).
	 *
	 * @param array<string, array<int, array>> $source
	 * @param array<string, array<int, array>> $target
	 */
	private function copyNestedSubmenuBranches( string $parent_slug, array $source, array &$target, array &$visited_slugs = [] ): void {
		if ( $parent_slug === '' || isset( $visited_slugs[ $parent_slug ] ) ) {
			return;
		}

		if ( ! isset( $source[ $parent_slug ] ) || ! is_array( $source[ $parent_slug ] ) ) {
			return;
		}

		$visited_slugs[ $parent_slug ] = true;

		foreach ( $source[ $parent_slug ] as $submenu_item ) {
			$child_slug = $submenu_item[2] ?? '';
			if ( $child_slug === '' || ! isset( $source[ $child_slug ] ) ) {
				continue;
			}
			if ( ! $this->shouldLoadNestedSubmenuBranch( $child_slug, $parent_slug, $visited_slugs ) ) {
				continue;
			}
			if ( ! isset( $target[ $child_slug ] ) ) {
				$target[ $child_slug ] = $source[ $child_slug ];
			}
			$this->copyNestedSubmenuBranches( $child_slug, $source, $target, $visited_slugs );
		}
	}

	/**
	 * @param array<string, string>           $role_names
	 * @param array<string, \WP_Role>         $role_objects
	 * @return array<string, string>
	 */
	private function getEligibleRolesForCapability( string $capability, array $role_names, array $role_objects ): array {
		$eligible_roles = [];
		foreach ( $role_names as $role_key => $role_label ) {
			$role = $role_objects[ $role_key ] ?? null;
			if ( $role && $role->has_cap( $capability ) ) {
				$eligible_roles[ $role_key ] = $role_label;
			}
		}

		return $eligible_roles;
	}

	/**
	 * Check if a menu item should be hidden for the current user based on hide_rules.
	 *
	 * @param array $item Menu config item with 'hidden' and optional 'hide_rules'.
	 * @return bool True if the item should be hidden for the current user.
	 */
	private function getHideRulesUserContext(): array {
		if ( $this->hide_rules_user_context !== null ) {
			return $this->hide_rules_user_context;
		}

		$user = wp_get_current_user();
		$this->hide_rules_user_context = [
			'user_id'    => get_current_user_id(),
			'user_roles' => ! empty( $user->roles ) ? $user->roles : [],
		];

		return $this->hide_rules_user_context;
	}

	private function isHiddenForCurrentUser( array $item ): bool {
		if ( empty( $item['hidden'] ) ) {
			return false;
		}

		$hide_rules = $item['hide_rules'] ?? null;
		if ( empty( $hide_rules ) || ! is_array( $hide_rules ) ) {
			return true;
		}

		$type = $hide_rules['type'] ?? 'all';
		if ( $type === 'all' ) {
			return true;
		}

		$users = $hide_rules['users'] ?? [];
		if ( is_array( $users ) && ! empty( $users ) ) {
			if ( in_array( $this->getHideRulesUserContext()['user_id'], array_map( 'intval', $users ), true ) ) {
				return true;
			}
		}

		$roles = $hide_rules['roles'] ?? [];
		if ( ! is_array( $roles ) ) {
			$roles = [];
		}

		$user_roles = $this->getHideRulesUserContext()['user_roles'];
		if ( $type === 'specific' ) {
			return ! empty( array_intersect( $user_roles, $roles ) );
		}
		if ( $type === 'except' ) {
			return empty( array_intersect( $user_roles, $roles ) );
		}

		return true;
	}

	private function shouldRemoveMenuItemForCurrentUser( array $item ): bool {
		return $this->isHiddenForCurrentUser( $item ) && ! $this->usesSoftHideBehavior( $item );
	}

	private function usesSoftHideBehavior( array $item ): bool {
		if ( empty( $item['hidden'] ) ) {
			return false;
		}

		$hide_rules = $item['hide_rules'] ?? null;
		if ( ! is_array( $hide_rules ) ) {
			return false;
		}

		if ( ( $hide_rules['mode'] ?? 'remove' ) !== 'toggle' ) {
			return false;
		}

		if ( ! empty( $hide_rules['users'] ) ) {
			return false;
		}

		$type = $hide_rules['type'] ?? 'all';
		if ( ( $type === 'specific' || $type === 'except' ) && ! empty( $hide_rules['roles'] ) ) {
			return false;
		}

		return true;
	}

	private function userCanSeeCustomItem( array $item ): bool {
		if ( empty( $item['is_custom'] ) ) {
			return true;
		}

		$eligible = $item['eligible_roles'] ?? [];
		if ( ! is_array( $eligible ) || $eligible === [] ) {
			return true;
		}

		$user_roles = $this->getHideRulesUserContext()['user_roles'];

		return ! empty( array_intersect( $user_roles, array_keys( $eligible ) ) );
	}

	/**
	 * @param array<int, string> $menu_row
	 * @return array<int, string>
	 */
	private function applySoftHideMenuClasses( array $menu_row ): array {
		$menu_row[4] = trim( ( $menu_row[4] ?? '' ) . ' wpps-soft-hidden' );

		return $menu_row;
	}

	/**
	 * @param array<int, string> $submenu_row
	 * @return array<int, string>
	 */
	private function maybeApplySoftHideSubmenuClasses( array $submenu_row, array $config_item ): array {
		if ( $this->isHiddenForCurrentUser( $config_item ) && $this->usesSoftHideBehavior( $config_item ) ) {
			$submenu_row[4] = trim( ( $submenu_row[4] ?? '' ) . ' wpps-soft-hidden' );
		}

		return $submenu_row;
	}

	private function getItemSlug( array $item ): string {
		if ( ! empty( $item['is_custom'] ) ) {
			return $this->resolveCustomMenuSlug( $item );
		}

		return $this->sanitizeMenuSlug( (string) ( $item['menu_slug'] ?? '' ) );
	}

	/**
	 * @param array<int, array<int, string>> $new_submenu
	 * @param array<int, string>              $row
	 */
	private function appendSubmenuRow( array &$new_submenu, string $parent_slug, array $row ): void {
		$row_slug = $row[2] ?? '';
		if ( $parent_slug === '' || $row_slug === '' ) {
			return;
		}

		if ( ! isset( $new_submenu[ $parent_slug ] ) ) {
			$new_submenu[ $parent_slug ] = [];
		}

		foreach ( $new_submenu[ $parent_slug ] as $existing ) {
			if ( ( $existing[2] ?? '' ) === $row_slug ) {
				return;
			}
		}

		$new_submenu[ $parent_slug ][] = $row;
	}

	private function mergeMenuTitleBubbles( string $original_title, string $new_label ): string {
		$new_plain = trim( wp_strip_all_tags( $new_label ) );
		if ( $new_plain === '' ) {
			return $original_title;
		}

		$bubble_parts  = $this->extractMenuTitleBubbleHtml( $original_title );
		$base_original = trim( wp_strip_all_tags( $this->stripMenuTitleBubbles( $original_title ) ) );
		if ( $bubble_parts === [] ) {
			return $new_plain;
		}
		if ( $new_plain === $base_original ) {
			return $original_title;
		}

		$bubbles = '';
		foreach ( $bubble_parts as $span_html ) {
			$inner = trim( wp_strip_all_tags( $span_html ) );
			if ( $inner !== '' && stripos( $new_plain, $inner ) !== false ) {
				continue;
			}
			$bubbles .= ' ' . $span_html;
		}

		return $new_plain . $bubbles;
	}

	private function sanitizeMenuSlug( string $slug ): string {
		$slug = trim( wp_strip_all_tags( $slug ) );
		if ( $slug === '' ) {
			return '';
		}

		if ( preg_match( '#^https?://#i', $slug ) ) {
			$clean = esc_url_raw( $slug );

			return is_string( $clean ) ? $clean : '';
		}

		// WP menu slugs are file/query strings (edit.php?post_type=page, admin.php?page=foo/bar).
		// Only strip control chars and HTML/attribute breakers — do NOT strip / : + # etc.
		$slug = str_replace( [ "\r", "\n", "\t", "\0" ], '', $slug );
		if ( $slug === '' || preg_match( '/[<>"\']/', $slug ) ) {
			return '';
		}

		return $slug;
	}

	/**
	 * Compact form used by older module versions that stripped "/:#+" from slugs.
	 */
	private function legacyCompactSlug( string $slug ): string {
		return preg_replace( '/[^a-zA-Z0-9_\-\.\?\=&%]/', '', $slug ) ?? '';
	}

	private function resolveCustomMenuSlug( array $item ): string {
		$url = $this->getCustomMenuTargetUrl( $item );
		if ( $url !== '' ) {
			return 'wpps-link-' . substr( md5( $url ), 0, 12 );
		}

		$slug = (string) ( $item['menu_slug'] ?? '' );
		if ( preg_match( '#^https?://#i', $slug ) ) {
			return 'wpps-link-' . substr( md5( $slug ), 0, 12 );
		}

		$clean = $this->sanitizeMenuSlug( $slug );

		return $clean !== '' ? $clean : 'wpps-custom-' . substr( md5( wp_json_encode( $item ) ), 0, 10 );
	}

	private function getCustomMenuTargetUrl( array $item ): string {
		$url = isset( $item['url'] ) ? (string) $item['url'] : '';
		if ( $url !== '' && filter_var( $url, FILTER_VALIDATE_URL ) ) {
			return esc_url_raw( $url ) ?: '';
		}

		$slug = (string) ( $item['menu_slug'] ?? '' );
		if ( preg_match( '#^https?://#i', $slug ) ) {
			return esc_url_raw( $slug ) ?: '';
		}

		return '';
	}

	public function registerCustomMenuPages(): void {
		$menu_config = $this->getSettings()['menu_config'] ?? null;
		if ( empty( $menu_config ) || ! is_array( $menu_config ) ) {
			return;
		}

		$registered_slugs = [];

		foreach ( $menu_config as $item ) {
			if ( empty( $item['is_custom'] ) ) {
				continue;
			}

			if ( $this->shouldRemoveMenuItemForCurrentUser( $item ) || ! $this->userCanSeeCustomItem( $item ) ) {
				continue;
			}

			$slug        = $this->resolveCustomMenuSlug( $item );
			$url         = $this->getCustomMenuTargetUrl( $item );
			$parent_slug = ! empty( $item['parent_slug'] ) ? $this->sanitizeMenuSlug( (string) $item['parent_slug'] ) : '';

			if ( $slug === '' || isset( $registered_slugs[ $slug ] ) ) {
				continue;
			}

			$registered_slugs[ $slug ] = true;

			$title = trim( wp_strip_all_tags( $item['title'] ?? __( 'Custom Link', 'wp-powersuite' ) ) );
			$cap   = $item['capability'] ?? 'read';
			$icon  = $item['icon'] ?? 'dashicons-admin-generic';
			$callback = static function () use ( $url ): void {
				if ( $url !== '' ) {
					wp_safe_redirect( $url );
					exit;
				}
			};

			if ( $parent_slug !== '' && $parent_slug !== $slug ) {
				add_submenu_page(
					$parent_slug,
					$title,
					$title,
					$cap,
					$slug,
					$callback
				);
				continue;
			}

			add_menu_page(
				$title,
				$title,
				$cap,
				$slug,
				$callback,
				$icon
			);
		}

		foreach ( $menu_config as $parent_item ) {
			if ( empty( $parent_item['submenu'] ) || ! is_array( $parent_item['submenu'] ) ) {
				continue;
			}

			$parent_menu_slug = $this->getItemSlug( $parent_item );
			if ( $parent_menu_slug === '' ) {
				continue;
			}

			$visited_register_slugs = [];
			$this->registerCustomSubmenuTree( $parent_menu_slug, $parent_item['submenu'], $visited_register_slugs, $registered_slugs );
		}
	}

	/**
	 * @param array<int, array<string, mixed>> $submenu_items
	 * @param array<string, true>              $visited_slugs
	 * @param array<string, true>              $registered_slugs
	 */
	private function registerCustomSubmenuTree( string $parent_slug, array $submenu_items, array &$visited_slugs = [], array &$registered_slugs = [] ): void {
		if ( $parent_slug !== '' ) {
			$visited_slugs[ $parent_slug ] = true;
		}

		foreach ( $submenu_items as $sub_item ) {
			if ( empty( $sub_item['is_custom'] ) ) {
				$child_slug = ! empty( $sub_item['menu_slug'] ) ? $this->sanitizeMenuSlug( (string) $sub_item['menu_slug'] ) : '';
				if (
					$child_slug !== ''
					&& ! empty( $sub_item['submenu'] )
					&& is_array( $sub_item['submenu'] )
					&& $this->shouldLoadNestedSubmenuBranch( $child_slug, $parent_slug, $visited_slugs )
				) {
					$this->registerCustomSubmenuTree( $child_slug, $sub_item['submenu'], $visited_slugs, $registered_slugs );
				}
				continue;
			}

			if ( $this->shouldRemoveMenuItemForCurrentUser( $sub_item ) || ! $this->userCanSeeCustomItem( $sub_item ) ) {
				continue;
			}

			$sub_slug = $this->resolveCustomMenuSlug( $sub_item );
			$sub_url  = $this->getCustomMenuTargetUrl( $sub_item );
			if ( $sub_slug === '' || isset( $registered_slugs[ $sub_slug ] ) ) {
				continue;
			}

			$registered_slugs[ $sub_slug ] = true;

			$sub_title = trim( wp_strip_all_tags( $sub_item['title'] ?? __( 'Custom Link', 'wp-powersuite' ) ) );
			$sub_cap   = $sub_item['capability'] ?? 'read';

			add_submenu_page(
				$parent_slug,
				$sub_title,
				$sub_title,
				$sub_cap,
				$sub_slug,
				static function () use ( $sub_url ): void {
					if ( $sub_url !== '' ) {
						wp_safe_redirect( $sub_url );
						exit;
					}
				}
			);

			if (
				! empty( $sub_item['submenu'] )
				&& is_array( $sub_item['submenu'] )
				&& $this->shouldLoadNestedSubmenuBranch( $sub_slug, $parent_slug, $visited_slugs )
			) {
				$this->registerCustomSubmenuTree( $sub_slug, $sub_item['submenu'], $visited_slugs, $registered_slugs );
			}
		}
	}

	public function printAdminMenuOrganizerCss(): void {
		$menu_config = $this->getSettings()['menu_config'] ?? null;
		if ( empty( $menu_config ) || ! is_array( $menu_config ) ) {
			return;
		}
		?>
		<style>
			#adminmenu .wpps-soft-hidden { display: none !important; }
			#adminmenu.wpps-show-hidden .wpps-soft-hidden { display: block !important; }
			#adminmenu .wpps-show-all-toggle { border-top: 1px solid rgba(255,255,255,.08); margin-top: 4px; }
			#adminmenu .wpps-show-all-toggle > a { font-style: italic; }
			#adminmenu .wp-submenu li.wpps-flattened-child > a { padding-left: 24px; }
		</style>
		<?php
	}

	public function maybeRestrictScreenAccess(): void {
		if ( ! is_admin() || ! is_user_logged_in() ) {
			return;
		}

		$menu_config = $this->getSettings()['menu_config'] ?? null;
		if ( empty( $menu_config ) || ! is_array( $menu_config ) ) {
			return;
		}

		$page_id = $this->getCurrentAdminPageUrlFragment();
		if ( $page_id === '' ) {
			return;
		}

		if ( $this->menuConfigBlocksPageAccess( $menu_config, $page_id ) ) {
			wp_die( esc_html__( 'Sorry, you are not allowed to access this page.', 'wp-powersuite' ), '', [ 'response' => 403 ] );
		}
	}

	/**
	 * @param array<int, array<string, mixed>> $menu_config
	 */
	private function menuConfigBlocksPageAccess( array $menu_config, string $page_id ): bool {
		foreach ( $menu_config as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			if ( $this->menuItemBlocksPageAccess( $page_id, $item ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * @param array<string, mixed> $item
	 */
	private function menuItemBlocksPageAccess( string $page_id, array $item ): bool {
		if ( $this->pageMatchesMenuItem( $page_id, $item ) ) {
			if ( $this->shouldRemoveMenuItemForCurrentUser( $item ) && $this->isHiddenForCurrentUser( $item ) ) {
				return true;
			}
		}

		if ( ! empty( $item['submenu'] ) && is_array( $item['submenu'] ) ) {
			foreach ( $item['submenu'] as $sub_item ) {
				if ( is_array( $sub_item ) && $this->menuItemBlocksPageAccess( $page_id, $sub_item ) ) {
					return true;
				}
			}
		}

		return false;
	}

	private function getCurrentAdminPageUrlFragment(): string {
		global $wp;

		$current = add_query_arg( $wp->query_vars ?? [] );

		if ( $current === '/wp-admin/' || $current === '/wp-admin/index.php' ) {
			return 'index.php';
		}

		if ( str_contains( $current, '?page=' ) ) {
			$parts = explode( '?page=', $current, 2 );
			$page  = $parts[1] ?? '';

			return $page;
		}

		$fragment = ltrim( str_replace( '/wp-admin/', '', $current ), '/' );
		// Keep query string for post_type etc.; pageMatches compares full fragment or slug&.
		return $fragment;
	}

	private function pageMatchesMenuItem( string $page_id, array $item ): bool {
		$slug = $this->getItemSlug( $item );
		if ( $slug === '' ) {
			return false;
		}

		if ( $page_id === $slug ) {
			return true;
		}

		// Allow match when WP adds extra query args after the page slug.
		return str_starts_with( $page_id, $slug . '&' );
	}

	public function handleSearchUsers(): void {
		check_ajax_referer( 'wpps_menu_organizer', 'nonce' );
		if ( ! $this->canManagePlugin() || ! current_user_can( 'list_users' ) ) {
			wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'wp-powersuite' ) ] );
		}

		$term = isset( $_POST['term'] ) ? sanitize_text_field( wp_unslash( $_POST['term'] ) ) : '';
		if ( strlen( $term ) < 2 ) {
			wp_send_json_success( [ 'users' => [] ] );
		}

		$query = new \WP_User_Query( [
			'search'         => '*' . $term . '*',
			'search_columns' => [ 'user_login', 'user_email', 'display_name' ],
			'number'         => 15,
			'orderby'        => 'display_name',
			'order'          => 'ASC',
			'fields'         => [ 'ID', 'user_login', 'display_name' ],
		] );

		$seen_ids = [];
		$results  = [];
		foreach ( $query->get_results() as $user ) {
			$id = (int) $user->ID;
			if ( isset( $seen_ids[ $id ] ) ) {
				continue;
			}
			$seen_ids[ $id ] = true;
			$results[]      = [
				'id'           => $id,
				'display_name' => $user->display_name,
				'user_login'   => $user->user_login,
			];
		}

		wp_send_json_success( [ 'users' => $results ] );
	}

	private function sanitizeMenuConfig( array $config ): array {
		foreach ( $config as $key => $item ) {
			if ( ! is_array( $item ) ) {
				unset( $config[ $key ] );
				continue;
			}
			$config[ $key ] = $this->sanitizeMenuConfigItem( $item );
			if (
				! empty( $config[ $key ]['is_separator'] )
				&& $this->isNativeWordPressSeparatorSlug( (string) ( $config[ $key ]['menu_slug'] ?? '' ) )
			) {
				unset( $config[ $key ] );
			}
		}

		$config = $this->dedupeMenuConfig( array_values( $config ) );

		return $this->normalizeMenuConfigPositions( $config );
	}

	/**
	 * @param array<int, array<string, mixed>> $config
	 * @return array<int, array<string, mixed>>
	 */
	private function dedupeMenuConfig( array $config ): array {
		$seen   = [];
		$result = [];

		foreach ( $config as $item ) {
			$slug = $this->getItemSlug( $item );
			if ( $slug !== '' && isset( $seen[ $slug ] ) ) {
				continue;
			}
			if ( $slug !== '' ) {
				$seen[ $slug ] = true;
			}
			if ( ! empty( $item['submenu'] ) && is_array( $item['submenu'] ) ) {
				$item['submenu'] = $this->dedupeSubmenuConfig( $item['submenu'] );
			}
			$result[] = $item;
		}

		return $result;
	}

	/**
	 * @param array<int, array<string, mixed>> $submenu
	 * @return array<int, array<string, mixed>>
	 */
	private function dedupeSubmenuConfig( array $submenu ): array {
		$seen   = [];
		$result = [];

		foreach ( $submenu as $sub ) {
			$slug = $this->getItemSlug( $sub );
			if ( $slug !== '' && isset( $seen[ $slug ] ) ) {
				continue;
			}
			if ( $slug !== '' ) {
				$seen[ $slug ] = true;
			}
			if ( ! empty( $sub['submenu'] ) && is_array( $sub['submenu'] ) ) {
				$sub['submenu'] = $this->dedupeSubmenuConfig( $sub['submenu'] );
			}
			$result[] = $sub;
		}

		return $result;
	}

	/**
	 * @param array<int, array<string, mixed>> $config
	 * @return array<int, array<string, mixed>>
	 */
	private function normalizeMenuConfigPositions( array $config ): array {
		foreach ( $config as $index => &$item ) {
			$item['position'] = $index;
			if ( ! empty( $item['submenu'] ) && is_array( $item['submenu'] ) ) {
				foreach ( $item['submenu'] as $sub_index => &$sub ) {
					$sub['position'] = $sub_index;
				}
				unset( $sub );
			}
		}
		unset( $item );

		return $config;
	}

	private function sanitizeMenuConfigItem( array $item ): array {
		if ( isset( $item['parent_slug'] ) ) {
			$item['parent_slug'] = $this->sanitizeMenuSlug( (string) $item['parent_slug'] );
		}
		if ( isset( $item['submenu_child_order'] ) && is_array( $item['submenu_child_order'] ) ) {
			$ordered = [];
			foreach ( $item['submenu_child_order'] as $ordered_slug ) {
				$clean = $this->sanitizeMenuSlug( (string) $ordered_slug );
				if ( $clean !== '' && ! in_array( $clean, $ordered, true ) ) {
					$ordered[] = $clean;
				}
			}
			$item['submenu_child_order'] = $ordered;
		} else {
			unset( $item['submenu_child_order'] );
		}
		if ( isset( $item['title'] ) ) {
			$item['title'] = sanitize_text_field( $item['title'] );
		}
		if ( isset( $item['page_title'] ) ) {
			$item['page_title'] = sanitize_text_field( $item['page_title'] );
		}
		if ( isset( $item['menu_slug'] ) ) {
			$item['menu_slug'] = $this->sanitizeMenuSlug( (string) $item['menu_slug'] );
		}
		if ( isset( $item['url'] ) ) {
			$url = (string) $item['url'];
			$item['url'] = preg_match( '#^https?://#i', $url ) ? ( esc_url_raw( $url ) ?: '' ) : '';
		}
		if ( isset( $item['icon'] ) ) {
			$item['icon'] = $this->sanitizeMenuIcon( (string) $item['icon'] );
		}
		if ( isset( $item['capability'] ) ) {
			$item['capability'] = $this->sanitizeMenuCapability( (string) $item['capability'] );
		}
		if ( ! empty( $item['hide_rules'] ) && is_array( $item['hide_rules'] ) ) {
			$mode = $item['hide_rules']['mode'] ?? 'remove';
			$item['hide_rules']['mode'] = in_array( $mode, [ 'toggle', 'remove' ], true ) ? $mode : 'remove';
			if ( isset( $item['hide_rules']['users'] ) && is_array( $item['hide_rules']['users'] ) ) {
				$item['hide_rules']['users'] = array_values( array_filter( array_map( 'absint', $item['hide_rules']['users'] ) ) );
			}
			if ( isset( $item['hide_rules']['roles'] ) && is_array( $item['hide_rules']['roles'] ) ) {
				$item['hide_rules']['roles'] = array_values( array_filter( array_map( 'sanitize_key', $item['hide_rules']['roles'] ) ) );
			}
		}
		if ( ! empty( $item['submenu'] ) && is_array( $item['submenu'] ) ) {
			foreach ( $item['submenu'] as $sk => $sub ) {
				if ( is_array( $sub ) ) {
					$item['submenu'][ $sk ] = $this->sanitizeMenuConfigItem( $sub );
				}
			}
		}

		if ( ! empty( $item['is_custom'] ) ) {
			$target_url = $this->getCustomMenuTargetUrl( $item );
			if ( $target_url !== '' ) {
				$item['url'] = $target_url;
			}
			$item['menu_slug'] = $this->resolveCustomMenuSlug( $item );
			if ( ! $this->isWppsCustomMenuSlug( $item['menu_slug'] ) ) {
				unset( $item['is_custom'], $item['url'] );
			} else {
				$item['is_custom'] = true;
			}
		} else {
			unset( $item['is_custom'], $item['url'] );
		}

		if ( isset( $item['eligible_roles'] ) && is_array( $item['eligible_roles'] ) ) {
			$item['eligible_roles'] = $this->sanitizeEligibleRoles( $item['eligible_roles'] );
		}

		return $item;
	}

	/**
	 * @param array<string, mixed> $eligible_roles
	 * @return array<string, string>
	 */
	private function sanitizeEligibleRoles( array $eligible_roles ): array {
		$role_names = wp_roles()->get_names();
		$sanitized  = [];

		foreach ( $eligible_roles as $role_key => $label ) {
			$key = sanitize_key( (string) $role_key );
			if ( $key === '' || ! isset( $role_names[ $key ] ) ) {
				continue;
			}
			$sanitized[ $key ] = is_string( $label ) ? sanitize_text_field( $label ) : (string) $role_names[ $key ];
		}

		return $sanitized;
	}

	/**
	 * @param array<int, array<string, mixed>> $config
	 */
	private function countMenuConfigItems( array $config ): int {
		$count = 0;
		$this->countMenuConfigItemsRecursive( $config, $count );

		return $count;
	}

	/**
	 * @param array<int, array<string, mixed>> $items
	 */
	private function countMenuConfigItemsRecursive( array $items, int &$count ): void {
		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			++$count;
			if ( ! empty( $item['submenu'] ) && is_array( $item['submenu'] ) ) {
				$this->countMenuConfigItemsRecursive( $item['submenu'], $count );
			}
		}
	}

	private function sanitizeMenuIcon( string $icon ): string {
		$icon = trim( $icon );
		if ( $icon === '' || $icon === 'none' || $icon === 'div' ) {
			return $icon;
		}

		if ( preg_match( '/^dashicons-[a-zA-Z0-9_\-]+$/', $icon ) ) {
			return $icon;
		}

		// WordPress / plugin SVG data-URI menu icons (common in WP 6+).
		if ( preg_match( '/^data:image\/svg\+xml/i', $icon ) ) {
			if ( strlen( $icon ) <= 100000 && ! preg_match( '/<script/i', $icon ) ) {
				return $icon;
			}

			return '';
		}

		// Site-relative icon path.
		if ( str_starts_with( $icon, '/' ) ) {
			$absolute = home_url( $icon );
			$clean    = esc_url_raw( $absolute );

			return is_string( $clean ) && $clean !== '' ? $clean : '';
		}

		$url = esc_url_raw( $icon );
		if ( ! is_string( $url ) || $url === '' ) {
			return '';
		}

		$site_host = wp_parse_url( home_url(), PHP_URL_HOST );
		$icon_host = wp_parse_url( $url, PHP_URL_HOST );
		if ( $site_host && $icon_host && strtolower( (string) $site_host ) === strtolower( (string) $icon_host ) ) {
			return $url;
		}

		// Keep empty rather than forcing a wrong generic icon onto every item.
		return '';
	}

	private function sanitizeMenuCapability( string $capability ): string {
		$capability = sanitize_key( $capability );
		if ( $capability === '' ) {
			return 'read';
		}

		static $allowed_capabilities = null;
		if ( $allowed_capabilities === null ) {
			$allowed_capabilities = [];
			foreach ( wp_roles()->roles as $role ) {
				if ( empty( $role['capabilities'] ) || ! is_array( $role['capabilities'] ) ) {
					continue;
				}
				foreach ( array_keys( $role['capabilities'] ) as $cap ) {
					$allowed_capabilities[ sanitize_key( $cap ) ] = true;
				}
			}
		}

		return isset( $allowed_capabilities[ $capability ] ) ? $capability : 'read';
	}

	private function getAssetUrl( string $filename ): string {
		$plugin_url = defined( 'WP_POWERSUITE_PLUGIN_URL' ) ? WP_POWERSUITE_PLUGIN_URL : '';
		$base_url = $plugin_url . 'modules/' . self::MODULE_ID . '/assets/';
		return $base_url . $filename;
	}

	private function getSettings(): array {
		if ( $this->settings_cache !== null ) {
			return $this->settings_cache;
		}

		$this->settings_cache = [
			'menu_config' => $this->loadMenuConfig(),
		];

		return $this->settings_cache;
	}

	/**
	 * Load menu config from its dedicated option (autoload=no), not the shared OptionStore blob.
	 *
	 * @return array<int, array<string, mixed>>|null
	 */
	private function loadMenuConfig(): ?array {
		$this->maybeMigrateMenuConfigFromOptionStore();

		$config = get_option( self::MENU_CONFIG_OPTION, null );
		if ( is_array( $config ) && $config !== [] ) {
			return $config;
		}

		return null;
	}

	/**
	 * @param array<int, array<string, mixed>> $menu_config
	 */
	private function saveMenuConfig( array $menu_config ): bool {
		$this->settings_cache = null;

		return update_option( self::MENU_CONFIG_OPTION, $menu_config, false );
	}

	private function deleteMenuConfig(): bool {
		$this->settings_cache = null;

		return delete_option( self::MENU_CONFIG_OPTION );
	}

	/**
	 * Move legacy menu_config out of wp_powersuite_settings so it is not loaded on every request.
	 */
	private function maybeMigrateMenuConfigFromOptionStore(): void {
		if ( get_option( self::MENU_CONFIG_MIGRATED_FLAG, '' ) === '1' ) {
			return;
		}

		$existing = get_option( self::MENU_CONFIG_OPTION, null );
		if ( is_array( $existing ) && $existing !== [] ) {
			update_option( self::MENU_CONFIG_MIGRATED_FLAG, '1', false );
			return;
		}

		$settings = $this->moduleSettings()->all( [] );
		$legacy   = is_array( $settings ) ? ( $settings['menu_config'] ?? null ) : null;
		if ( is_array( $legacy ) && $legacy !== [] ) {
			update_option( self::MENU_CONFIG_OPTION, $legacy, false );
			unset( $settings['menu_config'] );
			$this->moduleSettings()->save( $settings );
		}

		update_option( self::MENU_CONFIG_MIGRATED_FLAG, '1', false );
	}

	/**
	 * Log handler failures only when WP_DEBUG is enabled.
	 */
	private function logHandlerException( string $context, \Exception $e ): void {
		if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
			return;
		}

		error_log( $context . ': ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
	}
}
