<?php
/**
 * Accessibility Widget module.
 *
 * Renders a floating visitor-assistance toolbar with visual, reading, and
 * navigation controls. Preferences are stored client-side (localStorage); admin
 * settings live in the shared PowerSuite settings option.
 *
 * @package AccessibilityWidget
 */

namespace AccessibilityWidget;

use WP_Post;
use WP_PowerSuite\Core\AbstractModule;
use WP_PowerSuite\Core\Http\AjaxRouter;
use WP_PowerSuite\Core\RemoteUrlValidator;
use AccessibilityWidget\Includes\AccessibilityAudit;
use AccessibilityWidget\Includes\AccessibilityProfiles;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

require_once __DIR__ . '/FeatureGate.php';
require_once __DIR__ . '/includes/class-accessibility-audit.php';
require_once __DIR__ . '/includes/class-accessibility-profiles.php';

class Module extends AbstractModule {

	private const MODULE_ID      = 'accessibility-widget';
	private const MODULE_VERSION = '2.0.0';
	private const SHORTCODE      = 'wpps_accessibility_widget';
	private const STORAGE_KEY    = 'wpps_a11y_prefs';
	private const SCRIPT_HANDLE  = 'wpps-accessibility-widget';
	private const STYLE_HANDLE   = 'wpps-accessibility-widget';

	/** @var array<string, mixed>|null */
	private ?array $settings_cache = null;

	private static bool $shortcode_rendered = false;

	private static bool $assets_enqueued = false;

	private static bool $early_assets_printed = false;

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( 'Accessibility Widget', 'wppowersuite-lite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'A floating visitor-assistance widget with visual and navigation controls. Helps visitors adjust the interface; it does not replace accessible themes, semantic markup, or a full WCAG audit.', 'wppowersuite-lite' );
	}

	public function getContext(): string|array {
		return [ 'frontend', 'admin', 'ajax' ];
	}

	/* ---------------------------------------------------------------------
	 * Settings model
	 * ------------------------------------------------------------------- */

	/** @return list<string> */
	public static function getFeatureKeys(): array {
		return [
			'accessibility_profiles',
			'text_size',
			'line_height',
			'letter_spacing',
			'word_spacing',
			'high_contrast',
			'light_contrast',
			'dark_contrast',
			'grayscale',
			'invert_colors',
			'low_saturation',
			'high_saturation',
			'protanopia',
			'deuteranopia',
			'tritanopia',
			'highlight_links',
			'highlight_headings',
			'large_cursor',
			'big_black_cursor',
			'reading_guide',
			'reading_mask',
			'text_align_left',
			'dyslexia_font',
			'readable_font',
			'pause_animations',
			'hide_images',
			'mute_media',
			'simplify_layout',
			'text_to_speech',
			'link_navigator',
			'tooltips',
			'skip_to_content',
			'focus_outlines',
			'underline_links',
			'page_structure',
			'accessibility_statement',
		];
	}

	/**
	 * Tool registry: maps each orderable tool to its panel section, control
	 * type, and the SVG/label data used for rendering. `accessibility_statement`
	 * is intentionally excluded — it is a footer link, not a panel control.
	 *
	 * @return array<string, array{section: string, type: string}>
	 */
	public static function getToolRegistry(): array {
		return [
			'accessibility_profiles' => [ 'section' => 'profiles', 'type' => 'profiles' ],
			'text_size'              => [ 'section' => 'vision', 'type' => 'level' ],
			'high_contrast'          => [ 'section' => 'vision', 'type' => 'toggle' ],
			'light_contrast'         => [ 'section' => 'vision', 'type' => 'toggle' ],
			'dark_contrast'          => [ 'section' => 'vision', 'type' => 'toggle' ],
			'grayscale'              => [ 'section' => 'vision', 'type' => 'toggle' ],
			'invert_colors'          => [ 'section' => 'vision', 'type' => 'toggle' ],
			'low_saturation'         => [ 'section' => 'vision', 'type' => 'toggle' ],
			'high_saturation'        => [ 'section' => 'vision', 'type' => 'toggle' ],
			'protanopia'             => [ 'section' => 'vision', 'type' => 'toggle' ],
			'deuteranopia'           => [ 'section' => 'vision', 'type' => 'toggle' ],
			'tritanopia'             => [ 'section' => 'vision', 'type' => 'toggle' ],
			'highlight_links'        => [ 'section' => 'vision', 'type' => 'toggle' ],
			'highlight_headings'     => [ 'section' => 'vision', 'type' => 'toggle' ],
			'large_cursor'           => [ 'section' => 'vision', 'type' => 'toggle' ],
			'big_black_cursor'       => [ 'section' => 'vision', 'type' => 'toggle' ],
			'line_height'            => [ 'section' => 'reading', 'type' => 'level' ],
			'letter_spacing'         => [ 'section' => 'reading', 'type' => 'level' ],
			'word_spacing'           => [ 'section' => 'reading', 'type' => 'level' ],
			'readable_font'          => [ 'section' => 'reading', 'type' => 'toggle' ],
			'dyslexia_font'          => [ 'section' => 'reading', 'type' => 'toggle' ],
			'reading_guide'          => [ 'section' => 'reading', 'type' => 'toggle' ],
			'reading_mask'           => [ 'section' => 'reading', 'type' => 'toggle' ],
			'text_align_left'        => [ 'section' => 'reading', 'type' => 'toggle' ],
			'skip_to_content'        => [ 'section' => 'navigation', 'type' => 'toggle' ],
			'focus_outlines'         => [ 'section' => 'navigation', 'type' => 'toggle' ],
			'underline_links'        => [ 'section' => 'navigation', 'type' => 'toggle' ],
			'tooltips'               => [ 'section' => 'navigation', 'type' => 'toggle' ],
			'page_structure'         => [ 'section' => 'navigation', 'type' => 'toggle' ],
			'link_navigator'         => [ 'section' => 'navigation', 'type' => 'link_navigator' ],
			'text_to_speech'         => [ 'section' => 'tools', 'type' => 'tts' ],
			'pause_animations'       => [ 'section' => 'motion', 'type' => 'toggle' ],
			'mute_media'             => [ 'section' => 'motion', 'type' => 'toggle' ],
			'simplify_layout'        => [ 'section' => 'motion', 'type' => 'toggle' ],
			'hide_images'            => [ 'section' => 'motion', 'type' => 'toggle' ],
		];
	}

	/** @return list<string> */
	public static function getToolKeys(): array {
		return array_keys( self::getToolRegistry() );
	}

	/** @return list<string> */
	public static function getButtonIcons(): array {
		return [ 'accessibility', 'wheelchair', 'wheelchair-move', 'person', 'eye', 'low-vision', 'ear', 'hands', 'braille' ];
	}

	/** @return list<string> */
	public static function getPositions(): array {
		return [ 'bottom-right', 'bottom-left', 'middle-right', 'middle-left', 'top-right', 'top-left' ];
	}

	/** @return list<string> */
	public static function getButtonSizes(): array {
		return [ 'small', 'medium', 'large' ];
	}

	/** @return list<string> */
	public static function getIconSizes(): array {
		return [ 'small', 'medium', 'large' ];
	}

	/** @return list<string> */
	public static function getStatementTypes(): array {
		return [ 'link', 'text' ];
	}

	/**
	 * Glyph scale (fraction of the button diameter) per icon size choice. A
	 * smaller scale leaves more padding around the icon.
	 *
	 * @return array<string, float>
	 */
	public static function getIconScaleMap(): array {
		return [
			'small'  => 0.40,
			'medium' => 0.50,
			'large'  => 0.62,
		];
	}

	/** @return list<string> */
	public static function getPanelLayouts(): array {
		return [ 'list', 'grid' ];
	}

	/** @return array<string, mixed> */
	public static function getDefaultSettings(): array {
		$features = [];
		foreach ( self::getFeatureKeys() as $key ) {
			$features[ $key ] = true;
		}

		return [
			'frontend_enabled'    => true,
			'show_on_mobile'      => true,
			'auto_display'        => true,
			'save_preferences'    => true,
			'position'            => 'bottom-right',
			'button_size'         => 'medium',
			'button_icon'         => 'accessibility',
			'button_icon_size'    => 'medium',
			'button_icon_color'   => '#ffffff',
			'button_icon_color_auto' => true,
			'button_color'        => '#2271b1',
			'widget_offset'       => 20,
			'widget_offset_y'     => 20,
			'panel_layout'        => 'list',
			'panel_title'         => '',
			'button_label'        => '',
			'custom_css_class'    => '',
			'excluded_post_types' => [],
			'excluded_post_ids'   => '',
			'exclude_urls'        => '',
			'statement_type'      => 'link',
			'statement_url'       => '',
			'statement_text'      => '',
			'statement_content'   => '',
			'show_footer_note'    => true,
			'footer_note'         => '',
			'tool_order'          => self::getToolKeys(),
			'features'            => $features,
		];
	}

