<?php
defined( 'ABSPATH' ) || exit;

/**
 * Advanced Menu — a WordPress navigation menu with mega-panel support.
 *
 * Separate from Jalpi_Element_Menu by design: that element stays a lightweight
 * nav, while this one adds mega panels whose content is authored with the
 * builder itself.
 *
 * Storage shape:
 *   content.megaItems[ <menu_item_id> ] = [ 'enabled', 'panelIndex', 'cols', … ]
 *   children[ panelIndex ]              = a container node holding the widgets
 *
 * A mega panel is therefore a real builder subtree rendered through
 * Jalpi_Renderer::render_children(), exactly like a Tabs (Advanced) pane, so
 * every widget behaves inside a panel as it does anywhere else. The mapping is
 * by explicit panelIndex rather than array position because menu items are
 * ordered in Appearance → Menus, where reordering must not re-point panels.
 *
 * Interactive behaviour lives in initAdvMenus() in assets/js/jalpi-frontend.js,
 * driven entirely by the data-jalpi-advmenu payload emitted here.
 */
class Jalpi_Element_Adv_Menu extends Jalpi_Element {

	public static function type(): string {
		return 'adv-menu';
	}

	/** Depth guard — a corrupt or self-referencing tree must not recurse forever. */
	private const MAX_DEPTH = 10;

	/* ── Allow-lists. Shared by render and CSS so the two never disagree. ── */

	private const LAYOUTS       = [ 'horizontal', 'vertical' ];
	private const TRIGGERS      = [ 'hover', 'click' ];
	private const PANEL_WIDTHS  = [ 'container', 'full', 'custom' ];
	private const POSITIONS     = [ 'left', 'center', 'right' ];
	private const ALIGNMENTS    = [ 'left', 'center', 'right', 'justify' ];
	private const INDICATORS    = [ 'arrow', 'plus', 'none' ];
	private const ANIMATIONS    = [ 'fade', 'slide-down', 'slide-up', 'zoom', 'scale', 'none' ];
	private const MOBILE_LAYOUTS = [ 'accordion', 'offcanvas', 'dropdown' ];

