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

namespace WPXero\MenuVerse\Module\Bottom_Menu;

use WPXero\MenuVerse\Compat\Edd;
use WPXero\MenuVerse\Compat\Woo;
use WPXero\MenuVerse\Compat\Yith_Wishlist;
use WPXero\MenuVerse\Frontend\Icon_Library;
use WPXero\MenuVerse\Frontend\Renderer;
use WPXero\MenuVerse\Modules\Abstract_Module;

defined( 'ABSPATH' ) || exit;

/**
 * App style navigation bar pinned to the bottom of the viewport.
 */
final class Module extends Abstract_Module {

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

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

	/**
	 * The `show_labels` default declared in block.json.
	 *
	 * Kept in step with that file: an attribute still sitting on its default
	 * is not an override, and treating it as one would make the module's own
	 * setting impossible to inherit.
	 *
	 * @var bool
	 */
	const BLOCK_SHOWS_LABELS = true;

	/**
	 * Pixels a rounded bar is lifted off the edges of the screen.
	 *
	 * Mirrored by `--menuverse-bar-float` in the module stylesheet, which is
	 * where the lifting actually happens. The value is repeated here because
	 * the page's own bottom padding is written in PHP — a custom property
	 * defined on the bar cannot be read from `body`, which is its ancestor.
	 *
	 * @var int
	 */
	const FLOAT_GAP = 8;

	/**
	 * Angle every two colour blend is painted at.
	 *
	 * A bar is far wider than it is tall, so a diagonal runs out of height
	 * almost at once and the blend finishes within the first couple of
	 * buttons. Close to horizontal, it travels the length of the bar and both
	 * colours are actually visible in it.
	 *
	 * One constant rather than a number written at each site: the bar and the
	 * circle behind an icon are the same gesture, and two angles drifting apart
	 * is a difference nobody would ever have chosen on purpose. The dashboard's
	 * preset swatch repeats it in `src/admin/components/presets.js`, which is
	 * the only copy — the live preview renders this markup and inherits it.
	 *
	 * @var string
	 */
	const GRADIENT_ANGLE = '100deg';

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

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

