<?php
/**
 * Line Hover Animation extension — applies `dsm/line-hover` visuals to links (`<a>`) inside any module via Design tab.
 *
 * @package DiviSupreme
 */

declare(strict_types=1);

namespace DiviSupreme\Extensions;

use DiviSupreme\Includes\DsmFrontendAssetRegistry;
use DiviSupreme\Includes\DsmFrontendModuleAssetRegistry;

if ( ! defined( 'ABSPATH' ) ) {
	die( 'Direct access forbidden.' );
}

/**
 * SSR + asset wiring for Divi Supreme Line Hover Animation extension.
 *
 * Mirrors options and CSS behavior of the standalone `dsm/line-hover` module. Targets `<a>` only
 * (Divi button modules use `<a class="et_pb_button">`).
 */
final class LineHoverAnimation {

	private const INNER_HOVER_WRAP_CLASS = 'dsm-lhe-line-hover-target';

	private const ALLOWED_EFFECTS = array(
		'underline_sweep_right',
		'double_line_reveal',
		'double_line_sweep_right',
		'underline_swipe_text_shift',
		'line_sweep_across',
		'lines_connect',
		'lines_pop_up',
		'dashed_line_animate',
		'underline_shift',
		'glitch_effect',
		'upward_line_sweep',
		'line_bends_animation',
		'line_expands_inward',
		'zigzag_line_animation',
		'pulse_line_effect',
	);

	/**
	 * SVG markup mirrored from `packages/module-library/line-hover/edit.js` (escaped for UTF-8).
	 */
	private const SVG_LINE_BENDS = '<svg class="link__graphic link__graphic--slide" width="300%" height="100%" viewBox="0 0 1200 60" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg"><path d="M0,56.5c0,0,298.666,0,399.333,0C448.336,56.5,513.994,46,597,46c77.327,0,135,10.5,200.999,10.5c95.996,0,402.001,0,402.001,0"/></svg>';

	private const SVG_LINE_EXPANDS = '<svg class="link__graphic link__graphic--stroke link__graphic--arc" width="100%" height="1.2em" viewBox="0 0 59 18" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg"><path d="M.945.149C12.3 16.142 43.573 22.572 58.785 10.842" pathLength="1"></path></svg>';

	private const SVG_ZIGZAG = '<svg class="link__graphic link__graphic--stroke link__graphic--scribble" width="100%" height="0.6em" viewBox="0 0 101 9" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg"><path d="M.426 1.973C4.144 1.567 17.77-.514 21.443 1.48 24.296 3.026 24.844 4.627 27.5 7c3.075 2.748 6.642-4.141 10.066-4.688 7.517-1.2 13.237 5.425 17.59 2.745C58.5 3 60.464-1.786 66 2c1.996 1.365 3.174 3.737 5.286 4.41 5.423 1.727 25.34-7.981 29.14-1.294" pathLength="1"></path></svg>';

	public function __construct() {
		add_filter( 'render_block', array( $this, 'filter_render_block' ), 15, 2 );
	}

