<?php
/**
 * Mega menu module.
 *
 * @package MenuVerse
 */

namespace WPXero\MenuVerse\Module\Mega_Menu;

use WPXero\MenuVerse\Frontend\Icon_Library;
use WPXero\MenuVerse\Frontend\Renderer;
use WPXero\MenuVerse\Menus\Menu_Tree;
use WPXero\MenuVerse\Modules\Abstract_Module;
use WPXero\MenuVerse\Modules\Menu_Backed;

defined( 'ABSPATH' ) || exit;

/**
 * Horizontal navigation whose items can open full mega panels.
 */
final class Module extends Abstract_Module {

	use Menu_Backed;

	/**
	 * Shortcode tag.
	 *
	 * @var string
	 */
	const SHORTCODE = 'menuverse_mega_menu';

	/**
	 * Block name.
	 *
	 * @var string
	 */
	const BLOCK = 'menuverse/mega-menu';

	/**
	 * Rendered panel content keyed by panel ID.
	 *
	 * @var array<int,string>
	 */
	private $panel_cache = array();

	/**
	 * Marks the module as not yet released.
	 *
	 * Read statically by the registry, which treats an unavailable module as
	 * switched off however the option reads: it never boots, registers no
	 * hooks and renders nothing, and the dashboard shows it as coming soon
	 * rather than as a menu that can be set up today. Whatever someone had
	 * already saved for it is left alone, so removing this constant when the
	 * module ships restores their settings rather than resetting them.
	 *
	 * @var bool
	 */
	const COMING_SOON = true;

	/**
	 * Whether this module's dashboard screen embeds the block editor.
	 *
	 * Panels are built with blocks, so editing one means loading the whole
	 * block editor — script, styles, the media modal and a handful of
	 * `/wp/v2` preloads. That is by far the heaviest thing the dashboard can
	 * ask for, and it is worth nothing on a screen that never opens a panel,
	 * so the dashboard asks for it rather than loading it unconditionally.
	 *
	 * Read statically, like `COMING_SOON`, so the registry can answer without
	 * constructing anything — and paired with it: while this module is
	 * unavailable the panel editor cannot be reached, so nothing needs the
	 * block editor and none of it is loaded at all.
	 *
	 * @var bool
	 */
	const NEEDS_BLOCK_EDITOR = true;

	/**
	 * Returns the module identifier.
	 *
	 * @return string
	 */
	public static function id() {
		return 'mega_menu';
	}

	/**
	 * Returns the translated module label.
	 *
	 * @return string
	 */
	public static function label() {
		return __( 'Mega Menu', 'menuverse' );
	}

	/**
	 * Returns the translated module description.
	 *
	 * @return string
	 */
	public static function description() {
		return __( 'Large drop down panels you lay out yourself, for sites with a lot to fit in. Becomes a simple list on phones.', 'menuverse' );
	}

	/**
	 * Returns the settings schema.
	 *
	 * @return array
	 */
	public static function schema() {
		return require __DIR__ . '/schema.php';
	}

	/**
	 * Registers hooks. Runs only when the module is enabled.
	 *
	 * @return void
	 */
	public function boot() {
		( new Panel_Post_Type() )->boot();

		add_shortcode( self::SHORTCODE, array( $this, 'render_shortcode' ) );
		add_action( 'init', array( $this, 'register_block' ) );
		add_action( 'wp', array( $this, 'resolve_render_paths' ) );
	}

	/**
	 * Registers the block type.
	 *
	 * @return void
	 */
	public function register_block() {
		if ( ! function_exists( 'register_block_type' ) ) {
			return;
		}

		register_block_type(
			__DIR__ . '/block.json',
			array(
				'render_callback' => array( $this, 'render_block' ),
			)
		);
	}

	/**
	 * Renders the panels while the head is still open.
	 *
	 * Panels are rendered here rather than at output time so the core block
	 * styles they need are enqueued before the head closes. Rendering later
	 * would leave block content unstyled.
	 *
	 * @return void
	 */
	protected function on_early_assets() {
		add_action( 'wp_enqueue_scripts', array( $this, 'prime_panels' ), 19 );
	}

	/**
	 * Renders every panel this request will show, ahead of the document head.
	 *
	 * @return void
	 */
	public function prime_panels() {
		$tree = $this->resolve_menu( $this->settings() );

		if ( ! $tree instanceof Menu_Tree ) {
			return;
		}

		foreach ( $tree->items() as $item ) {
			$extras = isset( $item['extras'] ) ? $item['extras'] : array();

			if ( empty( $extras['mega'] ) || empty( $extras['panel_id'] ) ) {
				continue;
			}

			$this->render_panel_content( (int) $extras['panel_id'] );
		}
	}

