<?php
/**
 * Shared menu markup renderer.
 *
 * @package MenuVerse
 */

namespace WPXero\MenuVerse\Frontend;

use WPXero\MenuVerse\Menus\Menu_Tree;

defined( 'ABSPATH' ) || exit;

/**
 * Turns a normalized menu tree into accessible list markup.
 *
 * Every module that presents a menu as a list — mobile, vertical, the mobile
 * fallback of the mega menu — renders through this class, so keyboard support
 * and ARIA wiring are written once.
 */
final class Renderer {

	/**
	 * Counter used to build unique element ids within a request.
	 *
	 * @var int
	 */
	private static $sequence = 0;

	/**
	 * Normalized URL of the current request.
	 *
	 * Resolved once per request rather than once per menu item: the address
	 * being viewed cannot change while the page is being built, and every item
	 * of every menu asks for it.
	 *
	 * @var string|null
	 */
	private static $current_url = null;

	/**
	 * Returns the next unique id suffix.
	 *
	 * @return int
	 */
	public static function next_id() {
		++self::$sequence;

		return self::$sequence;
	}

	/**
	 * Renders a menu tree as nested lists.
	 *
	 * @param Menu_Tree $tree Menu tree.
	 * @param array     $args Rendering options.
	 * @return string
	 */
	public static function render_tree( Menu_Tree $tree, array $args = array() ) {
		$args = wp_parse_args(
			$args,
			array(
				'base_class'      => 'menuverse-menu',
				'id_prefix'       => 'menuverse-menu',
				'submenu_toggles' => true,
				'toggle_icon'     => 'chevron-down',
				'toggle_attrs'    => '',
				'expand_current'  => false,
				'max_depth'       => 0,
				/* translators: %s: menu item title. */
				'toggle_label'    => __( 'Show submenu of %s', 'menuverse' ),
			)
		);

		$items = self::mark_current( $tree->items() );

		return self::render_level( $items, $args, 0 );
	}