	/**
	 * When enabled from module attrs, enqueue split `line-hover` frontend CSS once (same handle as `dsm/line-hover`).
	 *
	 * Skips when the monolithic module bundle is already queued (dynamic assets OFF / fallback already includes Line Hover styles).
	 *
	 * @param string                $block_content Block HTML.
	 * @param array<string, mixed>  $block         Parsed block array.
	 * @return string
	 */
	public function filter_render_block( $block_content, $block ): string {

		if ( ! dsm_is_line_hover_animation_extension_enabled() ) {
			return $block_content;
		}

		if (
			! is_string( $block_content )
			|| '' === $block_content
			|| ! is_array( $block )
			|| ! isset( $block['attrs'] )
			|| ! is_array( $block['attrs'] )
		) {
			return $block_content;
		}

		// Desktop gate: same as VB when the active breakpoint is desktop.
		// Phone/tablet "Use Line Hover Animation" off is handled via scope classes + CSS
		// (SSR cannot omit decoration only for one viewport).
		if ( ! $this->extension_use_toggle_on( $block['attrs'], 'desktop' ) ) {
			return $block_content;
		}

		$this->maybe_enqueue_shared_line_hover_frontend_styles();

		$effect_raw = isset( $block['attrs']['dsm_lhe_animation_effect']['innerContent']['desktop']['value'] )
			? (string) $block['attrs']['dsm_lhe_animation_effect']['innerContent']['desktop']['value']
			: '';
		$effect     = '' !== $effect_raw ? $this->sanitize_underscore_slug( $effect_raw ) : '';
		if ( ! in_array( $effect, self::ALLOWED_EFFECTS, true ) ) {
			$effect = 'underline_sweep_right';
		}

		$duration = $this->normalize_duration(
			$block['attrs']['dsm_lhe_animation_duration']['innerContent']['desktop']['value'] ?? '300ms'
		);

		$delay = $this->normalize_duration(
			$block['attrs']['dsm_lhe_animation_delay']['innerContent']['desktop']['value'] ?? '0ms'
		);

		$line_offset = $this->normalize_line_offset(
			$block['attrs']['dsm_lhe_line_offset']['innerContent']['desktop']['value'] ?? '4px'
		);

		$color_leaf = $block['attrs']['dsm_lhe_animation_color']['innerContent']['desktop']['value'] ?? null;

		$line_color_paint = $this->normalize_line_hover_color_for_scope(
			$this->line_hover_stringify_color_attr_leaf( $color_leaf )
		);

		$menu_active_enabled = $this->extension_menu_active_enabled( $block['attrs'] );

		$menu_active_color_leaf = $block['attrs']['dsm_lhe_menu_active_color']['innerContent']['desktop']['value'] ?? null;

		$menu_active_color_raw = $this->line_hover_stringify_color_attr_leaf( $menu_active_color_leaf );

		$menu_active_color_paint = '' !== trim( $menu_active_color_raw )
			? $this->normalize_line_hover_color_for_scope( $menu_active_color_raw )
			: '';

		$inner_markup = $this->decorate_markup(
			$block_content,
			$effect,
			$menu_active_enabled
		);

		$scope_classes = 'dsm-supreme-line-hover-scope dsm-line-hover-container';

		if ( $menu_active_enabled ) {
			$scope_classes .= ' dsm-lhe-menu-active-on';
		}

		// Match VB `dsmInnerContentPlainValue` inheritance: mute hover CSS when Use is off.
		if ( ! $this->extension_use_toggle_on( $block['attrs'], 'tablet' ) ) {
			$scope_classes .= ' dsm-lhe-off-tablet';
		}

		if ( ! $this->extension_use_toggle_on( $block['attrs'], 'phone' ) ) {
			$scope_classes .= ' dsm-lhe-off-phone';
		}

		$scope_style = sprintf(
			'--dsm-animation-duration:%1$s;--dsm-animation-delay:%2$s;--dsm-line-hover-gap:%3$s;--dsm-lhe-line-color:%4$s;',
			esc_attr( $duration ),
			esc_attr( $delay ),
			esc_attr( $line_offset ),
			esc_attr( $line_color_paint )
		);

		if ( '' !== $menu_active_color_paint ) {
			$scope_style .= sprintf(
				'--dsm-lhe-menu-active-color:%1$s;',
				esc_attr( $menu_active_color_paint )
			);
		}

		return sprintf(
			'<div class="%1$s" style="%2$s">%3$s</div>',
			esc_attr( $scope_classes ),
			$scope_style,
			$inner_markup
		);
	}

	/**
	 * Enqueue `public/packages/module-library/line-hover/frontend.css` when needed for the extension SSR output.
	 *
	 * Deferred to the split handle (`dsm-frontend-line-hover`) so WordPress dedupes with the standalone `dsm/line-hover`
	 * module. Does not enqueue when the full module stylesheet bundle already includes Line Hover (`dsm-supreme-modules-style`).
	 *
	 * @return void
	 */
	private function maybe_enqueue_shared_line_hover_frontend_styles(): void {
		if ( DsmFrontendAssetRegistry::should_use_divi_dynamic_assets_pipeline() ) {
			return;
		}
		DsmFrontendModuleAssetRegistry::register_per_module_frontend_styles();

		$lh_handle = DsmFrontendModuleAssetRegistry::frontend_style_handle_for_library_folder( 'line-hover' );

		if ( wp_style_is( DsmFrontendModuleAssetRegistry::CORE_MODULES_STYLE_HANDLE, 'enqueued' ) ) {
			return;
		}

		if ( wp_style_is( $lh_handle, 'enqueued' ) ) {
			return;
		}

		if ( wp_style_is( $lh_handle, 'registered' ) ) {
			wp_enqueue_style( $lh_handle );
		}
	}