	/**
	 * Renders a panel's blocks once per request.
	 *
	 * @param int $panel_id Panel post ID.
	 * @return string|null Rendered content, or null when the panel is unusable.
	 */
	private function render_panel_content( $panel_id ) {
		if ( array_key_exists( $panel_id, $this->panel_cache ) ) {
			return $this->panel_cache[ $panel_id ];
		}

		$panel = get_post( $panel_id );

		if ( ! $panel instanceof \WP_Post || Panel_Post_Type::POST_TYPE !== $panel->post_type || 'publish' !== $panel->post_status ) {
			$this->panel_cache[ $panel_id ] = null;

			return null;
		}

		$blocks = parse_blocks( $panel->post_content );

		$this->enqueue_block_styles( $blocks );

		$this->panel_cache[ $panel_id ] = do_blocks( $panel->post_content );

		return $this->panel_cache[ $panel_id ];
	}

	/**
	 * Enqueues the stylesheet each block inside a panel needs.
	 *
	 * WordPress attaches core block styles as those blocks render inside the
	 * main content. Panel content is not the main content, and several themes
	 * drop the aggregated stylesheet, so a panel would otherwise show
	 * unstyled columns. Only the handles the panel actually uses are added.
	 *
	 * @param array[] $blocks Parsed blocks.
	 * @return void
	 */
	private function enqueue_block_styles( array $blocks ) {
		foreach ( $blocks as $block ) {
			$name = isset( $block['blockName'] ) ? (string) $block['blockName'] : '';

			if ( '' !== $name ) {
				$handle = 'wp-block-' . str_replace( 'core/', '', $name );

				if ( wp_style_is( $handle, 'registered' ) ) {
					wp_enqueue_style( $handle );
				}
			}

			if ( ! empty( $block['innerBlocks'] ) ) {
				$this->enqueue_block_styles( $block['innerBlocks'] );
			}
		}
	}

	/**
	 * Registers the module's frontend assets without enqueueing them.
	 *
	 * @return void
	 */
	public function register_assets() {
		$this->register_module_assets();
	}

	/**
	 * Renders the module for the shortcode.
	 *
	 * @param array|string $atts Shortcode attributes.
	 * @return string
	 */
	public function render_shortcode( $atts ) {
		$atts = shortcode_atts(
			array(
				'menu'       => '',
				'trigger'    => '',
				'breakpoint' => '',
				'align'      => '',
			),
			is_array( $atts ) ? $atts : array(),
			self::SHORTCODE
		);

		$args = array();

		if ( '' !== $atts['menu'] ) {
			$args['menu'] = sanitize_text_field( $atts['menu'] );
		}

		if ( '' !== $atts['trigger'] ) {
			$args['trigger'] = sanitize_key( $atts['trigger'] );
		}

		if ( '' !== $atts['breakpoint'] ) {
			$args['breakpoint'] = (int) $atts['breakpoint'];
		}

		if ( '' !== $atts['align'] ) {
			$args['bar_align'] = sanitize_key( $atts['align'] );
		}

		return $this->output( $this->clean( $args ), array( 'source' => 'shortcode' ) );
	}

	/**
	 * Renders the module for the block.
	 *
	 * @param array $attributes Block attributes.
	 * @return string
	 */
	public function render_block( $attributes ) {
		$args = array();

		foreach ( array( 'menu', 'trigger', 'bar_align', 'breakpoint' ) as $key ) {
			if ( isset( $attributes[ $key ] ) && '' !== $attributes[ $key ] && 0 !== $attributes[ $key ] ) {
				$args[ $key ] = $attributes[ $key ];
			}
		}

		return $this->output( $this->clean( $args ), array( 'source' => 'block' ) );
	}

	/**
	 * Renders the module markup.
	 *
	 * @param array $settings Resolved settings.
	 * @param array $context  Render context.
	 * @return string
	 */
	public function render( array $settings, array $context ) {
		$tree = $this->resolve_menu( $settings );

		if ( ! $tree instanceof Menu_Tree || ! $tree->items() ) {
			return '';
		}

		$uid     = Renderer::next_id();
		$root_id = 'menuverse-mega-' . $uid;
		$items   = Renderer::mark_current( $tree->items() );

		$classes = array(
			'menuverse-mega',
			'menuverse-mega--align-' . sanitize_html_class( $settings['bar_align'] ),
			'menuverse-mega--' . sanitize_html_class( $settings['panel_width'] ),
			'menuverse-mega--trigger-' . sanitize_html_class( $settings['trigger'] ),
		);

		if ( $settings['panel_shadow'] ) {
			$classes[] = 'menuverse-mega--shadow';
		}

		$html = sprintf(
			'<div id="%1$s" class="%2$s" style="%3$s" data-menuverse-mega data-mv-trigger="%4$s" data-mv-open-delay="%5$d" data-mv-close-delay="%6$d" data-mv-breakpoint="%7$d">',
			esc_attr( $root_id ),
			esc_attr( implode( ' ', $classes ) ),
			esc_attr( $this->style_tokens( $settings ) ),
			esc_attr( $settings['trigger'] ),
			(int) $settings['hover_delay'],
			(int) $settings['close_delay'],
			(int) $settings['breakpoint']
		);

		$html .= sprintf(
			'<nav class="menuverse-mega__nav" aria-label="%s"><ul class="menuverse-mega__list">',
			esc_attr__( 'Main', 'menuverse' )
		);

		foreach ( $items as $index => $item ) {
			$html .= $this->render_top_item( $item, $settings, $root_id . '-' . $index );
		}

		$html .= '</ul></nav></div>';

		$this->add_instance_css( $this->instance_css( $root_id, $settings ) );

		return $html;
	}