	/**
	 * @param array<string, mixed> $raw
	 * @return array<string, mixed>
	 */
	public static function normalizeSettings( array $raw ): array {
		$defaults = self::getDefaultSettings();
		$settings = wp_parse_args( $raw, $defaults );

		// The module's own on/off is handled by the PowerSuite module system, so
		// the widget always renders on the frontend when the module is active.
		$settings['frontend_enabled'] = true;
		$settings['show_on_mobile']   = ! empty( $settings['show_on_mobile'] );
		$settings['auto_display']     = ! empty( $settings['auto_display'] );
		$settings['save_preferences'] = ! empty( $settings['save_preferences'] );

		$settings['position']         = self::sanitizeChoice( $settings['position'] ?? '', self::getPositions(), 'bottom-right' );
		$settings['button_size']      = self::sanitizeChoice( $settings['button_size'] ?? '', self::getButtonSizes(), 'medium' );
		$settings['button_icon']      = self::sanitizeChoice( $settings['button_icon'] ?? '', self::getButtonIcons(), 'accessibility' );
		$settings['button_icon_size'] = self::sanitizeChoice( $settings['button_icon_size'] ?? '', self::getIconSizes(), 'medium' );
		$settings['panel_layout']     = self::sanitizeChoice( $settings['panel_layout'] ?? '', self::getPanelLayouts(), 'list' );
		$settings['statement_type']   = self::sanitizeChoice( $settings['statement_type'] ?? '', self::getStatementTypes(), 'link' );

		$color                    = sanitize_hex_color( (string) ( $settings['button_color'] ?? '' ) );
		$settings['button_color'] = ( is_string( $color ) && $color !== '' ) ? $color : '#2271b1';

		$icon_color                    = sanitize_hex_color( (string) ( $settings['button_icon_color'] ?? '' ) );
		$settings['button_icon_color'] = ( is_string( $icon_color ) && $icon_color !== '' ) ? $icon_color : '#ffffff';
		$settings['button_icon_color_auto'] = ! empty( $settings['button_icon_color_auto'] );

		$offset                  = (int) ( $settings['widget_offset'] ?? 20 );
		$settings['widget_offset'] = max( 0, min( 120, $offset ) );

		// Vertical edge spacing falls back to the horizontal value so sites
		// saved before this setting existed keep their symmetric spacing.
		$offset_y                    = array_key_exists( 'widget_offset_y', $raw )
			? (int) $raw['widget_offset_y']
			: (int) $settings['widget_offset'];
		$settings['widget_offset_y'] = max( 0, min( 120, $offset_y ) );

		// Drop the removed visitor-repositioning setting from legacy saves.
		unset( $settings['allow_move'] );

		$settings['panel_title']  = self::sanitizeShortText( $settings['panel_title'] ?? '', 120 );
		$settings['button_label'] = self::sanitizeShortText( $settings['button_label'] ?? '', 120 );
		$settings['statement_text'] = self::sanitizeShortText( $settings['statement_text'] ?? '', 120 );
		$settings['statement_content'] = wp_kses_post( (string) ( $settings['statement_content'] ?? '' ) );
		$settings['show_footer_note'] = ! empty( $settings['show_footer_note'] );
		$settings['footer_note']      = self::sanitizeShortText( $settings['footer_note'] ?? '', 300 );

		$class                        = sanitize_html_class( (string) ( $settings['custom_css_class'] ?? '' ) );
		$settings['custom_css_class'] = $class;

		$settings['excluded_post_types'] = self::sanitizePostTypes( $settings['excluded_post_types'] ?? [] );
		$settings['excluded_post_ids']   = self::sanitizePostIds( $settings['excluded_post_ids'] ?? '' );
		$settings['exclude_urls']        = self::sanitizeUrlPatterns( $settings['exclude_urls'] ?? '' );
		$settings['statement_url']       = esc_url_raw( (string) ( $settings['statement_url'] ?? '' ) );

		$settings['tool_order'] = self::sanitizeToolOrder( $settings['tool_order'] ?? [] );

		// Feature visibility. The admin form sends an `enabled_features` list
		// (comma-separated keys) so a tool that is turned off is reliably
		// recorded as false. Programmatic callers may still pass a `features`
		// map directly.
		$normalized = [];
		if ( array_key_exists( 'enabled_features', $raw ) ) {
			$list = $raw['enabled_features'];
			if ( is_string( $list ) ) {
				$list = preg_split( '/[\s,]+/', trim( $list ) ) ?: [];
			}
			$list = is_array( $list ) ? array_map( 'sanitize_key', $list ) : [];
			foreach ( self::getFeatureKeys() as $key ) {
				$normalized[ $key ] = in_array( $key, $list, true );
			}
		} else {
			$features = is_array( $settings['features'] ?? null ) ? $settings['features'] : [];
			foreach ( self::getFeatureKeys() as $key ) {
				$normalized[ $key ] = array_key_exists( $key, $features )
					? ! empty( $features[ $key ] )
					: ! empty( $defaults['features'][ $key ] );
			}
		}
		$settings['features'] = $normalized;
		unset( $settings['enabled_features'] );

		// Freemium: force premium tools off when the site is not licensed.
		$settings = FeatureGate::applyPremiumTierLimits( $settings );

		return $settings;
	}

	/**
	 * @param mixed         $value
	 * @param list<string>  $allowed
	 */
	private static function sanitizeChoice( $value, array $allowed, string $fallback ): string {
		$value = sanitize_key( (string) $value );
		return in_array( $value, $allowed, true ) ? $value : $fallback;
	}

	/**
	 * @param mixed $value
	 */
	private static function sanitizeShortText( $value, int $max ): string {
		$text = sanitize_text_field( (string) $value );
		return function_exists( 'mb_substr' ) ? mb_substr( $text, 0, $max ) : substr( $text, 0, $max );
	}

	/**
	 * @param mixed $raw
	 * @return list<string>
	 */
	private static function sanitizeToolOrder( $raw ): array {
		if ( is_string( $raw ) ) {
			$raw = preg_split( '/[\s,]+/', $raw ) ?: [];
		}
		if ( ! is_array( $raw ) ) {
			$raw = [];
		}

		$valid_keys = self::getToolKeys();
		$ordered    = [];
		foreach ( $raw as $key ) {
			$key = sanitize_key( (string) $key );
			if ( $key !== '' && in_array( $key, $valid_keys, true ) && ! in_array( $key, $ordered, true ) ) {
				$ordered[] = $key;
			}
		}

		// Append any tools not present in the saved order (forward compatibility).
		foreach ( $valid_keys as $key ) {
			if ( ! in_array( $key, $ordered, true ) ) {
				$ordered[] = $key;
			}
		}

		return $ordered;
	}

	/* ---------------------------------------------------------------------
	 * Lifecycle
	 * ------------------------------------------------------------------- */

	protected function registerHooks(): void {
		add_filter( 'wp_powersuite_sanitize_module_settings', [ $this, 'filterSanitizeModuleSettings' ], 10, 3 );
		add_action( 'wp_powersuite_module_settings_saved', [ $this, 'onSettingsSaved' ], 10, 2 );

		// Advisory admin scanner. Capability + nonce are enforced by AjaxRouter.
		// Registered regardless of license tier so the admin tool always works.
		AjaxRouter::registerAdminAction( 'wpps_a11y_run_checks', [ $this, 'handleRunChecks' ] );

		// Frontend output hooks only belong in the real frontend. is_admin()
		// is true for both wp-admin screens and admin-ajax requests, so this
		// also avoids registering them on every PowerSuite AJAX call.
		if ( is_admin() ) {
			return;
		}

		add_shortcode( self::SHORTCODE, [ $this, 'renderShortcode' ] );
		add_action( 'wp_enqueue_scripts', [ $this, 'maybeEnqueueAssets' ] );
		add_action( 'wp_body_open', [ $this, 'printEarlyAssets' ], 0 );
		add_action( 'wp_footer', [ $this, 'maybeRenderFloatingWidget' ], 20 );
	}

	/**
	 * @param array<string, mixed> $settings
	 * @param string               $module_id
	 * @param string               $context
	 * @return array<string, mixed>
	 */
	public function filterSanitizeModuleSettings( array $settings, string $module_id, string $context ): array {
		unset( $context );

		if ( $module_id !== self::MODULE_ID ) {
			return $settings;
		}

		$existing = $this->moduleSettings()->all( [] );
		$existing = is_array( $existing ) ? $existing : [];

		return FeatureGate::preservePremiumSettingsOnSave( $settings, $existing );
	}

	public function onSettingsSaved( string $module_id, array $settings ): void {
		unset( $settings );

		if ( $module_id === self::MODULE_ID ) {
			$this->settings_cache = null;
		}
	}

