<?php
/**
 * BaseCloud Boost — Lazy Loader & LCP Optimizer
 *
 * Single output-buffer pass that:
 *  1. Detects the first above-fold image (LCP candidate) and injects a
 *     <link rel="preload"> hint into <head>.
 *  2. Marks the LCP image with fetchpriority="high" loading="eager".
 *  3. Adds loading="lazy" decoding="async" to all subsequent images.
 *  4. Adds loading="lazy" to iframes.
 *  5. Sets preload="none" on <video> tags to prevent auto-download.
 *  6. Rewrites image src/srcset to a modern format (WebP/AVIF) when available,
 *     covering every page builder — handled in the same pass for speed.
 *
 * IMPORTANT (1.0.1): lazy loading now relies on the browser's native
 * loading="lazy" and keeps every image's real src intact. Earlier versions
 * swapped src for a placeholder and hid images with CSS until JavaScript ran —
 * if that script failed, images/videos/iframes stayed invisible. That entire
 * mechanism has been removed, so media always appears.
 *
 * Runs at template_redirect priority 99 (inner buffer), so the cache engine at
 * priority 0 (outer buffer) stores the already-optimised HTML.
 *
 * @package BaseCloud\Boost\Optimizer
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Optimizer;

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

/**
 * Lazy-loads below-fold images, iframes, and videos.
 */
class Lazy_Loader {

	/** @var int Output buffer level before our ob_start call. */
	private int $ob_level = 0;

	/** @var Image_Optimizer|null Shared modern-format helper (when serving is enabled). */
	private ?Image_Optimizer $images = null;

	/**
	 * Boot lazy loader.
	 *
	 * @return void
	 */
	public function boot(): void {
		$lazy_images  = BCBOOST_get_option( 'lazy_load_images', true );
		$lazy_iframes = BCBOOST_get_option( 'lazy_load_iframes', true );
		$lazy_videos  = BCBOOST_get_option( 'lazy_load_videos', true );

		if ( is_admin() ) {
			return;
		}

		// Serve modern formats inside this same pass (no second full-page regex).
		if ( Image_Optimizer::is_enabled() ) {
			$this->images = new Image_Optimizer();
		}

		// Nothing to do if every transform is disabled.
		if ( ! $lazy_images && ! $lazy_iframes && ! $lazy_videos && null === $this->images ) {
			return;
		}

		// Priority 99 = inner buffer; runs before Cache_Engine (priority 0 outer).
		add_action( 'template_redirect', [ $this, 'start_buffer' ], 99 );
	}

	/**
	 * Start output buffering.
	 *
	 * @return void
	 */
	public function start_buffer(): void {
		if ( is_feed() ) {
			return;
		}

		// Never rewrite images / inject preloads inside a builder editor/preview
		// (Divi ?et_fb=1, Elementor, Customizer, post previews) — the editor needs
		// its DOM untouched. Central gate — see basecloud-boost.php.
		if ( ! BCBOOST_is_optimizable_request() ) {
			return;
		}

		$this->ob_level = ob_get_level();
		ob_start( [ $this, 'process_html' ] );
		add_action( 'shutdown', [ $this, 'end_output_buffer' ], PHP_INT_MAX );
	}

	/**
	 * Explicitly close our output buffer on shutdown.
	 *
	 * @return void
	 */
	public function end_output_buffer(): void {
		if ( ob_get_level() > $this->ob_level ) {
			ob_end_flush();
		}
	}