	public function render( array $node, Jalpi_Renderer $renderer ): string {
		$id       = sanitize_html_class( $node['id'] ?? '' );
		$content  = (array) ( $node['content'] ?? [] );
		$settings = (array) ( $node['settings'] ?? [] );
		$styles   = (array) ( $node['styles'] ?? [] );
		$children = (array) ( $node['children'] ?? [] );
		$a        = $renderer->get_css_attrs( $node );

		$items = $this->get_items( $content );

		// An empty menu renders nothing — an empty <nav> is noise for assistive
		// technology and adds a stray landmark to the page.
		if ( empty( $items ) ) {
			return '';
		}

		// Resolve each default FIRST, then validate, so an absent key actually
		// falls back instead of yielding null.
		$layout     = $this->keyword( $content['layout'] ?? '', self::LAYOUTS, 'horizontal' );
		$trigger    = $this->keyword( $content['megaTrigger'] ?? '', self::TRIGGERS, 'hover' );
		$panel_w    = $this->keyword( $content['panelWidth'] ?? '', self::PANEL_WIDTHS, 'container' );
		$dropdown_p = $this->keyword( $content['dropdownPosition'] ?? '', self::POSITIONS, 'left' );
		$align      = $this->keyword( $content['align'] ?? '', self::ALIGNMENTS, 'left' );
		$indicator  = $this->keyword( $content['submenuIndicator'] ?? '', self::INDICATORS, 'arrow' );
		$animation  = $this->keyword( $content['animation'] ?? '', self::ANIMATIONS, 'fade' );
		$mobile_l   = $this->keyword( $content['mobileLayout'] ?? '', self::MOBILE_LAYOUTS, 'accordion' );

		$breakpoint  = max( 320, min( 1440, (int) ( $content['breakpoint'] ?? 1024 ) ) );
		$open_delay  = max( 0, min( 2000, (int) ( $content['openDelay'] ?? 100 ) ) );
		$close_delay = max( 0, min( 2000, (int) ( $content['closeDelay'] ?? 200 ) ) );
		$duration    = max( 0, min( 3000, (int) ( $content['animationDuration'] ?? 250 ) ) );
		$sticky      = ! empty( $content['stickyMobileHeader'] );

		$mega_items = $this->normalise_mega_items( $content['megaItems'] ?? [] );

		$aria_label = sanitize_text_field(
			$content['ariaLabel'] ?? __( 'Main navigation', 'jalpi-page-builder' )
		);

		$this->emit_css( $renderer, $id, $styles, $settings, $content, $layout, $align, $breakpoint, $duration );
		$renderer->set_needs_js( 'generic' );

		// jalpi-{id} is what every per-node rule in emit_css() targets — without
		// it the element's own colours, spacing and breakpoint never apply.
		$classes  = 'jalpi-el jalpi-advmenu jalpi-' . $id;
		$classes .= ' jalpi-advmenu--' . $layout;
		$classes .= ' jalpi-advmenu--trigger-' . $trigger;
		$classes .= ' jalpi-advmenu--align-' . $align;
		$classes .= ' jalpi-advmenu--mobile-' . $mobile_l;
		$classes .= ' jalpi-advmenu--anim-' . $animation;
		if ( $sticky ) {
			$classes .= ' jalpi-advmenu--sticky';
		}
		$classes .= $a['extra_cls'];

		$cfg = [
			'trigger'      => $trigger,
			'breakpoint'   => $breakpoint,
			'openDelay'    => $open_delay,
			'closeDelay'   => $close_delay,
			'animation'    => $animation,
			'duration'     => $duration,
			'mobileLayout' => $mobile_l,
			'panelWidth'   => $panel_w,
			'position'     => $dropdown_p,
			'sticky'       => $sticky,
		];

		$panel_id = 'jalpi-advmenu-panel-' . $id;

		$html = '<nav class="' . esc_attr( $classes ) . '"' . $a['id_attr']
			. ' aria-label="' . esc_attr( $aria_label ) . '"'
			. " data-jalpi-advmenu='" . esc_attr( (string) wp_json_encode( $cfg ) ) . "'>";

		// The toggle is always rendered but hidden above the breakpoint by CSS,
		// so crossing the breakpoint causes no layout shift.
		$html .= '<button type="button" class="jalpi-advmenu__toggle" aria-expanded="false"'
			. ' aria-controls="' . esc_attr( $panel_id ) . '">'
			. '<span class="jalpi-advmenu__toggle-box" aria-hidden="true">'
			. '<span class="jalpi-advmenu__toggle-bar"></span>'
			. '<span class="jalpi-advmenu__toggle-bar"></span>'
			. '<span class="jalpi-advmenu__toggle-bar"></span>'
			. '</span>'
			. '<span class="screen-reader-text">' . esc_html__( 'Menu', 'jalpi-page-builder' ) . '</span>'
			. '</button>';

		$html .= '<div class="jalpi-advmenu__panel" id="' . esc_attr( $panel_id ) . '">';

		// Off-canvas needs a dismiss affordance that is not the hamburger, which
		// scrolls out of reach once the drawer covers the viewport.
		if ( 'offcanvas' === $mobile_l ) {
			$html .= '<button type="button" class="jalpi-advmenu__close">'
				. '<span aria-hidden="true">&times;</span>'
				. '<span class="screen-reader-text">' . esc_html__( 'Close menu', 'jalpi-page-builder' ) . '</span>'
				. '</button>';
		}

		$html .= $this->render_list( $items, $mega_items, $children, $renderer, $indicator, $panel_w, $dropdown_p, 0 );
		$html .= '</div>';

		// Backdrop for the off-canvas drawer; inert and hidden until JS opens it.
		if ( 'offcanvas' === $mobile_l ) {
			$html .= '<div class="jalpi-advmenu__backdrop" hidden></div>';
		}

		return $html . '</nav>';
	}

	/**
	 * Resolve the item tree from the selected WordPress menu.
	 *
	 * @return array<int,array> Normalised items: label, url, target, children…
	 */
	private function get_items( array $content ): array {
		$menu_id = (int) ( $content['menuId'] ?? 0 );

		// A theme location is resolved at render time so the menu follows
		// whatever the theme currently has assigned.
		$location = sanitize_key( $content['menuLocation'] ?? '' );
		if ( $location ) {
			$assigned = get_theme_mod( 'nav_menu_locations', [] );
			if ( ! empty( $assigned[ $location ] ) ) {
				$menu_id = (int) $assigned[ $location ];
			}
		}

		if ( ! $menu_id ) {
			return [];
		}

		// wp_get_nav_menu_object() resolves WPML/Polylang's translated menu via
		// their filters, so a translated page gets its own menu automatically.
		$menu_obj = wp_get_nav_menu_object( $menu_id );
		if ( ! $menu_obj ) {
			return [];
		}

		$objects = wp_get_nav_menu_items( $menu_obj->term_id );
		if ( ! $objects ) {
			return [];
		}

		// _wp_menu_item_classes_by_context() is what sets current-menu-item,
		// current-menu-parent and current-menu-ancestor on each object. Calling
		// it here means those native classes survive onto our markup.
		_wp_menu_item_classes_by_context( $objects );

		return $this->build_tree( $objects );
	}