	/**
	 * Normalize Line Hover "Animation Line Color" for `--dsm-lhe-line-color`.
	 *
	 * `sanitize_hex_color()` alone rejects CSS variables (`var(--*)`), color functions (`rgb()`, `hsl()`, …),
	 * keywords, and Divi-encoded `$variable(...)$` globals. Inline custom properties accept the same `<color>`
	 * subset Divi emits from the Color control.
	 *
	 * @param string $raw Raw stored value (typically a string).
	 * @return string Safe color token for HTML `style=""` via `esc_attr()`.
	 */
	private function normalize_line_hover_color_for_scope( string $raw ): string {
		$fallback = '#2ea3f2';

		$t = trim( $raw );

		if ( '' === $t ) {
			return $fallback;
		}

		if ( class_exists( \ET\Builder\Packages\StyleLibrary\Utils\Utils::class ) ) {
			$resolved = \ET\Builder\Packages\StyleLibrary\Utils\Utils::resolve_dynamic_variable( $t );
			if ( is_string( $resolved ) ) {
				$t = trim( $resolved );
			}

			if ( '' === $t ) {
				return $fallback;
			}
		}

		return $this->sanitize_css_paint_value_for_scope_custom_property(
			$t,
			$fallback
		);
	}

	/**
	 * Allow hex, keywords, `var()`, `rgb()`, `rgba()`, `hsl()`, `hsla()` for `--dsm-*` injection (not hex-only).
	 *
	 * @param string $candidate Trimmed candidate.
	 * @param string $fallback Default when rejected.
	 */
	private function sanitize_css_paint_value_for_scope_custom_property( string $candidate, string $fallback ): string {
		$t = trim( wp_strip_all_tags( $candidate ) );

		if ( '' === $t || strlen( $t ) > 350 ) {
			return $fallback;
		}

		if ( preg_match( '/[<>"`\\\\]|(\x00)/', $t ) ) {
			return $fallback;
		}

		$danger = '/\b(?:expression|behavior|javascript|@import)\b|\burl\s*\(\s*["\']?\s*javascript/i';
		if ( preg_match( $danger, $t ) ) {
			return $fallback;
		}

		$maybe_hex = sanitize_hex_color( $t );
		if ( is_string( $maybe_hex ) && '' !== $maybe_hex ) {
			return strtolower( $maybe_hex );
		}

		if ( preg_match( '/^(?:currentcolor|inherit|transparent)$/i', $t ) ) {
			return $t;
		}

		if ( preg_match( '/^var\s*\(/i', $t )
			|| preg_match( '/^(?:rgb|rgba|hsl|hsla)\s*\(/i', $t ) ) {
			return $this->line_hover_allow_paren_color_paint( $t, $fallback, $danger );
		}

		return $fallback;
	}

	/**
	 * Confirm `var()` / `rgba()` tokens cannot break out of the inline declaration.
	 *
	 * @param string $candidate Color-like string.
	 * @param string $fallback Fallback.
	 * @param string $danger    PCRE rejecting script-like patterns.
	 */
	private function line_hover_allow_paren_color_paint( string $candidate, string $fallback, string $danger ): string {
		if ( false !== strpos( $candidate, ';' ) || false !== strpos( $candidate, '}' ) ) {
			return $fallback;
		}

		if ( preg_match( $danger, $candidate ) ) {
			return $fallback;
		}

		return $candidate;
	}

	/**
	 * Normalize Color control leaf to string (`var()`, hex, `$variable(...)$`).
	 *
	 * @param mixed $leaf Stored `innerContent.desktop.value` (often string or wrapped object).
	 * @param int   $depth Recursion guard.
	 * @return string
	 */
	private function line_hover_stringify_color_attr_leaf( $leaf, int $depth = 0 ): string {
		if ( $depth > 8 ) {
			return '';
		}
		if ( is_string( $leaf ) ) {
			return $leaf;
		}
		if ( is_int( $leaf ) || is_float( $leaf ) ) {
			return (string) $leaf;
		}
		if ( is_array( $leaf ) && isset( $leaf['value'] ) ) {
			return $this->line_hover_stringify_color_attr_leaf( $leaf['value'], $depth + 1 );
		}

		return '';
	}

	/**
	 * Normalize Range / string durations to CSS time values.
	 *
	 * @param mixed $leaf Stored innerContent desktop value (string or { value, unit }).
	 * @return string
	 */
	private function normalize_duration( $leaf ): string {
		if ( is_string( $leaf ) ) {
			$t = trim( $leaf );

			return '' !== $t ? $t : '300ms';
		}
		if ( is_array( $leaf ) && isset( $leaf['value'] ) ) {
			$unit = isset( $leaf['unit'] ) && is_string( $leaf['unit'] ) ? $leaf['unit'] : 'ms';
			$v    = isset( $leaf['value'] ) ? trim( (string) $leaf['value'] ) : '';

			return '' !== $v ? $v . $unit : '300ms';
		}

		return '300ms';
	}