	/**
	 * Process the full HTML output in a single pass.
	 *
	 * @param string $html Full page HTML.
	 * @return string      Optimised HTML.
	 */
	public function process_html( string $html ): string {
		if ( strlen( $html ) < 100 ) {
			return $html;
		}

		// Only operate on full HTML documents.
		if ( strpos( $html, '<html' ) === false && strpos( $html, '<!DOCTYPE' ) === false ) {
			return $html;
		}

		// ── Protect <noscript> blocks ──────────────────────────────────────
		$noscript_map = [];
		$html         = preg_replace_callback(
			'/<noscript[^>]*>.*?<\/noscript>/is',
			static function ( array $m ) use ( &$noscript_map ): string {
				$key                  = '%%BCBOOST_NS_' . count( $noscript_map ) . '%%';
				$noscript_map[ $key ] = $m[0];
				return $key;
			},
			$html
		) ?? $html;

		$lcp_url    = '';
		$lcp_srcset = '';
		$lcp_sizes  = '';
		$lcp_done   = false;
		$first_done = false;
		$lazy_imgs  = (bool) BCBOOST_get_option( 'lazy_load_images', true );
		$auto_sizes = (bool) BCBOOST_get_option( 'auto_sizes', true );

		// Leading images to keep EAGER beyond the hero. lazy_skip_count is the total
		// number of top-of-document content images that stay eager (hero + the next
		// N−1); the hero is handled by the promotion paths, so this budget covers the
		// extras. Mobile's first viewport often holds 2-3 images — lazy-loading them
		// delays paint and can hide the true LCP.
		$eager_budget = max( 0, (int) BCBOOST_get_option( 'lazy_skip_count', 2 ) - 1 );

		// If the theme/page already designates an LCP image (fetchpriority="high"),
		// respect it: don't impose our own first-image guess and never lazy-load it.
		// EXCEPTION: themes very often mark the header LOGO high-priority, which is
		// not the real LCP — a high-priority logo must NOT stop us from preloading
		// the actual hero, so it doesn't count as a page-designated priority image.
		$page_has_priority = false;
		if ( preg_match_all( '/<img\b[^>]*fetchpriority\s*=\s*["\']high["\'][^>]*>/i', $html, $pm ) ) {
			foreach ( $pm[0] as $ptag ) {
				if ( ! self::is_logo( $ptag ) && ! self::is_insignificant( $ptag ) && ! self::is_hidden( $ptag ) ) {
					$page_has_priority = true;
					break;
				}
			}
		}

		// Admin-defined patterns (URL fragments or class names) for above-the-fold
		// images that must never be lazy-loaded, e.g. a hero the heuristic misses.
		$lazy_exclude = array_filter( array_map(
			'trim',
			explode( "\n", (string) BCBOOST_get_option( 'lazy_exclude', '' ) )
		) );

		// ── Process <img> tags ─────────────────────────────────────────────
		if ( $lazy_imgs || null !== $this->images ) {
			$html = preg_replace_callback(
				'/<img([^>]*)>/i',
				function ( array $m ) use ( &$first_done, &$lcp_url, &$lcp_srcset, &$lcp_sizes, &$lcp_done, &$eager_budget, $lazy_imgs, $auto_sizes, $page_has_priority, $lazy_exclude ): string {
					$attrs = $m[1];

					// Preserve a trailing self-closing slash so appended attributes
					// don't land after it (which produced malformed "... / attr>").
					$self_close = '';
					if ( preg_match( '#\s*/\s*$#', $attrs ) ) {
						$attrs      = preg_replace( '#\s*/\s*$#', '', $attrs ) ?? $attrs;
						$self_close = ' /';
					}

					// Serve a modern format (WebP/AVIF) where one exists — every image.
					if ( null !== $this->images ) {
						$attrs = $this->images->modernize_attrs( $attrs );
					}

					// Reserve layout space (CLS) by adding intrinsic width/height when
					// both are missing and derivable from the WordPress -WxH filename.
					// Browsers use these to compute aspect-ratio even when CSS sizes the
					// image (height:auto), so it only prevents reflow — it never resizes.
					$attrs = $this->maybe_add_dimensions( $attrs );

					$emit = static function ( string $a ) use ( $self_close, $auto_sizes ): string {
						// sizes="auto, …" on LAZY responsive images (the WP 6.7 core
						// mechanism, applied here for older WP and builder markup):
						// supporting browsers pick the srcset candidate matching the
						// image's RENDERED width — smaller downloads on phones (PSI
						// "Properly size images"). Legacy browsers skip the invalid
						// `auto` token and use the original list. Spec-valid only
						// with loading=lazy; never applied when `auto` is present.
						if ( $auto_sizes
							&& preg_match( '/\bloading\s*=\s*["\']lazy["\']/i', $a )
							&& strpos( $a, 'srcset=' ) !== false ) {
							$a = preg_replace_callback(
								'/((?<![-\w])sizes\s*=\s*(["\']))((?:(?!\2).)*)(\2)/i',
								static function ( array $sm ): string {
									if ( stripos( ltrim( $sm[3] ), 'auto' ) === 0 ) {
										return $sm[0]; // core (WP 6.7+) already did it
									}
									return $sm[1] . 'auto, ' . $sm[3] . $sm[4];
								},
								$a,
								1
							) ?? $a;
						}
						return '<img' . $a . $self_close . '>';
					};

					if ( ! $lazy_imgs ) {
						return $emit( $attrs );
					}

					// Skip data URIs (inline SVG / tracking pixels).
					if ( strpos( $attrs, 'src="data:' ) !== false || strpos( $attrs, "src='data:" ) !== false ) {
						return $emit( $attrs );
					}

					// Skip 1×1 tracking pixels. The value must be EXACTLY 1: the
					// negative lookahead stops "1" from matching the first digit of
					// 1000/1200/150 etc. (which silently exempted most real content
					// images from lazy-loading AND from LCP promotion — the monitornet
					// mis-preload: every 1xxx-wide image was skipped, so a carousel
					// thumbnail with no dimensions became the "first" content image).
					if ( preg_match( '/\b(?:width|height)\s*=\s*["\']?1["\']?(?![\d%.])/i', $attrs ) ) {
						return $emit( $attrs );
					}

					// Add decoding="async" to every image (safe, no repaint cost).
					if ( strpos( $attrs, 'decoding=' ) === false ) {
						$attrs .= ' decoding="async"';
					}

					// Admin-excluded above-the-fold images: treat exactly like a
					// designated LCP (eager, never lazy).
					$user_excluded = false;
					foreach ( $lazy_exclude as $pattern ) {
						if ( '' !== $pattern && false !== strpos( $attrs, $pattern ) ) {
							$user_excluded = true;
							break;
						}
					}

					// Already-prioritised images (fetchpriority="high" / loading="eager")
					// are the real LCP. Manually-excluded images opt out too. Promote
					// the first such image so it loads immediately — even when a JS
					// lazy-loader has placeholdered it — and record it for the preload.
					$is_priority = preg_match( '/fetchpriority\s*=\s*["\']high["\']/i', $attrs )
						|| preg_match( '/loading\s*=\s*["\']eager["\']/i', $attrs );

					if ( $is_priority || $user_excluded || preg_match( '/\b(no-lazy|skip-lazy|data-no-lazy)\b/i', $attrs ) ) {
						// A logo — or an insignificant/hidden image — that carries
						// high-priority markers is not the LCP: leave it as-is (it
						// already loads eagerly) and keep scanning for the real hero
						// instead of preloading it. Same three vetoes as the
						// first-in-flow path and LCP_Optimizer::find_promoted_img().
						if ( $is_priority && ! $user_excluded
							&& ( self::is_logo( $attrs ) || self::is_insignificant( $attrs ) || self::is_hidden( $attrs ) ) ) {
							return $emit( $attrs );
						}
						$first_done = true;
						$attrs      = $this->promote_lcp_image( $attrs, ! $lcp_done, $lcp_url, $lcp_srcset, $lcp_sizes );
						if ( ! $lcp_done && '' !== $lcp_url ) {
							$lcp_done = true;
						}
						return $emit( $attrs );
					}

					if ( ! $first_done ) {
						// Hidden by its own attributes (aria-hidden, hidden,
						// display:none) — e.g. a duplicate responsive variant or a
						// pre-hidden panel image: it cannot be the first paint's LCP.
						// Leave it exactly as-is and keep scanning for the real hero.
						if ( self::is_hidden( $attrs ) ) {
							return $emit( $attrs );
						}

						// An obvious logo — or an insignificant image (icon, badge,
						// SVG, tiny declared dimensions) — is not the LCP: keep it
						// eager (it's above the fold) but don't preload it or count it
						// as the hero; the next in-flow image becomes the LCP candidate.
						if ( self::is_logo( $attrs ) || self::is_insignificant( $attrs ) ) {
							if ( strpos( $attrs, 'loading=' ) === false ) {
								$attrs .= ' loading="eager"';
							}
							return $emit( $attrs );
						}

						// ── First in-flow image ───────────────────────────────
						$first_done = true;

						if ( ! $page_has_priority ) {
							// No designated hero elsewhere — this first image is the
							// LCP: promote it (un-lazy + high priority) and preload it.
							$attrs = $this->promote_lcp_image( $attrs, ! $lcp_done, $lcp_url, $lcp_srcset, $lcp_sizes );
							if ( ! $lcp_done && '' !== $lcp_url ) {
								$lcp_done = true;
							}
						} elseif ( strpos( $attrs, 'loading=' ) === false ) {
							// A hero is prioritised elsewhere (e.g. header logo first);
							// keep this one eager — not lazy — without stealing priority.
							$attrs .= ' loading="eager"';
						}
					} elseif ( strpos( $attrs, 'loading=' ) === false ) {
						// Keep the next few leading images EAGER — on mobile they are
						// usually still in the first viewport, so lazy-loading them
						// delays paint (PSI "LCP image was lazy-loaded"). Hidden or
						// insignificant images (icons/badges/tracking) don't spend the
						// budget. Once it's spent, native-lazy the rest and hint
						// fetchpriority="low" so the hero keeps the bandwidth.
						if ( $eager_budget > 0 && ! self::is_hidden( $attrs ) && ! self::is_insignificant( $attrs ) ) {
							$eager_budget--;
							$attrs .= ' loading="eager"';
						} else {
							$attrs .= ' loading="lazy"';
							if ( ! preg_match( '/\bfetchpriority\s*=/i', $attrs ) ) {
								$attrs .= ' fetchpriority="low"';
							}
						}
					}

					return $emit( $attrs );
				},
				$html
			) ?? $html;
		}

		// ── Process <iframe> tags ──────────────────────────────────────────
		if ( BCBOOST_get_option( 'lazy_load_iframes', true ) ) {
			$html = preg_replace_callback(
				'/<iframe([^>]*)>/i',
				static function ( array $m ): string {
					$attrs = $m[1];

					if ( strpos( $attrs, 'loading=' ) !== false ||
					     strpos( $attrs, 'data-no-lazy' ) !== false ) {
						return $m[0];
					}

					return '<iframe' . $attrs . ' loading="lazy">';
				},
				$html
			) ?? $html;
		}

		// ── Prefer .webm siblings for self-hosted MP4 <source> videos ──────
		// Same trusted pattern as WebP image siblings: Boost NEVER transcodes
		// video (PHP can't, and re-encoding on shared hosting would be both
		// slow and lossy) — but when a .webm copy exists on disk next to the
		// .mp4, a <source type="video/webm"> is injected FIRST so supporting
		// browsers stream the (usually much smaller) WebM while everything
		// else keeps the MP4. Only <source>-based videos are touched: rewriting
		// a src="" attribute into child sources changes the DOM shape that
		// theme JS may rely on. CDN URL rewriting runs after this pass, so
		// emitted sibling URLs are CDN-rewritten like every other asset.
		if ( BCBOOST_get_option( 'serve_webm', true ) ) {
			$html = preg_replace_callback(
				'#(<video\b[^>]*>)(.*?)(</video>)#is',
				function ( array $vm ): string {
					$inner = $vm[2];
					// Already offers WebM — leave the author's ordering alone.
					if ( preg_match( '/\.webm\b/i', $inner ) ) {
						return $vm[0];
					}
					if ( ! preg_match( '/<source\b[^>]*\bsrc\s*=\s*(["\'])([^"\']+\.mp4)(?:\?[^"\']*)?\1[^>]*>/i', $inner, $sm, PREG_OFFSET_CAPTURE ) ) {
						return $vm[0];
					}
					$webm = $this->webm_sibling_url( $sm[2][0] );
					if ( null === $webm ) {
						return $vm[0];
					}
					$inject = '<source src="' . esc_url( $webm ) . '" type="video/webm">';
					$inner  = substr_replace( $inner, $inject, (int) $sm[0][1], 0 );
					return $vm[1] . $inner . $vm[3];
				},
				$html
			) ?? $html;
		}

		// ── Process <video> tags ───────────────────────────────────────────
		if ( BCBOOST_get_option( 'lazy_load_videos', true ) ) {
			$html = preg_replace_callback(
				'/<video(\s[^>]*)>/i',
				static function ( array $m ): string {
					$attrs = $m[1];

					if ( strpos( $attrs, 'data-no-lazy' ) !== false ) {
						return $m[0];
					}

					// An autoplaying video (hero/background sections) must start
					// buffering immediately — preload="none" delays or stalls the
					// very element the page opens with (its poster is often the
					// LCP). Builder players (Elementor/Oxygen/RevSlider) also
					// re-read the tag and choke on a forced preload swap. Only
					// passive click-to-play videos are demoted.
					if ( preg_match( '/\bautoplay\b/i', $attrs ) ) {
						return $m[0];
					}

					// Remove any existing preload attribute, then set preload="none".
					$attrs = preg_replace( '/\spreload\s*=\s*["\'][^"\']*["\']/', '', $attrs ) ?? $attrs;
					return '<video' . $attrs . ' preload="none">';
				},
				$html
			) ?? $html;
		}

		// ── Inject <link rel="preload"> for the LCP image into <head> ──────
		// When the image kept a responsive srcset, preload with imagesrcset/
		// imagesizes so the browser fetches the exact variant it will display
		// (avoids a wasted preload). Otherwise preload the single resolved URL.
		if ( $lcp_url !== '' &&
		     strpos( $lcp_url, 'data:' ) === false &&
		     strpos( $lcp_url, 'gravatar.com' ) === false ) {

			$use_srcset = ( '' !== $lcp_srcset && '' !== $lcp_sizes && 'auto' !== strtolower( trim( $lcp_sizes ) ) );

			// Don't duplicate a preload the theme/block already emitted.
			$already_preloaded = $use_srcset
				? (bool) preg_match( '/<link[^>]+rel=["\']preload["\'][^>]+imagesrcset=/i', $html )
				: (bool) preg_match(
					'/<link[^>]+rel=["\']preload["\'][^>]+' . preg_quote( $lcp_url, '/' ) . '/i',
					$html
				);

			if ( ! $already_preloaded ) {
				$type      = $this->get_image_mime( $lcp_url );
				$type_attr = $type !== '' ? ' type="' . $type . '"' : '';

				// The shared id (also used by LCP_Optimizer) marks this as THE Boost
				// LCP preload: exactly one per page. When LCP_Optimizer later finds a
				// background/slider hero EARLIER in the document, it replaces this
				// link by id — never two competing Boost preloads.
				if ( $use_srcset ) {
					$preload = '<link id="bcboost-lcp-preload" rel="preload" as="image"' . $type_attr .
					           ' imagesrcset="' . esc_attr( $lcp_srcset ) . '"' .
					           ' imagesizes="' . esc_attr( $lcp_sizes ) . '"' .
					           ' fetchpriority="high">' . "\n";
				} else {
					$preload = '<link id="bcboost-lcp-preload" rel="preload" as="image"' . $type_attr .
					           ' href="' . esc_url( $lcp_url ) . '"' .
					           ' fetchpriority="high">' . "\n";
				}

				$html = preg_replace( '/(<\/head>)/i', $preload . '$1', $html, 1 ) ?? $html;
			}
		}

		// ── Restore <noscript> blocks ──────────────────────────────────────
		foreach ( $noscript_map as $key => $original ) {
			$html = str_replace( $key, $original, $html );
		}

		return $html;
	}

