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

namespace WPXero\MenuVerse\Module\Mobile_Menu;

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

defined( 'ABSPATH' ) || exit;

/**
 * Off canvas navigation for small screens.
 */
final class Module extends Abstract_Module {

	use Menu_Backed;

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

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

	/**
	 * Height WordPress gives its toolbar on the screens this module covers.
	 *
	 * Core switches the toolbar to 46px at 782px wide and makes it scroll with
	 * the page rather than stay pinned. Only the panel and a pinned button
	 * need to clear it, and only at the top of the page.
	 *
	 * @var int
	 */
	const ADMIN_BAR_HEIGHT = 46;

	/**
	 * Marks this module as released but still settling.
	 *
	 * Read statically by `Module_Registry::is_beta()`. It gates nothing — the
	 * module boots, renders and saves like any other — and only changes what
	 * the dashboard says about it. Take the constant off to drop the label.
	 *
	 * @var bool
	 */
	const BETA = true;

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

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

	/**
	 * Returns the translated module description.
	 *
	 * @return string
	 */
	public static function description() {
		return __( 'The menu slides in from the side of the screen when someone taps the menu button. The usual choice for 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() {
		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' ),
			)
		);
	}

	/**
	 * 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 5;
	}

	/**
	 * 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'       => '',
				'side'       => '',
				'breakpoint' => '',
				'label'      => '',
				'header'     => '',
			),
			is_array( $atts ) ? $atts : array(),
			self::SHORTCODE
		);

		$args = array();

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

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

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

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

		if ( '' !== $atts['header'] ) {
			$args['header_bar'] = in_array( strtolower( $atts['header'] ), array( '1', 'true', 'yes', 'on' ), true );
		}

		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', 'panel_side', 'trigger_label', 'breakpoint', 'header_bar' ) as $key ) {
			if ( isset( $attributes[ $key ] ) && '' !== $attributes[ $key ] ) {
				$args[ $key ] = $attributes[ $key ];
			}
		}

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

	/**
	 * Decides whether the automatic placement may run on this request.
	 *
	 * These rules govern the automatic placement only. Someone who has put the
	 * block on a page, or written the shortcode into a template, has already
	 * said where they want the menu — having a site wide rule quietly blank it
	 * would look like a bug from inside the editor.
	 *
	 * @param array $settings Resolved settings.
	 * @return bool
	 */
	protected function passes_display_rules( array $settings ) {
		if ( ! empty( $settings['hide_for_admins'] ) && current_user_can( 'manage_options' ) ) {
			return false;
		}

		$mode = isset( $settings['show_on'] ) ? (string) $settings['show_on'] : 'all';

		if ( 'specific' === $mode ) {
			$pages = $this->page_ids( $settings['show_on_pages'] );

			// An empty list means nothing was chosen, and "only on the pages I
			// picked" with nothing picked is nowhere.
			return ! empty( $pages ) && is_page( $pages );
		}

		if ( 'exclude' === $mode ) {
			$pages = $this->page_ids( $settings['show_on_exclude_pages'] );

			return empty( $pages ) || ! is_page( $pages );
		}

		return true;
	}

	/**
	 * Reduces a stored page rule to the ids worth testing.
	 *
	 * Zeroes are dropped rather than looked up. The schema deliberately puts no
	 * minimum on these, so anything unrecognised arrives as 0 instead of being
	 * clamped up into a real page id and targeting the wrong page.
	 *
	 * @param mixed $value Stored list.
	 * @return int[]
	 */
	private function page_ids( $value ) {
		return array_values( array_filter( array_map( 'absint', (array) $value ) ) );
	}

	/**
	 * 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-mobile-' . $uid;
		$panel_id = $root_id . '-panel';
		$label    = '' !== $settings['trigger_label'] ? $settings['trigger_label'] : __( 'Menu', 'menuverse' );
		$header   = ! empty( $settings['header_bar'] );
		$push     = 'push' === $settings['panel_motion'] && 'fullscreen' !== $settings['panel_side'];

		// The header bar places the button itself, so a placement chosen for a
		// loose button would otherwise pin it on top of the bar it now lives in.
		$placement = $header ? 'inline' : $settings['trigger_placement'];

		$tokens = $this->style_tokens( $settings );

		$classes = array(
			'menuverse-mobile',
			'menuverse-mobile--' . sanitize_html_class( $settings['panel_side'] ),
			'menuverse-mobile--trigger-' . sanitize_html_class( $placement ),
			'menuverse-mobile--anim-' . sanitize_html_class( $settings['animation'] ),
			'menuverse-mobile--submenu-' . sanitize_html_class( $settings['submenu_mode'] ),
		);

		if ( $header ) {
			$classes[] = 'menuverse-mobile--header';
			$classes[] = 'menuverse-mobile--header-' . sanitize_html_class( $settings['header_trigger_side'] );

			if ( ! empty( $settings['header_sticky'] ) ) {
				$classes[] = 'menuverse-mobile--header-sticky';
			}

			if ( ! empty( $settings['header_shadow'] ) ) {
				$classes[] = 'menuverse-mobile--header-line';
			}
		}

		if ( $push ) {
			$classes[] = 'menuverse-mobile--push';
		}

		if ( ! empty( $settings['panel_dividers'] ) ) {
			$classes[] = 'menuverse-mobile--dividers';
		}

		$accordion = 'accordion' === $settings['submenu_mode'];

		$list = Renderer::render_tree(
			$tree,
			array(
				'base_class'      => 'menuverse-mobile',
				'id_prefix'       => $root_id,
				'submenu_toggles' => $accordion,
				// Opening the branch the visitor is already in happens in the
				// markup rather than on load, so the right section is visible
				// in the first paint instead of expanding a moment later.
				'expand_current'  => $accordion && ! empty( $settings['submenu_auto_current'] ),
				'max_depth'       => (int) $settings['max_depth'],
			)
		);

		$trigger = $this->render_trigger( $panel_id, $label, $settings );

		$html = sprintf(
			'<div id="%1$s" class="%2$s" style="%3$s" data-menuverse-mobile data-mv-scroll-lock="%4$s" data-mv-single="%5$s" data-mv-submenu="%6$s" data-mv-close-link="%7$s" data-mv-close-scroll="%8$s" data-mv-swipe="%9$s" data-mv-push="%10$s" data-mv-hide-scroll="%11$s">',
			esc_attr( $root_id ),
			esc_attr( implode( ' ', $classes ) ),
			esc_attr( $tokens ),
			$settings['scroll_lock'] ? '1' : '0',
			$settings['submenu_single'] ? '1' : '0',
			esc_attr( $settings['submenu_mode'] ),
			$settings['close_on_link'] ? '1' : '0',
			$settings['close_on_scroll'] ? '1' : '0',
			$settings['swipe_to_close'] ? '1' : '0',
			$push ? '1' : '0',
			$header && ! empty( $settings['header_sticky'] ) && ! empty( $settings['header_hide_on_scroll'] ) ? '1' : '0'
		);

		$html .= $header
			? $this->render_header( $trigger, $settings )
			: $trigger;

		$html .= '<div class="menuverse-mobile__overlay" data-mv-overlay hidden></div>';

		$html .= sprintf(
			'<div class="menuverse-mobile__panel" id="%1$s" role="dialog" aria-modal="true" aria-label="%2$s" hidden>',
			esc_attr( $panel_id ),
			esc_attr( $label )
		);

		if ( $settings['show_close'] ) {
			$html .= sprintf(
				'<div class="menuverse-mobile__head"><button type="button" class="menuverse-mobile__close" data-mv-close><span class="menuverse-sr-only">%1$s</span>%2$s</button></div>',
				esc_html__( 'Close menu', 'menuverse' ),
				Icon_Library::get( 'close', 'menuverse-mobile__close-icon' )
			);
		}

		if ( ! empty( $settings['show_search'] ) ) {
			$html .= $this->render_search( $root_id, $settings );
		}

		$html .= sprintf(
			'<nav class="menuverse-mobile__nav" aria-label="%s">%s</nav>',
			esc_attr( $label ),
			$list
		);

		$html .= $this->render_social( $settings );
		$html .= $this->render_html( $settings );

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

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

		return $html;
	}

	/**
	 * Builds the button that opens the panel.
	 *
	 * The animated variants draw their own three lines rather than using an
	 * icon from the library: the fold to a cross moves each line separately,
	 * which a single path cannot do.
	 *
	 * @param string $panel_id Panel element id.
	 * @param string $label    Accessible name for the button.
	 * @param array  $settings Resolved settings.
	 * @return string
	 */
	private function render_trigger( $panel_id, $label, array $settings ) {
		$animation = (string) $settings['trigger_animation'];
		$animated  = 'none' !== $animation;

		$glyph = $animated
			? '<span class="menuverse-mobile__bars" aria-hidden="true"><span></span><span></span><span></span></span>'
			: Icon_Library::get( $settings['trigger_icon'], 'menuverse-mobile__trigger-icon' );

		return sprintf(
			'<button type="button" class="menuverse-mobile__trigger%1$s" aria-expanded="false" aria-controls="%2$s" aria-label="%3$s" data-mv-open>%4$s%5$s</button>',
			$animated ? ' menuverse-mobile__trigger--' . sanitize_html_class( $animation ) : '',
			esc_attr( $panel_id ),
			esc_attr( $label ),
			$glyph,
			$settings['trigger_show_label']
				? '<span class="menuverse-mobile__trigger-label">' . esc_html( $label ) . '</span>'
				: ''
		);
	}

	/**
	 * Builds the bar across the top of the screen.
	 *
	 * @param string $trigger  Rendered menu button.
	 * @param array  $settings Resolved settings.
	 * @return string
	 */
	private function render_header( $trigger, array $settings ) {
		return sprintf(
			'<div class="menuverse-mobile__bar" data-mv-bar>%1$s%2$s</div>',
			$this->render_brand( $settings ),
			$trigger
		);
	}

	/**
	 * Builds whatever sits opposite the button in the top bar.
	 *
	 * The logo is the one already set under Appearance rather than a second
	 * upload of its own: a site that has one has it here without being asked,
	 * and a site that changes it changes it in one place.
	 *
	 * @param array $settings Resolved settings.
	 * @return string
	 */
	private function render_brand( array $settings ) {
		$mode = (string) $settings['header_brand'];

		if ( 'none' === $mode ) {
			// An empty span still holds its half of the bar, which is what
			// keeps a button set to the end edge at the end edge.
			return '<span class="menuverse-mobile__brand"></span>';
		}

		$inner = '';

		if ( 'logo' === $mode ) {
			$logo_id = (int) get_theme_mod( 'custom_logo' );

			if ( $logo_id ) {
				$inner = wp_get_attachment_image(
					$logo_id,
					'full',
					false,
					array(
						'class' => 'menuverse-mobile__logo',
						'alt'   => get_bloginfo( 'name', 'display' ),
					)
				);
			}
		}

		if ( '' === $inner ) {
			// Falls through to words for "site name", for "your own words",
			// and for a logo on a site that has never set one — an empty bar
			// beside a menu button reads as a broken header.
			$text = 'custom' === $mode && '' !== $settings['header_brand_text']
				? $settings['header_brand_text']
				: get_bloginfo( 'name', 'display' );

			$inner = '<span class="menuverse-mobile__brand-text">' . esc_html( $text ) . '</span>';
		}

		return sprintf(
			'<a class="menuverse-mobile__brand" href="%1$s" rel="home">%2$s</a>',
			esc_url( home_url( '/' ) ),
			$inner
		);
	}

	/**
	 * Builds the search box shown above the links.
	 *
	 * Plain markup pointed at the site's own search results page, so a theme
	 * that has replaced that page keeps its version of it.
	 *
	 * @param string $root_id  Root element id.
	 * @param array  $settings Resolved settings.
	 * @return string
	 */
	private function render_search( $root_id, array $settings ) {
		$field_id = $root_id . '-search';
		$label    = __( 'Search this site', 'menuverse' );

		$placeholder = '' !== $settings['search_placeholder']
			? $settings['search_placeholder']
			: __( 'Search…', 'menuverse' );

		return sprintf(
			'<form class="menuverse-mobile__search" role="search" method="get" action="%1$s"><label class="menuverse-sr-only" for="%2$s">%3$s</label><input class="menuverse-mobile__search-field" id="%2$s" type="search" name="s" placeholder="%4$s" /><button type="submit" class="menuverse-mobile__search-submit"><span class="menuverse-sr-only">%3$s</span>%5$s</button></form>',
			esc_url( home_url( '/' ) ),
			esc_attr( $field_id ),
			esc_html( $label ),
			esc_attr( $placeholder ),
			Icon_Library::get( 'search', 'menuverse-mobile__search-icon', 20 )
		);
	}

	/**
	 * Builds the row of icons under the links.
	 *
	 * @param array $settings Resolved settings.
	 * @return string
	 */
	private function render_social( array $settings ) {
		$rows = array();

		foreach ( (array) $settings['social_links'] as $row ) {
			$row = (array) $row;
			$url = isset( $row['url'] ) ? (string) $row['url'] : '';

			// A row with no address is one somebody is still filling in. It is
			// kept in the settings and left out of the page.
			if ( '' === $url ) {
				continue;
			}

			$icon   = isset( $row['icon'] ) && '' !== $row['icon'] ? (string) $row['icon'] : 'link-2';
			$name   = isset( $row['label'] ) && '' !== $row['label'] ? (string) $row['label'] : Icon_Library::label( $icon );
			$rows[] = sprintf(
				'<a class="menuverse-mobile__social-link" href="%1$s"><span class="menuverse-sr-only">%2$s</span>%3$s</a>',
				esc_url( $url ),
				esc_html( $name ),
				Icon_Library::get( $icon, 'menuverse-mobile__social-icon' )
			);
		}

		if ( empty( $rows ) ) {
			return '';
		}

		return sprintf(
			'<div class="menuverse-mobile__social" role="list">%s</div>',
			implode( '', $rows )
		);
	}

	/**
	 * Builds the free text block under the links.
	 *
	 * @param array $settings Resolved settings.
	 * @return string
	 */
	private function render_html( array $settings ) {
		$content = trim( (string) $settings['panel_html'] );

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

		// Stored through the schema's `html` format, which runs wp_kses_post()
		// on the way in and again on every read, so what arrives here is
		// already reduced to the markup a post may contain.
		return '<div class="menuverse-mobile__html">' . $content . '</div>';
	}

	/**
	 * Builds the per instance stylesheet.
	 *
	 * The breakpoint has to become a real media query rather than a custom
	 * property, otherwise the trigger would be visible before JavaScript runs.
	 *
	 * @param string $root_id  Root element id.
	 * @param array  $settings Resolved settings.
	 * @return string
	 */
	private function instance_css( $root_id, array $settings ) {
		$breakpoint = (int) $settings['breakpoint'];
		$above      = $breakpoint + 1;

		$css = sprintf(
			'@media (min-width:%1$dpx){#%2$s{display:none}}',
			$above,
			$root_id
		);

		$selector = $this->clean_selector( $settings['hide_selector'] );

		if ( '' !== $selector ) {
			$css .= sprintf(
				'@media (max-width:%1$dpx){%2$s{display:none}}',
				$breakpoint,
				$selector
			);
		}

		$css .= $this->offset_css( $root_id, $breakpoint, $settings );

		return $css;
	}

	/**
	 * Builds the rules that keep the menu clear of everything else on screen.
	 *
	 * Two separate offsets, both resolved here rather than as custom
	 * properties: a property left unset falls back to its default, and both of
	 * these have to be exactly nothing when they do not apply.
	 *
	 * @param string $root_id    Root element id.
	 * @param int    $breakpoint Widest screen the menu appears on.
	 * @param array  $settings   Resolved settings.
	 * @return string
	 */
	private function offset_css( $root_id, $breakpoint, array $settings ) {
		$rules = '';

		if ( ! empty( $settings['header_bar'] ) && ! empty( $settings['header_offset_page'] ) ) {
			$height = '' !== (string) $settings['header_height'] ? (string) $settings['header_height'] : '56px';

			// The bar grows into the notch, so the gap left for it has to as
			// well. Zero in a normal browser tab; the real inset only when the
			// page is running full screen or installed to the home screen.
			$rules .= sprintf(
				'body{padding-block-start:calc(%s + var(--menuverse-safe-top, 0px))}',
				$height
			);
		}

		// WordPress switches its toolbar to 46px and lets it scroll away on
		// these widths, so only the elements pinned to the top of the viewport
		// need to clear it, and the header bar deliberately does not: leaving
		// a permanent gap for a strip that has already scrolled off would look
		// like a bug on every page but the first screenful.
		if ( ! empty( $settings['admin_bar_offset'] ) && is_admin_bar_showing() ) {
			$rules .= sprintf(
				'#%1$s{--menuverse-admin-bar:%2$dpx}',
				$root_id,
				self::ADMIN_BAR_HEIGHT
			);
		}

		if ( '' === $rules ) {
			return '';
		}

		return sprintf( '@media (max-width:%1$dpx){%2$s}', $breakpoint, $rules );
	}

	/**
	 * Reduces a user supplied CSS selector to a safe subset.
	 *
	 * @param string $selector Raw selector.
	 * @return string
	 */
	private function clean_selector( $selector ) {
		$selector = trim( (string) $selector );

		if ( '' === $selector ) {
			return '';
		}

		if ( ! preg_match( '/^[a-zA-Z0-9_\-.#,>\s\[\]="\':()]+$/', $selector ) ) {
			return '';
		}

		if ( false !== stripos( $selector, 'url(' ) || false !== stripos( $selector, 'expression' ) ) {
			return '';
		}

		return $selector;
	}
}