	/**
	 * Normalize Line Offset for `--dsm-line-hover-gap` (px).
	 *
	 * @param mixed $leaf Stored innerContent leaf (string or range object).
	 * @return string Safe length token, e.g. `4px`.
	 */
	private function normalize_line_offset( $leaf ): string {
		$fallback = '4px';

		if ( is_array( $leaf ) && isset( $leaf['value'] ) ) {
			$unit = isset( $leaf['unit'] ) && is_string( $leaf['unit'] ) ? $leaf['unit'] : 'px';
			$v    = trim( (string) $leaf['value'] );

			if ( '' === $v ) {
				return $fallback;
			}

			$leaf = $v . ( '' !== $unit ? $unit : 'px' );
		}

		if ( ! is_string( $leaf ) ) {
			return $fallback;
		}

		$t = trim( $leaf );

		if ( '' === $t ) {
			return $fallback;
		}

		if ( 1 === preg_match( '/^(-?\d+(?:\.\d+)?)(px)?$/i', $t, $m ) ) {
			return $m[1] . 'px';
		}

		return $fallback;
	}

	/**
	 * Add line-hover classes, data attributes, and SVG children inside module HTML (SSR).
	 *
	 * @param string $content  Full module output (before outer scope wrapper).
	 * @param string $effect Whitelisted animation slug (with underscores).
	 * @param bool   $mark_menu_current When true, tag menu links in `current-menu-item` / `current_page_item` with `dsm-lhe-menu-active`.
	 * @return string
	 */
	private function decorate_markup( string $content, string $effect, bool $mark_menu_current = false ): string {
		libxml_use_internal_errors( true );
		$dom = new \DOMDocument();

		$wrapped = '<?xml encoding="UTF-8"?><div class="dsm-lhe-xml-root">' . $content . '</div>';
		if ( function_exists( 'mb_encode_numericentity' ) ) {
			$wrapped = mb_encode_numericentity( $wrapped, array( 0x80, 0x10ffff, 0, 0xfffffff ), 'UTF-8' );
		}
		if ( '' === trim( wp_strip_all_tags( $wrapped ) ) && false === strpos( $wrapped, '<' ) ) {
			return $content;
		}

		$parsed = @$dom->loadHTML( $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR );
		libxml_clear_errors();
		if ( false === $parsed ) {
			return $content;
		}

		$document_root = $dom->documentElement;
		if ( ! $document_root instanceof \DOMElement ) {
			return $content;
		}

		$xpath      = new \DOMXPath( $dom );
		$candidates = $xpath->query( '//a' );

		if ( false === $candidates ) {
			return $content;
		}

		foreach ( $candidates as $node ) {
			if ( ! ( $node instanceof \DOMElement ) ) {
				continue;
			}

			if ( ! $this->node_should_receive_line_hover( $node ) ) {
				continue;
			}

			if ( $this->anchor_has_line_hover_inner_span( $node ) ) {
				continue;
			}

			$target_el = $node;

			if ( $this->node_is_et_pb_button( $node ) ) {
				$target_el = $this->ensure_et_pb_hover_inner_wrap( $dom, $node );
			} elseif ( $this->node_is_divi_menu_nav_link( $node ) ) {
				$target_el = $this->ensure_menu_hover_inner_wrap( $dom, $node );
			} elseif ( $this->node_is_madmenu_nav_link( $node ) ) {
				// MadMenu flex `<a>` is taller than the label — decorate `.dvmm_link_text` so SVG `top: 1.1em` sits under text.
				$target_el = $this->ensure_madmenu_hover_target( $dom, $node );
			}

			if ( $target_el === $node && ! $target_el->hasChildNodes() ) {
				continue;
			}

			$target_class_lc = strtolower( $target_el->getAttribute( 'class' ) ?? '' );

			if ( false !== strpos( $target_class_lc, 'dsm-line-hover-link' ) ) {
				continue;
			}

			$existing = $target_el->getAttribute( 'class' );

			$dsm_effect_class = 'dsm_' . $effect;

			$classes_to_add = 'dsm-line-hover-link ' . $dsm_effect_class . ' ';
			if ( '' !== $existing ) {
				$target_el->setAttribute( 'class', trim( $classes_to_add . $existing ) );
			} else {
				$target_el->setAttribute( 'class', trim( $classes_to_add ) );
			}

			if ( 'underline_swipe_text_shift' === $effect ) {
				$data_text = $this->collapse_element_text_content( $target_el );
				if ( '' !== $data_text ) {
					$target_el->setAttribute( 'data-text', $data_text );
				}
			}

			if ( 'line_bends_animation' === $effect ) {
				$this->append_svg_markup( $dom, $target_el, self::SVG_LINE_BENDS );
			} elseif ( 'line_expands_inward' === $effect ) {
				$this->append_svg_markup( $dom, $target_el, self::SVG_LINE_EXPANDS );
			} elseif ( 'zigzag_line_animation' === $effect ) {
				$this->append_svg_markup( $dom, $target_el, self::SVG_ZIGZAG );
			}

			if (
				$mark_menu_current
				&& $this->node_is_wp_nav_current_link( $node )
			) {
				$this->append_class_to_element( $target_el, 'dsm-lhe-menu-active' );
			}
		}

		$inner = '';

		foreach ( $document_root->childNodes ?? array() as $child ) {
			$inner .= $dom->saveHTML( $child );
		}

		return $inner;
	}