	/**
	 * Renders one top level item, with a panel, a submenu, or neither.
	 *
	 * @param array  $item     Normalized item.
	 * @param array  $settings Resolved settings.
	 * @param string $id_base  Unique id prefix for this item.
	 * @return string
	 */
	private function render_top_item( array $item, array $settings, $id_base ) {
		$extras   = isset( $item['extras'] ) ? $item['extras'] : array();
		$panel_id = ! empty( $extras['mega'] ) ? (int) $extras['panel_id'] : 0;
		$panel    = $panel_id ? $this->panel_markup( $panel_id, $settings, $id_base . '-panel' ) : '';
		$children = ! empty( $item['children'] ) ? $item['children'] : array();
		$has_drop = '' === $panel && ! empty( $children );
		$target   = $id_base . ( '' !== $panel ? '-panel' : '-sub' );

		$classes = array( 'menuverse-mega__item' );

		if ( '' !== $panel ) {
			$classes[] = 'menuverse-mega__item--mega';
		} elseif ( $has_drop ) {
			$classes[] = 'menuverse-mega__item--parent';
		}

		if ( ! empty( $item['current'] ) || ! empty( $item['current_ancestor'] ) ) {
			$classes[] = 'menuverse-mega__item--current';
		}

		$html = sprintf( '<li class="%s">', esc_attr( implode( ' ', $classes ) ) );

		$html .= sprintf(
			'<a class="menuverse-mega__link" href="%s"%s><span>%s</span></a>',
			$item['url'] ? esc_url( $item['url'] ) : '#',
			! empty( $item['current'] ) ? ' aria-current="page"' : '',
			esc_html( $item['title'] )
		);

		if ( '' !== $panel || $has_drop ) {
			$html .= sprintf(
				'<button type="button" class="menuverse-mega__toggle" aria-expanded="false" aria-controls="%1$s" data-mv-toggle><span class="menuverse-sr-only">%2$s</span>%3$s</button>',
				esc_attr( $target ),
				esc_html(
					sprintf(
						/* translators: %s: menu item title. */
						__( 'Show submenu of %s', 'menuverse' ),
						$item['title']
					)
				),
				$settings['show_arrows'] ? Icon_Library::get( 'chevron-down', 'menuverse-mega__arrow', 16 ) : ''
			);
		}

		if ( '' !== $panel ) {
			$html .= str_replace( '{{id}}', esc_attr( $target ), $panel );
		} elseif ( $has_drop ) {
			$html .= $this->render_dropdown( $children, $target );
		}

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

	/**
	 * Renders a plain dropdown for items without a panel.
	 *
	 * @param array[] $children Child items.
	 * @param string  $id       Element id.
	 * @return string
	 */
	private function render_dropdown( array $children, $id ) {
		$html = sprintf( '<div class="menuverse-mega__dropdown" id="%s" hidden><ul>', esc_attr( $id ) );

		foreach ( $children as $child ) {
			$html .= sprintf(
				'<li><a class="menuverse-mega__sublink" href="%s"%s>%s</a>%s</li>',
				$child['url'] ? esc_url( $child['url'] ) : '#',
				! empty( $child['current'] ) ? ' aria-current="page"' : '',
				esc_html( $child['title'] ),
				! empty( $child['children'] ) ? $this->render_nested( $child['children'] ) : ''
			);
		}

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

	/**
	 * Renders levels below the first dropdown level.
	 *
	 * @param array[] $children Child items.
	 * @return string
	 */
	private function render_nested( array $children ) {
		$html = '<ul class="menuverse-mega__nested">';

		foreach ( $children as $child ) {
			$html .= sprintf(
				'<li><a class="menuverse-mega__sublink" href="%s">%s</a>%s</li>',
				$child['url'] ? esc_url( $child['url'] ) : '#',
				esc_html( $child['title'] ),
				! empty( $child['children'] ) ? $this->render_nested( $child['children'] ) : ''
			);
		}

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

	/**
	 * Renders a mega panel's block content.
	 *
	 * @param int    $panel_id Panel post ID.
	 * @param array  $settings Resolved settings.
	 * @param string $id       Element id, substituted by the caller.
	 * @return string Empty string when the panel is missing or empty.
	 */
	private function panel_markup( $panel_id, array $settings, $id ) {
		$content = $this->render_panel_content( $panel_id );

		if ( null === $content ) {
			return '';
		}

		$meta = Panel_Post_Type::settings( $panel_id );

		if ( '' === trim( wp_strip_all_tags( $content ) ) && false === strpos( $content, '<img' ) ) {
			return '';
		}

		$width = 'inherit' === $meta['width'] ? $settings['panel_width'] : $meta['width'];

		$style = sprintf( '--menuverse-mega-columns:%d', (int) $meta['columns'] );

		if ( '' !== $meta['background'] ) {
			$style .= ';--menuverse-mega-panel-background:' . $meta['background'];
		}

		if ( '' !== $meta['padding'] ) {
			$style .= ';--menuverse-mega-panel-padding:' . $meta['padding'];
		}

		return sprintf(
			'<div class="menuverse-mega__panel menuverse-mega__panel--%1$s" id="{{id}}" style="%2$s" hidden><div class="menuverse-mega__panel-inner">%3$s</div></div>',
			esc_attr( $width ),
			esc_attr( $style ),
			// Panel content is post content produced by the block editor and
			// already filtered on save; do_blocks renders the registered
			// blocks. Escaping here would strip the markup it produces.
			$content // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		);
	}

	/**
	 * Builds the per instance stylesheet.
	 *
	 * @param string $root_id  Root element id.
	 * @param array  $settings Resolved settings.
	 * @return string
	 */
	private function instance_css( $root_id, array $settings ) {
		$above = (int) $settings['breakpoint'] + 1;
		$id    = '#' . $root_id;

		// The stylesheet describes the accordion, which is both the phone
		// layout and the correct no-script layout. Everything that turns it
		// into a horizontal bar is generated here, because the breakpoint is a
		// user setting and a media query cannot read a custom property.
		$rules = array(
			"{$id} .menuverse-mega__list{display:flex;flex-wrap:wrap;gap:var(--menuverse-mega-gap)}",
			"{$id} .menuverse-mega__item{flex-wrap:nowrap;justify-content:flex-start;border-block-end:0}",
			"{$id} .menuverse-mega__link{flex:none}",
			"{$id} .menuverse-mega__toggle{width:28px;height:28px}",
			"{$id} .menuverse-mega__panel,{$id} .menuverse-mega__dropdown{position:absolute;z-index:var(--menuverse-z);inset-block-start:100%;inset-inline-start:0;flex-basis:auto}",
			"{$id} .menuverse-mega__panel--contained{width:min(var(--menuverse-mega-max-width),calc(100vw - var(--menuverse-space) * 4))}",
			// A full width panel is anchored to the viewport, not to its menu
			// item: the usual calc(50% - 50vw) bleed only lands correctly when
			// the item happens to sit at the centre of the page.
			"{$id} .menuverse-mega__panel--full{position:fixed;inset-inline:0;width:auto;margin-inline-start:0;inset-block-start:var(--menuverse-mega-top,100%)}",
			"{$id} .menuverse-mega__dropdown{min-width:220px;padding-block:var(--menuverse-space);border-radius:var(--menuverse-radius)}",
			"{$id} .menuverse-mega__panel-inner{padding-inline:var(--menuverse-mega-panel-padding);grid-template-columns:repeat(var(--menuverse-mega-columns),minmax(0,1fr))}",
			"{$id} .menuverse-mega__item--hide-desktop{display:none}",
		);

		if ( 'center' === $settings['bar_align'] ) {
			$rules[] = "{$id} .menuverse-mega__list{justify-content:center}";
		} elseif ( 'end' === $settings['bar_align'] ) {
			$rules[] = "{$id} .menuverse-mega__list{justify-content:flex-end}";
		}

		if ( ! empty( $settings['panel_shadow'] ) ) {
			$rules[] = "{$id} .menuverse-mega__panel,{$id} .menuverse-mega__dropdown{box-shadow:0 1px 2px rgba(15,23,42,.06),0 12px 28px rgba(15,23,42,.1)}";
			$rules[] = "{$id} .menuverse-mega__panel--contained{border-radius:var(--menuverse-radius)}";
		}

		$mobile = sprintf(
			'@media (max-width:%1$dpx){%2$s .menuverse-mega__item--hide-mobile{display:none}}',
			(int) $settings['breakpoint'],
			$id
		);

		return sprintf( '@media (min-width:%dpx){%s}%s', $above, implode( '', $rules ), $mobile );
	}
}