	/**
	 * Turn WordPress's flat menu-item list into a nested tree.
	 *
	 * wp_get_nav_menu_items() returns items in order with a menu_item_parent
	 * pointer; nesting them here keeps render_list() a simple recursion.
	 */
	private function build_tree( array $objects ): array {
		$by_parent = [];

		foreach ( $objects as $obj ) {
			$by_parent[ (int) $obj->menu_item_parent ][] = [
				'id'          => (int) $obj->ID,
				'label'       => $obj->title,
				'url'         => $obj->url,
				'target'      => $obj->target,
				'attr_title'  => (string) ( $obj->attr_title ?? '' ),
				'description' => (string) ( $obj->description ?? '' ),
				'xfn'         => (string) ( $obj->xfn ?? '' ),
				// 'current' is the page actually being viewed; 'active' also
				// covers its parents and ancestors. They are kept apart because
				// aria-current must mark only the real current page, while the
				// active styling follows the whole open branch.
				'current'     => ! empty( $obj->current ),
				'active'      => ! empty( $obj->current )
					|| ! empty( $obj->current_item_parent )
					|| ! empty( $obj->current_item_ancestor ),
				'classes'     => is_array( $obj->classes ) ? array_filter( $obj->classes ) : [],
				'children'    => [],
			];
		}

		$attach = function ( int $parent, int $depth ) use ( &$attach, $by_parent ): array {
			if ( $depth > self::MAX_DEPTH || empty( $by_parent[ $parent ] ) ) {
				return [];
			}

			$out = [];
			foreach ( $by_parent[ $parent ] as $item ) {
				$item['children'] = $attach( $item['id'], $depth + 1 );
				$out[]            = $item;
			}
			return $out;
		};

		return $attach( 0, 0 );
	}

	/**
	 * Normalise the mega-panel map into menu_item_id => config.
	 *
	 * Only entries that are enabled AND point at a panel are kept, so a stale
	 * mapping left behind by a deleted menu item costs nothing at render time.
	 *
	 * @return array<int,array{cols:int,panelIndex:int}>
	 */
	private function normalise_mega_items( $raw ): array {
		if ( ! is_array( $raw ) ) {
			return [];
		}

		$out = [];
		foreach ( $raw as $item_id => $cfg ) {
			$item_id = (int) $item_id;
			if ( $item_id <= 0 || ! is_array( $cfg ) || empty( $cfg['enabled'] ) ) {
				continue;
			}

			$panel_index = (int) ( $cfg['panelIndex'] ?? -1 );
			if ( $panel_index < 0 ) {
				continue;
			}

			$out[ $item_id ] = [
				'cols'       => max( 1, min( 12, (int) ( $cfg['cols'] ?? 3 ) ) ),
				'panelIndex' => $panel_index,
			];
		}

		return $out;
	}

