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

/**
 * Menu — navigation built from a WordPress menu.
 *
 * Markup is a real <nav> wrapping nested <ul>/<li>/<a>, so the menu is
 * crawlable, works with JavaScript disabled, and is navigable by keyboard and
 * screen reader without extra scripting.
 *
 * Interactive behaviour (dropdown toggling and the responsive panel) lives in
 * initMenus() in assets/js/jalpi-frontend.js, driven entirely by the data-*
 * attributes emitted here.
 */
class Jalpi_Element_Menu extends Jalpi_Element {

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

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

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

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

		// 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 the default FIRST, then validate. Testing
		// ( $content['x'] ?? 'default' ) but returning $content['x'] yields null
		// whenever the key is absent, so the default is never actually used.
		$layout = (string) ( $content['layout'] ?? 'horizontal' );
		if ( ! in_array( $layout, [ 'horizontal', 'vertical' ], true ) ) {
			$layout = 'horizontal';
		}

		$responsive = (string) ( $content['responsiveMenu'] ?? 'dropdown' );
		if ( ! in_array( $responsive, [ 'dropdown', 'none' ], true ) ) {
			$responsive = 'dropdown';
		}

		$breakpoint = max( 320, min( 1440, (int) ( $content['mobileBreakpoint'] ?? 768 ) ) );

		$submenu_icon = (string) ( $content['submenuIcon'] ?? 'chevron' );
		if ( ! in_array( $submenu_icon, [ 'chevron', 'caret', 'plus', 'none' ], true ) ) {
			$submenu_icon = 'chevron';
		}

		$trigger = (string) ( $content['dropdownTrigger'] ?? 'hover' );
		if ( ! in_array( $trigger, [ 'hover', 'click' ], true ) ) {
			$trigger = 'hover';
		}

		$show_submenus = ! isset( $content['showSubmenus'] ) || ! empty( $content['showSubmenus'] );

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

		$this->emit_css( $renderer, $id, $styles, $settings, $content, $layout, $breakpoint );
		$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-menu jalpi-' . $id . ' jalpi-menu--' . $layout;
		$classes .= ' jalpi-menu--trigger-' . $trigger;
		$classes .= $a['extra_cls'];

		$cfg = [
			'trigger'    => $trigger,
			'responsive' => $responsive,
			'breakpoint' => $breakpoint,
		];

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

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

		$html .= '<div class="jalpi-menu__panel" id="jalpi-menu-panel-' . esc_attr( $id ) . '">';
		$html .= $this->render_list( $items, $submenu_icon, $show_submenus, 0 );
		$html .= '</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, ?Jalpi_Renderer $renderer = null ): array {
		// Design previews (Template Library, kit preview) show the template's
		// OWN demo items, so the preview represents the design rather than
		// whatever menu the current site happens to have assigned. On a real
		// page these are ignored and the live WordPress menu wins.
		if ( $renderer && $renderer->is_preview_mode() ) {
			$demo = $this->get_demo_items( $content );
			if ( $demo ) {
				return $demo;
			}
		}

		$menu_id  = (int) ( $content['menuId'] ?? 0 );
		$assigned = get_theme_mod( 'nav_menu_locations', [] );