	/* ---------------------------------------------------------------------
	 * Frontend output
	 * ------------------------------------------------------------------- */

	public function maybeEnqueueAssets(): void {
		if ( is_admin() || ! $this->shouldLoadFrontend() ) {
			return;
		}

		$settings = $this->getSettings();
		if ( empty( $settings['auto_display'] ) && ! $this->pageHasShortcode() ) {
			return;
		}

		$this->enqueueAssets();
	}

	/**
	 * SVG color-blind filter definitions + an early preferences bootstrap that
	 * applies stored visitor choices before the page paints (prevents FOUC).
	 * Printed at wp_body_open so the SVG defs exist before any filter resolves.
	 */
	public function printEarlyAssets(): void {
		if ( is_admin() || self::$early_assets_printed || ! $this->shouldLoadFrontend() ) {
			return;
		}

		$settings = $this->getSettings();
		if ( empty( $settings['auto_display'] ) && ! $this->pageHasShortcode() ) {
			return;
		}

		self::$early_assets_printed = true;

		if ( FeatureGate::isPremiumLicensed() ) {
			echo self::getColorBlindSvgFilters(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG markup.
		}

		// The pre-paint bootstrap only makes sense when preferences persist.
		if ( empty( $settings['save_preferences'] ) ) {
			return;
		}

		$key = wp_json_encode( self::STORAGE_KEY );
		// JSON list of preference keys the admin currently allows. Stored
		// preferences for disabled (or unlicensed) tools are ignored.
		$allowed = wp_json_encode( self::getEnabledPrefKeys( is_array( $settings['features'] ?? null ) ? $settings['features'] : [] ) );

		// Bootstrap script: reads localStorage and applies classes + the
		// composed filter custom property before first paint (prevents FOUC).
		// The only server-provided values are the two JSON-encoded constants.
		$js = "(function(){try{var K={$key};var A={$allowed};var raw=localStorage.getItem(K);if(!raw)return;var p=JSON.parse(raw);if(!p||typeof p!=='object')return;function ok(k){return A.indexOf(k)>-1;}var d=document.documentElement;var b=document.body;if(!b)return;var cls={readableFont:'wpps-a11y-readable-font',dyslexiaFont:'wpps-a11y-dyslexia-font',textAlignLeft:'wpps-a11y-text-align-left',highContrast:'wpps-a11y-high-contrast',lightContrast:'wpps-a11y-light-contrast',darkContrast:'wpps-a11y-dark-contrast',highlightLinks:'wpps-a11y-highlight-links',highlightHeadings:'wpps-a11y-highlight-headings',largeCursor:'wpps-a11y-large-cursor',bigBlackCursor:'wpps-a11y-big-black-cursor',pauseAnimations:'wpps-a11y-pause-animations',hideImages:'wpps-a11y-hide-images',simplifyLayout:'wpps-a11y-simplify-layout',focusOutlines:'wpps-a11y-focus-outlines',underlineLinks:'wpps-a11y-underline-links'};var ts=ok('textScale')?(parseInt(p.textScale,10)||0):0;if(ts>0){d.classList.add('wpps-a11y-text-scale-'+Math.min(ts,5));}var lv={lineHeightLevel:'wpps-a11y-line-height-',letterSpacingLevel:'wpps-a11y-letter-spacing-',wordSpacingLevel:'wpps-a11y-word-spacing-'};Object.keys(lv).forEach(function(k){if(!ok(k))return;var n=parseInt(p[k],10)||0;if(n>0){b.classList.add(lv[k]+Math.min(n,3));}});Object.keys(cls).forEach(function(k){if(ok(k)&&p[k]){b.classList.add(cls[k]);}});var fm={grayscale:'grayscale(1)',invertColors:'invert(1) hue-rotate(180deg)',lowSaturation:'saturate(0.5)',highSaturation:'saturate(1.6)',protanopia:\"url('#wpps-a11y-protanopia')\",deuteranopia:\"url('#wpps-a11y-deuteranopia')\",tritanopia:\"url('#wpps-a11y-tritanopia')\"};var f=[];Object.keys(fm).forEach(function(k){if(ok(k)&&p[k]){f.push(fm[k]);}});if(f.length){d.style.setProperty('--wpps-a11y-filter',f.join(' '));}}catch(e){}})();";
		\WP_PowerSuite\Core\InlineAssets::scriptNow( 'wpps-a11y-early-prefs', $js );
	}

	/**
	 * Client-side preference keys the current feature configuration allows.
	 * Used to ignore stored visitor preferences for tools the admin disabled
	 * (or that the license tier forces off).
	 *
	 * @param array<string, bool> $features
	 * @return list<string>
	 */
	public static function getEnabledPrefKeys( array $features ): array {
		$map = [
			'text_size'          => 'textScale',
			'line_height'        => 'lineHeightLevel',
			'letter_spacing'     => 'letterSpacingLevel',
			'word_spacing'       => 'wordSpacingLevel',
			'readable_font'      => 'readableFont',
			'dyslexia_font'      => 'dyslexiaFont',
			'text_align_left'    => 'textAlignLeft',
			'high_contrast'      => 'highContrast',
			'light_contrast'     => 'lightContrast',
			'dark_contrast'      => 'darkContrast',
			'grayscale'          => 'grayscale',
			'invert_colors'      => 'invertColors',
			'low_saturation'     => 'lowSaturation',
			'high_saturation'    => 'highSaturation',
			'protanopia'         => 'protanopia',
			'deuteranopia'       => 'deuteranopia',
			'tritanopia'         => 'tritanopia',
			'highlight_links'    => 'highlightLinks',
			'highlight_headings' => 'highlightHeadings',
			'large_cursor'       => 'largeCursor',
			'big_black_cursor'   => 'bigBlackCursor',
			'reading_guide'      => 'readingGuide',
			'reading_mask'       => 'readingMask',
			'skip_to_content'    => 'skipToContent',
			'focus_outlines'     => 'focusOutlines',
			'underline_links'    => 'underlineLinks',
			'tooltips'           => 'tooltips',
			'page_structure'     => 'pageStructure',
			'pause_animations'   => 'pauseAnimations',
			'hide_images'        => 'hideImages',
			'mute_media'         => 'muteMedia',
			'simplify_layout'    => 'simplifyLayout',
		];

		$keys = [];
		foreach ( $map as $feature => $pref ) {
			if ( ! empty( $features[ $feature ] ) ) {
				$keys[] = $pref;
			}
		}

		return $keys;
	}

	public function maybeRenderFloatingWidget(): void {
		if ( is_admin() || self::$shortcode_rendered || ! $this->shouldLoadFrontend() ) {
			return;
		}

		$settings = $this->getSettings();
		if ( empty( $settings['auto_display'] ) ) {
			return;
		}

		echo $this->buildWidgetMarkup( $settings, false ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped within builder.
	}

	/**
	 * @param array<string, mixed> $atts
	 */
	public function renderShortcode( $atts = [], ?string $content = null, string $tag = '' ): string {
		unset( $atts, $content, $tag );

		if ( is_admin() && ! wp_doing_ajax() ) {
			return '';
		}
		if ( ! $this->shouldLoadFrontend() ) {
			return '';
		}

		self::$shortcode_rendered = true;
		$this->enqueueAssets();

		return $this->buildWidgetMarkup( $this->getSettings(), true );
	}

	private function enqueueAssets(): void {
		if ( self::$assets_enqueued ) {
			return;
		}
		self::$assets_enqueued = true;

		$dir  = __DIR__ . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR;
		$base = function_exists( 'wp_powersuite_module_asset_url' )
			? rtrim( wp_powersuite_module_asset_url( __FILE__, 'assets' ), '/' ) . '/'
			: '';

		$css = $dir . 'accessibility-widget.css';
		if ( is_readable( $css ) ) {
			wp_enqueue_style( self::STYLE_HANDLE, $base . 'accessibility-widget.css', [], (string) filemtime( $css ) );

			$settings = $this->getSettings();
			$color    = (string) ( $settings['button_color'] ?? '#2271b1' );
			$on       = self::contrastColor( $color );

			$scale_map = self::getIconScaleMap();
			$scale     = $scale_map[ (string) ( $settings['button_icon_size'] ?? 'medium' ) ] ?? 0.50;

			$vars  = '--wpps-a11y-btn-color:' . esc_attr( $color ) . ';';
			$vars .= '--wpps-a11y-on-accent:' . esc_attr( $on ) . ';';
			$vars .= '--wpps-a11y-icon-scale:' . esc_attr( (string) $scale ) . ';';

			// Icon color follows the auto-contrast color unless the admin set a
			// fixed color (Automatic unchecked).
			$icon_color = (string) ( $settings['button_icon_color'] ?? '' );
			if ( empty( $settings['button_icon_color_auto'] ) && $icon_color !== '' ) {
				$vars .= '--wpps-a11y-icon-color:' . esc_attr( $icon_color ) . ';';
			}

			// Only override the edge offsets when the admin changed them, so
			// the responsive (mobile) defaults are preserved otherwise.
			$offset = (int) ( $settings['widget_offset'] ?? 20 );
			if ( $offset !== 20 ) {
				$vars .= '--wpps-a11y-offset:' . (int) $offset . 'px;';
			}
			$offset_y = (int) ( $settings['widget_offset_y'] ?? 20 );
			if ( $offset_y !== 20 ) {
				$vars .= '--wpps-a11y-offset-y:' . (int) $offset_y . 'px;';
			}

			wp_add_inline_style( self::STYLE_HANDLE, ':root{' . $vars . '}' );
		}

		$js = $dir . 'accessibility-widget.js';
		if ( is_readable( $js ) ) {
			$deps       = [];
			$premium_js = $dir . 'premium.js';
			if ( is_readable( $premium_js ) && FeatureGate::isPremiumLicensed() ) {
				wp_enqueue_script(
					self::SCRIPT_HANDLE . '-premium',
					plugins_url( basename( $premium_js ), $premium_js ),
					[],
					(string) filemtime( $premium_js ),
					true
				);
				$deps[] = self::SCRIPT_HANDLE . '-premium';
			}
			wp_enqueue_script( self::SCRIPT_HANDLE, $base . 'accessibility-widget.js', $deps, (string) filemtime( $js ), true );
			wp_localize_script( self::SCRIPT_HANDLE, 'wppsA11yWidget', $this->getScriptConfig() );
		}
	}

	/** @return array<string, mixed> */
	private function getScriptConfig(): array {
		$settings = $this->getSettings();

		return [
			'storageKey'       => self::STORAGE_KEY,
			'savePreferences'  => ! empty( $settings['save_preferences'] ),
			'features'         => $settings['features'] ?? [],
			'profiles'         => class_exists( AccessibilityProfiles::class, false ) ? AccessibilityProfiles::getDefinitions() : [],
			'i18n'             => [
				'skipLinkText'     => __( 'Skip to main content', 'wppowersuite-lite' ),
				'noHeadings'       => __( 'No headings found on this page.', 'wppowersuite-lite' ),
				'pageStructure'    => __( 'Page structure', 'wppowersuite-lite' ),
				'statusOn'         => __( 'On', 'wppowersuite-lite' ),
				'statusOff'        => __( 'Off', 'wppowersuite-lite' ),
				/* translators: 1: current level, 2: maximum level */
				'levelStatus'      => __( 'Level %1$s of %2$s', 'wppowersuite-lite' ),
				/* translators: level control announcement when turned off */
				'levelOff'         => __( 'Off', 'wppowersuite-lite' ),
				'preferencesReset' => __( 'Accessibility preferences reset.', 'wppowersuite-lite' ),
				'profileApplied'   => __( 'Accessibility profile applied.', 'wppowersuite-lite' ),
				'ttsUnsupported'   => __( 'Text-to-speech is not supported in this browser.', 'wppowersuite-lite' ),
				'selectTextTts'    => __( 'Select text on the page, then activate read aloud.', 'wppowersuite-lite' ),
			],
		];
	}

	/**
	 * @param array<string, mixed> $settings
	 */
	private function buildWidgetMarkup( array $settings, bool $is_shortcode ): string {
		$position  = (string) ( $settings['position'] ?? 'bottom-right' );
		$size      = (string) ( $settings['button_size'] ?? 'medium' );
		$icon      = (string) ( $settings['button_icon'] ?? 'accessibility' );
		$layout    = (string) ( $settings['panel_layout'] ?? 'list' );
		$extra_cls = (string) ( $settings['custom_css_class'] ?? '' );
		$panel_id  = 'wpps-a11y-panel-' . wp_unique_id();
		$button_id = 'wpps-a11y-trigger-' . wp_unique_id();

		$title = trim( (string) ( $settings['panel_title'] ?? '' ) );
		if ( $title === '' ) {
			$title = __( 'Accessibility options', 'wppowersuite-lite' );
		}

		$open_label = trim( (string) ( $settings['button_label'] ?? '' ) );
		if ( $open_label === '' ) {
			$open_label = __( 'Open accessibility options', 'wppowersuite-lite' );
		}

		$classes = [
			'wpps-a11y-widget',
			'wpps-a11y-widget--pos-' . sanitize_html_class( $position ),
			'wpps-a11y-widget--size-' . sanitize_html_class( $size ),
			'wpps-a11y-widget--layout-' . sanitize_html_class( $layout ),
		];
		if ( $is_shortcode ) {
			$classes[] = 'wpps-a11y-widget--shortcode';
		}
		if ( empty( $settings['show_on_mobile'] ) ) {
			$classes[] = 'wpps-a11y-widget--hide-mobile';
		}
		if ( $extra_cls !== '' ) {
			$classes[] = $extra_cls;
		}

		$features = is_array( $settings['features'] ?? null ) ? $settings['features'] : [];

		ob_start();
		?>
		<div class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>" data-wpps-a11y-root>
			<button
				type="button"
				id="<?php echo esc_attr( $button_id ); ?>"
				class="wpps-a11y-widget__trigger"
				aria-label="<?php echo esc_attr( $open_label ); ?>"
				aria-haspopup="dialog"
				aria-expanded="false"
				aria-controls="<?php echo esc_attr( $panel_id ); ?>"
			>
				<span class="wpps-a11y-widget__icon" aria-hidden="true"><?php echo self::getIconSvg( $icon ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG. ?></span>
			</button>

			<div
				id="<?php echo esc_attr( $panel_id ); ?>"
				class="wpps-a11y-widget__panel"
				role="dialog"
				aria-modal="false"
				aria-labelledby="<?php echo esc_attr( $panel_id ); ?>-title"
				hidden
			>
				<div class="wpps-a11y-widget__panel-header">
					<h2 id="<?php echo esc_attr( $panel_id ); ?>-title" class="wpps-a11y-widget__panel-title"><?php echo esc_html( $title ); ?></h2>
					<button type="button" class="wpps-a11y-widget__close" aria-label="<?php echo esc_attr__( 'Close accessibility options', 'wppowersuite-lite' ); ?>">
						<?php echo self::getIcon( 'close' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static icon markup. ?>
					</button>
				</div>

				<div class="wpps-a11y-widget__panel-body">
					<?php echo $this->renderProfilesBlock( $settings, $features ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped per control. ?>
					<?php echo $this->renderOversizeRow(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static markup. ?>
					<?php echo $this->renderToolGrid( $settings, $features ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped per control. ?>
				</div>

				<div class="wpps-a11y-widget__panel-footer">
					<?php echo $this->renderStatement( $settings, $features, $panel_id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped within. ?>

					<button type="button" class="wpps-a11y-widget__reset-all">
						<?php esc_html_e( 'Reset all settings', 'wppowersuite-lite' ); ?>
					</button>

					<?php $footer_note = self::getFooterNote( $settings ); ?>
					<?php if ( $footer_note !== '' ) : ?>
						<p class="wpps-a11y-widget__disclaimer"><?php echo esc_html( $footer_note ); ?></p>
					<?php endif; ?>
				</div>
			</div>
		</div>
		<?php
		return (string) ob_get_clean();
	}

	/**
	 * Footer note shown at the bottom of the panel. Editable in the module
	 * settings; an empty custom note falls back to the default, and the note
	 * can be hidden entirely with `show_footer_note`.
	 *
	 * @param array<string, mixed> $settings
	 */
	public static function getFooterNote( array $settings ): string {
		if ( empty( $settings['show_footer_note'] ) ) {
			return '';
		}

		$note = trim( (string) ( $settings['footer_note'] ?? '' ) );
		if ( $note !== '' ) {
			return $note;
		}

		return __( 'Adjust these options to suit your needs. Your choices apply only on this device and can be reset anytime.', 'wppowersuite-lite' );
	}

	/**
	 * Accessibility profiles block (collapsible list of preset profiles).
	 *
	 * @param array<string, mixed> $settings
	 * @param array<string, bool>  $features
	 */
	private function renderProfilesBlock( array $settings, array $features ): string {
		if ( empty( $features['accessibility_profiles'] ) ) {
			return '';
		}

		$definitions = class_exists( AccessibilityProfiles::class, false ) ? AccessibilityProfiles::getDefinitions() : [];
		if ( $definitions === [] ) {
			return '';
		}

		$cards = '';
		foreach ( $definitions as $profile_key => $profile ) {
			$cards .= sprintf(
				'<button type="button" class="wpps-a11y-profile" data-profile="%1$s" aria-pressed="false">'
					. '<span class="wpps-a11y-profile__icon" aria-hidden="true">%3$s</span>'
					. '<span class="wpps-a11y-profile__label">%2$s</span>'
					. '<span class="wpps-a11y-sr-only">%4$s</span>'
					. '</button>',
				esc_attr( (string) $profile_key ),
				esc_html( (string) ( $profile['label'] ?? $profile_key ) ),
				self::getProfileIcon( (string) $profile_key ),
				esc_html( (string) ( $profile['description'] ?? '' ) )
			);
		}

		$chevron = self::getIcon( 'chevron-down' );

		return '<details class="wpps-a11y-widget__profiles">'
			. '<summary class="wpps-a11y-widget__profiles-summary">'
				. '<span class="wpps-a11y-widget__profiles-head-icon" aria-hidden="true">' . self::getProfileIcon( 'default' ) . '</span>'
				. '<span class="wpps-a11y-widget__profiles-title">' . esc_html__( 'Accessibility profiles', 'wppowersuite-lite' ) . '</span>'
				. '<span class="wpps-a11y-widget__profiles-chevron" aria-hidden="true">' . $chevron . '</span>'
			. '</summary>'
			. '<div class="wpps-a11y-widget__profiles-grid">' . $cards . '</div>'
			. '</details>';
	}

	/**
	 * Oversized-widget toggle row (enlarges the panel for easier interaction).
	 */
	private function renderOversizeRow(): string {
		return '<div class="wpps-a11y-widget__oversize-row">'
			. '<span class="wpps-a11y-widget__oversize-icon" aria-hidden="true">' . self::getToolIcon( 'text_size' ) . '</span>'
			. '<span class="wpps-a11y-widget__oversize-label">' . esc_html__( 'Oversized widget', 'wppowersuite-lite' ) . '</span>'
			. '<button type="button" class="wpps-a11y-widget__oversize-toggle" data-action="toggle-oversize" role="switch" aria-checked="false" aria-label="' . esc_attr__( 'Toggle oversized widget', 'wppowersuite-lite' ) . '"><span class="wpps-a11y-widget__oversize-knob" aria-hidden="true"></span></button>'
			. '</div>';
	}

	/**
	 * Accessibility statement footer control. Supports a link (opens in a new
	 * tab) or inline text (opens a modal inside the panel).
	 *
	 * @param array<string, mixed> $settings
	 * @param array<string, bool>  $features
	 */
	private function renderStatement( array $settings, array $features, string $panel_id ): string {
		if ( empty( $features['accessibility_statement'] ) ) {
			return '';
		}

		$type = (string) ( $settings['statement_type'] ?? 'link' );
		$text = trim( (string) ( $settings['statement_text'] ?? '' ) );
		if ( $text === '' ) {
			$text = __( 'Accessibility statement', 'wppowersuite-lite' );
		}

		if ( $type === 'text' ) {
			$content = (string) ( $settings['statement_content'] ?? '' );
			if ( trim( wp_strip_all_tags( $content ) ) === '' ) {
				return '';
			}

			$modal_id = $panel_id . '-statement';

			return '<button type="button" class="wpps-a11y-widget__statement-link" data-action="open-statement" aria-haspopup="dialog" aria-controls="' . esc_attr( $modal_id ) . '">'
					. esc_html( $text )
				. '</button>'
				. '<div id="' . esc_attr( $modal_id ) . '" class="wpps-a11y-widget__statement-modal" role="dialog" aria-label="' . esc_attr( $text ) . '" hidden>'
					. '<div class="wpps-a11y-widget__statement-modal-inner">'
						. '<div class="wpps-a11y-widget__statement-modal-head">'
							. '<span class="wpps-a11y-widget__statement-modal-title">' . esc_html( $text ) . '</span>'
							. '<button type="button" class="wpps-a11y-widget__statement-close" data-action="close-statement" aria-label="' . esc_attr__( 'Close accessibility statement', 'wppowersuite-lite' ) . '">'
								. self::getIcon( 'close' )
							. '</button>'
						. '</div>'
						. '<div class="wpps-a11y-widget__statement-modal-body">' . wp_kses_post( wpautop( $content ) ) . '</div>'
					. '</div>'
				. '</div>';
		}

		$url = esc_url( (string) ( $settings['statement_url'] ?? '' ) );
		if ( $url === '' ) {
			return '';
		}

		return '<a class="wpps-a11y-widget__statement-link" href="' . esc_url( $url ) . '" target="_blank" rel="noopener noreferrer">'
				. esc_html( $text )
				. '<span class="wpps-a11y-sr-only">' . esc_html__( '(opens in a new tab)', 'wppowersuite-lite' ) . '</span>'
			. '</a>';
	}

	/**
	 * Build the flat tool grid in admin-defined order (UserWay-style cards).
	 *
	 * @param array<string, mixed> $settings
	 * @param array<string, bool>  $features
	 */
	private function renderToolGrid( array $settings, array $features ): string {
		$registry = self::getToolRegistry();
		$order    = is_array( $settings['tool_order'] ?? null ) ? $settings['tool_order'] : self::getToolKeys();

		$cards = '';
		foreach ( $order as $tool_key ) {
			if ( ! isset( $registry[ $tool_key ] ) || empty( $features[ $tool_key ] ) ) {
				continue;
			}
			if ( $tool_key === 'accessibility_profiles' ) {
				continue; // Rendered in its own block.
			}
			$cards .= $this->renderCard( $tool_key, $registry[ $tool_key ]['type'] );
		}

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

		return '<div class="wpps-a11y-widget__grid">' . $cards . '</div>';
	}

	private function renderCard( string $tool_key, string $type ): string {
		$label = self::getToolLabel( $tool_key );
		$icon  = self::getToolIcon( $tool_key );

		switch ( $type ) {
			case 'level':
				return $this->cardMarkup( $tool_key, $label, $icon, [
					'feature'   => $tool_key,
					'levels'    => 3,
					'aria_role' => 'pressed',
				] );

			case 'link_navigator':
				return $this->cardMarkup( $tool_key, $label, $icon, [ 'action' => 'next-link' ] );

			case 'tts':
				return $this->cardMarkup( $tool_key, $label, $icon, [ 'action' => 'read-selection' ] );

			case 'toggle':
			default:
				return $this->cardMarkup( $tool_key, $label, $icon, [
					'feature'   => $tool_key,
					'aria_role' => 'pressed',
				] );
		}
	}

	/**
	 * @param array{feature?: string, action?: string, levels?: int, aria_role?: string} $args
	 */
	private function cardMarkup( string $tool_key, string $label, string $icon, array $args ): string {
		$attrs = ' data-tool="' . esc_attr( $tool_key ) . '"';
		if ( ! empty( $args['feature'] ) ) {
			$attrs .= ' data-feature="' . esc_attr( (string) $args['feature'] ) . '"';
		}
		if ( ! empty( $args['action'] ) ) {
			$attrs .= ' data-action="' . esc_attr( (string) $args['action'] ) . '"';
		}
		if ( ! empty( $args['levels'] ) ) {
			$attrs .= ' data-levels="' . (int) $args['levels'] . '"';
		}
		if ( ( $args['aria_role'] ?? '' ) === 'pressed' ) {
			$attrs .= ' aria-pressed="false"';
		}

		$dots = '';
		if ( ! empty( $args['levels'] ) ) {
			$dots = '<span class="wpps-a11y-card__levels" aria-hidden="true">';
			for ( $i = 0; $i < (int) $args['levels']; $i++ ) {
				$dots .= '<span class="wpps-a11y-card__dot"></span>';
			}
			$dots .= '</span>';
		}

		return '<button type="button" class="wpps-a11y-card"' . $attrs . '>'
			. '<span class="wpps-a11y-card__check" aria-hidden="true"></span>'
			. '<span class="wpps-a11y-card__icon" aria-hidden="true">' . $icon . '</span>'
			. '<span class="wpps-a11y-card__label">' . esc_html( $label ) . '</span>'
			. $dots
			. '</button>';
	}

	/** @return array<string, string> */
	public static function getToolLabels(): array {
		return [
			'accessibility_profiles' => __( 'Accessibility profiles', 'wppowersuite-lite' ),
			'text_size'              => __( 'Bigger Text', 'wppowersuite-lite' ),
			'line_height'            => __( 'Line Height', 'wppowersuite-lite' ),
			'letter_spacing'         => __( 'Letter Spacing', 'wppowersuite-lite' ),
			'word_spacing'           => __( 'Word Spacing', 'wppowersuite-lite' ),
			'high_contrast'          => __( 'High Contrast', 'wppowersuite-lite' ),
			'light_contrast'         => __( 'Light Contrast', 'wppowersuite-lite' ),
			'dark_contrast'          => __( 'Dark Contrast', 'wppowersuite-lite' ),
			'grayscale'              => __( 'Grayscale', 'wppowersuite-lite' ),
			'invert_colors'          => __( 'Invert Colors', 'wppowersuite-lite' ),
			'low_saturation'         => __( 'Low Saturation', 'wppowersuite-lite' ),
			'high_saturation'        => __( 'High Saturation', 'wppowersuite-lite' ),
			'protanopia'             => __( 'Protanopia', 'wppowersuite-lite' ),
			'deuteranopia'           => __( 'Deuteranopia', 'wppowersuite-lite' ),
			'tritanopia'             => __( 'Tritanopia', 'wppowersuite-lite' ),
			'highlight_links'        => __( 'Highlight Links', 'wppowersuite-lite' ),
			'highlight_headings'     => __( 'Highlight Titles', 'wppowersuite-lite' ),
			'large_cursor'           => __( 'Big Cursor', 'wppowersuite-lite' ),
			'big_black_cursor'       => __( 'Black Cursor', 'wppowersuite-lite' ),
			'reading_guide'          => __( 'Reading Guide', 'wppowersuite-lite' ),
			'reading_mask'           => __( 'Reading Mask', 'wppowersuite-lite' ),
			'text_align_left'        => __( 'Align Text', 'wppowersuite-lite' ),
			'dyslexia_font'          => __( 'Dyslexia Friendly', 'wppowersuite-lite' ),
			'readable_font'          => __( 'Readable Font', 'wppowersuite-lite' ),
			'pause_animations'       => __( 'Pause Animations', 'wppowersuite-lite' ),
			'hide_images'            => __( 'Hide Images', 'wppowersuite-lite' ),
			'mute_media'             => __( 'Mute Sounds', 'wppowersuite-lite' ),
			'simplify_layout'        => __( 'Simplified View', 'wppowersuite-lite' ),
			'text_to_speech'         => __( 'Read Aloud', 'wppowersuite-lite' ),
			'link_navigator'         => __( 'Jump Links', 'wppowersuite-lite' ),
			'skip_to_content'        => __( 'Skip to Content', 'wppowersuite-lite' ),
			'focus_outlines'         => __( 'Highlight Focus', 'wppowersuite-lite' ),
			'underline_links'        => __( 'Underline Links', 'wppowersuite-lite' ),
			'tooltips'               => __( 'Tooltips', 'wppowersuite-lite' ),
			'page_structure'         => __( 'Page Structure', 'wppowersuite-lite' ),
			'accessibility_statement' => __( 'Accessibility statement link', 'wppowersuite-lite' ),
		];
	}

	private static function getToolLabel( string $key ): string {
		$labels = self::getToolLabels();
		return $labels[ $key ] ?? ucwords( str_replace( '_', ' ', $key ) );
	}

	/**
	 * Per-tool icon, mapped to a bundled inline SVG glyph.
	 */
	public static function getToolIcon( string $key ): string {
		$map = [
			'text_size'          => 'text-height',
			'line_height'        => 'arrows-up-down',
			'letter_spacing'     => 'arrows-left-right',
			'word_spacing'       => 'arrows-left-right-line',
			'high_contrast'      => 'contrast',
			'light_contrast'     => 'sun',
			'dark_contrast'      => 'moon',
			'grayscale'          => 'droplet-slash',
			'invert_colors'      => 'invert',
			'low_saturation'     => 'droplet',
			'high_saturation'    => 'saturation-up',
			'protanopia'         => 'glasses',
			'deuteranopia'       => 'glasses',
			'tritanopia'         => 'glasses',
			'highlight_links'    => 'link',
			'highlight_headings' => 'heading',
			'large_cursor'       => 'cursor',
			'big_black_cursor'   => 'cursor',
			'reading_guide'      => 'ruler',
			'reading_mask'       => 'mask',
			'text_align_left'    => 'align-left',
			'dyslexia_font'      => 'font',
			'readable_font'      => 'font',
			'pause_animations'   => 'pause',
			'hide_images'        => 'image',
			'mute_media'         => 'volume-mute',
			'simplify_layout'    => 'layout',
			'text_to_speech'     => 'volume-high',
			'link_navigator'     => 'forward',
			'skip_to_content'    => 'forward-step',
			'focus_outlines'     => 'crosshairs',
			'underline_links'    => 'underline',
			'tooltips'           => 'message',
			'page_structure'     => 'list',
			'accessibility_statement' => 'info',
		];

		return self::getIcon( $map[ $key ] ?? 'circle-question' );
	}

	/**
	 * Icon for a bundled accessibility profile.
	 */
	public static function getProfileIcon( string $key ): string {
		$map = [
			'motor_impaired'    => 'hand-pointer',
			'visually_impaired' => 'eye-low-vision',
			'color_blind'       => 'palette',
			'dyslexia'          => 'font',
			'adhd'              => 'brain',
			'cognitive'         => 'lightbulb',
			'seizure_safe'      => 'bolt',
			'default'           => 'universal-access',
		];

		return self::getIcon( $map[ $key ] ?? 'universal-access' );
	}

	/**
	 * Pick black or white text for legibility on top of the given accent color
	 * (WCAG relative-luminance threshold). Used for the header, trigger, active
	 * cards/profiles, and the reset button so a custom accent stays readable.
	 */
	public static function contrastColor( string $hex ): string {
		$hex = ltrim( trim( $hex ), '#' );
		if ( strlen( $hex ) === 3 ) {
			$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
		}
		if ( strlen( $hex ) !== 6 || ! ctype_xdigit( $hex ) ) {
			return '#ffffff';
		}

		$channels = [];
		foreach ( [ 0, 2, 4 ] as $offset ) {
			$value = hexdec( substr( $hex, $offset, 2 ) ) / 255;
			$channels[] = $value <= 0.03928 ? $value / 12.92 : pow( ( $value + 0.055 ) / 1.055, 2.4 );
		}
		$luminance = 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2];

		return $luminance > 0.5 ? '#1d2327' : '#ffffff';
	}

	/**
	 * Bundled inline SVG icon set. Icons are self-contained (no icon font, no
	 * CDN request, no external font download) and inherit the surrounding text
	 * color and size via currentColor and 1em dimensions. Sub-elements that
	 * need a solid fill declare it explicitly.
	 *
	 * @var array<string, string>
	 */
	private const ICON_PATHS = [
		// UI chrome.
		'close'                  => '<path d="M6 6l12 12M18 6L6 18"/>',
		'chevron-down'           => '<path d="M6 9l6 6 6-6"/>',
		// Trigger / profile glyphs.
		'universal-access'       => '<circle cx="12" cy="12" r="9.25"/><circle cx="12" cy="7.4" r="1.6" fill="currentColor" stroke="none"/><path d="M7.2 10.2c3.2.9 6.4.9 9.6 0M12 12v2.8M9.7 18l2.3-3.2 2.3 3.2"/>',
		'wheelchair'             => '<circle cx="10" cy="16.5" r="4.75"/><circle cx="10" cy="4.6" r="1.7" fill="currentColor" stroke="none"/><path d="M10 8v4.5h5.2l2 5h2.3"/>',
		'wheelchair-move'        => '<circle cx="11" cy="16.5" r="4.75"/><circle cx="13" cy="4.6" r="1.7" fill="currentColor" stroke="none"/><path d="M13 8l-2 4.5h5.2l1.6 4M2.5 8h3M2.5 11h2"/>',
		'person'                 => '<circle cx="12" cy="4.6" r="1.9" fill="currentColor" stroke="none"/><path d="M8.5 9.2h7M12 9.2v5.2l-2.6 5.6M12 14.4l2.6 5.6"/>',
		'eye'                    => '<path d="M2.5 12S6 5.8 12 5.8 21.5 12 21.5 12 18 18.2 12 18.2 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="3"/>',
		'eye-low-vision'         => '<path d="M2.5 12S6 5.8 12 5.8 21.5 12 21.5 12 18 18.2 12 18.2 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="3"/><path d="M4 4l16 16"/>',
		'ear'                    => '<path d="M8.5 18.5a6.9 6.9 0 0 1-1.7-4.6A5.7 5.7 0 0 1 12.5 8a4.3 4.3 0 0 1 4.3 4.3c0 1.7-.9 2.5-1.8 3.3-.8.8-1.3 1.4-1.4 2.3a2.4 2.4 0 0 1-4.7-.1"/><path d="M12.5 10.7a1.7 1.7 0 0 1 1.7 1.7M4.5 5.5a10.5 10.5 0 0 1 15 0"/>',
		'hands-asl'              => '<path d="M3 12.5l4.5-1.2 3-3.8M21 11.5l-4.5 1.2-3 3.8"/><circle cx="6.8" cy="5.8" r="1.9"/><circle cx="17.2" cy="18.2" r="1.9"/>',
		'braille'                => '<circle cx="7" cy="5.5" r="1.7" fill="currentColor" stroke="none"/><circle cx="7" cy="12" r="1.7" fill="currentColor" stroke="none"/><circle cx="7" cy="18.5" r="1.7" fill="currentColor" stroke="none"/><circle cx="15" cy="5.5" r="1.7" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.7"/><circle cx="15" cy="18.5" r="1.7"/>',
		'hand-pointer'           => '<path d="M9 11.5V5a1.5 1.5 0 0 1 3 0v5.2M12 10.2V8.7a1.5 1.5 0 0 1 3 0v2.5M15 11.2v-.4a1.5 1.5 0 0 1 3 0v3.4a6.8 6.8 0 0 1-6.8 6.8 6.4 6.4 0 0 1-5.3-2.9l-2.2-3.4a1.55 1.55 0 0 1 2.4-1.9L9 15.5"/>',
		'palette'                => '<path d="M12 3a9 9 0 1 0 0 18c1.4 0 1.9-1 1.4-1.9-.5-1 0-2.1 1.5-2.1H17a4 4 0 0 0 4-4c0-5.1-4.1-10-9-10Z"/><circle cx="7.6" cy="11.2" r="1.1" fill="currentColor" stroke="none"/><circle cx="10.4" cy="7.6" r="1.1" fill="currentColor" stroke="none"/><circle cx="14.8" cy="7.6" r="1.1" fill="currentColor" stroke="none"/>',
		'brain'                  => '<path d="M12 5.4V18.6M12 5.4A2.4 2.4 0 0 0 9.6 3 2.4 2.4 0 0 0 7.2 5.4v.4A3.4 3.4 0 0 0 4.6 9a3.4 3.4 0 0 0 .5 6A3 3 0 0 0 8 21c1.9 0 4-1 4-2.4M12 5.4A2.4 2.4 0 0 1 14.4 3a2.4 2.4 0 0 1 2.4 2.4v.4A3.4 3.4 0 0 1 19.4 9a3.4 3.4 0 0 1-.5 6A3 3 0 0 1 16 21c-1.9 0-4-1-4-2.4"/>',
		'lightbulb'              => '<path d="M9.2 17.6h5.6M10 20.8h4M12 3.2a6 6 0 0 0-3.9 10.5c.7.7 1.2 1.5 1.4 2.4h5c.2-.9.7-1.7 1.4-2.4A6 6 0 0 0 12 3.2Z"/>',
		'bolt'                   => '<path d="M13 2.5 4.5 14H11l-1.5 7.5L18 9.5h-6.5L13 2.5Z"/>',
		// Tool glyphs.
		'text-height'            => '<path d="M4 6.5V4.5h9v2M8.5 4.5v13M6 17.5h5"/><path d="M18.5 5.5v13M16.5 7.5l2-2 2 2M16.5 16.5l2 2 2-2"/>',
		'arrows-up-down'         => '<path d="M12 3.5v17M8.5 7 12 3.5 15.5 7M8.5 17 12 20.5l3.5-3.5"/>',
		'arrows-left-right'      => '<path d="M3.5 12h17M7 8.5 3.5 12 7 15.5M17 8.5 20.5 12 17 15.5"/>',
		'arrows-left-right-line' => '<path d="M3 5v14M21 5v14M7 12h10M9.5 9.5 7 12l2.5 2.5M14.5 9.5 17 12l-2.5 2.5"/>',
		'contrast'               => '<circle cx="12" cy="12" r="9"/><path d="M12 3v18a9 9 0 0 0 0-18Z" fill="currentColor" stroke="none"/>',
		'sun'                    => '<circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5 5l1.4 1.4M17.6 17.6 19 19M5 19l1.4-1.4M17.6 6.4 19 5"/>',
		'moon'                   => '<path d="M20 14.6A8.5 8.5 0 1 1 9.4 4 6.8 6.8 0 0 0 20 14.6Z"/>',
		'droplet'                => '<path d="M12 3s6 6.4 6 10.4a6 6 0 0 1-12 0C6 9.4 12 3 12 3Z"/>',
		'droplet-slash'          => '<path d="M12 3s6 6.4 6 10.4a6 6 0 0 1-.7 2.8M7 8.8c-.6 1.7-1 3.3-1 4.6a6 6 0 0 0 9.6 4.8"/><path d="M4 4l16 16"/>',
		'saturation-up'          => '<path d="M10 5.5s5 5.4 5 8.8a5 5 0 0 1-10 0c0-3.4 5-8.8 5-8.8Z"/><path d="M18 4h4M20 2v4"/>',
		'invert'                 => '<rect x="4" y="4" width="12" height="12" rx="2"/><path d="M8 20h10a2 2 0 0 0 2-2V8"/>',
		'glasses'                => '<circle cx="7" cy="15" r="3.4"/><circle cx="17" cy="15" r="3.4"/><path d="M10.4 15h3.2M3.6 15 5.2 8h1.6M20.4 15 18.8 8h-1.6"/>',
		'link'                   => '<path d="M10 14a4 4 0 0 0 6 .4l3-3a4 4 0 0 0-5.7-5.7l-1.6 1.6"/><path d="M14 10a4 4 0 0 0-6-.4l-3 3a4 4 0 0 0 5.7 5.7l1.6-1.6"/>',
		'heading'                => '<path d="M6 4.5v15M18 4.5v15M6 12h12"/>',
		'cursor'                 => '<path d="M5.5 3.5 12 20l2.3-6.2L20.5 11 5.5 3.5Z"/>',
		'ruler'                  => '<rect x="2.5" y="9" width="19" height="6" rx="1"/><path d="M6.5 9v2.6M10.5 9v2.6M14.5 9v2.6M18.5 9v2.6"/>',
		'mask'                   => '<path d="M3 6.8c3-1.3 6-1.3 9 0 3-1.3 6-1.3 9 0v4.4a9 9 0 0 1-9 6.8 9 9 0 0 1-9-6.8V6.8Z"/><circle cx="8.4" cy="11" r="1.1" fill="currentColor" stroke="none"/><circle cx="15.6" cy="11" r="1.1" fill="currentColor" stroke="none"/>',
		'align-left'             => '<path d="M4 6h16M4 10h10M4 14h16M4 18h10"/>',
		'font'                   => '<path d="M6 19.5 12 4.5l6 15M8.2 14.5h7.6"/>',
		'pause'                  => '<rect x="6.5" y="4.5" width="3.6" height="15" rx="1"/><rect x="13.9" y="4.5" width="3.6" height="15" rx="1"/>',
		'image'                  => '<rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 15.5l-5-5-8.5 8.5"/>',
		'volume-mute'            => '<path d="M11 5 6.5 9H3v6h3.5L11 19V5Z"/><path d="M15 9.5l5 5M20 9.5l-5 5"/>',
		'volume-high'            => '<path d="M11 5 6.5 9H3v6h3.5L11 19V5Z"/><path d="M15 9a4.2 4.2 0 0 1 0 6M17.6 6.4a8 8 0 0 1 0 11.2"/>',
		'layout'                 => '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 4v16M3 12h18"/>',
		'forward'                => '<path d="M4 5.5v13l8-6.5-8-6.5ZM12 5.5v13l8-6.5-8-6.5Z"/>',
		'forward-step'           => '<path d="M5 5.5v13l10-6.5L5 5.5Z"/><path d="M19 5v14"/>',
		'crosshairs'             => '<circle cx="12" cy="12" r="7"/><path d="M12 2.5V5M12 19v2.5M2.5 12H5M19 12h2.5"/>',
		'underline'              => '<path d="M7 4v6a5 5 0 0 0 10 0V4M5 20h14"/>',
		'message'                => '<path d="M21 12a8 8 0 0 1-8 8H4.5L6 16.4A8 8 0 1 1 21 12Z"/>',
		'list'                   => '<path d="M9 6h12M9 12h12M9 18h12"/><circle cx="4.5" cy="6" r="1.2" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.2" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.2" fill="currentColor" stroke="none"/>',
		'info'                   => '<circle cx="12" cy="12" r="9"/><path d="M12 11v5.5"/><circle cx="12" cy="7.8" r="1.1" fill="currentColor" stroke="none"/>',
		'circle-question'        => '<circle cx="12" cy="12" r="9"/><path d="M9.4 9.4a2.6 2.6 0 1 1 4 2.2c-.9.6-1.4 1.1-1.4 2"/><circle cx="12" cy="17" r="1.1" fill="currentColor" stroke="none"/>',
	];

	/**
	 * Render a bundled inline SVG icon. Decorative by default (aria-hidden);
	 * accessible names live on the surrounding control.
	 */
	public static function getIcon( string $name ): string {
		$body = self::ICON_PATHS[ $name ] ?? self::ICON_PATHS['circle-question'];
		return '<svg class="wpps-a11y-ico" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' . $body . '</svg>';
	}

	/**
	 * Trigger-button glyph. Maps the saved choice to a bundled inline SVG.
	 */
	public static function getIconSvg( string $icon ): string {
		$map = [
			'accessibility'   => 'universal-access',
			'wheelchair'      => 'wheelchair',
			'wheelchair-move' => 'wheelchair-move',
			'person'          => 'person',
			'eye'             => 'eye',
			'low-vision'      => 'eye-low-vision',
			'ear'             => 'ear',
			'hands'           => 'hands-asl',
			'braille'         => 'braille',
		];

		$name = $map[ sanitize_key( $icon ) ] ?? 'universal-access';

		return self::getIcon( $name );
	}

	public static function getColorBlindSvgFilters(): string {
		return '<svg class="wpps-a11y-svg-filters" aria-hidden="true" focusable="false" width="0" height="0" style="position:absolute;width:0;height:0;overflow:hidden">'
			. '<filter id="wpps-a11y-protanopia"><feColorMatrix type="matrix" values="0.567,0.433,0,0,0 0.558,0.442,0,0,0 0,0.242,0.758,0,0 0,0,0,1,0"/></filter>'
			. '<filter id="wpps-a11y-deuteranopia"><feColorMatrix type="matrix" values="0.625,0.375,0,0,0 0.7,0.3,0,0,0 0,0.3,0.7,0,0 0,0,0,1,0"/></filter>'
			. '<filter id="wpps-a11y-tritanopia"><feColorMatrix type="matrix" values="0.95,0.05,0,0,0 0,0.433,0.567,0,0 0,0.475,0.525,0,0 0,0,0,1,0"/></filter>'
			. '</svg>';
	}

	/* ---------------------------------------------------------------------
	 * Frontend gating
	 * ------------------------------------------------------------------- */

	private function shouldLoadFrontend(): bool {
		$settings = $this->getSettings();
		if ( empty( $settings['frontend_enabled'] ) ) {
			return false;
		}

		if ( $this->isExcludedByUrl( $settings ) ) {
			return false;
		}

		if ( is_singular() ) {
			$post = get_post();
			if ( $post instanceof WP_Post ) {
				if ( $this->isPostTypeExcluded( $post->post_type, $settings ) ) {
					return false;
				}
				if ( $this->isPostIdExcluded( (int) $post->ID, $settings ) ) {
					return false;
				}
			}
		}

		return true;
	}

	private function pageHasShortcode(): bool {
		if ( ! is_singular() ) {
			return false;
		}
		$post = get_post();
		if ( ! $post instanceof WP_Post ) {
			return false;
		}
		$content = (string) ( $post->post_content ?? '' );
		return $content !== '' && has_shortcode( $content, self::SHORTCODE );
	}

	/**
	 * @param array<string, mixed> $settings
	 */
	private function isExcludedByUrl( array $settings ): bool {
		$patterns = trim( (string) ( $settings['exclude_urls'] ?? '' ) );
		if ( $patterns === '' ) {
			return false;
		}

		$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['REQUEST_URI'] ) ) : '';
		$path        = (string) ( strtok( $request_uri, '?' ) ?: $request_uri );
		$path        = '/' . ltrim( $path, '/' );

		foreach ( array_filter( array_map( 'trim', explode( "\n", $patterns ) ) ) as $line ) {
			if ( $line === '' ) {
				continue;
			}

			if ( str_contains( $line, '*' ) ) {
				$regex = '#^' . str_replace( '\*', '.*', preg_quote( $line, '#' ) ) . '$#i';
				if ( preg_match( $regex, $path ) ) {
					return true;
				}
				continue;
			}

			$normalized = '/' . trim( $line, '/' );
			// Match the exact path or a sub-path, with a real boundary so
			// "/cart" never matches "/cartoon".
			if ( $path === $normalized || str_starts_with( $path, $normalized . '/' ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * @param array<string, mixed> $settings
	 */
	private function isPostTypeExcluded( string $post_type, array $settings ): bool {
		$excluded = is_array( $settings['excluded_post_types'] ?? null ) ? $settings['excluded_post_types'] : [];
		return in_array( $post_type, $excluded, true );
	}

	/**
	 * @param array<string, mixed> $settings
	 */
	private function isPostIdExcluded( int $post_id, array $settings ): bool {
		if ( $post_id <= 0 ) {
			return false;
		}
		$ids = self::parsePostIds( (string) ( $settings['excluded_post_ids'] ?? '' ) );
		return in_array( $post_id, $ids, true );
	}

	/** @return list<int> */
	private static function parsePostIds( string $raw ): array {
		$parts = preg_split( '/[\s,]+/', trim( $raw ) ) ?: [];
		$ids   = [];
		foreach ( $parts as $part ) {
			$id = absint( $part );
			if ( $id > 0 && ! in_array( $id, $ids, true ) ) {
				$ids[] = $id;
			}
		}
		return $ids;
	}

	/**
	 * @param mixed $types
	 * @return list<string>
	 */
	private static function sanitizePostTypes( $types ): array {
		if ( ! is_array( $types ) ) {
			return [];
		}
		$out = [];
		foreach ( $types as $type ) {
			$key = sanitize_key( (string) $type );
			if ( $key !== '' && post_type_exists( $key ) && ! in_array( $key, $out, true ) ) {
				$out[] = $key;
			}
		}
		return $out;
	}

	/**
	 * @param mixed $raw
	 */
	private static function sanitizePostIds( $raw ): string {
		if ( is_array( $raw ) ) {
			$raw = implode( ',', array_map( 'strval', $raw ) );
		}
		return implode( ',', self::parsePostIds( (string) $raw ) );
	}

	/**
	 * @param mixed $raw
	 */
	private static function sanitizeUrlPatterns( $raw ): string {
		$lines = preg_split( '/\r\n|\r|\n/', (string) $raw ) ?: [];
		$out   = [];
		foreach ( $lines as $line ) {
			$line = trim( sanitize_text_field( (string) $line ) );
			if ( $line !== '' && ! in_array( $line, $out, true ) ) {
				$out[] = $line;
			}
		}
		return implode( "\n", $out );
	}

	/* ---------------------------------------------------------------------
	 * Admin advisory scanner (hardened)
	 * ------------------------------------------------------------------- */

	public function handleRunChecks(): void {
		$url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( (string) $_POST['url'] ) ) : '';
		if ( $url === '' ) {
			wp_send_json_error(
				[
					'message' => __( 'Please provide a valid URL to scan.', 'wppowersuite-lite' ),
					'code'    => 'invalid_url',
				]
			);
			return;
		}

		// SSRF protection: only allow scanning public URLs that belong to this
		// site. wp_safe_remote_get adds a second layer by rejecting internal
		// hosts/IPs at the HTTP layer.
		if ( ! RemoteUrlValidator::isSameSiteUrl( $url ) ) {
			wp_send_json_error(
				[
					'message' => __( 'You can only scan URLs on this site.', 'wppowersuite-lite' ),
					'code'    => 'url_not_allowed',
				]
			);
			return;
		}

		$response = wp_safe_remote_get(
			$url,
			[
				'timeout'             => 15,
				'redirection'         => 2,
				'limit_response_size' => 2 * MB_IN_BYTES,
				'user-agent'          => 'WP-PowerSuite-Accessibility-Check/2.0',
			]
		);

		if ( is_wp_error( $response ) ) {
			wp_send_json_error(
				[
					'message' => $response->get_error_message(),
					'code'    => 'request_failed',
				]
			);
			return;
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		if ( $code < 200 || $code >= 400 ) {
			wp_send_json_error(
				[
					/* translators: %d: HTTP status code */
					'message' => sprintf( __( 'The URL returned HTTP status %d.', 'wppowersuite-lite' ), $code ),
					'code'    => 'bad_status',
				]
			);
			return;
		}

		$report = AccessibilityAudit::buildReport( (string) wp_remote_retrieve_body( $response ) );

		wp_send_json_success(
			[
				'issues'     => $report['issues'],
				'score'      => $report['score'],
				'summary'    => $report['summary'],
				'checks_run' => $report['checks_run'],
				'url'        => $url,
				'message'    => __( 'Scan complete. This advisory report maps common issues to WCAG success criteria but does not certify compliance.', 'wppowersuite-lite' ),
			]
		);
	}

	/* ---------------------------------------------------------------------
	 * Settings access
	 * ------------------------------------------------------------------- */

	/** @return array<string, mixed> */
	private function getSettings(): array {
		if ( $this->settings_cache !== null ) {
			return $this->settings_cache;
		}
		$raw                  = $this->moduleSettings()->all( [] );
		$this->settings_cache = self::normalizeSettings( is_array( $raw ) ? $raw : [] );
		return $this->settings_cache;
	}
}
