<?php
/**
 * Menu Builder Admin UI
 *
 * Handles the visual menu builder page, the live menu snapshot, the runtime
 * config apply hook, and all builder-related AJAX endpoints.
 *
 * @package Agency_Pulse_Admin_Menu
 * @since   1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class Agency_Pulse_Admin_Menu_Builder {

	/**
	 * @var Agency_Pulse_Admin_Menu_Settings_Manager
	 */
	private $settings_manager;

	/**
	 * Raw $menu captured at admin_menu priority 998 — before apply_menu_config()
	 * mutates it at priority 999 with custom labels. Used by get_live_menu_snapshot()
	 * so that original_title always reflects the true WordPress-registered title,
	 * not whatever label was previously saved.
	 *
	 * @var array|null
	 */
	private $raw_menu = null;

	/**
	 * Raw $submenu captured at the same time as $raw_menu.
	 *
	 * @var array|null
	 */
	private $raw_submenu = null;

	/**
	 * @param Agency_Pulse_Admin_Menu_Settings_Manager $settings_manager
	 */
	public function __construct( Agency_Pulse_Admin_Menu_Settings_Manager $settings_manager ) {
		$this->settings_manager = $settings_manager;
	}

	// -------------------------------------------------------------------------
	// Admin page rendering
	// -------------------------------------------------------------------------

	/**
	 * Render the Menu Builder admin page.
	 *
	 * Resolves the active configuration, builds a merged item list from the live
	 * snapshot and the saved config, then hands everything to the template.
	 *
	 * @since 1.0.0
	 */
	public function render_builder_page(): void {
		if ( ! current_user_can( Agency_Pulse_Admin_Menu_Constants::CAP_MANAGE_MENUS ) ) {
			wp_die( esc_html__( 'You do not have permission to access this page.', 'agency-pulse-admin-menu' ) );
		}

		$all_configs      = $this->settings_manager->get_all_configs();
		$active_config_id = $this->resolve_active_config_id( $all_configs );
		$active_config    = isset( $all_configs[ $active_config_id ] ) ? $all_configs[ $active_config_id ] : array();
		$active_config_name = isset( $active_config['name'] ) ? $active_config['name'] : '';
		$saved_config     = $this->settings_manager->get_config_items( $active_config_id );
		$snapshot         = $this->get_live_menu_snapshot();
		$merged_items     = $this->build_merged_list( $snapshot, $saved_config );
		$all_roles        = wp_roles()->get_names();
		$assignments      = $this->settings_manager->get_role_assignments();

		include AGENCY_PULSE_ADMIN_MENU_DIR . 'templates/admin-page.php';
	}

	/**
	 * Determine which config should be active in the builder.
	 *
	 * Priority: explicit URL param → current user's role assignment → first config.
	 * Public so that class-plugin.php can call it during asset enqueue, which fires
	 * before the page render method runs.
	 *
	 * @since 1.0.0
	 * @param array $all_configs All saved named configurations.
	 * @return string Config ID, or empty string if no configs exist.
	 */
	public function resolve_active_config_id( array $all_configs ): string {
		if ( empty( $all_configs ) ) {
			return '';
		}

		// 1. Explicit URL parameter.
		if ( ! empty( $_GET['config_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
			$requested = sanitize_key( wp_unslash( $_GET['config_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
			if ( isset( $all_configs[ $requested ] ) ) {
				return $requested;
			}
		}

		// 2. Config assigned to the current user's role.
		$assignments = $this->settings_manager->get_role_assignments();
		$user        = wp_get_current_user();
		foreach ( $user->roles as $role ) {
			if ( isset( $assignments[ $role ], $all_configs[ $assignments[ $role ] ] ) ) {
				return $assignments[ $role ];
			}
		}

		// 3. First config in the collection.
		reset( $all_configs );
		return (string) key( $all_configs );
	}

	/**
	 * Merge the live snapshot with the saved config into a single ordered list
	 * ready for the template to render.
	 *
	 * Algorithm:
	 *  1. If no saved config, convert the raw snapshot directly.
	 *  2. Otherwise, process saved config items in their saved position order.
	 *     Native items are annotated with snapshot data; custom containers use
	 *     config data only. Items in the config that no longer exist on the site
	 *     (plugin deactivated) are skipped silently.
	 *  3. Append any snapshot items not referenced in the saved config, in their
	 *     natural snapshot order. These are items from newly installed plugins.
	 *
	 * Sub-items (children) follow the same pattern within each parent.
	 * A global sub-item index spanning all snapshot parents handles reparented
	 * sub-items — an item moved to a different parent in the config can still
	 * be annotated with its original snapshot title and data.
	 *
	 * @since 1.0.0
	 * @param array $snapshot    Output of get_live_menu_snapshot().
	 * @param array $saved_config Saved items array for the active config.
	 * @return array Merged item list for the template.
	 */
	private function build_merged_list( array $snapshot, array $saved_config ): array {
		// The organizer's own menu item is always last via push_organizer_last() and is
		// not user-configurable, so strip it from both lists before building the UI.
		$organizer_slug = Agency_Pulse_Admin_Menu_Constants::PAGE_MENU_BUILDER;
		$snapshot = array_values( array_filter( $snapshot, function ( $item ) use ( $organizer_slug ) {
			return $item['slug'] !== $organizer_slug;
		} ) );
		$saved_config = array_values( array_filter( $saved_config, function ( $item ) use ( $organizer_slug ) {
			return $item['slug'] !== $organizer_slug;
		} ) );

		if ( empty( $saved_config ) ) {
			return $this->annotate_snapshot_as_merged( $snapshot );
		}

		// Build top-level snapshot index.
		$snapshot_index = array();
		foreach ( $snapshot as $snap_item ) {
			$snapshot_index[ $snap_item['slug'] ] = $snap_item;
		}

		// Build a flat sub-item snapshot index spanning all parents.
		// Handles reparented sub-items that now live under a different parent.
		$sub_snapshot_index = array();
		foreach ( $snapshot as $snap_item ) {
			foreach ( $snap_item['children'] as $child ) {
				$sub_snapshot_index[ $child['slug'] ] = $child;
			}
		}

		// Build a native parent index: child_slug => parent_slug.
		// Used to return sub-items to their original home when a custom container is removed.
		$sub_parent_index = array();
		foreach ( $snapshot as $snap_item ) {
			foreach ( $snap_item['children'] as $child ) {
				$sub_parent_index[ $child['slug'] ] = $snap_item['slug'];
			}
		}

		// Track which sub-item slugs the saved config has explicitly claimed.
		$claimed_children = array();
		foreach ( $saved_config as $cfg_item ) {
			if ( ! empty( $cfg_item['children'] ) ) {
				foreach ( $cfg_item['children'] as $cfg_child ) {
					$claimed_children[ $cfg_child['slug'] ] = true;
				}
			}
		}

		$merged      = array();
		$config_slugs = array();

		// Pass 1: saved config order.
		foreach ( $saved_config as $cfg_item ) {
			$slug           = $cfg_item['slug'];
			$config_slugs[] = $slug;

			if ( ! empty( $cfg_item['custom_container'] ) ) {
				// Custom container — no snapshot data available.
				$item = array(
					'slug'             => $slug,
					'title'            => ! empty( $cfg_item['label'] ) ? $cfg_item['label'] : __( 'Custom', 'agency-pulse-admin-menu' ),
					'original_title'   => '',
					'icon'             => ! empty( $cfg_item['icon'] ) ? $cfg_item['icon'] : 'dashicons-category',
					'original_icon'    => '',
					'hidden'           => ! empty( $cfg_item['hidden'] ),
					'separator'        => false,
					'custom_container' => true,
					'children'         => array(),
				);
			} elseif ( isset( $snapshot_index[ $slug ] ) ) {
				// Native item present in both config and snapshot.
				$snap = $snapshot_index[ $slug ];
				$item = array(
					'slug'             => $slug,
					'title'            => ! empty( $cfg_item['label'] ) ? $cfg_item['label'] : $snap['title'],
					'original_title'   => $snap['title'],
					'icon'             => ! empty( $cfg_item['icon'] ) ? $cfg_item['icon'] : $snap['icon'],
					'original_icon'    => $snap['icon'],
					'hidden'           => ! empty( $cfg_item['hidden'] ),
					'separator'        => $snap['separator'],
					'custom_container' => false,
					'children'         => array(),
				);
			} else {
				// In config but no longer in the live menu (plugin deactivated). Skip.
				continue;
			}

			// Build children for this item.
			$item['children'] = $this->build_children(
				! empty( $cfg_item['children'] ) ? $cfg_item['children'] : array(),
				! empty( $snapshot_index[ $slug ] ) ? $snapshot_index[ $slug ]['children'] : array(),
				$sub_snapshot_index,
				$claimed_children
			);

			$merged[] = $item;
		}

		// Pass 2: snapshot items not in the saved config (newly installed plugins).
		foreach ( $snapshot as $snap_item ) {
			if ( in_array( $snap_item['slug'], $config_slugs, true ) ) {
				continue;
			}
			$item = array(
				'slug'             => $snap_item['slug'],
				'title'            => $snap_item['title'],
				'original_title'   => $snap_item['title'],
				'icon'             => $snap_item['icon'],
				'original_icon'    => $snap_item['icon'],
				'hidden'           => false,
				'separator'        => $snap_item['separator'],
				'custom_container' => false,
				'children'         => array(),
			);
			foreach ( $snap_item['children'] as $snap_child ) {
				$item['children'][] = array(
					'slug'   => $snap_child['slug'],
					'title'  => $snap_child['title'],
					'hidden' => false,
				);
			}
			$merged[] = $item;
		}

		// Annotate custom container children with their native original parent slug.
		foreach ( $merged as &$item ) {
			if ( ! $item['custom_container'] ) {
				continue;
			}
			foreach ( $item['children'] as &$child ) {
				$child['original_parent'] = isset( $sub_parent_index[ $child['slug'] ] )
					? $sub_parent_index[ $child['slug'] ] : '';
			}
			unset( $child );
		}
		unset( $item );

		return $merged;
	}

	/**
	 * Build the children array for one merged top-level item.
	 *
	 * Config children are processed first (in saved order), annotated with
	 * snapshot data from the global sub-item index to handle reparented items.
	 * Snapshot children for this parent that are not claimed by any config
	 * parent are appended at the end.
	 *
	 * @since 1.0.0
	 * @param array $config_children    Children from the saved config for this parent.
	 * @param array $snapshot_children  Children from the snapshot for this parent.
	 * @param array $sub_snapshot_index Global flat index of all snapshot sub-items.
	 * @param array $claimed_children   Set of sub-item slugs claimed by any config parent.
	 * @return array
	 */
	private function build_children(
		array $config_children,
		array $snapshot_children,
		array $sub_snapshot_index,
		array &$claimed_children
	): array {
		$children = array();

		// Config children first, in saved order.
		foreach ( $config_children as $cfg_child ) {
			$child_slug     = $cfg_child['slug'];
			$snap_child     = isset( $sub_snapshot_index[ $child_slug ] ) ? $sub_snapshot_index[ $child_slug ] : null;
			$original_title = $snap_child ? $snap_child['title'] : $child_slug;
			$children[] = array(
				'slug'           => $child_slug,
				'title'          => ! empty( $cfg_child['label'] ) ? $cfg_child['label'] : $original_title,
				'original_title' => $original_title,
				'hidden'         => ! empty( $cfg_child['hidden'] ),
			);
		}

		// Append unclaimed snapshot children for this parent.
		foreach ( $snapshot_children as $snap_child ) {
			if ( isset( $claimed_children[ $snap_child['slug'] ] ) ) {
				continue;
			}
			$children[]                             = array(
				'slug'           => $snap_child['slug'],
				'title'          => $snap_child['title'],
				'original_title' => $snap_child['title'],
				'hidden'         => false,
			);
			$claimed_children[ $snap_child['slug'] ] = true;
		}

		return $children;
	}

	/**
	 * Convert a raw snapshot directly into the merged format.
	 *
	 * Used when no saved config exists — every item appears as-is with no
	 * customisations applied.
	 *
	 * @since 1.0.0
	 * @param array $snapshot Output of get_live_menu_snapshot().
	 * @return array
	 */
	private function annotate_snapshot_as_merged( array $snapshot ): array {
		$merged = array();
		foreach ( $snapshot as $snap_item ) {
			$item = array(
				'slug'             => $snap_item['slug'],
				'title'            => $snap_item['title'],
				'original_title'   => $snap_item['title'],
				'icon'             => $snap_item['icon'],
				'original_icon'    => $snap_item['icon'],
				'hidden'           => false,
				'separator'        => $snap_item['separator'],
				'custom_container' => false,
				'children'         => array(),
			);
			foreach ( $snap_item['children'] as $snap_child ) {
				$item['children'][] = array(
					'slug'           => $snap_child['slug'],
					'title'          => $snap_child['title'],
					'original_title' => $snap_child['title'],
					'hidden'         => false,
				);
			}
			$merged[] = $item;
		}
		return $merged;
	}

	// -------------------------------------------------------------------------
	// Live menu snapshot
	// -------------------------------------------------------------------------

	/**
	 * Produce a clean, serialisable representation of the current admin menu.
	 *
	 * Reads the global $menu and $submenu arrays after all admin_menu callbacks
	 * have fired, normalises labels by stripping HTML badge markup, and sorts
	 * both arrays by their numeric position keys so the snapshot order matches
	 * the visual sidebar order.
	 *
	 * @since 1.0.0
	 * @return array[] Top-level items, each with a 'children' key.
	 */
	/**
	 * Snapshot $menu and $submenu before apply_menu_config() modifies them.
	 *
	 * Hooked to admin_menu at priority 998 (one before apply_menu_config at 999).
	 * Stores a shallow copy of both globals so get_live_menu_snapshot() can read
	 * the original WordPress-registered titles rather than any previously saved
	 * custom labels.
	 *
	 * @since 1.0.0
	 */
	public function capture_pre_apply_state(): void {
		global $menu, $submenu;
		if ( is_array( $menu ) ) {
			$this->raw_menu    = $menu;
			$this->raw_submenu = is_array( $submenu ) ? $submenu : array();
		}
	}

	public function get_live_menu_snapshot(): array {
		// Use the pre-apply copy if available; fall back to live globals otherwise.
		$source_menu    = $this->raw_menu ?? null;
		$source_submenu = $this->raw_submenu ?? null;

		if ( null === $source_menu ) {
			global $menu, $submenu;
			$source_menu    = $menu;
			$source_submenu = $submenu;
		}

		$snapshot = array();

		if ( ! is_array( $source_menu ) ) {
			return $snapshot;
		}

		// Sort by numeric position key so snapshot order matches sidebar order.
		$sorted_menu = $source_menu;
		ksort( $sorted_menu );

		foreach ( $sorted_menu as $position => $item ) {
			$slug = isset( $item[2] ) ? $item[2] : '';

			$entry = array(
				'position'   => (int) $position,
				'title'      => isset( $item[0] ) ? $this->clean_menu_title( $item[0] ) : '',
				'capability' => isset( $item[1] ) ? $item[1] : '',
				'slug'       => $slug,
				'page_title' => isset( $item[3] ) ? $item[3] : '',
				'css_class'  => isset( $item[4] ) ? $item[4] : '',
				'icon'       => isset( $item[6] ) ? $item[6] : '',
				'separator'  => strpos( $slug, 'separator' ) !== false,
				'children'   => array(),
			);

			if ( isset( $source_submenu[ $slug ] ) && is_array( $source_submenu[ $slug ] ) ) {
				$sorted_sub = $source_submenu[ $slug ];
				ksort( $sorted_sub );
				foreach ( $sorted_sub as $sub_position => $sub_item ) {
					$entry['children'][] = array(
						'position'   => (int) $sub_position,
						'title'      => isset( $sub_item[0] ) ? $this->clean_menu_title( $sub_item[0] ) : '',
						'capability' => isset( $sub_item[1] ) ? $sub_item[1] : '',
						'slug'       => isset( $sub_item[2] ) ? $sub_item[2] : '',
						'page_title' => isset( $sub_item[3] ) ? $sub_item[3] : '',
					);
				}
			}

			$snapshot[] = $entry;
		}

		return $snapshot;
	}

	/**
	 * Strip notification badge spans (and their text) from a raw menu title.
	 *
	 * WordPress appends HTML badge spans to certain menu titles at runtime, e.g.:
	 *   "Plugins <span class="update-plugins count-2"><span class="plugin-count">2</span></span>"
	 *
	 * wp_strip_all_tags() removes the tags but keeps the inner text, leaving "Plugins 2".
	 * This method iteratively removes span elements AND their content (innermost first),
	 * then delegates to wp_strip_all_tags() for any remaining markup.
	 *
	 * @since 1.0.0
	 * @param string $raw Raw menu title from $menu[i][0].
	 * @return string Clean display label.
	 */
	private function clean_menu_title( string $raw ): string {
		$title = $raw;
		$prev  = null;

		// Each pass strips the innermost spans (those containing only text, no child
		// elements). Repeating until stable handles arbitrary nesting depth.
		while ( $prev !== $title ) {
			$prev  = $title;
			$title = preg_replace( '/<span\b[^>]*>[^<]*<\/span>/i', '', $title );
		}

		return trim( wp_strip_all_tags( $title ) );
	}

	// -------------------------------------------------------------------------
	// Runtime config apply
	// -------------------------------------------------------------------------

	/**
	 * Apply the saved menu configuration to the live WordPress menu globals.
	 *
	 * Hooked to admin_menu at priority 999 — runs after every plugin has
	 * registered its items. Mutates $menu and $submenu in-place; WordPress then
	 * renders the sidebar from the modified arrays with no further plugin involvement.
	 *
	 * Skips application on plugin-owned pages so the builder always shows
	 * the full unmodified menu (including hidden items the admin may want to restore).
	 *
	 * See MENU_APPLY_DESIGN.md for the full design rationale.
	 *
	 * @since 1.0.0
	 */
	public function apply_menu_config(): void {
		global $menu, $submenu;

		// Step 1 — Resolve the config for the current user.
		$config = apply_filters(
			Agency_Pulse_Admin_Menu_Constants::FILTER_MENU_CONFIG,
			$this->settings_manager->get_config_for_user( wp_get_current_user() )
		);

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

		do_action( Agency_Pulse_Admin_Menu_Constants::HOOK_BEFORE_APPLY_CONFIG, $config );

		// Step 2 — Register any custom containers so they exist in $menu before
		// the sort step rewrites the array order.
		$this->register_custom_containers( $config );

		// Step 3 — Build a slug-keyed config index for O(1) lookup in later steps.
		$config_index = array();
		foreach ( $config as $i => $cfg_item ) {
			$config_index[ $cfg_item['slug'] ] = array_merge( $cfg_item, array( 'position' => $i ) );
		}

		// Step 4 — Build a flat sub-item map: child_slug => [ parent_slug, submenu_entry ].
		// Spans all $submenu parents so reparented items can be located regardless of
		// which parent they originally came from.
		$sub_map = array();
		foreach ( $submenu as $parent_slug => $sub_entries ) {
			foreach ( $sub_entries as $sub_entry ) {
				$child_slug = $sub_entry[2] ?? '';
				if ( ! empty( $child_slug ) && ! isset( $sub_map[ $child_slug ] ) ) {
					$sub_map[ $child_slug ] = array(
						'parent_slug'   => $parent_slug,
						'submenu_entry' => $sub_entry,
					);
				}
			}
		}

		// Step 5 — Move reparented sub-items to their new parents.
		// A sub-item is reparented when its config parent differs from its original
		// WordPress parent in the sub-item map.
		foreach ( $config_index as $parent_slug => $parent_cfg ) {
			if ( empty( $parent_cfg['children'] ) ) {
				continue;
			}
			foreach ( $parent_cfg['children'] as $cfg_child ) {
				$child_slug = $cfg_child['slug'];

				if ( ! isset( $sub_map[ $child_slug ] ) ) {
					continue;
				}

				$original_parent = $sub_map[ $child_slug ]['parent_slug'];
				if ( $original_parent === $parent_slug ) {
					continue; // Not reparented — nothing to move.
				}

				// Remove from original parent.
				if ( isset( $submenu[ $original_parent ] ) ) {
					foreach ( $submenu[ $original_parent ] as $key => $entry ) {
						if ( ( $entry[2] ?? '' ) === $child_slug ) {
							unset( $submenu[ $original_parent ][ $key ] );
							break;
						}
					}
				}

				// Insert into new parent (creating the $submenu slot if needed).
				if ( ! isset( $submenu[ $parent_slug ] ) ) {
					$submenu[ $parent_slug ] = array();
				}
				$submenu[ $parent_slug ][] = $sub_map[ $child_slug ]['submenu_entry'];
			}
		}

		// Step 6 — Reorder top-level $menu via WordPress's official custom menu order API.
		// The menu_order filter fires in wp-admin/includes/menu.php after admin_menu
		// completes, so registering here ensures it runs at exactly the right time.
		// Items not in the config (newly installed plugins) are appended in their
		// natural WordPress order.
		$ordered_slugs = array_keys( $config_index );
		add_filter( 'custom_menu_order', '__return_true' );
		add_filter( 'menu_order', function ( $current_order ) use ( $ordered_slugs ) {
			$result = $ordered_slugs;
			foreach ( $current_order as $slug ) {
				if ( ! in_array( $slug, $ordered_slugs, true ) ) {
					$result[] = $slug;
				}
			}
			return $result;
		} );

		// Step 7 — Apply custom top-level labels and icons.
		foreach ( $menu as &$menu_item ) {
			$slug = $menu_item[2] ?? '';
			if ( isset( $config_index[ $slug ]['label'] ) && null !== $config_index[ $slug ]['label'] ) {
				$menu_item[0] = esc_html( $config_index[ $slug ]['label'] );
			}
			if ( isset( $config_index[ $slug ]['icon'] ) && null !== $config_index[ $slug ]['icon'] ) {
				$menu_item[6] = esc_attr( $config_index[ $slug ]['icon'] );
			}
		}
		unset( $menu_item );

		// Step 8 — Hide top-level items marked hidden in the config.
		// The safety list (provided via filter) prevents critical pages from being
		// hidden even if the saved config marks them as such.
		$safety_list = apply_filters(
			Agency_Pulse_Admin_Menu_Constants::FILTER_HIDDEN_ITEMS,
			array( Agency_Pulse_Admin_Menu_Constants::PAGE_MENU_BUILDER )
		);

		foreach ( $config_index as $slug => $cfg_item ) {
			if ( ! empty( $cfg_item['hidden'] ) && ! in_array( $slug, $safety_list, true ) ) {
				remove_menu_page( $slug );
			}
		}

		// Steps 9–11 — For each config parent that has saved children:
		// reorder, apply labels, then hide.
		foreach ( $config_index as $parent_slug => $parent_cfg ) {
			if ( empty( $parent_cfg['children'] ) || ! isset( $submenu[ $parent_slug ] ) ) {
				continue;
			}

			// Build child config index for this parent.
			$child_config = array();
			foreach ( $parent_cfg['children'] as $ci => $cfg_child ) {
				$child_config[ $cfg_child['slug'] ] = array_merge( $cfg_child, array( 'position' => $ci ) );
			}

			// Step 9 — Reorder sub-items. Children absent from config sort to the end.
			usort( $submenu[ $parent_slug ], function ( $a, $b ) use ( $child_config ) {
				$pa = isset( $child_config[ $a[2] ] ) ? $child_config[ $a[2] ]['position'] : PHP_INT_MAX;
				$pb = isset( $child_config[ $b[2] ] ) ? $child_config[ $b[2] ]['position'] : PHP_INT_MAX;
				return $pa <=> $pb;
			} );

			// Step 10 — Apply custom sub-item labels.
			foreach ( $submenu[ $parent_slug ] as &$sub_entry ) {
				$child_slug = $sub_entry[2] ?? '';
				if ( isset( $child_config[ $child_slug ]['label'] ) && null !== $child_config[ $child_slug ]['label'] ) {
					$sub_entry[0] = esc_html( $child_config[ $child_slug ]['label'] );
				}
			}
			unset( $sub_entry );

			// Step 11 — Hide hidden sub-items.
			foreach ( $child_config as $child_slug => $child_cfg ) {
				if ( ! empty( $child_cfg['hidden'] ) ) {
					remove_submenu_page( $parent_slug, $child_slug );
				}
			}
		}

		do_action( Agency_Pulse_Admin_Menu_Constants::HOOK_AFTER_APPLY_CONFIG, $config );
	}

	/**
	 * Register any custom containers in the resolved config as real WordPress
	 * top-level menu pages.
	 *
	 * Called at the start of apply_menu_config() (which runs at admin_menu priority
	 * 999) so the containers exist in $menu before the sort step reorders it.
	 *
	 * Containers use 'read' as their capability so they are visible to any
	 * logged-in user who is assigned this config — access control is enforced at
	 * the child-page level by each child's own capability requirement.
	 *
	 * The page callback redirects to the first accessible child item so that
	 * clicking the container in the sidebar navigates somewhere useful.
	 *
	 * @since 1.0.0
	 * @param array $config Resolved config items array.
	 */
	private function register_custom_containers( array $config ): void {
		global $menu;

		// Build a quick set of already-registered top-level slugs.
		$registered = array();
		foreach ( $menu as $item ) {
			$registered[ $item[2] ] = true;
		}

		foreach ( $config as $cfg_item ) {
			if ( empty( $cfg_item['custom_container'] ) ) {
				continue;
			}

			$slug = $cfg_item['slug'];

			// Already registered (e.g. on a second call or persistent registration).
			if ( isset( $registered[ $slug ] ) ) {
				continue;
			}

			$name = ! empty( $cfg_item['label'] )
				? $cfg_item['label']
				: __( 'Custom', 'agency-pulse-admin-menu' );
			$icon = ! empty( $cfg_item['icon'] ) ? $cfg_item['icon'] : 'dashicons-category';

			// Capture slug for the closure — PHP closures capture by value with `use`.
			$container_slug = $slug;

			add_menu_page(
				$name,
				$name,
				'read',
				$container_slug,
				static function () use ( $container_slug ) {
					// Redirect to the first accessible child when the container is clicked.
					global $submenu;
					if ( ! empty( $submenu[ $container_slug ] ) ) {
						$first = reset( $submenu[ $container_slug ] );
						$url   = $first[2] ?? '';
						if ( ! empty( $url ) ) {
							$redirect = ( false !== strpos( $url, '.php' ) || 0 === strpos( $url, 'http' ) )
								? admin_url( $url )
								: admin_url( 'admin.php?page=' . rawurlencode( $url ) );
							wp_safe_redirect( $redirect );
							exit;
						}
					}
				},
				$icon,
				999 // Temporary position; usort() in apply_menu_config() sets the final order.
			);
		}
	}

	// -------------------------------------------------------------------------
	// AJAX handlers — builder config management
	// -------------------------------------------------------------------------

	/**
	 * AJAX: Save a menu configuration.
	 *
	 * Accepts a JSON-encoded config array, validates the config ID, sanitizes
	 * every item against the live snapshot whitelist, and persists the result.
	 *
	 * @since 1.0.0
	 */
	public function ajax_save_config(): void {
		check_ajax_referer( Agency_Pulse_Admin_Menu_Constants::AJAX_SAVE_CONFIG, 'nonce' );
		if ( ! current_user_can( Agency_Pulse_Admin_Menu_Constants::CAP_MANAGE_MENUS ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'agency-pulse-admin-menu' ) ), 403 );
		}

		$config_id = isset( $_POST['config_id'] ) ? sanitize_key( wp_unslash( $_POST['config_id'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
		$raw_json  = isset( $_POST['config'] )    ? wp_unslash( $_POST['config'] ) : '';                     // phpcs:ignore WordPress.Security.ValidatedSanitizedInput

		if ( empty( $config_id ) ) {
			wp_send_json_error( array( 'message' => __( 'Missing configuration ID.', 'agency-pulse-admin-menu' ) ) );
		}

		if ( null === $this->settings_manager->get_config( $config_id ) ) {
			wp_send_json_error( array( 'message' => __( 'Configuration not found.', 'agency-pulse-admin-menu' ) ) );
		}

		$raw_items = json_decode( $raw_json, true );
		if ( ! is_array( $raw_items ) ) {
			wp_send_json_error( array( 'message' => __( 'Invalid configuration data.', 'agency-pulse-admin-menu' ) ) );
		}

		$sanitized = $this->sanitize_config_items( $raw_items );

		if ( $this->settings_manager->save_config_items( $config_id, $sanitized ) ) {
			wp_send_json_success( array( 'message' => __( 'Menu saved.', 'agency-pulse-admin-menu' ) ) );
		} else {
			wp_send_json_error( array( 'message' => __( 'Could not save the configuration.', 'agency-pulse-admin-menu' ) ) );
		}
	}

	/**
	 * AJAX: Reset a config's items to empty, restoring WordPress defaults.
	 *
	 * @since 1.0.0
	 */
	public function ajax_reset_config(): void {
		check_ajax_referer( Agency_Pulse_Admin_Menu_Constants::AJAX_SAVE_CONFIG, 'nonce' );
		if ( ! current_user_can( Agency_Pulse_Admin_Menu_Constants::CAP_MANAGE_MENUS ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'agency-pulse-admin-menu' ) ), 403 );
		}

		$config_id = isset( $_POST['config_id'] ) ? sanitize_key( wp_unslash( $_POST['config_id'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput

		if ( empty( $config_id ) || null === $this->settings_manager->get_config( $config_id ) ) {
			wp_send_json_error( array( 'message' => __( 'Configuration not found.', 'agency-pulse-admin-menu' ) ) );
		}

		if ( $this->settings_manager->save_config_items( $config_id, array() ) ) {
			wp_send_json_success( array( 'message' => __( 'Configuration reset.', 'agency-pulse-admin-menu' ) ) );
		} else {
			wp_send_json_error( array( 'message' => __( 'Could not reset the configuration.', 'agency-pulse-admin-menu' ) ) );
		}
	}

	/**
	 * AJAX: Return the live menu snapshot and a config's saved items.
	 *
	 * Used by the builder to refresh data without a full page reload.
	 *
	 * @since 1.0.0
	 */
	public function ajax_get_current_menu(): void {
		check_ajax_referer( Agency_Pulse_Admin_Menu_Constants::AJAX_SAVE_CONFIG, 'nonce' );
		if ( ! current_user_can( Agency_Pulse_Admin_Menu_Constants::CAP_MANAGE_MENUS ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'agency-pulse-admin-menu' ) ), 403 );
		}

		$config_id = isset( $_POST['config_id'] ) ? sanitize_key( wp_unslash( $_POST['config_id'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput

		wp_send_json_success( array(
			'snapshot' => $this->get_live_menu_snapshot(),
			'config'   => $config_id ? $this->settings_manager->get_config_items( $config_id ) : array(),
		) );
	}

	/**
	 * AJAX: Create a new named configuration.
	 *
	 * Returns the new config ID so the caller can redirect to it.
	 *
	 * @since 1.0.0
	 */
	public function ajax_create_config(): void {
		check_ajax_referer( Agency_Pulse_Admin_Menu_Constants::AJAX_SAVE_CONFIG, 'nonce' );
		if ( ! current_user_can( Agency_Pulse_Admin_Menu_Constants::CAP_MANAGE_MENUS ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'agency-pulse-admin-menu' ) ), 403 );
		}

		$name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput

		if ( empty( $name ) ) {
			wp_send_json_error( array( 'message' => __( 'A configuration name is required.', 'agency-pulse-admin-menu' ) ) );
		}

		if ( mb_strlen( $name ) > 100 ) {
			wp_send_json_error( array( 'message' => __( 'Configuration name must be 100 characters or fewer.', 'agency-pulse-admin-menu' ) ) );
		}

		$config_id = $this->settings_manager->create_config( $name );
		wp_send_json_success( array( 'config_id' => $config_id ) );
	}

	/**
	 * AJAX: Rename an existing configuration.
	 *
	 * @since 1.0.0
	 */
	public function ajax_rename_config(): void {
		check_ajax_referer( Agency_Pulse_Admin_Menu_Constants::AJAX_SAVE_CONFIG, 'nonce' );
		if ( ! current_user_can( Agency_Pulse_Admin_Menu_Constants::CAP_MANAGE_MENUS ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'agency-pulse-admin-menu' ) ), 403 );
		}

		$config_id = isset( $_POST['config_id'] ) ? sanitize_key( wp_unslash( $_POST['config_id'] ) ) : '';        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
		$name      = isset( $_POST['name'] )      ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput

		if ( empty( $config_id ) || null === $this->settings_manager->get_config( $config_id ) ) {
			wp_send_json_error( array( 'message' => __( 'Configuration not found.', 'agency-pulse-admin-menu' ) ) );
		}

		if ( empty( $name ) ) {
			wp_send_json_error( array( 'message' => __( 'A configuration name is required.', 'agency-pulse-admin-menu' ) ) );
		}

		if ( mb_strlen( $name ) > 100 ) {
			wp_send_json_error( array( 'message' => __( 'Configuration name must be 100 characters or fewer.', 'agency-pulse-admin-menu' ) ) );
		}

		if ( $this->settings_manager->rename_config( $config_id, $name ) ) {
			wp_send_json_success( array( 'message' => __( 'Configuration renamed.', 'agency-pulse-admin-menu' ) ) );
		} else {
			wp_send_json_error( array( 'message' => __( 'Could not rename the configuration.', 'agency-pulse-admin-menu' ) ) );
		}
	}

	/**
	 * AJAX: Duplicate an existing configuration under a new name.
	 *
	 * Returns the new config ID so the caller can redirect to it.
	 *
	 * @since 1.0.0
	 */
	public function ajax_duplicate_config(): void {
		check_ajax_referer( Agency_Pulse_Admin_Menu_Constants::AJAX_SAVE_CONFIG, 'nonce' );
		if ( ! current_user_can( Agency_Pulse_Admin_Menu_Constants::CAP_MANAGE_MENUS ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'agency-pulse-admin-menu' ) ), 403 );
		}

		$config_id = isset( $_POST['config_id'] ) ? sanitize_key( wp_unslash( $_POST['config_id'] ) ) : '';         // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
		$new_name  = isset( $_POST['new_name'] )  ? sanitize_text_field( wp_unslash( $_POST['new_name'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput

		if ( empty( $config_id ) || null === $this->settings_manager->get_config( $config_id ) ) {
			wp_send_json_error( array( 'message' => __( 'Source configuration not found.', 'agency-pulse-admin-menu' ) ) );
		}

		if ( empty( $new_name ) ) {
			wp_send_json_error( array( 'message' => __( 'A name for the duplicate is required.', 'agency-pulse-admin-menu' ) ) );
		}

		if ( mb_strlen( $new_name ) > 100 ) {
			wp_send_json_error( array( 'message' => __( 'Configuration name must be 100 characters or fewer.', 'agency-pulse-admin-menu' ) ) );
		}

		$new_id = $this->settings_manager->duplicate_config( $config_id, $new_name );
		if ( $new_id ) {
			wp_send_json_success( array( 'config_id' => $new_id ) );
		} else {
			wp_send_json_error( array( 'message' => __( 'Could not duplicate the configuration.', 'agency-pulse-admin-menu' ) ) );
		}
	}

	/**
	 * AJAX: Delete a configuration by ID.
	 *
	 * Checks blocking conditions explicitly before calling delete so that
	 * descriptive error messages can be returned to the caller.
	 *
	 * @since 1.0.0
	 */
	public function ajax_delete_config(): void {
		check_ajax_referer( Agency_Pulse_Admin_Menu_Constants::AJAX_SAVE_CONFIG, 'nonce' );
		if ( ! current_user_can( Agency_Pulse_Admin_Menu_Constants::CAP_MANAGE_MENUS ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'agency-pulse-admin-menu' ) ), 403 );
		}

		$config_id = isset( $_POST['config_id'] ) ? sanitize_key( wp_unslash( $_POST['config_id'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput

		if ( empty( $config_id ) ) {
			wp_send_json_error( array( 'message' => __( 'Missing configuration ID.', 'agency-pulse-admin-menu' ) ) );
		}

		$all_configs = $this->settings_manager->get_all_configs();

		if ( ! isset( $all_configs[ $config_id ] ) ) {
			wp_send_json_error( array( 'message' => __( 'Configuration not found.', 'agency-pulse-admin-menu' ) ) );
		}

		if ( count( $all_configs ) <= 1 ) {
			wp_send_json_error( array( 'message' => __( 'The last configuration cannot be deleted.', 'agency-pulse-admin-menu' ) ) );
		}

		$assignments = $this->settings_manager->get_role_assignments();
		if ( in_array( $config_id, $assignments, true ) ) {
			wp_send_json_error( array( 'message' => __( 'This configuration is assigned to one or more roles. Remove the role assignment before deleting.', 'agency-pulse-admin-menu' ) ) );
		}

		if ( $this->settings_manager->delete_config( $config_id ) ) {
			wp_send_json_success( array( 'message' => __( 'Configuration deleted.', 'agency-pulse-admin-menu' ) ) );
		} else {
			wp_send_json_error( array( 'message' => __( 'Could not delete the configuration.', 'agency-pulse-admin-menu' ) ) );
		}
	}

	// -------------------------------------------------------------------------
	// Config sanitization
	// -------------------------------------------------------------------------

	/**
	 * Sanitize a raw config items array submitted from the builder JS.
	 *
	 * Validates and sanitizes field types. Slug whitelist validation against the
	 * live $menu/$submenu globals is intentionally NOT performed here because
	 * WordPress does not populate those globals during admin-ajax.php requests
	 * (admin_menu does not fire in that context), which would cause every
	 * submitted slug to fail validation and be dropped silently.
	 *
	 * Instead, slugs are sanitized with sanitize_key() and custom container slugs
	 * are validated against their expected pattern. Unknown slugs are passed
	 * through — apply_menu_config() skips any slug not present in $menu/$submenu
	 * at runtime, making that function the correct validation gatekeeper.
	 *
	 * Items and children that fail any validation rule are silently dropped.
	 *
	 * @since 1.0.0
	 * @param array $raw_items Raw items array from json_decode.
	 * @return array Sanitized items array safe to persist.
	 */
	private function sanitize_config_items( array $raw_items ): array {
		$sanitized = array();

		foreach ( $raw_items as $raw_item ) {
			if ( ! is_array( $raw_item ) ) {
				continue;
			}

			$slug             = isset( $raw_item['slug'] ) ? sanitize_text_field( wp_unslash( (string) $raw_item['slug'] ) ) : '';
			$custom_container = ! empty( $raw_item['custom_container'] );
			$separator        = ! empty( $raw_item['separator'] );

			if ( empty( $slug ) ) {
				continue;
			}

			// Custom containers must match the plugin-generated slug pattern.
			if ( $custom_container && ! preg_match( '/^aamo_container_\d+$/', $slug ) ) {
				continue;
			}

			$label = null;
			if ( isset( $raw_item['label'] ) && null !== $raw_item['label'] ) {
				$label = sanitize_text_field( (string) $raw_item['label'] );
				if ( '' === $label ) {
					$label = null;
				}
			}

			$icon = null;
			if ( isset( $raw_item['icon'] ) && null !== $raw_item['icon'] ) {
				$icon = sanitize_text_field( (string) $raw_item['icon'] );
				if ( '' === $icon ) {
					$icon = null;
				}
			}

			$item = array(
				'slug'             => $slug,
				'position'         => absint( $raw_item['position'] ?? 0 ),
				'label'            => $label,
				'hidden'           => ! empty( $raw_item['hidden'] ),
				'separator'        => $separator,
				'custom_container' => $custom_container,
				'icon'             => $icon,
				'children'         => array(),
			);

			// Sanitize children.
			if ( ! empty( $raw_item['children'] ) && is_array( $raw_item['children'] ) ) {
				foreach ( $raw_item['children'] as $raw_child ) {
					if ( ! is_array( $raw_child ) ) {
						continue;
					}

					$child_slug = isset( $raw_child['slug'] ) ? sanitize_text_field( wp_unslash( (string) $raw_child['slug'] ) ) : '';
					if ( empty( $child_slug ) ) {
						continue;
					}

					$child_label = null;
					if ( isset( $raw_child['label'] ) && null !== $raw_child['label'] ) {
						$child_label = sanitize_text_field( (string) $raw_child['label'] );
						if ( '' === $child_label ) {
							$child_label = null;
						}
					}

					$item['children'][] = array(
						'slug'     => $child_slug,
						'position' => absint( $raw_child['position'] ?? 0 ),
						'label'    => $child_label,
						'hidden'   => ! empty( $raw_child['hidden'] ),
					);
				}
			}

			$sanitized[] = $item;
		}

		return $sanitized;
	}
}