	/**
	 * Promote an image to LCP status: guarantee it loads immediately and record
	 * it for the <head> preload.
	 *
	 * Many themes/plugins ship a JavaScript lazy-loader (lazysizes and friends)
	 * that swaps an image's real source for a 1×1 / SVG placeholder and keeps the
	 * original in a data-* attribute, only restoring it once JS runs. Applied to
	 * the hero, this is catastrophic for LCP — fetchpriority="high" is wasted on a
	 * placeholder and the real image only downloads after the lazy script fires.
	 * For the one LCP image we therefore restore the real source, strip the lazy
	 * hooks (data-* + trigger class) so the loader can't re-hide it, and mark it
	 * high-priority. Below-fold images are left untouched.
	 *
	 * @param string $attrs      Inner <img> attributes (no angle brackets).
	 * @param bool   $record     Whether to record this image for the preload.
	 * @param string $lcp_url    Receives the real image URL (when $record).
	 * @param string $lcp_srcset Receives the responsive srcset (non-lazy images).
	 * @param string $lcp_sizes  Receives the sizes attribute (non-lazy images).
	 * @return string Rewritten attributes.
	 */
	private function promote_lcp_image( string $attrs, bool $record, string &$lcp_url, string &$lcp_srcset, string &$lcp_sizes ): string {
		$get = static function ( string $name, string $a ): string {
			return preg_match( '/(?<![-\w])' . $name . '\s*=\s*["\']([^"\']*)["\']/i', $a, $m ) ? $m[1] : '';
		};

		$cur_src = $get( 'src', $attrs );

		// Resolve the real source a lazy-loader may have parked in a data-* attr.
		$lazy_src = '';
		foreach ( [ 'data-orig-src', 'data-lazy-src', 'data-src' ] as $a ) {
			$v = $get( $a, $attrs );
			if ( '' !== $v && strpos( $v, 'data:' ) !== 0 ) {
				$lazy_src = $v;
				break;
			}
		}

		$placeholder_src = ( '' === $cur_src || strpos( $cur_src, 'data:' ) === 0 );
		$is_lazy = ( '' !== $lazy_src )
			|| $placeholder_src
			|| ( '' !== $get( 'data-srcset', $attrs ) )
			|| (bool) preg_match( '/\bclass\s*=\s*["\'][^"\']*\blazyload(?:ed|ing)?\b/i', $attrs );

		if ( $is_lazy ) {
			$real_src = $placeholder_src ? $lazy_src : $cur_src;
			if ( '' === $real_src || strpos( $real_src, 'data:' ) === 0 ) {
				return $attrs; // No usable real source — leave it for the lazy-loader.
			}

			// Point src at the real image.
			if ( preg_match( '/(?<![-\w])src\s*=\s*["\'][^"\']*["\']/i', $attrs ) ) {
				$attrs = preg_replace( '/(?<![-\w])src\s*=\s*["\'][^"\']*["\']/i', 'src="' . $real_src . '"', $attrs, 1 ) ?? $attrs;
			} else {
				$attrs .= ' src="' . $real_src . '"';
			}
			// Drop placeholder/responsive srcset + sizes so the single real src is
			// used and matches the preload (a data: srcset would otherwise win).
			$attrs = preg_replace( '/\s(?:srcset|sizes)\s*=\s*["\'][^"\']*["\']/i', '', $attrs ) ?? $attrs;
			// Remove the lazy-loader's data-* hooks and trigger class.
			$attrs = preg_replace( '/\sdata-(?:orig-src|lazy-src|src|srcset|lazy-srcset|sizes)\s*=\s*["\'][^"\']*["\']/i', '', $attrs ) ?? $attrs;
			$attrs = preg_replace_callback(
				'/\sclass\s*=\s*(["\'])(.*?)\1/i',
				static function ( array $cm ): string {
					$cls = trim( (string) preg_replace( '/\s*\blazyload(?:ed|ing)?\b/i', '', $cm[2] ) );
					return '' === $cls ? '' : ' class=' . $cm[1] . $cls . $cm[1];
				},
				$attrs
			) ?? $attrs;

			if ( $record ) {
				$lcp_url    = $real_src;
				$lcp_srcset = '';
				$lcp_sizes  = '';
			}
		} elseif ( $record && '' !== $cur_src && strpos( $cur_src, 'data:' ) !== 0 ) {
			// Not lazy-loaded — keep its responsive srcset and preload that exact set.
			$lcp_url    = $cur_src;
			$lcp_srcset = $get( 'srcset', $attrs );
			$lcp_sizes  = $get( 'sizes', $attrs );
		}

		// High-priority load hints (shared by both paths).
		if ( ! preg_match( '/\bfetchpriority\s*=/i', $attrs ) ) {
			$attrs .= ' fetchpriority="high"';
		}
		if ( preg_match( '/\bloading\s*=/i', $attrs ) ) {
			$attrs = preg_replace( '/\bloading\s*=\s*["\'][^"\']*["\']/i', 'loading="eager"', $attrs ) ?? $attrs;
		} else {
			$attrs .= ' loading="eager"';
		}
		if ( ! preg_match( '/\bdecoding\s*=/i', $attrs ) ) {
			$attrs .= ' decoding="async"';
		}

		return $attrs;
	}