		// 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 && ! empty( $assigned[ $location ] ) ) {
			$menu_id = (int) $assigned[ $location ];
		}

		// Nothing resolved yet — fall back to the site's own primary menu, the
		// way a normal WordPress theme does. Without this a template whose
		// menuLocation names a slot the active theme does not register (kits
		// ship 'primary'; block themes often register nothing) renders an empty
		// <nav>, and the header silently loses its navigation.
		if ( ! $menu_id && $assigned ) {
			foreach ( [ 'primary', 'main', 'menu-1', 'header', 'top' ] as $preferred ) {
				if ( ! empty( $assigned[ $preferred ] ) ) {
					$menu_id = (int) $assigned[ $preferred ];
					break;
				}
			}
			// Still nothing: any assigned location beats no menu at all.
			if ( ! $menu_id ) {
				$first   = array_filter( (array) $assigned );
				$menu_id = $first ? (int) reset( $first ) : 0;
			}
		}

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

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

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

	/**
	 * Build the item tree from the node's own `demoItems`, used only in preview
	 * mode so a template shows the navigation it was designed with.
	 *
	 * Shape mirrors build_tree() output so render_list() needs no special case.
	 * Accepts either plain strings ("Shop") or objects
	 * ({label, url, children:[…]}).
	 *
	 * @return array<int,array>
	 */
	private function get_demo_items( array $content, int $depth = 0, ?array $raw = null ): array {
		$raw ??= $content['demoItems'] ?? [];
		if ( ! is_array( $raw ) || $depth > self::MAX_DEPTH ) {
			return [];
		}

		$out = [];
		foreach ( $raw as $item ) {
			if ( is_string( $item ) ) {
				$item = [ 'label' => $item ];
			}
			if ( ! is_array( $item ) ) {
				continue;
			}

			$label = sanitize_text_field( (string) ( $item['label'] ?? '' ) );
			if ( '' === $label ) {
				continue;
			}

			$children = isset( $item['children'] ) && is_array( $item['children'] )
				? $this->get_demo_items( $content, $depth + 1, $item['children'] )
				: [];

			// Link each demo item to its page by slug so the standalone kit
			// preview can navigate. handle_site_preview() rewrites every
			// "/slug" href to that page's preview URL; an empty href here would
			// leave the whole preview menu unclickable. "Home" maps to "/".
			// An explicit `url` on the item wins. On the live site this demo
			// menu is replaced by the real WordPress menu, so this only ever
			// affects the preview.
			$slug = sanitize_title( $label );
			$url  = isset( $item['url'] ) && '' !== $item['url']
				? (string) $item['url']
				: ( 'home' === $slug ? '/' : '/' . $slug );

			$out[] = [
				'id'       => 0,
				'label'    => $label,
				'url'      => $url,
				'target'   => '',
				'current'  => ! empty( $item['current'] ),
				'classes'  => [],
				'children' => $children,
			];
		}

		return $out;
	}

	/**
	 * 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,
				'current'  => ! empty( $obj->current ) || ! 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 );
	}

	/**
	 * Render one level of the tree, recursing into children.
	 */
	private function render_list( array $items, string $submenu_icon, bool $show_submenus, int $depth ): string {
		if ( empty( $items ) || $depth > self::MAX_DEPTH ) {
			return '';
		}

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

		foreach ( $items as $item ) {
			$has_children = $show_submenus && ! empty( $item['children'] );

			$li_classes = [ 'jalpi-menu__item' ];
			if ( $has_children ) {
				$li_classes[] = 'jalpi-menu__item--has-children';
			}
			if ( ! empty( $item['current'] ) ) {
				$li_classes[] = 'is-current';
			}
			foreach ( (array) $item['classes'] as $extra ) {
				$cls = sanitize_html_class( $extra );
				if ( $cls ) {
					$li_classes[] = $cls;
				}
			}

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

			$url = $item['url'] ? esc_url( $item['url'] ) : '#';
			// noopener on new-tab links: without it the opened page can reach
			// back through window.opener.
			$target = '_blank' === $item['target'] ? ' target="_blank" rel="noopener noreferrer"' : '';
			$aria   = ! empty( $item['current'] ) ? ' aria-current="page"' : '';

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

			if ( $has_children ) {
				// A separate button owns submenu toggling so the link itself
				// stays a real navigable link for keyboard and screen readers.
				if ( 'none' !== $submenu_icon ) {
					$html .= '<button type="button" class="jalpi-menu__expand" aria-expanded="false" tabindex="-1">'
						. '<span class="jalpi-menu__indicator jalpi-menu__indicator--' . esc_attr( $submenu_icon ) . '" 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>';
				}

				$html .= $this->render_list( $item['children'], $submenu_icon, $show_submenus, $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,
		int $breakpoint
	): void {
		$sel = '.jalpi-el.jalpi-' . $id;
		$css = '';

		$align = (string) ( $content['align'] ?? 'left' );
		if ( ! in_array( $align, [ 'left', 'center', 'right', 'space-between' ], true ) ) {
			$align = 'left';
		}
		$justify = [
			'left'          => 'flex-start',
			'center'        => 'center',
			'right'         => 'flex-end',
			'space-between' => '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-menu to
		// outrank the stylesheet's single-class defaults.
		$css .= "{$sel}.jalpi-menu .jalpi-menu__list{justify-content:{$justify};}";

		// Menu width honours the alignment so a narrowed menu still sits where
		// the author asked.
		$menu_width = $this->len( $content['menuWidth'] ?? '' );
		if ( $menu_width ) {
			$margin = 'center' === $align ? '0 auto' : ( 'right' === $align ? '0 0 0 auto' : '0 auto 0 0' );
			$css   .= "{$sel}.jalpi-menu{width:{$menu_width};max-width:100%;margin:{$margin};}";
		}

		// Item Spacing (content) and Item Gap (style) both drive the same
		// flex gap; the style value wins when set so the Style tab stays the
		// finer-grained control.
		$gap = $this->len( $settings['menu_gap'] ?? '' ) ?: $this->len( $content['itemSpacing'] ?? '' );
		if ( $gap ) {
			$css .= "{$sel}.jalpi-menu .jalpi-menu__list{gap:{$gap};}";
		}

		// Link typography + normal colours.
		$link  = '';
		$link .= $this->css_prop( 'color', Jalpi_Sanitizer::sanitize_color( $settings['menu_color'] ?? '' ) );
		$link .= $this->css_prop( 'font-size', $this->len( $settings['menu_font_size'] ?? '' ) );
		$link .= $this->css_prop( 'font-weight', $this->num( $settings['menu_font_weight'] ?? '' ) );
		$link .= $this->css_prop( 'letter-spacing', $this->len( $settings['menu_letter_spacing'] ?? '' ) );
		$link .= $this->css_prop( 'text-transform', $this->keyword(
			$settings['menu_text_transform'] ?? '',
			[ 'none', 'uppercase', 'lowercase', 'capitalize' ]
		) );
		$link .= $this->css_prop( 'padding', $this->len4( $settings['menu_padding'] ?? '' ) );
		$link .= $this->css_prop( 'margin', $this->len4( $settings['menu_margin'] ?? '' ) );
		if ( $link ) {
			$css .= "{$sel}.jalpi-menu .jalpi-menu__link{{$link}}";
		}

		// Hover / active states.
		$hover  = '';
		$hover .= $this->css_prop( 'color', Jalpi_Sanitizer::sanitize_color( $settings['menu_color_hover'] ?? '' ) );
		$hover .= $this->css_prop( 'background', Jalpi_Sanitizer::sanitize_color( $settings['menu_bg_hover'] ?? '' ) );
		if ( $hover ) {
			$css .= "{$sel}.jalpi-menu .jalpi-menu__link:hover,{$sel}.jalpi-menu .jalpi-menu__link:focus-visible{{$hover}}";
		}

		$active  = '';
		$active .= $this->css_prop( 'color', Jalpi_Sanitizer::sanitize_color( $settings['menu_color_active'] ?? '' ) );
		$active .= $this->css_prop( 'background', Jalpi_Sanitizer::sanitize_color( $settings['menu_bg_active'] ?? '' ) );
		if ( $active ) {
			$css .= "{$sel}.jalpi-menu .is-current > .jalpi-menu__link{{$active}}";
		}

		// Menu background / border / radius / shadow.
		$box  = '';
		$box .= $this->css_prop( 'background', Jalpi_Sanitizer::sanitize_color( $settings['menu_bg'] ?? '' ) );
		$box .= $this->css_prop( 'border-radius', $this->len4( $settings['menu_radius'] ?? '' ) );

		$border_style = $this->keyword(
			$settings['menu_border_style'] ?? '',
			[ 'none', 'solid', 'dashed', 'dotted', 'double' ]
		);
		if ( $border_style && 'none' !== $border_style ) {
			$bw   = $this->len( $settings['menu_border_width'] ?? '' ) ?: '1px';
			$bc   = Jalpi_Sanitizer::sanitize_color( $settings['menu_border_color'] ?? '' ) ?: 'currentColor';
			$box .= "border:{$bw} {$border_style} {$bc};";
		}

		$shadow = $this->shadow( $settings['menu_shadow'] ?? [] );
		if ( $shadow ) {
			$box .= 'box-shadow:' . $shadow . ';';
		}

		$box .= $renderer->styles_to_css( $styles );
		if ( $box ) {
			$css .= "{$sel}{{$box}}";
		}

		// Dropdown panel styling.
		$sub  = '';
		$sub .= $this->css_prop( 'background', Jalpi_Sanitizer::sanitize_color( $settings['submenu_bg'] ?? '' ) );
		$sub .= $this->css_prop( 'min-width', $this->len( $settings['submenu_width'] ?? '' ) );
		$sub .= $this->css_prop( 'border-radius', $this->len4( $settings['submenu_radius'] ?? '' ) );
		if ( $sub ) {
			$css .= "{$sel}.jalpi-menu .jalpi-menu__sub{{$sub}}";
		}

		$sub_link  = $this->css_prop( 'color', Jalpi_Sanitizer::sanitize_color( $settings['submenu_color'] ?? '' ) );
		$sub_link .= $this->css_prop( 'padding', $this->len4( $settings['submenu_padding'] ?? '' ) );
		if ( $sub_link ) {
			$css .= "{$sel}.jalpi-menu .jalpi-menu__sub .jalpi-menu__link{{$sub_link}}";
		}

		$renderer->add_css( $css );

		// Responsive collapse is driven by the [data-collapsed="1"] attribute
		// that initMenus() 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-menu .jalpi-menu__toggle{display:inline-flex;}}"
		);
		$renderer->add_css(
			"@media (min-width:{$min}px){{$sel}.jalpi-menu .jalpi-menu__toggle{display:none;}}"
		);
	}

	// ── 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 len( $value ): string {
		$value = trim( (string) $value );
		return preg_match( '/^-?\d+(\.\d+)?(px|em|rem|%|vw|vh)$/', $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 ( count( $parts ) > 4 ) {
			return '';
		}
		foreach ( $parts as $part ) {
			if ( ! preg_match( '/^-?\d+(\.\d+)?(px|em|rem|%)$/', $part ) ) {
				return '';
			}
		}
		return implode( ' ', $parts );
	}

	private function num( $value ): string {
		$value = trim( (string) $value );
		return preg_match( '/^\d{3}$/', $value ) ? $value : '';
	}

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

	/** 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 = Jalpi_Sanitizer::sanitize_color( $value['color'] );
		if ( ! $colour ) {
			return '';
		}
		$x    = (int) ( $value['x'] ?? 0 );
		$y    = (int) ( $value['y'] ?? 0 );
		$blur = max( 0, (int) ( $value['blur'] ?? 0 ) );
		return "{$x}px {$y}px {$blur}px {$colour}";
	}
}
