<?php
/**
 * Block menu builder module.
 *
 * @package MenuVerse
 */

namespace WPXero\MenuVerse\Module\Block_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;

/**
 * A navigation built from blocks, rendered by the same code in both places.
 *
 * The parent and item blocks are both dynamic. That is what makes editor and
 * frontend parity real rather than a promise: one PHP renderer produces the
 * markup, and the editor canvas uses the same class names and the same
 * stylesheet.
 */
final class Module extends Abstract_Module {

	use Menu_Backed;

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

	/**
	 * Parent block name.
	 *
	 * @var string
	 */
	const BLOCK = 'menuverse/navigation';

	/**
	 * Item block name.
	 *
	 * @var string
	 */
	const ITEM_BLOCK = 'menuverse/nav-item';


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

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

	/**
	 * Returns the translated module description.
	 *
	 * @return string
	 */
	public static function description() {
		return __( 'Build a menu in the editor, item by item, and style it there like anything else on the page.', '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() {
		add_shortcode( self::SHORTCODE, array( $this, 'render_shortcode' ) );

		// The stylesheet is registered on init rather than wp_enqueue_scripts
		// so the editor can load the very same file the frontend uses.
		add_action( 'init', array( $this, 'register_block_style' ), 4 );
		add_action( 'init', array( $this, 'register_blocks' ) );
		add_action( 'wp', array( $this, 'resolve_render_paths' ) );
	}

	/**
	 * Registers the shared stylesheet used by the frontend and the editor.
	 *
	 * @return void
	 */
	public function register_block_style() {
		$this->register_module_assets( true, false );
	}

	/**
	 * Registers both block types.
	 *
	 * @return void
	 */
	public function register_blocks() {
		if ( ! function_exists( 'register_block_type' ) ) {
			return;
		}

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

		register_block_type(
			__DIR__ . '/nav-item/block.json',
			array(
				'render_callback' => array( $this, 'render_item_block' ),
			)
		);
	}

	/**
	 * Returns the priority the automatic placement renders at.
	 *
	 * Keeps a stable order against the other modules that also place
	 * themselves on `wp_body_open`.
	 *
	 * @return int
	 */
	protected function auto_insert_priority() {
		return 15;
	}

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

	/**
	 * Renders the parent navigation block.
	 *
	 * @param array  $attributes Block attributes.
	 * @param string $content    Rendered inner blocks.
	 * @return string
	 */
	public function render_navigation_block( $attributes, $content ) {
		if ( '' === trim( (string) $content ) ) {
			return '';
		}

		$settings = $this->settings();

		foreach ( array( 'layout', 'submenu_trigger', 'breakpoint' ) as $key ) {
			if ( isset( $attributes[ $key ] ) && '' !== $attributes[ $key ] && 0 !== $attributes[ $key ] ) {
				$settings[ $key ] = $attributes[ $key ];
			}
		}

		if ( ! empty( $attributes['align_items'] ) ) {
			$settings['align'] = $attributes['align_items'];
		}

		$this->enqueue();

		// The tokens must go through get_block_wrapper_attributes, not into a
		// second style attribute: a duplicate attribute is ignored by the
		// browser, and block supports emit a style of their own.
		$wrapper = get_block_wrapper_attributes(
			array(
				'class' => $this->root_classes( $settings ),
				'style' => $this->style_tokens( $settings ),
			)
		);

		return $this->wrap( $wrapper, $content, $settings );
	}

	/**
	 * Renders one item block.
	 *
	 * @param array  $attributes Block attributes.
	 * @param string $content    Rendered child items.
	 * @return string
	 */
	public function render_item_block( $attributes, $content ) {
		$label = isset( $attributes['label'] ) ? (string) $attributes['label'] : '';
		$url   = isset( $attributes['url'] ) ? (string) $attributes['url'] : '';

		if ( '' === trim( $label ) ) {
			return '';
		}

		$children = trim( (string) $content );
		$sub_id   = 'menuverse-nav-sub-' . Renderer::next_id();

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

		if ( '' !== $children ) {
			$classes[] = 'menuverse-nav__item--parent';
		}

		if ( '' !== $url && Renderer::is_current_url( $url ) ) {
			$classes[] = 'menuverse-nav__item--current';
			$current   = true;
		} else {
			$current = false;
		}

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

		$icon = isset( $attributes['icon'] ) ? (string) $attributes['icon'] : '';
		$body = '';

		if ( '' !== $icon && Icon_Library::has( $icon ) ) {
			$body .= Icon_Library::get( $icon, 'menuverse-nav__icon', 18 );
		}

		$body .= '<span class="menuverse-nav__label">' . esc_html( $label ) . '</span>';

		if ( ! empty( $attributes['badge'] ) ) {
			$body .= '<span class="menuverse-nav__badge">' . esc_html( $attributes['badge'] ) . '</span>';
		}

		if ( '' === $url ) {
			$html .= '<span class="menuverse-nav__link">' . $body . '</span>';
		} else {
			$rel = isset( $attributes['rel'] ) ? trim( (string) $attributes['rel'] ) : '';

			if ( ! empty( $attributes['opensInNewTab'] ) ) {
				$rel = trim( $rel . ' noopener' );
			}

			$html .= sprintf(
				'<a class="menuverse-nav__link" href="%1$s"%2$s%3$s%4$s>%5$s</a>',
				esc_url( $url ),
				! empty( $attributes['opensInNewTab'] ) ? ' target="_blank"' : '',
				'' !== $rel ? ' rel="' . esc_attr( $rel ) . '"' : '',
				$current ? ' aria-current="page"' : '',
				$body
			);
		}

		if ( '' !== $children ) {
			// The toggle is always rendered: it is the only way to open a
			// submenu from the keyboard, so it is not an optional flourish.
			$html .= sprintf(
				'<button type="button" class="menuverse-nav__toggle" aria-expanded="false" aria-controls="%1$s" data-mv-toggle><span class="menuverse-sr-only">%2$s</span>%3$s</button>',
				esc_attr( $sub_id ),
				esc_html(
					sprintf(
						/* translators: %s: menu item title. */
						__( 'Show submenu of %s', 'menuverse' ),
						$label
					)
				),
				Icon_Library::get( 'chevron-down', 'menuverse-nav__arrow', 16 )
			);

			$html .= sprintf(
				'<ul class="menuverse-nav__sublist" id="%s" hidden>%s</ul>',
				esc_attr( $sub_id ),
				// Child items are produced by this same method, one level down.
				$children // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			);
		}

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

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

		$args = array();

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

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

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

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

	/**
	 * Renders the module from a stored menu, for every non block path.
	 *
	 * The block builds its own items; the shortcode, the template tag and
	 * automatic insertion have no inner blocks to read, so they render an
	 * existing menu through the same markup and the same stylesheet.
	 *
	 * @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 '';
		}

		$list = Renderer::render_tree(
			$tree,
			array(
				'base_class'      => 'menuverse-nav',
				'id_prefix'       => 'menuverse-nav',
				'submenu_toggles' => true,
				'toggle_attrs'    => 'data-mv-toggle',
			)
		);

		return $this->wrap(
			sprintf(
				'class="%s" style="%s"',
				esc_attr( $this->root_classes( $settings ) ),
				esc_attr( $this->style_tokens( $settings ) )
			),
			$list,
			$settings,
			false
		);
	}

	/**
	 * Wraps rendered items in the navigation shell.
	 *
	 * @param string $wrapper  Wrapper attributes, already escaped.
	 * @param string $items    Rendered list items or a complete list.
	 * @param array  $settings Resolved settings.
	 * @param bool   $wrap_ul  Whether the items still need a list element.
	 * @return string
	 */
	private function wrap( $wrapper, $items, array $settings, $wrap_ul = true ) {
		$root_id = 'menuverse-nav-' . Renderer::next_id();

		$list = $wrap_ul
			? '<ul class="menuverse-nav__list">' . $items . '</ul>'
			: $items;

		$html = sprintf(
			'<nav %1$s id="%2$s" data-menuverse-nav data-mv-trigger="%3$s" data-mv-breakpoint="%4$d" aria-label="%5$s">%6$s</nav>',
			// Wrapper attributes come from get_block_wrapper_attributes or are
			// escaped above.
			$wrapper, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			esc_attr( $root_id ),
			esc_attr( $settings['submenu_trigger'] ),
			(int) $settings['breakpoint'],
			esc_attr__( 'Menu', 'menuverse' ),
			// Items are built by this class, escaped as they are assembled.
			$list // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		);

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

		return $html;
	}

	/**
	 * Builds the root class list.
	 *
	 * @param array $settings Resolved settings.
	 * @return string
	 */
	private function root_classes( array $settings ) {
		$classes = array(
			'menuverse-nav',
			'menuverse-nav--' . sanitize_html_class( $settings['layout'] ),
			'menuverse-nav--align-' . sanitize_html_class( $settings['align'] ),
			'menuverse-nav--trigger-' . sanitize_html_class( $settings['submenu_trigger'] ),
		);

		return implode( ' ', $classes );
	}

	/**
	 * 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 ) {
		if ( 'horizontal' !== $settings['layout'] ) {
			return '';
		}

		// The stylesheet holds the horizontal layout on a class, so the editor
		// canvas matches the frontend. All this adds is the stacking that
		// happens below the instance's own breakpoint.
		return sprintf(
			'@media (max-width:%1$dpx){#%2$s > .menuverse-nav__list{flex-direction:column;align-items:stretch}#%2$s .menuverse-nav__item{position:static}#%2$s .menuverse-nav__sublist{position:static;min-width:0;flex-basis:100%%;padding:0;padding-inline-start:var(--menuverse-space);border-radius:0;background:transparent;box-shadow:none}}',
			(int) $settings['breakpoint'],
			$root_id
		);
	}
}