	/**
	 * Heuristic: does this <img> tag/attribute string look like a site logo?
	 *
	 * Themes routinely mark the header logo fetchpriority="high", which would
	 * otherwise make Boost preload the logo instead of the real hero. A false
	 * positive only means "don't preload this one" — it never stops the image from
	 * loading — so the check is kept conservative (class/id mentioning logo/brand,
	 * or a filename containing "logo").
	 *
	 * Public + static: LCP_Optimizer uses the same heuristic when arbitrating
	 * hero candidates, so "what counts as a logo" has a single definition.
	 *
	 * @param string $attrs <img> attribute string or full tag.
	 * @return bool
	 */
	public static function is_logo( string $attrs ): bool {
		if ( preg_match( '/(?:class|id)\s*=\s*["\'][^"\']*\b(?:custom-logo|site-logo|logo|site-branding|brand)\b[^"\']*["\']/i', $attrs ) ) {
			return true;
		}
		if ( preg_match( '/(?<![-\w])(?:src|data-src|data-lazy-src|data-orig-src)\s*=\s*["\'][^"\']*logo[^"\']*\.(?:png|jpe?g|webp|avif|svg|gif)/i', $attrs ) ) {
			return true;
		}
		return false;
	}

	/**
	 * Heuristic: is this element hidden by its OWN attributes? Catches
	 * aria-hidden="true", a bare `hidden` attribute (or a lone `hidden` class
	 * token à la Tailwind), and inline display:none / visibility:hidden — e.g.
	 * duplicate mobile/desktop variants or a pre-hidden widget. A hidden element
	 * cannot be the first paint's LCP, so it must never be promoted or preloaded.
	 *
	 * This deliberately checks only the element itself, not its ancestors — the
	 * single-pass regex pipeline has no ancestor context, and container-level
	 * hidden state (inactive tab panels etc.) is handled where the container tag
	 * IS in hand (see LCP_Optimizer::find_inline_bg_hero()). Like is_logo(), a
	 * false positive only means "don't preload this one" — the image still loads.
	 *
	 * Public + static: LCP_Optimizer applies the same definition to its
	 * candidates, so "what counts as hidden" has a single source of truth.
	 *
	 * @param string $attrs Attribute string or full tag.
	 * @return bool
	 */
	public static function is_hidden( string $attrs ): bool {
		if ( preg_match( '/\baria-hidden\s*=\s*["\']true["\']/i', $attrs ) ) {
			return true;
		}
		// Bare `hidden` ATTRIBUTE. Quoted values are blanked first so tokens
		// inside class/style/JSON values (overflow:hidden, hidden-desktop,
		// {"hidden":…}) can never false-positive; the lookbehind then excludes
		// aria-hidden / data-hidden attribute names.
		$names = (string) preg_replace( '/(["\']).*?\1/s', '$1$1', $attrs );
		if ( preg_match( '/(?<![-\w])hidden(?![-\w])/i', $names ) ) {
			return true;
		}
		// Inline display:none / visibility:hidden on the element itself. The
		// lookbehinds keep overflow/backface-visibility:hidden from matching.
		if ( preg_match( '/\bstyle\s*=\s*(["\'])[^"\']*(?:(?<![-\w])display\s*:\s*none|(?<![-\w])visibility\s*:\s*hidden)[^"\']*\1/i', $attrs ) ) {
			return true;
		}
		return false;
	}