	/**
	 * Returns the translated module description.
	 *
	 * @return string
	 */
	public static function description() {
		return __( 'A row of buttons fixed along the bottom of the screen on phones, the way a mobile app looks.', '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' ),
			)
		);
	}

	/**
	 * The bar belongs at the end of the document, not the start of the body.
	 *
	 * Ahead of wp_print_footer_scripts (priority 20), which is where
	 * print_late_styles() flushes styles queued during the body.
	 *
	 * @return string
	 */
	protected function auto_insert_hook() {
		return 'wp_footer';
	}

	/**
	 * Returns the priority the automatic placement renders at.
	 *
	 * @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(
				'labels'     => '',
				'visible'    => '',
				'breakpoint' => '',
			),
			is_array( $atts ) ? $atts : array(),
			self::SHORTCODE
		);

		$args = array();

		if ( '' !== $atts['labels'] ) {
			$args['show_labels'] = rest_sanitize_boolean( $atts['labels'] );
		}

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

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

		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();

		// The numeric attributes use 0 to mean "not set here", so anything
		// else is a real override. `show_labels` cannot work that way: it is a
		// boolean, WP_Block fills in its default before this runs, and every
		// comparison against a boolean of a different type is true — which
		// silently forced labels on for every block placement and made the
		// module's own setting unreachable. It is an override only when the
		// editor actually moved it away from the block default.
		foreach ( array( 'max_visible', 'breakpoint' ) as $key ) {
			if ( isset( $attributes[ $key ] ) && '' !== $attributes[ $key ] && 0 !== $attributes[ $key ] ) {
				$args[ $key ] = $attributes[ $key ];
			}
		}

		if ( isset( $attributes['show_labels'] ) && self::BLOCK_SHOWS_LABELS !== (bool) $attributes['show_labels'] ) {
			$args['show_labels'] = (bool) $attributes['show_labels'];
		}

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

	/**
	 * The bottom bar has its own item list rather than a menu, so it has
	 * content exactly when that list is not empty.
	 *
	 * @param array $settings Resolved settings.
	 * @return bool
	 */
	public function has_content( array $settings ) {
		// Only whether anything survives matters here, so this stops at the
		// first usable item instead of resolving permalinks and current page
		// matching for the whole list and discarding the result.
		foreach ( (array) $settings['items'] as $item ) {
			$item = array_merge( $this->item_defaults(), (array) $item );

			if ( null !== $this->resolve_item( $item, $settings ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Renders the module markup.
	 *
	 * @param array $settings Resolved settings.
	 * @param array $context  Render context.
	 * @return string
	 */
	public function render( array $settings, array $context ) {
		$preview = isset( $context['source'] ) && 'preview' === $context['source'];
		$items   = $this->prepare_items( $settings, $preview );

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

		// Icons off and wording off together leave a row of blank buttons, so
		// the wording is turned back on for this render. The stored value is
		// untouched: switching the icons back on has to give the bar the person
		// actually configured, not one this line quietly rewrote.
		if ( empty( $settings['show_icons'] ) ) {
			$settings['show_labels'] = true;
		}

		$uid       = Renderer::next_id();
		$root_id   = 'menuverse-bottom-' . $uid;
		$drawer_id = $root_id . '-drawer';
		$visible   = (int) $settings['max_visible'];
		$overflow  = array();
		$scrolls   = 'scroll' === (string) $settings['overflow_mode'];

		// Sideways scrolling keeps every item in the bar, so there is nothing
		// to move behind a "More" button and `max_visible` stops applying —
		// the schema hides it in that mode for the same reason.
		if ( ! $scrolls && count( $items ) > $visible ) {
			$overflow = array_slice( $items, $visible - 1 );
			$items    = array_slice( $items, 0, $visible - 1 );
		}

		$classes = array(
			'menuverse-bottom',
			'menuverse-bottom--indicator-' . sanitize_html_class( $settings['active_indicator'] ),
			'menuverse-bottom--bg-' . sanitize_html_class( $settings['bg_type'] ),
			empty( $settings['sticky'] ) ? 'menuverse-bottom--static' : 'menuverse-bottom--sticky',
		);

		if ( ! $settings['show_labels'] ) {
			$classes[] = 'menuverse-bottom--no-labels';
		}

		if ( ! $settings['show_border'] ) {
			$classes[] = 'menuverse-bottom--no-border';
		}

		if ( empty( $settings['show_icons'] ) ) {
			$classes[] = 'menuverse-bottom--no-icons';
		}

		if ( $scrolls ) {
			$classes[] = 'menuverse-bottom--scroll-x';
		}

		if ( 'spread' !== (string) $settings['item_align'] ) {
			$classes[] = 'menuverse-bottom--align-' . sanitize_html_class( $settings['item_align'] );
		}

		if ( 'none' !== (string) $settings['bar_shadow'] ) {
			$classes[] = 'menuverse-bottom--shadow-' . sanitize_html_class( $settings['bar_shadow'] );
		}

		if ( 'glass' === $settings['bg_type'] && empty( $settings['glass_border'] ) ) {
			$classes[] = 'menuverse-bottom--no-glass-edge';
		}

		if ( ! empty( $settings['safe_area'] ) ) {
			$classes[] = 'menuverse-bottom--safe-area';
		}

		// The dot is drawn by the stylesheet on the current item, so the only
		// thing to decide here is whether that rule applies at all. It is
		// suppressed under the lifted indicator, which already fills the same
		// spot — the schema hides the switch there, and this keeps a value
		// saved before that pairing existed from resurfacing it.
		if ( ! empty( $settings['active_pulse'] ) && 'raised' !== $settings['active_indicator'] ) {
			$classes[] = 'menuverse-bottom--pulse';
		}

		// Rounded corners are worth nothing while the bar is flush with the
		// bottom of the screen, which crops the lower two off. The class is
		// what lifts it clear; the radius itself arrives as a token. CSS has
		// no way to ask whether a custom property is above zero, so the
		// decision is made here.
		if ( (int) $settings['bar_radius'] > 0 ) {
			$classes[] = 'menuverse-bottom--floating';
		}

		// Sliding away on scroll only makes sense while the bar is actually
		// following the page, so the two settings are resolved together here
		// rather than leaving the script to work it out.
		$scroll_hide = ! empty( $settings['sticky'] ) && ! empty( $settings['scroll_hide_enabled'] );

		if ( $scroll_hide ) {
			$classes[] = 'menuverse-bottom--scroll-hide';
		}

		$search_items = array_filter(
			array_merge( $items, $overflow ),
			static function ( $item ) {
				return 'search' === $item['action'];
			}
		);

		$panel_items = array_filter(
			array_merge( $items, $overflow ),
			static function ( $item ) {
				return 'panel' === $item['action'];
			}
		);

		// Configuration reaches the script through data attributes rather than
		// wp_localize_script, because the preview renders the module by
		// injecting its scripts by URL and would never receive a localized
		// object.
		$data = sprintf( ' data-mv-scroll-hide="%d"', $scroll_hide ? 1 : 0 );

		if ( $this->needs_counts( $items, $overflow ) ) {
			$data .= sprintf(
				' data-mv-cart-url="%1$s" data-mv-wishlist-url="%2$s"',
				esc_url( Woo::cart_url() ),
				esc_url( Yith_Wishlist::is_active() ? rest_url( 'menuverse/v1/counts/wishlist' ) : '' )
			);
		}

		$html = sprintf(
			'<div id="%1$s" class="%2$s" style="%3$s"%4$s>',
			esc_attr( $root_id ),
			esc_attr( implode( ' ', $classes ) ),
			esc_attr( $this->style_tokens( $settings, $this->token_exclusions( $settings ) ) ),
			$data
		);

		$html .= sprintf(
			'<nav class="menuverse-bottom__bar" aria-label="%s">',
			esc_attr__( 'Quick navigation', 'menuverse' )
		);

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

		if ( ! empty( $overflow ) ) {
			$more_icon = (string) $settings['more_icon'];

			$html .= sprintf(
				'<button type="button" class="menuverse-bottom__item menuverse-bottom__item--more" aria-expanded="false" aria-controls="%1$s" data-mv-more>%2$s%3$s</button>',
				esc_attr( $drawer_id ),
				Icon_Library::get(
					Icon_Library::has( $more_icon ) ? $more_icon : 'dots-horizontal',
					'menuverse-bottom__icon'
				),
				$this->label_markup( $settings['more_label'], $settings )
			);
		}

		$html .= '</nav>';

		if ( ! empty( $overflow ) ) {
			$html .= $this->render_drawer( $drawer_id, $overflow, $settings, $root_id );
		}

		// One sheet per search button rather than one shared between them.
		// Two search buttons on the same bar are only ever there because they
		// look in different places — the whole site and the shop, say — and a
		// single sheet could only ever have carried one of those.
		foreach ( $search_items as $item ) {
			$html .= $this->render_search( $this->search_id( $root_id, $item ), $item );
		}

		// One sheet per panel item, for the same reason: two buttons carrying
		// panels are two different panels.
		foreach ( $panel_items as $item ) {
			$html .= $this->render_panel( $this->panel_id( $root_id, $item ), $item );
		}

		$html .= '</div>';

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

		return $html;
	}

	/**
	 * Renders one bar item.
	 *
	 * @param array  $item     Prepared item.
	 * @param array  $settings Resolved settings.
	 * @param string $root_id  Root element id.
	 * @return string
	 */
	private function render_item( array $item, array $settings, $root_id ) {
		$classes = array( 'menuverse-bottom__item' );

		if ( $item['current'] ) {
			$classes[] = 'menuverse-bottom__item--current';
		}

		if ( 'top' === $item['position'] ) {
			$classes[] = 'menuverse-bottom__item--label-top';
		}

		if ( '' !== $item['icon_bg'] ) {
			$classes[] = 'menuverse-bottom__item--filled';
		}

		// Added to the list rather than substituted into the finished string:
		// the base class is a prefix of every modifier, so a replace would
		// rewrite those too and destroy them.
		if ( 'incomplete' === $item['action'] ) {
			$classes[] = 'menuverse-bottom__item--incomplete';
		}

		$attributes = sprintf( ' class="%s"', esc_attr( implode( ' ', $classes ) ) );
		$style      = $this->item_style( $item );

		if ( '' !== $style ) {
			$attributes .= sprintf( ' style="%s"', esc_attr( $style ) );
		}

		$badge = $this->badge_markup( $item );

		$inner = sprintf(
			'<span class="menuverse-bottom__glyph">%1$s%2$s</span>%3$s',
			$this->icon_markup( $item ),
			$badge,
			$this->label_markup( $item['label'], $settings, $item['show_label'] )
		);

		// Only ever reached in the dashboard preview: the item has no
		// destination yet, so it is shown as unfinished rather than as
		// something that can be pressed.
		if ( 'incomplete' === $item['action'] ) {
			return sprintf(
				'<button type="button"%1$s disabled aria-disabled="true" title="%2$s">%3$s</button>',
				$attributes,
				esc_attr__( 'This one still needs a destination, so visitors will not see it yet.', 'menuverse' ),
				$inner
			);
		}

		// Search and popup do something on the page rather than going
		// somewhere, so they are buttons. A link with no destination would be
		// a lie to anyone navigating by keyboard or screen reader.
		if ( 'search' === $item['action'] ) {
			return sprintf(
				'<button type="button"%1$s aria-expanded="false" aria-controls="%2$s" data-mv-search="%2$s">%3$s</button>',
				$attributes,
				esc_attr( $this->search_id( $root_id, $item ) ),
				$inner
			);
		}

		if ( 'panel' === $item['action'] ) {
			return sprintf(
				'<button type="button"%1$s aria-expanded="false" aria-controls="%2$s" data-mv-panel="%2$s">%3$s</button>',
				$attributes,
				esc_attr( $this->panel_id( $root_id, $item ) ),
				$inner
			);
		}

		if ( 'popup' === $item['action'] ) {
			return sprintf(
				'<button type="button"%1$s data-mv-popup="%2$d">%3$s</button>',
				$attributes,
				$item['popup_id'],
				$inner
			);
		}

		$attributes .= sprintf( ' href="%s"', esc_url( $item['url'] ) );

		if ( '_blank' === $item['target'] ) {
			$attributes .= sprintf( ' target="_blank" rel="%s"', esc_attr( $item['rel'] ) );
		}

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

		return sprintf( '<a%1$s>%2$s</a>', $attributes, $inner );
	}

	/**
	 * Builds the glyph for one item, whichever kind of icon it uses.
	 *
	 * Both states are emitted together rather than picked here, and the
	 * stylesheet shows one of them. That is what lets a bar be cached as one
	 * piece of markup while still lighting up the button for the page being
	 * read — the current item carries a class, and the swap costs no second
	 * render. An item with nothing to swap to emits one glyph as before.
	 *
	 * @param array $item Prepared item.
	 * @return string
	 */
	private function icon_markup( array $item ) {
		if ( '' !== $item['image'] ) {
			$markup = $this->image_markup( $item['image'], $item, 'normal' );

			if ( '' !== $item['image_active'] ) {
				$markup .= $this->image_markup( $item['image_active'], $item, 'active' );
			}

			return $markup;
		}

		$markup = Icon_Library::get(
			$item['icon'],
			'' !== $item['active_icon']
				? 'menuverse-bottom__icon menuverse-bottom__icon--normal'
				: 'menuverse-bottom__icon'
		);

		if ( '' !== $item['active_icon'] ) {
			$markup .= Icon_Library::get(
				$item['active_icon'],
				'menuverse-bottom__icon menuverse-bottom__icon--active'
			);
		}

		return $markup;
	}

	/**
	 * Builds one image icon.
	 *
	 * The alt text is deliberately empty: the wording underneath already names
	 * the destination, and where it is switched off the accessible name comes
	 * from the link itself. A second reading of the same word is noise to
	 * anybody listening to the bar.
	 *
	 * @param string $url   Image address.
	 * @param array  $item  Prepared item.
	 * @param string $state Which of the two states this is.
	 * @return string
	 */
	private function image_markup( $url, array $item, $state ) {
		$classes = 'menuverse-bottom__icon menuverse-bottom__icon--image';

		if ( '' !== $item['image_active'] ) {
			$classes .= ' menuverse-bottom__icon--' . $state;
		}

		return sprintf(
			'<img class="%1$s" src="%2$s" alt="" loading="lazy" decoding="async" />',
			esc_attr( $classes ),
			esc_url( $url )
		);
	}

	/**
	 * Decides whether this visitor is one of the people an item is for.
	 *
	 * @param array $item Stored row, merged over the defaults.
	 * @return bool
	 */
	private function item_visible( array $item ) {
		switch ( (string) $item['visibility'] ) {
			case 'logged_in':
				return is_user_logged_in();

			case 'logged_out':
				return ! is_user_logged_in();

			case 'roles':
				$roles = array_filter( array_map( 'sanitize_key', (array) $item['visibility_roles'] ) );

				// Naming no role at all is the same question as "anybody
				// signed in", and answering it that way beats hiding an item
				// somebody has plainly finished configuring.
				if ( empty( $roles ) ) {
					return is_user_logged_in();
				}

				if ( ! is_user_logged_in() ) {
					return false;
				}

				return (bool) array_intersect( $roles, (array) wp_get_current_user()->roles );

			default:
				return true;
		}
	}

	/**
	 * Builds an item's badge, live or fixed.
	 *
	 * Shared with the drawer: a counter belongs to the item, not to whichever
	 * row it happens to land in, and an item pushed past `max_visible` used to
	 * lose its badge entirely.
	 *
	 * @param array $item Prepared item.
	 * @return string
	 */
	private function badge_markup( array $item ) {
		if ( '' !== $item['count'] ) {
			// Filled in by the browser, so it starts empty and stays hidden
			// until there is actually something to report.
			return sprintf(
				'<span class="menuverse-bottom__badge" data-mv-count="%s"></span>',
				esc_attr( $item['count'] )
			);
		}

		if ( '' !== $item['badge'] ) {
			return sprintf( '<span class="menuverse-bottom__badge">%s</span>', esc_html( $item['badge'] ) );
		}

		return '';
	}

	/**
	 * Tells whether any item asks for a live counter.
	 *
	 * @param array[] $items    Visible items.
	 * @param array[] $overflow Items in the drawer.
	 * @return bool
	 */
	private function needs_counts( array $items, array $overflow ) {
		foreach ( array_merge( $items, $overflow ) as $item ) {
			if ( '' !== $item['count'] ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Renders one item's drop up panel.
	 *
	 * Shortcodes run here rather than at save time so a panel keeps working
	 * when whatever it embeds changes, and the stored value stays the thing
	 * that was actually typed. The content passed through `wp_kses_post` on
	 * the way in, which the `html` schema format does, so it is printed as
	 * stored.
	 *
	 * @param string $panel_id Panel element id.
	 * @param array  $item     Prepared item.
	 * @return string
	 */
	private function render_panel( $panel_id, array $item ) {
		return sprintf(
			'<div class="menuverse-bottom__panel" id="%1$s" role="dialog" aria-label="%2$s" hidden><div class="menuverse-bottom__panel-head"><span class="menuverse-bottom__panel-title">%2$s</span><button type="button" class="menuverse-bottom__panel-close" data-mv-panel-close><span class="menuverse-sr-only">%3$s</span>%4$s</button></div><div class="menuverse-bottom__panel-body">%5$s</div></div>',
			esc_attr( $panel_id ),
			esc_attr( $item['label'] ),
			esc_html__( 'Close', 'menuverse' ),
			Icon_Library::get( 'close', 'menuverse-bottom__icon' ),
			do_shortcode( $item['panel_content'] )
		);
	}

	/**
	 * Renders the search sheet.
	 *
	 * The field is deliberately not focused when the sheet opens. Focusing it
	 * would throw the on screen keyboard up over half the screen before anyone
	 * has asked to type, which on a phone reads as the page misbehaving.
	 *
	 * @param string $search_id Sheet element id.
	 * @param array  $item      Prepared item the sheet belongs to.
	 * @return string
	 */
	private function render_search( $search_id, array $item ) {
		$post_type = $this->search_post_type( $item['search_scope'] );

		$label = '' !== $post_type
			? __( 'Search the shop', 'menuverse' )
			: __( 'Search this site', 'menuverse' );

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

		// WordPress reads `post_type` off the query for any search, so
		// narrowing one is a hidden field rather than a different endpoint or
		// a plugin's own route — which is what keeps this working on a shop
		// whose search results page the theme has taken over.
		$scope = '' !== $post_type
			? sprintf( '<input type="hidden" name="post_type" value="%s" />', esc_attr( $post_type ) )
			: '';

		return sprintf(
			'<div class="menuverse-bottom__search" id="%1$s" role="dialog" aria-label="%2$s" hidden><form class="menuverse-bottom__search-form" role="search" method="get" action="%3$s"><label class="menuverse-sr-only" for="%1$s-field">%2$s</label><input class="menuverse-bottom__search-field" id="%1$s-field" type="search" name="s" placeholder="%4$s" />%5$s<button type="submit" class="menuverse-bottom__search-submit">%6$s</button><button type="button" class="menuverse-bottom__search-close" data-mv-search-close><span class="menuverse-sr-only">%7$s</span>%8$s</button></form></div>',
			esc_attr( $search_id ),
			esc_attr( $label ),
			esc_url( home_url( '/' ) ),
			esc_attr( $placeholder ),
			$scope,
			esc_html__( 'Go', 'menuverse' ),
			esc_html__( 'Close', 'menuverse' ),
			Icon_Library::get( 'close', 'menuverse-bottom__icon' )
		);
	}

	/**
	 * Returns the post type a search scope narrows to.
	 *
	 * A scope whose shop is not installed returns nothing and the sheet
	 * searches the whole site. Deactivating WooCommerce would otherwise leave
	 * a button that reliably found zero results, which is worse than one that
	 * quietly widens.
	 *
	 * @param string $scope Stored search scope.
	 * @return string Post type, or an empty string for a site wide search.
	 */
	private function search_post_type( $scope ) {
		if ( 'woocommerce' === $scope && Woo::is_active() ) {
			return 'product';
		}

		if ( 'edd' === $scope && Edd::is_active() ) {
			return Edd::POST_TYPE;
		}

		return '';
	}

	/**
	 * Builds the DOM id of the sheet belonging to one search item.
	 *
	 * @param string $root_id Root element id.
	 * @param array  $item    Prepared item.
	 * @return string
	 */
	private function search_id( $root_id, array $item ) {
		return $root_id . '-search-' . (int) $item['search_index'];
	}

	/**
	 * Builds the element id for one item's panel.
	 *
	 * @param string $root_id Root element id.
	 * @param array  $item    Prepared item.
	 * @return string
	 */
	private function panel_id( $root_id, array $item ) {
		return $root_id . '-panel-' . (int) $item['sheet_index'];
	}

	/**
	 * Builds the inline custom properties one item sets for itself.
	 *
	 * Every one of these is optional, and an empty value is left out rather
	 * than written as an empty declaration: the stylesheet falls back to the
	 * bar wide token through `var()`, which only happens while the name is
	 * genuinely unset.
	 *
	 * @param array $item Prepared item.
	 * @return string Semicolon separated declarations, unescaped.
	 */
	private function item_style( array $item ) {
		$map = array(
			'--menuverse-item-bg'           => $item['icon_bg'],
			// Zero means "whatever the bar uses", so it is simply not emitted
			// and the bar's own icon size token stays in charge.
			'--menuverse-item-image-size'   => $item['image_width'] > 0 ? $item['image_width'] . 'px' : '',
			'--menuverse-item-color'        => $item['icon_color'],
			'--menuverse-item-active-color' => $item['icon_active_color'],
			'--menuverse-item-label'        => $item['text_color'],
			'--menuverse-item-label-active' => $item['text_active_color'],
		);

		$declarations = array();

		foreach ( $map as $token => $value ) {
			if ( '' !== $value ) {
				$declarations[] = $token . ':' . $value;
			}
		}

		return implode( ';', $declarations );
	}

	/**
	 * Renders the overflow drawer.
	 *
	 * @param string  $drawer_id Drawer element id.
	 * @param array[] $items     Overflow items.
	 * @param array   $settings  Resolved settings.
	 * @param string  $root_id   Root element id.
	 * @return string
	 */
	private function render_drawer( $drawer_id, array $items, array $settings, $root_id ) {
		$html = sprintf(
			'<div class="menuverse-bottom__overlay" data-mv-overlay hidden></div><div class="menuverse-bottom__drawer" id="%1$s" role="dialog" aria-modal="true" aria-label="%2$s" hidden><div class="menuverse-bottom__drawer-head"><span class="menuverse-bottom__drawer-title">%2$s</span><button type="button" class="menuverse-bottom__drawer-close" data-mv-close><span class="menuverse-sr-only">%3$s</span>%4$s</button></div><ul class="menuverse-bottom__drawer-list">',
			esc_attr( $drawer_id ),
			esc_attr( '' !== $settings['more_label'] ? $settings['more_label'] : __( 'More', 'menuverse' ) ),
			esc_html__( 'Close', 'menuverse' ),
			Icon_Library::get( 'close', 'menuverse-bottom__icon' )
		);

		foreach ( $items as $item ) {
			$classes = 'menuverse-bottom__drawer-link';
			$glyph   = $this->icon_markup( $item );
			$text    = esc_html( '' !== $item['label'] ? $item['label'] : Icon_Library::label( $item['icon'] ) );

			// An item that acts on the page keeps being a button down here
			// too, rather than turning into a link just because it moved into
			// the drawer.
			if ( '' !== $item['action'] ) {
				if ( 'incomplete' === $item['action'] ) {
					$trigger = ' disabled aria-disabled="true"';
					$classes = 'menuverse-bottom__drawer-link menuverse-bottom__item--incomplete';
				} elseif ( 'popup' === $item['action'] ) {
					$trigger = sprintf( ' data-mv-popup="%d"', $item['popup_id'] );
				} elseif ( 'panel' === $item['action'] ) {
					$trigger = sprintf(
						' aria-expanded="false" aria-controls="%1$s" data-mv-panel="%1$s"',
						esc_attr( $this->panel_id( $root_id, $item ) )
					);
				} else {
					$trigger = sprintf(
						' aria-expanded="false" aria-controls="%1$s" data-mv-search="%1$s"',
						esc_attr( $this->search_id( $root_id, $item ) )
					);
				}

				$html .= sprintf(
					'<li><button type="button" class="%1$s"%2$s>%3$s<span>%4$s</span></button></li>',
					esc_attr( $classes ),
					$trigger,
					$glyph,
					$text
				);

				continue;
			}

			$html .= sprintf(
				'<li><a class="menuverse-bottom__drawer-link%1$s" href="%2$s"%3$s><span class="menuverse-bottom__glyph">%4$s%5$s</span><span>%6$s</span></a></li>',
				$item['current'] ? ' menuverse-bottom__drawer-link--current' : '',
				esc_url( $item['url'] ),
				( '_blank' === $item['target'] ? sprintf( ' target="_blank" rel="%s"', esc_attr( $item['rel'] ) ) : '' ) . ( $item['current'] ? ' aria-current="page"' : '' ),
				$glyph,
				$this->badge_markup( $item ),
				$text
			);
		}

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

	/**
	 * Builds the label markup for an item.
	 *
	 * Labels stay in the document even when they are hidden visually, so the
	 * bar keeps its accessible names at every setting.
	 *
	 * @param string $label    Label text.
	 * @param array  $settings Resolved settings.
	 * @param bool   $visible  Whether this item wants its own label shown.
	 * @return string
	 */
	private function label_markup( $label, array $settings, $visible = true ) {
		if ( '' === $label ) {
			return '';
		}

		// The bar wide switch and the per item one both have to agree before
		// wording is drawn, but neither ever removes it from the document.
		$shown = ! empty( $settings['show_labels'] ) && $visible;

		return sprintf(
			'<span class="%s">%s</span>',
			$shown ? 'menuverse-bottom__label' : 'menuverse-sr-only',
			esc_html( $label )
		);
	}

	/**
	 * Normalizes the configured items and flags the current one.
	 *
	 * @param array $settings        Resolved settings.
	 * @param bool  $keep_incomplete Whether to keep items that have no
	 *                               destination yet, as the preview does.
	 * @return array[]
	 */
	private function prepare_items( array $settings, $keep_incomplete = false ) {
		$prepared = array();

		// Numbers the search buttons in the order they appear so each one can
		// own a sheet. Counted here rather than from the stored list, because
		// an item with no destination is dropped before it is ever rendered
		// and a gap in the numbering would outlive it.
		$search_index = 0;

		// Panels are numbered on a counter of their own. Sharing one with the
		// search sheets would work, but the two id prefixes differ, so a
		// separate count keeps each series starting at zero and reading as the
		// nth panel rather than the nth sheet of any kind.
		$panel_index = 0;

		foreach ( (array) $settings['items'] as $item ) {
			$item     = array_merge( $this->item_defaults(), (array) $item );
			$resolved = $this->resolve_item( $item, $settings );

			if ( null !== $resolved && 'search' === $resolved['action'] ) {
				$resolved['search_index'] = $search_index;
				++$search_index;
			}

			if ( null !== $resolved && 'panel' === $resolved['action'] ) {
				$resolved['sheet_index'] = $panel_index;
				++$panel_index;
			}

			if ( null === $resolved ) {
				// A destination that has not been filled in yet. Visitors get
				// nothing rather than a button that goes nowhere, but the
				// dashboard preview shows it, because someone who has just
				// applied a ready made look needs to see the item they still
				// have to finish rather than wonder where it went.
				if ( ! $keep_incomplete ) {
					continue;
				}

				$resolved = $this->placeholder_item( $item );
			}

			$prepared[] = $resolved;
		}

		return $this->single_current( $prepared );
	}

	/**
	 * Builds a stand in for an item whose destination is still empty.
	 *
	 * @param array $item Stored row, merged over the defaults.
	 * @return array
	 */
	private function placeholder_item( array $item ) {
		$icon = (string) $item['icon'];

		if ( ! Icon_Library::has( $icon ) ) {
			$icon = 'home';
		}

		$label = (string) $item['label'];

		return array(
			'icon'               => $icon,
			'active_icon'        => '',
			'image'              => 'image' === (string) $item['icon_source'] ? (string) $item['icon_image'] : '',
			'image_active'       => '',
			'image_width'        => (int) $item['icon_image_width'],
			'label'              => '' !== $label ? $label : Icon_Library::label( $icon ),
			'show_label'         => ! empty( $item['show_label'] ),
			'position'           => 'top' === $item['label_position'] ? 'top' : 'bottom',
			'url'                => '',
			'action'             => 'incomplete',
			'popup_id'           => 0,
			'target'             => '',
			'rel'                => '',
			'badge'              => '',
			'count'              => '',
			'icon_bg'            => $this->icon_bg( $item ),
			'icon_color'         => (string) $item['icon_color'],
			'icon_active_color'  => (string) $item['icon_active_color'],
			'text_color'         => (string) $item['text_color'],
			'text_active_color'  => (string) $item['text_active_color'],
			'search_scope'       => 'site',
			'search_placeholder' => '',
			'search_index'       => 0,
			'sheet_index'        => 0,
			'panel_content'      => '',
			'current'            => false,
		);
	}

	/**
	 * Fills in any item keys an older stored row is missing.
	 *
	 * @return array
	 */
	private function item_defaults() {
		return array(
			'icon_source'            => 'library',
			'icon'                   => 'home',
			'active_icon'            => '',
			'icon_image'             => '',
			'icon_image_active'      => '',
			'icon_image_width'       => 0,
			'panel_content'          => '',
			'visibility'             => 'everyone',
			'visibility_roles'       => array(),
			'label'                  => '',
			'show_label'             => true,
			'label_position'         => 'bottom',
			'link_type'              => 'url',
			'url'                    => '',
			'page_id'                => 0,
			'popup_id'               => 0,
			'whatsapp_number'        => '',
			'whatsapp_message'       => '',
			'call_number'            => '',
			'search_scope'           => 'site',
			'search_placeholder'     => '',
			'target'                 => '',
			'badge'                  => '',
			'icon_color'             => '',
			'icon_active_color'      => '',
			'text_color'             => '',
			'text_active_color'      => '',
			'icon_bg_enabled'        => false,
			'icon_bg_type'           => 'color',
			'icon_bg_color'          => '',
			'icon_bg_gradient_start' => '',
			'icon_bg_gradient_end'   => '',
			'show_cart_count'        => false,
			'show_wishlist_count'    => false,
		);
	}

	/**
	 * Turns one stored row into something renderable, or nothing.
	 *
	 * The destination is resolved strictly from `link_type`. That matters: an
	 * item switched from a web address to a phone number still has the old
	 * address stored, and reading it would send people somewhere they stopped
	 * pointing at long ago. Reading only the field that belongs to the current
	 * type makes every leftover inert without anything having to clean up.
	 *
	 * An item with no usable destination returns null and is dropped, rather
	 * than rendering a link to nowhere.
	 *
	 * @param array $item     Stored row, already merged over the defaults.
	 * @param array $settings Resolved settings.
	 * @return array|null
	 */
	private function resolve_item( array $item, array $settings ) {
		// Asked before anything else is resolved: an item this visitor is not
		// meant to see costs nothing to drop here, and dropping it rather than
		// hiding it with CSS keeps it out of the markup a cache might serve to
		// somebody else.
		if ( ! $this->item_visible( $item ) ) {
			return null;
		}

		$icon = (string) $item['icon'];

		if ( ! Icon_Library::has( $icon ) ) {
			$icon = 'home';
		}

		$active_icon = (string) $item['active_icon'];

		if ( ! Icon_Library::has( $active_icon ) ) {
			$active_icon = '';
		}

		// An image icon with no address falls back to the built in one rather
		// than rendering a broken picture: the row is half filled in, which is
		// a state the dashboard reaches on the way to a finished item.
		$image        = 'image' === (string) $item['icon_source'] ? (string) $item['icon_image'] : '';
		$image_active = '' !== $image ? (string) $item['icon_image_active'] : '';

		$type    = (string) $item['link_type'];
		$url     = '';
		$action  = '';
		$target  = '';
		$rel     = '';
		$current = false;

		switch ( $type ) {
			case 'page':
				$permalink = $item['page_id'] ? get_permalink( (int) $item['page_id'] ) : '';

				if ( ! $permalink ) {
					return null;
				}

				$url    = $permalink;
				$target = (string) $item['target'];

				// `is_page()` alone is an exact test, which quietly made the
				// "that page or below it" setting do nothing for page links —
				// an item for /services never lit up on /services/plumbing.
				// The page itself still counts even when the permalink match
				// misses, which covers front page and blog page setups.
				$current = is_page( (int) $item['page_id'] )
					|| $this->matches_current( $url, $settings );
				break;

			case 'whatsapp':
				$number = $this->digits( $item['whatsapp_number'] );

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

				$url = 'https://wa.me/' . rawurlencode( $number );

				if ( '' !== (string) $item['whatsapp_message'] ) {
					$url .= '?text=' . rawurlencode( (string) $item['whatsapp_message'] );
				}

				// Leaving the site for an app: a new tab keeps the page they
				// were reading, and noreferrer keeps the address out of the
				// handoff.
				$target = '_blank';
				$rel    = 'noopener noreferrer';
				break;

			case 'call':
				$number = $this->digits( $item['call_number'], true );

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

				$url = 'tel:' . $number;
				break;

			case 'search':
				$action = 'search';
				break;

			case 'popup':
				if ( ! $item['popup_id'] ) {
					return null;
				}

				$action = 'popup';
				break;

			case 'panel':
				// An empty panel is a button that opens nothing, which is the
				// same half finished state as a link with no address.
				if ( '' === trim( (string) $item['panel_content'] ) ) {
					return null;
				}

				$action = 'panel';
				break;

			default:
				$url = (string) $item['url'];

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

				$target  = (string) $item['target'];
				$current = $this->matches_current( $url, $settings );
				break;
		}

		if ( '_blank' === $target && '' === $rel ) {
			$rel = 'noopener';
		}

		$label = (string) $item['label'];

		return array(
			'icon'               => $icon,
			'active_icon'        => $active_icon,
			'image'              => $image,
			'image_active'       => $image_active,
			'image_width'        => (int) $item['icon_image_width'],
			'label'              => '' !== $label ? $label : Icon_Library::label( $icon ),
			'show_label'         => ! empty( $item['show_label'] ),
			'position'           => 'top' === $item['label_position'] ? 'top' : 'bottom',
			'url'                => $url,
			'action'             => $action,
			'popup_id'           => (int) $item['popup_id'],
			'target'             => $target,
			'rel'                => $rel,
			'badge'              => (string) $item['badge'],
			'count'              => $this->count_kind( $item ),
			'icon_bg'            => $this->icon_bg( $item ),
			'icon_color'         => (string) $item['icon_color'],
			'icon_active_color'  => (string) $item['icon_active_color'],
			'text_color'         => (string) $item['text_color'],
			'text_active_color'  => (string) $item['text_active_color'],
			'search_scope'       => (string) $item['search_scope'],
			'search_placeholder' => (string) $item['search_placeholder'],
			'search_index'       => 0,
			'sheet_index'        => 0,
			'panel_content'      => (string) $item['panel_content'],
			'current'            => $current,
		);
	}

	/**
	 * Tells whether a destination matches the page being viewed.
	 *
	 * @param string $url      Resolved destination.
	 * @param array  $settings Resolved settings.
	 * @return bool
	 */
	private function matches_current( $url, array $settings ) {
		return Renderer::is_current_url(
			$url,
			'exact' === $settings['active_match'] ? 'exact' : 'prefix'
		);
	}

	/**
	 * Reduces a stored telephone number to what the destination accepts.
	 *
	 * @param string $value      Stored number.
	 * @param bool   $allow_plus Whether a leading plus may survive.
	 * @return string
	 */
	private function digits( $value, $allow_plus = false ) {
		$value = preg_replace( '/[^0-9+]/', '', (string) $value );
		$plus  = ( $allow_plus && isset( $value[0] ) && '+' === $value[0] ) ? '+' : '';

		return $plus . str_replace( '+', '', $value );
	}

	/**
	 * Returns which live counter an item asks for, if any.
	 *
	 * The number itself is never rendered here. A cart or wishlist total
	 * belongs to one visitor, and a full page cache would happily serve one
	 * person's total to everybody, so the element goes out empty and the
	 * browser fills it in.
	 *
	 * @param array $item Stored row.
	 * @return string One of 'cart', 'wishlist', or an empty string.
	 */
	private function count_kind( array $item ) {
		$cart     = ! empty( $item['show_cart_count'] ) && Woo::is_active();
		$wishlist = ! empty( $item['show_wishlist_count'] ) && Yith_Wishlist::is_active();

		// One badge per item, so the two are a choice rather than a pair. The
		// dashboard says as much; before, turning both on silently showed the
		// cart and made the wishlist toggle look broken.
		if ( $cart && $wishlist ) {
			return 'cart';
		}

		if ( $cart ) {
			return 'cart';
		}

		return $wishlist ? 'wishlist' : '';
	}

	/**
	 * Builds the fill for the circle behind an item's icon.
	 *
	 * @param array $item Stored row.
	 * @return string CSS value, or an empty string when the circle is off.
	 */
	private function icon_bg( array $item ) {
		if ( empty( $item['icon_bg_enabled'] ) ) {
			return '';
		}

		if ( 'gradient' === $item['icon_bg_type'] ) {
			$start = (string) $item['icon_bg_gradient_start'];
			$end   = (string) $item['icon_bg_gradient_end'];

			return ( '' !== $start && '' !== $end )
				? sprintf( 'linear-gradient(%s,%s,%s)', self::GRADIENT_ANGLE, $start, $end )
				: '';
		}

		return (string) $item['icon_bg_color'];
	}

	/**
	 * Keeps at most one item highlighted.
	 *
	 * Several items can legitimately match the same page — a link with a query
	 * string resolves to the same path as its parent — and a bar with two
	 * highlighted items reads as a bug. The most specific match wins, and the
	 * first one wins a tie.
	 *
	 * @param array[] $items Prepared items.
	 * @return array[]
	 */
	private function single_current( array $items ) {
		$best   = -1;
		$length = -1;

		foreach ( $items as $index => $item ) {
			if ( ! $item['current'] ) {
				continue;
			}

			$path = (string) wp_parse_url( $item['url'], PHP_URL_PATH );

			if ( strlen( $path ) > $length ) {
				$length = strlen( $path );
				$best   = $index;
			}
		}

		foreach ( $items as $index => $item ) {
			$items[ $index ]['current'] = ( $index === $best );
		}

		return $items;
	}

	/**
	 * 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 ) {
		$breakpoint = (int) $settings['breakpoint'];

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

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

		return $css;
	}

	/**
	 * Reserves room at the end of the page so the bar covers nothing.
	 *
	 * The height cannot be read from a custom property here. `--menuverse-bar-height`
	 * is defined on the bar's own root, which is a descendant of `body`, and
	 * custom properties only inherit downwards — on `body` the name is simply
	 * unset and the fallback wins every time. So the value is resolved in PHP
	 * and written out literally.
	 *
	 * @param int   $breakpoint Widest screen the bar appears on.
	 * @param array $settings   Resolved settings.
	 * @return string
	 */
	private function body_padding_css( $breakpoint, array $settings ) {
		if ( empty( $settings['body_padding'] ) || empty( $settings['sticky'] ) ) {
			return '';
		}

		$height = (string) $settings['bar_height'];
		$height = '' !== $height ? $height : '58px';

		// Only the safe area variant reserves the home indicator strip, so the
		// padding must not add it when that is switched off.
		$inset = empty( $settings['safe_area'] )
			? ''
			: ' + var(--menuverse-safe-bottom, 0px)';

		// A rounded bar floats clear of the bottom edge, so it covers that
		// much more of the page than its own height.
		$float = (int) $settings['bar_radius'] > 0
			? sprintf( ' + %dpx', self::FLOAT_GAP )
			: '';

		return sprintf(
			'@media (max-width:%1$dpx){body{padding-block-end:calc(%2$s%3$s%4$s)}}',
			$breakpoint,
			$height,
			$inset,
			$float
		);
	}

	/**
	 * Builds the colour declarations for both schemes.
	 *
	 * Anything that changes with the colour scheme has to be emitted here
	 * rather than as an inline token: a declaration in the `style` attribute
	 * outranks every author rule, media query included, so a light value there
	 * would beat the dark one no matter what. Emitting both from the same
	 * stylesheet lets source order decide, which is what makes dark mode work
	 * for someone who has also chosen a light colour.
	 *
	 * @param string $root_id  Root element id.
	 * @param array  $settings Resolved settings.
	 * @return string
	 */
	private function scheme_css( $root_id, array $settings ) {
		$light = $this->scheme_tokens(
			array(
				'--menuverse-bar-background'         => $this->background_value( $settings['bg_type'], $settings, '' ),
				'--menuverse-bar-color'              => (string) $settings['bar_color'],
				'--menuverse-bar-active-color'       => (string) $settings['bar_active_color'],
				'--menuverse-bar-label-color'        => (string) $settings['label_color'],
				'--menuverse-bar-label-active-color' => (string) $settings['label_active_color'],
				'--menuverse-bar-underline-color'    => (string) $settings['underline_color'],
				'--menuverse-bar-pulse-color'        => (string) $settings['pulse_color'],
			)
		);

		$css = $light
			? sprintf( '#%1$s{%2$s}', $root_id, $light )
			: '';

		if ( empty( $settings['auto_dark_mode_enabled'] ) ) {
			return $css;
		}

		// The underline and the dot have no dark colour of their own. Both are
		// accents on the current item, so they follow the colour that item
		// already takes in dark mode rather than asking for two more pickers
		// that would almost always be set to the same value.
		$dark_color  = (string) $settings['dark_bar_color'];
		$dark_active = (string) $settings['dark_bar_active_color'];

		// The wording colours fall back to this scheme's own icon colours
		// rather than being left out. A custom property cannot be unset once
		// the light block has defined it, so an omitted dark value would not
		// mean "match the icons here" — it would mean "keep the light one",
		// and the wording would be the one part of the bar that never changed.
		$dark = $this->scheme_tokens(
			array(
				'--menuverse-bar-background'         => $this->background_value( $settings['dark_bg_type'], $settings, 'dark_' ),
				'--menuverse-bar-color'              => $dark_color,
				'--menuverse-bar-active-color'       => $dark_active,
				'--menuverse-bar-label-color'        => '' !== (string) $settings['dark_label_color'] ? (string) $settings['dark_label_color'] : $dark_color,
				'--menuverse-bar-label-active-color' => '' !== (string) $settings['dark_label_active_color'] ? (string) $settings['dark_label_active_color'] : $dark_active,
				'--menuverse-bar-underline-color'    => $dark_active,
				'--menuverse-bar-pulse-color'        => $dark_active,
			)
		);

		if ( ! $dark ) {
			return $css;
		}

		return $css . sprintf(
			'@media (prefers-color-scheme:dark){#%1$s{%2$s}}',
			$root_id,
			$dark
		);
	}

	/**
	 * Joins one scheme's declarations, dropping any that are unset.
	 *
	 * @param array $map Token name to value.
	 * @return string
	 */
	private function scheme_tokens( array $map ) {
		$declarations = array();

		foreach ( $map as $token => $value ) {
			if ( '' !== $value ) {
				$declarations[] = $token . ':' . $value;
			}
		}

		return implode( ';', $declarations );
	}

	/**
	 * Resolves one scheme's background to a single CSS value.
	 *
	 * A blend needs two colours in one value, so it can never come from the
	 * plain token pass. An incomplete blend returns nothing rather than
	 * `linear-gradient(100deg,,)`, which parses but is invalid once
	 * substituted, leaving the bar transparent instead of falling back.
	 *
	 * @param string $type     Background type for this scheme.
	 * @param array  $settings Resolved settings.
	 * @param string $prefix   Setting prefix, empty for light and `dark_` for dark.
	 * @return string
	 */
	private function background_value( $type, array $settings, $prefix ) {
		if ( 'gradient' === $type ) {
			$start = (string) $settings[ $prefix . 'bg_gradient_start' ];
			$end   = (string) $settings[ $prefix . 'bg_gradient_end' ];

			return ( '' !== $start && '' !== $end )
				? sprintf( 'linear-gradient(%s,%s,%s)', self::GRADIENT_ANGLE, $start, $end )
				: '';
		}

		// Frosted glass paints its tint through a separate property, so the
		// background token stays out of its way.
		if ( 'glass' === $type ) {
			return '';
		}

		// A picture, optionally under a wash of colour. The wash is written as
		// a two stop gradient because that is the only way to lay a flat
		// colour over an image in a single `background` shorthand, which is
		// what this token feeds.
		if ( 'image' === $type ) {
			$url = (string) $settings['bg_image'];

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

			$overlay = (string) $settings['bg_image_overlay'];
			$wash    = '' !== $overlay
				? sprintf( 'linear-gradient(%1$s,%1$s),', $overlay )
				: '';

			return sprintf( '%1$surl("%2$s") center/cover no-repeat', $wash, $url );
		}

		return (string) $settings[ '' === $prefix ? 'bar_background' : 'dark_bg_color' ];
	}

	/**
	 * Settings that must not reach the inline token pass on this render.
	 *
	 * The colours are permanently excluded because `scheme_css()` owns them.
	 * The stacking order is conditional: zero means "leave it alone", and a
	 * token is emitted for any number including zero — which would pin the bar
	 * to layer 0 and let every sticky header on the site cover it.
	 *
	 * @param array $settings Resolved settings.
	 * @return string[]
	 */
	private function token_exclusions( array $settings ) {
		$exclude = $this->scheme_managed_keys();

		if ( (int) $settings['z_index'] < 1 ) {
			$exclude[] = 'z_index';
		}

		// Zero is the stylesheet's own value, so emitting it only makes every
		// bar on every page carry a declaration that changes nothing.
		if ( (int) $settings['bar_padding'] < 1 ) {
			$exclude[] = 'bar_padding';
		}

		return $exclude;
	}

	/**
	 * Settings whose tokens are emitted by `scheme_css()` rather than inline.
	 *
	 * @return string[]
	 */
	private function scheme_managed_keys() {
		return array(
			'bar_background',
			'bg_gradient_start',
			'bg_gradient_end',
			'bar_color',
			'bar_active_color',
			'label_color',
			'label_active_color',
			'underline_color',
			'pulse_color',
		);
	}

	/**
	 * 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 bar — 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 ) ) );
	}
}