	/**
	 * Renders one level of the tree.
	 *
	 * @param array[] $items  Items at this level.
	 * @param array   $args   Rendering options.
	 * @param int     $depth  Zero based depth.
	 * @param string  $id     Element id for the list, when a toggle controls it.
	 * @param bool    $hidden Whether the list starts collapsed.
	 * @return string
	 */
	private static function render_level( array $items, array $args, $depth, $id = '', $hidden = false ) {
		if ( empty( $items ) ) {
			return '';
		}

		$base = $args['base_class'];
		$html = sprintf(
			'<ul class="%s"%s%s>',
			esc_attr( 0 === $depth ? $base . '__list' : $base . '__sublist' ),
			'' !== $id ? sprintf( ' id="%s"', esc_attr( $id ) ) : '',
			$hidden ? ' hidden' : ''
		);

		foreach ( $items as $item ) {
			$html .= self::render_item( $item, $args, $depth );
		}

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

	/**
	 * Renders a single item and its children.
	 *
	 * @param array $item  Normalized item.
	 * @param array $args  Rendering options.
	 * @param int   $depth Zero based depth.
	 * @return string
	 */
	private static function render_item( array $item, array $args, $depth ) {
		$base      = $args['base_class'];
		$max_depth = (int) $args['max_depth'];
		$children  = ! empty( $item['children'] ) && ( 0 === $max_depth || $depth + 1 < $max_depth )
			? $item['children']
			: array();
		$has_kids  = ! empty( $children );
		$extras    = isset( $item['extras'] ) ? $item['extras'] : array();
		$sub_id    = $args['id_prefix'] . '-sub-' . self::next_id();

		$classes = array( $base . '__item' );

		if ( $has_kids ) {
			$classes[] = $base . '__item--parent';
		}

		if ( ! empty( $item['current'] ) ) {
			$classes[] = $base . '__item--current';
		}

		foreach ( (array) $item['classes'] as $class ) {
			$classes[] = sanitize_html_class( $class );
		}

		if ( ! empty( $extras['hide_mobile'] ) ) {
			$classes[] = $base . '__item--hide-mobile';
		}

		if ( ! empty( $extras['hide_desktop'] ) ) {
			$classes[] = $base . '__item--hide-desktop';
		}

		// A branch containing the current page opens server side, so the right
		// section is already visible before any script runs.
		$expanded = ! empty( $args['expand_current'] ) && ! empty( $item['current_ancestor'] );

		if ( $expanded ) {
			$classes[] = 'is-expanded';
		}

		$attributes = array(
			'href' => $item['url'] ? esc_url( $item['url'] ) : '',
		);

		if ( '_blank' === $item['target'] ) {
			$attributes['target'] = '_blank';
			$attributes['rel']    = trim( $item['rel'] . ' noopener' );
		} elseif ( '' !== $item['rel'] ) {
			$attributes['rel'] = $item['rel'];
		}

		if ( ! empty( $item['current'] ) ) {
			$attributes['aria-current'] = 'page';
		}

		/**
		 * Filters the anchor attributes for a rendered menu item.
		 *
		 * @since 1.0.0
		 *
		 * @param array $attributes Attribute map.
		 * @param array $item       Normalized item.
		 * @param int   $depth      Zero based depth.
		 */
		$attributes = apply_filters( 'menuverse_item_attributes', $attributes, $item, $depth );

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

		$label = sprintf(
			'<span class="%s__label">%s</span>',
			esc_attr( $base ),
			esc_html( $item['title'] )
		);

		if ( ! empty( $extras['badge'] ) ) {
			$label .= sprintf(
				'<span class="%s__badge">%s</span>',
				esc_attr( $base ),
				esc_html( $extras['badge'] )
			);
		}

		if ( '' === $attributes['href'] ) {
			$html .= sprintf( '<span class="%s__link">%s</span>', esc_attr( $base ), $label );
		} else {
			$html .= sprintf(
				'<a class="%s__link"%s>%s</a>',
				esc_attr( $base ),
				self::attributes( $attributes ),
				$label
			);
		}

		if ( $has_kids && $args['submenu_toggles'] ) {
			$html .= sprintf(
				'<button type="button" class="%1$s__toggle" aria-expanded="%6$s" aria-controls="%2$s"%5$s><span class="menuverse-sr-only">%3$s</span>%4$s</button>',
				esc_attr( $base ),
				esc_attr( $sub_id ),
				esc_html( sprintf( $args['toggle_label'], $item['title'] ) ),
				Icon_Library::get( $args['toggle_icon'], $base . '__toggle-icon' ),
				// Only ever set by a module, never by user input.
				'' !== $args['toggle_attrs'] ? ' ' . $args['toggle_attrs'] : '',
				$expanded ? 'true' : 'false'
			);
		}

		if ( $has_kids ) {
			// The toggle needs an element to point `aria-controls` at, and a
			// collapsed branch starts hidden. Both are handed to the level
			// being rendered rather than patched into the string it returns.
			$html .= $args['submenu_toggles']
				? self::render_level( $children, $args, $depth + 1, $sub_id, ! $expanded )
				: self::render_level( $children, $args, $depth + 1 );
		}

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

	/**
	 * Builds an escaped attribute string.
	 *
	 * @param array $attributes Attribute map.
	 * @return string
	 */
	private static function attributes( array $attributes ) {
		$out = '';

		foreach ( $attributes as $name => $value ) {
			if ( '' === $value || null === $value ) {
				continue;
			}

			$out .= sprintf( ' %s="%s"', esc_attr( $name ), esc_attr( $value ) );
		}

		return $out;
	}

	/**
	 * Flags the item matching the current request, and its ancestors.
	 *
	 * @param array[] $items Normalized items.
	 * @return array[]
	 */
	public static function mark_current( array $items ) {
		foreach ( $items as $index => $item ) {
			$items[ $index ]['children'] = ! empty( $item['children'] )
				? self::mark_current( $item['children'] )
				: array();

			$is_current = self::is_current_url( $item['url'] );
			$has_active = false;

			foreach ( $items[ $index ]['children'] as $child ) {
				if ( ! empty( $child['current'] ) || ! empty( $child['current_ancestor'] ) ) {
					$has_active = true;
					break;
				}
			}

			$items[ $index ]['current']          = $is_current;
			$items[ $index ]['current_ancestor'] = $has_active;
		}

		return $items;
	}

	/**
	 * Determines whether a URL points at the page being viewed.
	 *
	 * @param string $url  URL to test.
	 * @param string $mode Either exact, or prefix to also match descendants.
	 * @return bool
	 */
	public static function is_current_url( $url, $mode = 'exact' ) {
		$current = self::current_url();

		if ( '' === $current || '' === (string) $url ) {
			return false;
		}

		$target = self::normalize_url( $url );

		if ( $target === $current ) {
			return true;
		}

		// A prefix match must stop at a path segment so /news never matches
		// /newsletter.
		return 'prefix' === $mode && '/' !== $target && 0 === strpos( $current, $target . '/' );
	}

	/**
	 * Returns the normalized URL for the current request.
	 *
	 * @return string
	 */
	private static function current_url() {
		if ( null !== self::$current_url ) {
			return self::$current_url;
		}

		if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
			self::$current_url = '';

			return self::$current_url;
		}

		$path = wp_unslash( $_SERVER['REQUEST_URI'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- normalized below.

		self::$current_url = self::normalize_url( home_url( esc_url_raw( $path ) ) );

		return self::$current_url;
	}

	/**
	 * Normalizes a URL for comparison.
	 *
	 * @param string $url URL to normalize.
	 * @return string
	 */
	private static function normalize_url( $url ) {
		$parts = wp_parse_url( $url );
		$path  = isset( $parts['path'] ) && '' !== $parts['path'] ? $parts['path'] : '/';
		$path  = untrailingslashit( strtolower( $path ) );

		// Keep the site root distinguishable from "no URL at all".
		return '' === $path ? '/' : $path;
	}
}