	/**
	 * Lowercase `[a-z0-9_]+` slug helper (preserve underscores; avoids `sanitize_key()` mangling).
	 *
	 * @param string $raw Raw string.
	 * @return string
	 */
	private function sanitize_underscore_slug( string $raw ): string {
		$normalized = strtolower( preg_replace( '/[^a-z0-9_]/', '', $raw ) );

		return is_string( $normalized ) ? $normalized : '';
	}

	/**
	 * Divi renders button modules as `<a class="et_pb_button">` — wrap inner content for flex + line sizing.
	 *
	 * @param \DOMElement $node Anchor.
	 * @return bool True when `.et_pb_button` is present.
	 */
	/**
	 * Whether "Use Line Hover Animation" is on for a breakpoint (VB inheritance order).
	 *
	 * Phone: phone → tablet → desktop. Tablet: tablet → desktop. Desktop: desktop only.
	 * Unset desktop defaults to off (matches settings `defaultAttr`).
	 *
	 * @param array<string, mixed> $attrs Block attrs.
	 * @param string               $mode  `desktop`|`tablet`|`phone`.
	 */
	private function extension_use_toggle_on( array $attrs, string $mode ): bool {
		$inner = $attrs['dsm_lhe_use']['innerContent'] ?? null;
		if ( ! is_array( $inner ) ) {
			return false;
		}

		$raw = $this->resolve_inner_content_plain_value( $inner, $mode );

		if ( '' === $raw ) {
			return false;
		}

		return 'on' === strtolower( $raw );
	}

	/**
	 * Read responsive `*.innerContent` like VB `dsmInnerContentPlainValue` (no store / breakpoint UI).
	 *
	 * @param array<string, mixed> $inner_content Attr `innerContent` bag.
	 * @param string               $mode          `desktop`|`tablet`|`phone`.
	 * @return string Raw stored value or empty string when unset through the inheritance chain.
	 */
	private function resolve_inner_content_plain_value( array $inner_content, string $mode ): string {
		if ( 'phone' === $mode ) {
			$order = array( 'phone', 'tablet', 'desktop' );
		} elseif ( 'tablet' === $mode ) {
			$order = array( 'tablet', 'desktop' );
		} else {
			$order = array( 'desktop' );
		}

		foreach ( $order as $key ) {
			if ( ! isset( $inner_content[ $key ] ) || ! is_array( $inner_content[ $key ] ) ) {
				continue;
			}

			if ( ! array_key_exists( 'value', $inner_content[ $key ] ) ) {
				continue;
			}

			$raw = $inner_content[ $key ]['value'];

			if ( null === $raw || ( is_string( $raw ) && '' === $raw ) ) {
				continue;
			}

			if ( is_bool( $raw ) ) {
				return $raw ? 'on' : 'off';
			}

			if ( is_scalar( $raw ) ) {
				return (string) $raw;
			}
		}

		return '';
	}

	/**
	 * Whether "highlight current menu page" is enabled (default on when unset).
	 *
	 * @param array<string, mixed> $attrs Block attrs.
	 */
	private function extension_menu_active_enabled( array $attrs ): bool {
		if ( ! isset( $attrs['dsm_lhe_menu_active']['innerContent']['desktop']['value'] ) ) {
			return true;
		}

		return 'on' === (string) $attrs['dsm_lhe_menu_active']['innerContent']['desktop']['value'];
	}