	/**
	 * Render one level of the tree, recursing into children.
	 *
	 * A top-level item flagged as mega renders its builder panel instead of a
	 * nested <ul>; everything else stays a normal nested list, so unlimited
	 * submenu depth still works alongside mega panels.
	 */
	private function render_list(
		array $items,
		array $mega_items,
		array $children,
		Jalpi_Renderer $renderer,
		string $indicator,
		string $panel_w,
		string $dropdown_p,
		int $depth
	): string {
		if ( empty( $items ) || $depth > self::MAX_DEPTH ) {
			return '';
		}

		$list_class = 0 === $depth ? 'jalpi-advmenu__list' : 'jalpi-advmenu__sub';
		$html       = '<ul class="' . esc_attr( $list_class ) . '">';

		foreach ( $items as $item ) {
			$mega = ( 0 === $depth && isset( $mega_items[ $item['id'] ] ) )
				? $mega_items[ $item['id'] ]
				: null;

			// A mega item only counts as such once its panel actually exists;
			// otherwise it degrades to a plain item rather than an empty popup.
			$panel_node = null;
			if ( $mega ) {
				$candidate = $children[ $mega['panelIndex'] ] ?? null;
				if ( is_array( $candidate ) ) {
					$panel_node = $candidate;
				} else {
					$mega = null;
				}
			}

			$has_sub      = ! empty( $item['children'] );
			$has_children = $mega ? true : $has_sub;

			$li_classes = [ 'jalpi-advmenu__item' ];
			if ( $has_children ) {
				$li_classes[] = 'jalpi-advmenu__item--has-children';
			}
			if ( $mega ) {
				$li_classes[] = 'jalpi-advmenu__item--mega';
			}
			// is-current follows the whole open branch (item, parent, ancestor)
			// so the Active colour highlights the trail to the current page,
			// matching the Navigation Menu element.
			if ( ! empty( $item['active'] ) ) {
				$li_classes[] = 'is-current';
			}
			// Preserve WordPress's own classes (current-menu-item,
			// current-menu-parent, current-menu-ancestor and any custom ones)
			// so themes and third-party CSS keep working.
			foreach ( (array) $item['classes'] as $extra ) {
				$cls = sanitize_html_class( $extra );
				if ( $cls ) {
					$li_classes[] = $cls;
				}
			}

			$html .= '<li class="' . esc_attr( implode( ' ', array_unique( $li_classes ) ) ) . '">';

			$url = $item['url'] ? esc_url( $item['url'] ) : '#';

			// A new tab needs noopener, or the opened page can reach back
			// through window.opener. The item's XFN relationship is merged into
			// the same rel rather than emitted as a second attribute, which
			// would be invalid and silently ignored.
			$rel_parts = [];
			$is_blank  = '_blank' === $item['target'];
			if ( $is_blank ) {
				$rel_parts[] = 'noopener';
				$rel_parts[] = 'noreferrer';
			}
			foreach ( preg_split( '/\s+/', (string) $item['xfn'], -1, PREG_SPLIT_NO_EMPTY ) as $token ) {
				$token = sanitize_html_class( $token );
				if ( '' !== $token ) {
					$rel_parts[] = $token;
				}
			}

			$target  = $is_blank ? ' target="_blank"' : '';
			$target .= $rel_parts
				? ' rel="' . esc_attr( implode( ' ', array_unique( $rel_parts ) ) ) . '"'
				: '';

			// aria-current marks ONLY the page actually being viewed — putting
			// it on ancestors too would misreport several links as "current".
			$aria  = ! empty( $item['current'] ) ? ' aria-current="page"' : '';
			$title = '' !== $item['attr_title'] ? ' title="' . esc_attr( $item['attr_title'] ) . '"' : '';

			$html .= '<a class="jalpi-advmenu__link" href="' . $url . '"' . $target . $aria . $title . '>'
				. '<span class="jalpi-advmenu__label">' . esc_html( $item['label'] ) . '</span>';

			if ( '' !== $item['description'] ) {
				$html .= '<span class="jalpi-advmenu__desc">' . esc_html( $item['description'] ) . '</span>';
			}

			$html .= '</a>';

			if ( $has_children && 'none' !== $indicator ) {
				// A separate button owns submenu toggling so the link itself
				// stays a real navigable link for keyboard and screen readers.
				$html .= '<button type="button" class="jalpi-advmenu__expand" aria-expanded="false" tabindex="-1">'
					. '<span class="jalpi-advmenu__indicator jalpi-advmenu__indicator--' . esc_attr( $indicator ) . '" aria-hidden="true"></span>'
					. '<span class="screen-reader-text">'
					/* translators: %s: parent menu item label. */
					. esc_html( sprintf( __( 'Toggle submenu for %s', 'jalpi-page-builder' ), $item['label'] ) )
					. '</span></button>';
			}

			if ( $mega && $panel_node ) {
				$mega_cls = 'jalpi-advmenu__mega'
					. ' jalpi-advmenu__mega--' . $panel_w
					. ' jalpi-advmenu__mega--pos-' . $dropdown_p;

				// The panel's widgets render through the normal pipeline, so a
				// widget inside a mega panel behaves exactly as it does on the
				// page — including its own CSS and JS registration.
				$html .= '<div class="' . esc_attr( $mega_cls ) . '" style="--jalpi-mega-cols:' . (int) $mega['cols'] . '">'
					. '<div class="jalpi-advmenu__mega-inner">'
					. $renderer->render_children( $panel_node )
					. '</div></div>';
			} elseif ( $has_sub ) {
				$html .= $this->render_list(
					$item['children'],
					$mega_items,
					$children,
					$renderer,
					$indicator,
					$panel_w,
					$dropdown_p,
					$depth + 1
				);
			}

			$html .= '</li>';
		}

		return $html . '</ul>';
	}