	/**
	 * Heuristic: is this <img> too insignificant to be the LCP hero? Catches
	 * icons, badges, avatars, tracking-ish assets and SVGs so the FIRST real
	 * content image gets the preload instead. Like is_logo(), a false positive
	 * only means "don't preload this one" — the image still loads eagerly.
	 *
	 *  - SVG sources are decorative/vector UI, never a photographic hero.
	 *  - Both declared dimensions under BCBOOST_lcp_min_dimension (default 100px)
	 *    → icon-sized. A wide-but-short banner (one large dimension) still counts
	 *    as a hero candidate.
	 *  - Filename says icon/sprite/avatar/spinner/placeholder/… .
	 *
	 * @param string $attrs <img> attribute string.
	 * @return bool
	 */
	public static function is_insignificant( string $attrs ): bool {
		if ( preg_match( '/(?<![-\w])(?:src|data-src|data-lazy-src|data-orig-src)\s*=\s*["\'][^"\']*\.svg(?:[?#][^"\']*)?["\']/i', $attrs ) ) {
			return true;
		}

		if ( preg_match( '/(?<![-\w])width\s*=\s*["\']?(\d+)/i', $attrs, $w )
			&& preg_match( '/(?<![-\w])height\s*=\s*["\']?(\d+)/i', $attrs, $h ) ) {
			/**
			 * Filter the minimum declared dimension (px) for an image to qualify
			 * as an LCP hero candidate. Images smaller than this in BOTH axes are
			 * skipped (kept eager, not preloaded).
			 *
			 * @param int $min Default 100.
			 */
			$min = (int) apply_filters( 'BCBOOST_lcp_min_dimension', 100 );
			if ( (int) $w[1] < $min && (int) $h[1] < $min ) {
				return true;
			}
		}

		if ( preg_match( '/(?<![-\w])(?:src|data-src|data-lazy-src|data-orig-src)\s*=\s*["\'][^"\']*(?:icon|sprite|avatar|gravatar|spinner|loader|placeholder|pixel|blank|spacer|badge|emoji|thumb)[^"\']*["\']/i', $attrs ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Add intrinsic width/height to an <img> when both are missing and can be read
	 * from the WordPress "-WIDTHxHEIGHT" filename suffix. Reserves layout space to
	 * reduce CLS. Only acts when NEITHER dimension is present (never overrides).
	 *
	 * @param string $attrs <img> attribute string.
	 * @return string
	 */
	private function maybe_add_dimensions( string $attrs ): string {
		if ( preg_match( '/(?<![-\w])width\s*=/i', $attrs ) || preg_match( '/(?<![-\w])height\s*=/i', $attrs ) ) {
			return $attrs;
		}
		if ( preg_match( '/(?<![-\w])(?:src|data-src|data-lazy-src|data-orig-src)\s*=\s*["\'][^"\']+?-(\d{2,5})x(\d{2,5})\.(?:jpe?g|png|gif|webp|avif)\b/i', $attrs, $m ) ) {
			$w = (int) $m[1];
			$h = (int) $m[2];
			if ( $w > 0 && $h > 0 ) {
				$attrs .= ' width="' . $w . '" height="' . $h . '"';
			}
		}
		return $attrs;
	}

	/**
	 * Resolve the .webm sibling URL for a local .mp4 URL, when the sibling file
	 * actually exists on disk. The URL's PATH is mapped through the
	 * /wp-content/ anchor (host-agnostic: works for origin and pre-rewrite CDN
	 * URLs alike); a '..' anywhere in the path bails. Returns the original URL
	 * string with only its extension swapped, so scheme/host survive untouched.
	 *
	 * @param string $url Entity-plain .mp4 URL from a <source src>.
	 * @return string|null Sibling .webm URL, or null when none exists.
	 */
	private function webm_sibling_url( string $url ): ?string {
		if ( ! defined( 'WP_CONTENT_DIR' ) ) {
			return null;
		}
		$path = (string) wp_parse_url( $url, PHP_URL_PATH );
		if ( '' === $path || strpos( $path, '..' ) !== false || ! preg_match( '/\.mp4$/i', $path ) ) {
			return null;
		}
		$pos = strpos( $path, '/wp-content/' );
		if ( false === $pos ) {
			return null;
		}
		$mp4_file  = WP_CONTENT_DIR . substr( $path, $pos + strlen( '/wp-content' ) );
		$webm_file = (string) preg_replace( '/\.mp4$/i', '.webm', $mp4_file );
		if ( ! is_file( $webm_file ) || (int) @filesize( $webm_file ) < 1024 ) {
			return null; // Missing, or too small to be a real encode (never serve a stub).
		}
		return (string) preg_replace( '/\.mp4((?:\?|#)[^"\']*)?$/i', '.webm$1', $url );
	}

	/**
	 * Return the MIME type for a given image URL extension.
	 *
	 * @param string $url Image URL.
	 * @return string MIME type or empty string.
	 */
	private function get_image_mime( string $url ): string {
		$ext = strtolower( pathinfo( strtok( $url, '?' ), PATHINFO_EXTENSION ) );
		$map = [
			'jpg'  => 'image/jpeg',
			'jpeg' => 'image/jpeg',
			'png'  => 'image/png',
			'webp' => 'image/webp',
			'gif'  => 'image/gif',
			'avif' => 'image/avif',
			'svg'  => 'image/svg+xml',
		];
		return $map[ $ext ] ?? '';
	}
}