	/**
	 * WordPress nav/page-list current item: direct `li > a` with exact current classes.
	 *
	 * Portable across Divi Menu, MadMenu, and other walkers that emit core WP classes.
	 * Requires `menu-item` or `page_item` on the `li` so random markup is not matched.
	 * Does not use `current-menu-parent` / `current-menu-ancestor`.
	 */
	private function node_is_wp_nav_current_link( \DOMElement $anchor ): bool {
		if ( 'a' !== strtolower( $anchor->nodeName ?? '' ) ) {
			return false;
		}

		if ( $this->node_is_et_pb_button( $anchor ) || $this->node_is_menu_chrome_link( $anchor ) ) {
			return false;
		}

		if ( $this->node_has_ancestor_class_fragment( $anchor, 'et_pb_menu__logo-wrap' )
			|| $this->node_has_ancestor_class_fragment( $anchor, 'et_pb_menu__logo-slot' )
			|| $this->node_has_ancestor_class_fragment( $anchor, 'et_pb_menu__logo' ) ) {
			return false;
		}

		$parent = $anchor->parentNode;

		if ( ! ( $parent instanceof \DOMElement ) || 'li' !== strtolower( $parent->nodeName ?? '' ) ) {
			return false;
		}

		$class_attr = strtolower( $parent->getAttribute( 'class' ) ?? '' );

		$is_nav_or_page_item = $this->class_attr_has_token( $class_attr, 'menu-item' )
			|| $this->class_attr_has_token( $class_attr, 'page_item' );

		if ( ! $is_nav_or_page_item ) {
			return false;
		}

		return $this->class_attr_has_token( $class_attr, 'current-menu-item' )
			|| $this->class_attr_has_token( $class_attr, 'current_page_item' );
	}