	/**
	 * Per-node CSS. Colours and typography apply to the link, spacing to the
	 * item, so the hover target stays the full padded area.
	 */
	private function emit_css(
		Jalpi_Renderer $renderer,
		string $id,
		array $styles,
		array $settings,
		array $content,
		string $layout,
		string $align,
		int $breakpoint,
		int $duration
	): void {
		$sel = '.jalpi-el.jalpi-' . $id;
		$css = '';

		$justify = [
			'left'    => 'flex-start',
			'center'  => 'center',
			'right'   => 'flex-end',
			'justify' => 'space-between',
		][ $align ];

		// Per-node CSS is injected inline in <head> while frontend.css loads
		// after it, so these rules qualify the selector with .jalpi-advmenu to
		// outrank the stylesheet's single-class defaults.
		$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__list{justify-content:{$justify};}";

		// Drives every transition in the stylesheet, so one control changes the
		// whole element's animation speed.
		$css .= "{$sel}.jalpi-advmenu{--jalpi-advmenu-duration:{$duration}ms;}";

		$menu_width = $this->len( $content['menuWidth'] ?? '' );
		if ( $menu_width ) {
			// margin-inline so a narrowed menu sits on the correct side under
			// RTL: 'right' means the end edge, which flips with direction.
			$margin = 'center' === $align
				? '0 auto'
				: ( 'right' === $align ? '0 0 0 auto' : '0 auto 0 0' );
			if ( 'center' !== $align ) {
				$inline = 'right' === $align ? 'auto 0' : '0 auto';
				$css   .= "{$sel}.jalpi-advmenu{margin-inline:{$inline};}";
			}
			$css   .= "{$sel}.jalpi-advmenu{width:{$menu_width};max-width:100%;margin:{$margin};}";
		}

		$gap = $this->len( $settings['am_gap'] ?? '' ) ?: $this->len( $content['itemSpacing'] ?? '' );
		if ( $gap ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__list{gap:{$gap};}";
		}

		// A custom mega width only means anything in 'custom' mode; in the other
		// modes the panel is sized by the container or the viewport.
		if ( 'custom' === ( $content['panelWidth'] ?? '' ) ) {
			$custom_w = $this->len( $content['panelCustomWidth'] ?? '' );
			if ( $custom_w ) {
				$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega--custom{width:{$custom_w};}";
			}
		}

		// ── Menu items ───────────────────────────────────────────────────────
		$link  = '';
		$link .= $this->css_prop( 'color', $this->col( $settings['am_color'] ?? '' ) );
		$link .= $this->css_prop( 'background', $this->col( $settings['am_bg'] ?? '' ) );
		$link .= $this->css_prop( 'font-family', $this->font_family( $settings['am_font_family'] ?? '' ) );
		$link .= $this->css_prop( 'font-size', $this->len( $settings['am_font_size'] ?? '' ) );
		$link .= $this->css_prop( 'font-weight', $this->weight( $settings['am_font_weight'] ?? '' ) );
		$link .= $this->css_prop( 'line-height', $this->line_height( $settings['am_line_height'] ?? '' ) );
		$link .= $this->css_prop( 'letter-spacing', $this->len( $settings['am_letter_spacing'] ?? '' ) );
		$link .= $this->css_prop( 'text-transform', $this->keyword(
			$settings['am_text_transform'] ?? '',
			[ 'none', 'uppercase', 'lowercase', 'capitalize' ],
			''
		) );
		$link .= $this->css_prop( 'padding', $this->len4( $settings['am_padding'] ?? '' ) );
		$link .= $this->css_prop( 'margin', $this->len4( $settings['am_margin'] ?? '' ) );
		$link .= $this->css_prop( 'border-radius', $this->len4( $settings['am_radius'] ?? '' ) );
		$link .= $this->border( $settings, 'am_border' );

		$shadow = $this->shadow( $settings['am_shadow'] ?? [] );
		if ( $shadow ) {
			$link .= 'box-shadow:' . $shadow . ';';
		}
		if ( $link ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__link{{$link}}";
		}

		$hover  = $this->css_prop( 'color', $this->col( $settings['am_color_hover'] ?? '' ) );
		$hover .= $this->css_prop( 'background', $this->col( $settings['am_bg_hover'] ?? '' ) );
		if ( $hover ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__link:hover,"
				. "{$sel}.jalpi-advmenu .jalpi-advmenu__link:focus-visible{{$hover}}";
		}

		$active  = $this->css_prop( 'color', $this->col( $settings['am_color_active'] ?? '' ) );
		$active .= $this->css_prop( 'background', $this->col( $settings['am_bg_active'] ?? '' ) );
		if ( $active ) {
			// Match both our own flag and WordPress's native current classes, so
			// the active style holds regardless of which one applies.
			$css .= "{$sel}.jalpi-advmenu .is-current > .jalpi-advmenu__link,"
				. "{$sel}.jalpi-advmenu .current-menu-item > .jalpi-advmenu__link,"
				. "{$sel}.jalpi-advmenu .current-menu-ancestor > .jalpi-advmenu__link{{$active}}";
		}

		// ── Mega panel ───────────────────────────────────────────────────────
		$panel = '';
		// A gradient is a composite value built from validated parts, so no raw
		// CSS string is ever accepted here.
		$grad = $this->gradient( $settings );
		if ( $grad ) {
			$panel .= 'background:' . $grad . ';';
		} else {
			$panel .= $this->css_prop( 'background', $this->col( $settings['am_panel_bg'] ?? '' ) );
		}
		$panel .= $this->css_prop( 'padding', $this->len4( $settings['am_panel_padding'] ?? '' ) );
		$panel .= $this->css_prop( 'margin', $this->len4( $settings['am_panel_margin'] ?? '' ) );
		$panel .= $this->css_prop( 'border-radius', $this->len4( $settings['am_panel_radius'] ?? '' ) );
		$panel .= $this->border( $settings, 'am_panel_border' );

		$panel_shadow = $this->shadow( $settings['am_panel_shadow'] ?? [] );
		if ( $panel_shadow ) {
			$panel .= 'box-shadow:' . $panel_shadow . ';';
		}
		if ( $panel ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega{{$panel}}";
		}

		// Overlay tint sits above the background but below the content, so it
		// needs its own layer rather than a second background.
		$overlay = $this->col( $settings['am_panel_overlay'] ?? '' );
		if ( $overlay ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega::before{content:'';position:absolute;inset:0;"
				. "background:{$overlay};pointer-events:none;border-radius:inherit;}";
		}

		// ── Columns ──────────────────────────────────────────────────────────
		$col_gap = $this->len( $settings['am_col_gap'] ?? '' );
		if ( $col_gap ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega-inner{gap:{$col_gap};}";
		}

		$col_align = $this->keyword( $settings['am_col_align'] ?? '', [ 'left', 'center', 'right' ], '' );
		if ( $col_align ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega-inner > *{text-align:{$col_align};}";
		}

		// Divider between columns, drawn on the start edge of every column but
		// the first so the panel's outer edges stay clean. Logical properties,
		// so under RTL the divider moves to the right-hand side with the flow
		// instead of stranding itself on the wrong edge.
		$div_w = $this->len( $settings['am_col_divider_width'] ?? '' );
		$div_c = $this->col( $settings['am_col_divider_color'] ?? '' );
		if ( $div_w && $div_c ) {
			$pad = $col_gap ? "padding-inline-start:{$col_gap};" : '';
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega-inner > * + *{"
				. "border-inline-start:{$div_w} solid {$div_c};{$pad}}";
		}

		// ── Panel typography: headings, links, icons, images, buttons ────────
		$heading  = $this->css_prop( 'color', $this->col( $settings['am_heading_color'] ?? '' ) );
		$heading .= $this->css_prop( 'font-family', $this->font_family( $settings['am_heading_font_family'] ?? '' ) );
		$heading .= $this->css_prop( 'font-size', $this->len( $settings['am_heading_font_size'] ?? '' ) );
		$heading .= $this->css_prop( 'font-weight', $this->weight( $settings['am_heading_font_weight'] ?? '' ) );
		$heading .= $this->css_prop( 'margin', $this->len4( $settings['am_heading_margin'] ?? '' ) );
		if ( $heading ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega :is(h1,h2,h3,h4,h5,h6){{$heading}}";
		}

		$plink  = $this->css_prop( 'color', $this->col( $settings['am_plink_color'] ?? '' ) );
		$plink .= $this->css_prop( 'font-family', $this->font_family( $settings['am_plink_font_family'] ?? '' ) );
		$plink .= $this->css_prop( 'font-size', $this->len( $settings['am_plink_font_size'] ?? '' ) );
		$plink .= $this->css_prop( 'font-weight', $this->weight( $settings['am_plink_font_weight'] ?? '' ) );
		if ( $plink ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega a{{$plink}}";
		}

		$plink_h = $this->css_prop( 'color', $this->col( $settings['am_plink_color_hover'] ?? '' ) );
		if ( $plink_h ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega a:hover,"
				. "{$sel}.jalpi-advmenu .jalpi-advmenu__mega a:focus-visible{{$plink_h}}";
		}

		$icon  = $this->css_prop( 'color', $this->col( $settings['am_icon_color'] ?? '' ) );
		$icon .= $this->css_prop( 'font-size', $this->len( $settings['am_icon_size'] ?? '' ) );
		if ( $icon ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega .jalpi-icon{{$icon}}";
		}

		$icon_gap = $this->len( $settings['am_icon_spacing'] ?? '' );
		if ( $icon_gap ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega .jalpi-icon{margin-inline-end:{$icon_gap};}";
		}

		$icon_h = $this->css_prop( 'color', $this->col( $settings['am_icon_color_hover'] ?? '' ) );
		if ( $icon_h ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega a:hover .jalpi-icon{{$icon_h}}";
		}

		$img  = $this->css_prop( 'border-radius', $this->len4( $settings['am_img_radius'] ?? '' ) );
		$img_shadow = $this->shadow( $settings['am_img_shadow'] ?? [] );
		if ( $img_shadow ) {
			$img .= 'box-shadow:' . $img_shadow . ';';
		}
		if ( $img ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega img{{$img}}";
		}

		$img_hover = $this->keyword( $settings['am_img_hover'] ?? '', [ 'none', 'zoom', 'lift', 'fade' ], '' );
		if ( $img_hover && 'none' !== $img_hover ) {
			$effect = [
				'zoom' => 'transform:scale(1.05);',
				'lift' => 'transform:translateY(-4px);',
				'fade' => 'opacity:.75;',
			][ $img_hover ];
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega img{transition:transform var(--jalpi-advmenu-duration) ease,opacity var(--jalpi-advmenu-duration) ease;}";
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega img:hover{{$effect}}";
		}

		$btn  = $this->css_prop( 'color', $this->col( $settings['am_btn_color'] ?? '' ) );
		$btn .= $this->css_prop( 'background', $this->col( $settings['am_btn_bg'] ?? '' ) );
		$btn .= $this->css_prop( 'font-family', $this->font_family( $settings['am_btn_font_family'] ?? '' ) );
		$btn .= $this->css_prop( 'font-size', $this->len( $settings['am_btn_font_size'] ?? '' ) );
		$btn .= $this->css_prop( 'font-weight', $this->weight( $settings['am_btn_font_weight'] ?? '' ) );
		$btn .= $this->css_prop( 'border-radius', $this->len4( $settings['am_btn_radius'] ?? '' ) );
		$btn .= $this->border( $settings, 'am_btn_border' );
		if ( $btn ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega .jalpi-btn{{$btn}}";
		}

		$btn_h  = $this->css_prop( 'color', $this->col( $settings['am_btn_color_hover'] ?? '' ) );
		$btn_h .= $this->css_prop( 'background', $this->col( $settings['am_btn_bg_hover'] ?? '' ) );
		if ( $btn_h ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__mega .jalpi-btn:hover,"
				. "{$sel}.jalpi-advmenu .jalpi-advmenu__mega .jalpi-btn:focus-visible{{$btn_h}}";
		}

		// ── Plain (non-mega) dropdown ────────────────────────────────────────
		$sub  = $this->css_prop( 'background', $this->col( $settings['am_sub_bg'] ?? '' ) );
		$sub .= $this->css_prop( 'min-width', $this->len( $settings['am_sub_width'] ?? '' ) );
		$sub .= $this->css_prop( 'border-radius', $this->len4( $settings['am_sub_radius'] ?? '' ) );
		if ( $sub ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__sub{{$sub}}";
		}

		$sub_link  = $this->css_prop( 'color', $this->col( $settings['am_sub_color'] ?? '' ) );
		$sub_link .= $this->css_prop( 'padding', $this->len4( $settings['am_sub_padding'] ?? '' ) );
		if ( $sub_link ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__sub .jalpi-advmenu__link{{$sub_link}}";
		}

		// ── Hamburger ────────────────────────────────────────────────────────
		$burger_size  = $this->len( $settings['am_burger_size'] ?? '' );
		$burger_color = $this->col( $settings['am_burger_color'] ?? '' );
		if ( $burger_size ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__toggle{font-size:{$burger_size};}";
		}
		if ( $burger_color ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__toggle-bar{background:{$burger_color};}";
		}
		$burger_hover = $this->col( $settings['am_burger_color_hover'] ?? '' );
		if ( $burger_hover ) {
			$css .= "{$sel}.jalpi-advmenu .jalpi-advmenu__toggle:hover .jalpi-advmenu__toggle-bar,"
				. "{$sel}.jalpi-advmenu .jalpi-advmenu__toggle:focus-visible .jalpi-advmenu__toggle-bar{background:{$burger_hover};}";
		}

		// Element-level box styles from the Style tab's generic controls.
		$box = $renderer->styles_to_css( $styles );
		if ( $box ) {
			$css .= "{$sel}{{$box}}";
		}

		$renderer->add_css( $css );

		// Responsive collapse is driven by the [data-collapsed="1"] attribute
		// that initAdvMenus() sets from this breakpoint. The media queries below
		// are a no-JS fallback, using a selector specific enough to beat the
		// stylesheet's base rule.
		$min = $breakpoint + 1;
		$renderer->add_css(
			"@media (max-width:{$breakpoint}px){{$sel}.jalpi-advmenu .jalpi-advmenu__toggle{display:inline-flex;}}"
		);
		$renderer->add_css(
			"@media (min-width:{$min}px){{$sel}.jalpi-advmenu .jalpi-advmenu__toggle{display:none;}"
			. "{$sel}.jalpi-advmenu .jalpi-advmenu__close{display:none;}}"
		);

		// Vertical menus stack their mega panel beside the list rather than
		// below it, which only makes sense above the breakpoint.
		if ( 'vertical' === $layout ) {
			$renderer->add_css(
				"@media (min-width:{$min}px){{$sel}.jalpi-advmenu .jalpi-advmenu__list{flex-direction:column;}}"
			);
		}
	}

	// ── Small validators ─────────────────────────────────────────────────────
	// Values reach a stylesheet, so each is shape-checked rather than escaped.

	private function css_prop( string $prop, string $value ): string {
		return '' === $value ? '' : $prop . ':' . $value . ';';
	}

	private function col( $value ): string {
		return Jalpi_Sanitizer::sanitize_color( (string) $value );
	}

	private function len( $value ): string {
		$value = trim( (string) $value );
		if ( '' === $value ) {
			return '';
		}
		// Bare numbers are the editor's slider format; treat them as pixels.
		if ( is_numeric( $value ) ) {
			return $value . 'px';
		}
		return preg_match( '/^-?\d+(\.\d+)?(px|em|rem|%|vw|vh)$/', $value ) ? $value : '';
	}

	/**
	 * Line-height keeps a bare number UNITLESS — "1.6" means 1.6× the font
	 * size, which is what the slider means. Running it through len() would
	 * emit "1.6px" and collapse the text.
	 */
	private function line_height( $value ): string {
		$value = trim( (string) $value );
		if ( '' === $value ) {
			return '';
		}
		if ( preg_match( '/^\d+(\.\d+)?$/', $value ) ) {
			return $value;
		}
		return preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $value ) ? $value : '';
	}

	/** One to four space-separated lengths (padding/margin/radius shorthand). */
	private function len4( $value ): string {
		$value = trim( (string) $value );
		if ( '' === $value ) {
			return '';
		}
		$parts = preg_split( '/\s+/', $value );
		if ( ! $parts || count( $parts ) > 4 ) {
			return '';
		}
		foreach ( $parts as $part ) {
			if ( ! preg_match( '/^-?\d+(\.\d+)?(px|em|rem|%)$/', $part ) ) {
				return '';
			}
		}
		return implode( ' ', $parts );
	}

	private function weight( $value ): string {
		$value = trim( (string) $value );
		return preg_match( '/^(100|200|300|400|500|600|700|800|900|normal|bold)$/', $value ) ? $value : '';
	}

	/**
	 * A font stack is free text, so it is restricted to the characters a family
	 * list can legitimately contain — no braces, semicolons or parentheses that
	 * could close the declaration and inject a new one.
	 */
	private function font_family( $value ): string {
		$value = trim( (string) $value );
		if ( '' === $value || strlen( $value ) > 200 ) {
			return '';
		}
		return preg_match( '/^[a-zA-Z0-9 ,\'"\-]+$/', $value ) ? $value : '';
	}

	private function keyword( $value, array $allowed, string $fallback ): string {
		$value = trim( (string) $value );
		return in_array( $value, $allowed, true ) ? $value : $fallback;
	}

	/** Assemble a border shorthand from validated parts. */
	private function border( array $settings, string $prefix ): string {
		$style = $this->keyword(
			$settings[ $prefix . '_style' ] ?? '',
			[ 'none', 'solid', 'dashed', 'dotted', 'double' ],
			''
		);
		if ( ! $style || 'none' === $style ) {
			return '';
		}
		$width = $this->len( $settings[ $prefix . '_width' ] ?? '' ) ?: '1px';
		$color = $this->col( $settings[ $prefix . '_color' ] ?? '' ) ?: 'currentColor';
		return "border:{$width} {$style} {$color};";
	}

	/** Assemble a box-shadow from validated parts; never pass through raw text. */
	private function shadow( $value ): string {
		if ( ! is_array( $value ) || empty( $value['color'] ) ) {
			return '';
		}
		$colour = $this->col( $value['color'] );
		if ( ! $colour ) {
			return '';
		}
		$x      = (int) ( $value['x'] ?? 0 );
		$y      = (int) ( $value['y'] ?? 0 );
		$blur   = max( 0, (int) ( $value['blur'] ?? 0 ) );
		$spread = (int) ( $value['spread'] ?? 0 );
		return "{$x}px {$y}px {$blur}px {$spread}px {$colour}";
	}

	/** Assemble a linear-gradient from validated parts. */
	private function gradient( array $settings ): string {
		if ( 'gradient' !== ( $settings['am_panel_bg_type'] ?? '' ) ) {
			return '';
		}
		$c1 = $this->col( $settings['am_panel_grad_color1'] ?? '' );
		$c2 = $this->col( $settings['am_panel_grad_color2'] ?? '' );
		if ( ! $c1 || ! $c2 ) {
			return '';
		}
		$angle = trim( (string) ( $settings['am_panel_grad_angle'] ?? '' ) );
		if ( is_numeric( $angle ) ) {
			$angle .= 'deg';
		}
		if ( ! preg_match( '/^-?\d+(\.\d+)?deg$/', $angle ) ) {
			$angle = '180deg';
		}
		return "linear-gradient({$angle},{$c1},{$c2})";
	}
}