	/**
	 * Whitespace-delimited CSS class token match (avoids `menu-item` matching inside `current-menu-item`).
	 */
	private function class_attr_has_token( string $class_attr, string $token ): bool {
		$token = strtolower( $token );

		if ( '' === $token || '' === $class_attr ) {
			return false;
		}

		foreach ( preg_split( '/\s+/', $class_attr ) as $part ) {
			if ( $part === $token ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Append a CSS class token when not already present.
	 */
	private function append_class_to_element( \DOMElement $element, string $class_token ): void {
		$existing   = $element->getAttribute( 'class' );
		$needle     = strtolower( $class_token );
		$existing_lc = strtolower( $existing );

		if ( false !== strpos( $existing_lc, $needle ) ) {
			return;
		}

		$element->setAttribute(
			'class',
			'' === $existing ? $class_token : trim( $existing . ' ' . $class_token )
		);
	}

	private function node_is_et_pb_button( \DOMElement $node ): bool {
		if ( 'a' !== strtolower( $node->nodeName ?? '' ) ) {
			return false;
		}

		$class_attr = strtolower( $node->getAttribute( 'class' ) ?? '' );

		return false !== strpos( $class_attr, 'et_pb_button' );
	}

	/**
	 * True when the anchor already has a decorated inner span (avoid double-wrap).
	 */
	private function anchor_has_line_hover_inner_span( \DOMElement $anchor ): bool {
		foreach ( $anchor->childNodes as $child ) {
			if ( ! ( $child instanceof \DOMElement ) ) {
				continue;
			}
			$class_attr = strtolower( $child->getAttribute( 'class' ) ?? '' );

			if ( false !== strpos( $class_attr, 'dsm-line-hover-link' ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Whether this anchor should receive line-hover classes (SSR + parity with VB `canvas-wrapper.js`).
	 *
	 * Inside `.et_pb_menu`, only primary nav `li > a` links — never logo, search, cart, etc.
	 * Inside MadMenu (`.dvmm_mad_menu`), only `li > a` item links — never mobile toggle / chrome.
	 */
	private function node_should_receive_line_hover( \DOMElement $node ): bool {
		if ( 'a' !== strtolower( $node->nodeName ?? '' ) ) {
			return false;
		}

		if ( $this->node_has_ancestor_class_fragment( $node, 'et_pb_menu' ) ) {
			return $this->node_is_divi_menu_nav_link( $node );
		}

		if ( $this->node_has_ancestor_class_fragment( $node, 'dvmm_mad_menu' ) ) {
			return $this->node_is_madmenu_nav_link( $node );
		}

		return true;
	}

	/**
	 * MadMenu (`dvmm/mad-menu`) primary / submenu item: `li > a` with a label, excluding chrome controls.
	 */
	private function node_is_madmenu_nav_link( \DOMElement $node ): bool {
		if ( 'a' !== strtolower( $node->nodeName ?? '' ) ) {
			return false;
		}

		if ( ! $this->node_has_ancestor_class_fragment( $node, 'dvmm_mad_menu' ) ) {
			return false;
		}

		if ( $this->node_is_madmenu_chrome_link( $node ) ) {
			return false;
		}

		$parent = $node->parentNode;

		if ( ! ( $parent instanceof \DOMElement )
			|| 'li' !== strtolower( $parent->nodeName ?? '' ) ) {
			return false;
		}

		return true;
	}

	/**
	 * MadMenu utility / chrome controls that must not receive line-hover decoration.
	 */
	private function node_is_madmenu_chrome_link( \DOMElement $node ): bool {
		$class_attr = strtolower( $node->getAttribute( 'class' ) ?? '' );

		return false !== strpos( $class_attr, 'dvmm_mobile_menu_toggle' )
			|| false !== strpos( $class_attr, 'dvmm_search' )
			|| false !== strpos( $class_attr, 'dvmm_cart' )
			|| false !== strpos( $class_attr, 'dvmm_go_back' );
	}

	/**
	 * Prefer MadMenu `.dvmm_link_text` as the hover target so line/SVG position against the label,
	 * not the flex-stretched `<a>` (keeps zigzag under text). Leaves `.dvmm_submenu_toggle` on the anchor.
	 *
	 * @param \DOMDocument $dom  Active document.
	 * @param \DOMElement  $host MadMenu item anchor.
	 * @return \DOMElement Label span or fallback wrap.
	 */
	private function ensure_madmenu_hover_target( \DOMDocument $dom, \DOMElement $host ): \DOMElement {
		foreach ( $host->childNodes as $child ) {
			if ( ! ( $child instanceof \DOMElement ) ) {
				continue;
			}

			$class_attr = strtolower( $child->getAttribute( 'class' ) ?? '' );

			if ( false !== strpos( $class_attr, 'dsm-line-hover-link' ) ) {
				return $child;
			}

			if ( false !== strpos( $class_attr, 'dvmm_link_text' ) ) {
				return $child;
			}
		}

		// No label span — wrap non-toggle children (parity with Divi Menu inner wrap).
		if ( ! $host->hasChildNodes() ) {
			return $host;
		}

		$span = $dom->createElement( 'span' );

		$to_move = array();
		foreach ( $host->childNodes as $child ) {
			if ( $child instanceof \DOMElement ) {
				$c = strtolower( $child->getAttribute( 'class' ) ?? '' );
				if ( false !== strpos( $c, 'dvmm_submenu_toggle' ) ) {
					continue;
				}
			}
			$to_move[] = $child;
		}

		foreach ( $to_move as $child ) {
			$span->appendChild( $child );
		}

		if ( $span->hasChildNodes() ) {
			if ( $host->firstChild ) {
				$host->insertBefore( $span, $host->firstChild );
			} else {
				$host->appendChild( $span );
			}
			return $span;
		}

		return $host;
	}

	/**
	 * Native Divi Menu (`divi/menu`) nav item: `li > a` inside the menu list, excluding chrome links.
	 * Used for inner-label wrap only — current-page active uses {@see node_is_wp_nav_current_link()}.
	 */
	private function node_is_divi_menu_nav_link( \DOMElement $node ): bool {
		if ( 'a' !== strtolower( $node->nodeName ?? '' ) ) {
			return false;
		}

		if ( $this->node_is_et_pb_button( $node ) || $this->node_is_menu_chrome_link( $node ) ) {
			return false;
		}

		if ( ! $this->node_has_ancestor_class_fragment( $node, 'et_pb_menu' ) ) {
			return false;
		}

		if ( $this->node_has_ancestor_class_fragment( $node, 'et_pb_menu__logo-wrap' )
			|| $this->node_has_ancestor_class_fragment( $node, 'et_pb_menu__logo-slot' )
			|| $this->node_has_ancestor_class_fragment( $node, 'et_pb_menu__logo' ) ) {
			return false;
		}

		$parent = $node->parentNode;

		if ( ! ( $parent instanceof \DOMElement )
			|| 'li' !== strtolower( $parent->nodeName ?? '' ) ) {
			return false;
		}

		return $this->node_is_inside_menu_nav_list( $node );
	}

	/**
	 * Desktop `.et-menu-nav` or mobile `.et_mobile_menu` list — excludes logo / utility regions.
	 */
	private function node_is_inside_menu_nav_list( \DOMElement $node ): bool {
		return $this->node_has_ancestor_class_fragment( $node, 'et-menu-nav' )
			|| $this->node_has_ancestor_class_fragment( $node, 'et_pb_menu__menu' )
			|| $this->node_has_ancestor_class_fragment( $node, 'et_mobile_menu' );
	}

	/**
	 * Menu module utility links (search, cart, etc.) — not primary nav items.
	 */
	private function node_is_menu_chrome_link( \DOMElement $node ): bool {
		$class_attr = strtolower( $node->getAttribute( 'class' ) ?? '' );

		return false !== strpos( $class_attr, 'et_pb_menu__search' )
			|| false !== strpos( $class_attr, 'et_pb_menu__cart' )
			|| false !== strpos( $class_attr, 'et_pb_menu__close-search' )
			|| false !== strpos( $class_attr, 'et_pb_menu__logo' );
	}

	/**
	 * Walk ancestors for a class attribute substring match.
	 */
	private function node_has_ancestor_class_fragment( \DOMElement $node, string $fragment ): bool {
		$needle = strtolower( $fragment );
		$current = $node->parentNode;

		while ( $current instanceof \DOMElement ) {
			$class_attr = strtolower( $current->getAttribute( 'class' ) ?? '' );

			if ( false !== strpos( $class_attr, $needle ) ) {
				return true;
			}

			$current = $current->parentNode;
		}

		return false;
	}

	/**
	 * Wrap menu link children in a span so line-hover pseudos hug the label, not padded `<a>` box.
	 *
	 * @param \DOMDocument $dom  Active document.
	 * @return \DOMElement Inner span or `host` when empty / already wrapped.
	 */
	private function ensure_menu_hover_inner_wrap( \DOMDocument $dom, \DOMElement $host ): \DOMElement {
		foreach ( $host->childNodes as $child ) {
			if ( ! ( $child instanceof \DOMElement ) ) {
				continue;
			}
			$class_attr = strtolower( $child->getAttribute( 'class' ) ?? '' );

			if ( false !== strpos( $class_attr, 'dsm-line-hover-link' ) ) {
				return $child;
			}
		}

		if ( ! $host->hasChildNodes() ) {
			return $host;
		}

		$span = $dom->createElement( 'span' );

		while ( $host->firstChild ) {
			$span->appendChild( $host->firstChild );
		}

		$host->appendChild( $span );

		return $span;
	}

	/**
	 * Wrap existing children in a span so line-hover pseudos sit on the label, not the flex box.
	 *
	 * @param \DOMDocument $dom  Active document.
	 * @return \DOMElement Label wrapper or `host` when empty.
	 */
	private function ensure_et_pb_hover_inner_wrap( \DOMDocument $dom, \DOMElement $host ): \DOMElement {
		foreach ( $host->childNodes as $child ) {
			if ( ! ( $child instanceof \DOMElement ) ) {
				continue;
			}
			$c = strtolower( $child->getAttribute( 'class' ) ?? '' );

			if ( false !== strpos( $c, self::INNER_HOVER_WRAP_CLASS ) ) {
				return $child;
			}
		}

		if ( ! $host->hasChildNodes() ) {
			return $host;
		}

		$span = $dom->createElement( 'span' );
		$span->setAttribute( 'class', self::INNER_HOVER_WRAP_CLASS );

		while ( $host->firstChild ) {
			$span->appendChild( $host->firstChild );
		}

		$host->appendChild( $span );

		return $span;
	}

	/**
	 * First-level text-ish content suitable for underline_swipe data-text attribute.
	 */
	private function collapse_element_text_content( \DOMElement $element ): string {
		return trim(
			preg_replace( '/\s+/u', ' ', (string) ( $element->textContent ?? '' ) )
		);
	}

	/**
	 * Append parsed SVG subtree to DOM element.
	 */
	private function append_svg_markup( \DOMDocument $dom, \DOMElement $into, string $svg_xml ): void {
		$frag = @$dom->createDocumentFragment();
		if ( ! ( $frag instanceof \DOMDocumentFragment ) ) {
			return;
		}
		$frag_ok = false;

		try {
			$result = @$frag->appendXML( $svg_xml );
			$frag_ok = ( true === $result );
		} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			$frag_ok = false;
		}
		if ( ! $frag_ok ) {
			$tmp = new \DOMDocument();
			@$tmp->loadXML( $svg_xml );
			$root = $tmp->documentElement;
			if ( $root instanceof \DOMElement ) {
				$imported = $dom->importNode( $root, true );
				$into->appendChild( $imported );
			}
			return;
		}
		$into->appendChild( $frag );
	}
}

new LineHoverAnimation();
