<?php
/**
 * BaseCloud Boost — LCP Optimizer
 *
 * Detects the page's real hero/LCP image at the output-buffer level and makes
 * sure the browser discovers it immediately (<link rel="preload" as="image"
 * fetchpriority="high"> in <head>), independent of how the hero is delivered:
 *
 *  1. Manual override  — post meta `bcboost_lcp_image` (or the
 *     `BCBOOST_lcp_image_url` filter) always wins. The escape hatch for pages
 *     the heuristics can't read.
 *  2. Content <img>    — the Lazy_Loader already promotes + preloads the first
 *     significant <img>; this class only arbitrates: when a background/slider
 *     hero sits EARLIER in the document, that one wins and the <img> preload
 *     is replaced (never two competing Boost preloads).
 *  3. Inline CSS background — `style="background-image:url(…)"` on an early
 *     above-the-fold container (the common Elementor/Divi hero pattern).
 *     CSS custom properties (--awb-background-image…) never count: a variable
 *     declaration is not a painted background on that element.
 *  4. Slider first slide — Slider Revolution / MetaSlider / Smart Slider /
 *     LayerSlider / Divi / Elementor slides, but ONLY when the first slide's
 *     image is statically present in the markup. If the slider builds its DOM
 *     purely in JS, we do nothing (never guess).
 *  5. Inline <style> background — the first section-level Elementor element
 *     whose background-image is declared in the page's OWN <style> blocks
 *     (Elementor internal-embedding post CSS, Boost's Critical CSS). Covers
 *     motion-effects-layer heroes (cxconsulting shape). No external stylesheet
 *     is ever fetched or parsed.
 *  6. Combined-CSS background — when the CSS combiner emitted a head bundle,
 *     the first section-level Elementor element with a background-image
 *     declared in that bundle (this is the pre-1.2.3 `preload_lcp_image`
 *     behaviour, which only worked with Combine CSS ON — now one path of many).
 *
 * Candidates are ranked by DOCUMENT ORDER — the earliest above-the-fold image
 * is the hero. Detection that finds nothing changes nothing (fail open), and
 * logs a WP_DEBUG_LOG hint pointing at the `bcboost_lcp_image` override —
 * silence beats a wrong preload.
 *
 * Runs on the BCBOOST_buffer filter at priority 12: after the CSS combiners
 * (9/10) so the bundle link is final, before the script pass (15). Gated by
 * the existing `preload_lcp_image` option (default on) and by
 * BCBOOST_is_optimizable_request() — builder editors/previews are never touched.
 *
 * @package BaseCloud\Boost\Optimizer
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Optimizer;

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

/**
 * Buffer-level LCP hero detection + preload/prioritisation.
 */
class LCP_Optimizer {

	/** Preload link id shared with the Lazy_Loader — one Boost LCP preload per page. */
	private const PRELOAD_ID = 'bcboost-lcp-preload';

	/**
	 * @var bool Set when the page's designated priority <img> was rejected on
	 *           ancestor context (e.g. a responsive-hidden wrapper): its
	 *           Lazy_Loader preload must then be stripped, not merely ignored.
	 */
	private bool $promoted_img_vetoed = false;

	/**
	 * Boot: hook the buffer filter. All gating happens per-request in process().
	 *
	 * @return void
	 */
	public function boot(): void {
		add_filter( 'BCBOOST_buffer', [ $this, 'process' ], 12 );
	}

	/**
	 * Filter callback — inject/arbitrate the LCP preload. Never throws: any
	 * failure logs under WP_DEBUG_LOG and returns the original HTML unchanged.
	 *
	 * @param string $html Full page HTML.
	 * @return string
	 */
	public function process( string $html ): string {
		if ( ! (bool) BCBOOST_get_option( 'preload_lcp_image', true ) ) {
			return $html;
		}
		// Builder editors / previews are never transformed (defense in depth —
		// the Cache_Engine gate can miss states knowable only at runtime).
		if ( ! BCBOOST_is_optimizable_request() ) {
			return $html;
		}
		if ( strlen( $html ) < 255 || stripos( $html, '</head>' ) === false ) {
			return $html;
		}

		try {
			return $this->optimize( $html );
		} catch ( \Throwable $e ) {
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( '[BaseCloud Boost] LCP_Optimizer failed open: ' . $e->getMessage() );
			}
			return $html;
		}
	}

	/**
	 * Core pass: collect hero candidates, pick the earliest, emit ONE preload.
	 *
	 * @param string $html Full page HTML.
	 * @return string
	 */
	private function optimize( string $html ): string {
		if ( ! preg_match( '#<head\b[^>]*>#i', $html, $hm, PREG_OFFSET_CAPTURE ) ) {
			return $html;
		}
		$head_open_end = (int) $hm[0][1] + strlen( $hm[0][0] );
		$head_close    = stripos( $html, '</head>' );
		if ( false === $head_close || $head_close <= $head_open_end ) {
			return $html;
		}
		$head       = substr( $html, $head_open_end, $head_close - $head_open_end );
		$body_start = $head_close + 7;
		$body       = substr( $html, $body_start );

		// ── Choose the hero ────────────────────────────────────────────────
		$this->promoted_img_vetoed = false;

		$winner = null;
		$manual = $this->manual_override_url();
		if ( '' !== $manual ) {
			$winner = [ 'url' => $manual, 'pos' => -1, 'source' => 'manual' ];
		} else {
			$candidates = [];
			foreach ( [
				$this->find_promoted_img( $body ),
				$this->find_inline_bg_hero( $body ),
				$this->find_video_poster_hero( $body ),
				$this->find_slider_hero( $body ),
				$this->find_style_block_bg_hero( $head, $body ),
				$this->find_elementor_file_bg_hero( $head, $body ),
				$this->find_bundle_bg_hero( $head, $body ),
			] as $c ) {
				if ( null !== $c ) {
					$candidates[] = $c;
				}
			}
			if ( empty( $candidates ) ) {
				// Silence beats a wrong preload. When the designated priority
				// image was context-vetoed (hidden on phones), the Lazy_Loader
				// preload already in <head> targets it — strip it too.
				if ( $this->promoted_img_vetoed ) {
					$html = preg_replace(
						'#<link\b[^>]*id=["\']' . self::PRELOAD_ID . '["\'][^>]*>\s*#i',
						'',
						$html,
						1
					) ?? $html;
				}
				// Leave a trail for the site owner: the per-page
				// `bcboost_lcp_image` custom field forces the right hero.
				if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG
					&& stripos( $html, self::PRELOAD_ID ) === false ) {
					error_log(
						'[BaseCloud Boost] LCP_Optimizer: no confident hero candidate on '
						. ( $_SERVER['REQUEST_URI'] ?? '?' )
						. ( $this->promoted_img_vetoed
							? ' (the priority image is hidden on phones)'
							: ' (hero background likely lives in an external stylesheet)' )
						. '. Set the bcboost_lcp_image custom field (image URL) on this page to force the preload.'
					);
				}
				return $html;
			}
			usort( $candidates, static function ( array $a, array $b ): int {
				return $a['pos'] <=> $b['pos'];
			} );
			$winner = $candidates[0];
		}

		// The first significant <img> won: the Lazy_Loader already un-lazied it,
		// set fetchpriority="high"/decoding="async" and emitted its preload
		// (srcset-aware). Nothing to add — single source of truth preserved.
		if ( 'promoted-img' === $winner['source'] ) {
			return $html;
		}

		$url = trim( html_entity_decode( (string) $winner['url'], ENT_QUOTES ) );
		if ( '' === $url ) {
			return $html;
		}

		// Body edit FIRST (head edits below would shift body offsets): serve the
		// inline-background hero as WebP/AVIF when a modern copy exists, so the
		// preload and the render fetch the exact same (smaller) URL.
		if ( 'inline-bg' === $winner['source'] && isset( $winner['tag'] ) ) {
			$modernized = $this->modernize_inline_bg( (string) $winner['tag'], $url );
			if ( null !== $modernized ) {
				$abs = $body_start + (int) $winner['pos'];
				if ( substr( $html, $abs, strlen( (string) $winner['tag'] ) ) === (string) $winner['tag'] ) {
					$html = substr_replace( $html, $modernized['tag'], $abs, strlen( (string) $winner['tag'] ) );
					$url  = $modernized['url'];
				}
			}
		}

		// Already preloaded (theme, block, or the Lazy_Loader chose the same
		// image) — never emit a duplicate.
		if ( preg_match( '#<link\b[^>]*rel=["\']preload["\'][^>]*' . preg_quote( $url, '#' ) . '#i', $html ) ) {
			return $html;
		}

		// One Boost preload per page: a background/slider/manual hero earlier in
		// the document supersedes the Lazy_Loader's <img> preload — replace it.
		$html = preg_replace(
			'#<link\b[^>]*id=["\']' . self::PRELOAD_ID . '["\'][^>]*>\s*#i',
			'',
			$html,
			1
		) ?? $html;

		$link = '<link id="' . self::PRELOAD_ID . '" rel="preload" as="image"'
			. $this->type_attr( $url )
			. ' href="' . esc_url( $url ) . '" fetchpriority="high" />';

		$html = substr_replace( $html, $link, $head_open_end, 0 );

		// Bandwidth arbitration — ONE hero per page. The winner here is a
		// background/slider/style-block/manual/video-poster hero (the
		// promoted-<img> case returned earlier), so any leftover
		// fetchpriority="high" on body images (WP core auto-priority, themes)
		// now competes with the preloaded hero for the same critical bandwidth
		// (cxconsulting: core-prioritised content image vs the real bg hero).
		// Demote by REMOVING the attribute — not forcing "low" — so if our
		// detection ever picked the wrong hero, those images merely return to
		// the browser's own priority heuristics. Fail open.
		$hero_url = $url;
		$html     = preg_replace_callback(
			'/<img\b[^>]*(?<![-\w])fetchpriority\s*=\s*["\']high["\'][^>]*>/i',
			static function ( array $m ) use ( $hero_url ): string {
				// The hero itself also rendered as an <img> — keep its priority.
				if ( '' !== $hero_url && false !== stripos( $m[0], $hero_url ) ) {
					return $m[0];
				}
				// Lookbehind, not \b: `data-fetchpriority` must never match (a \b
				// exists between `-` and `f`, and stripping there would destroy
				// third-party lazy-loader data attributes).
				return (string) preg_replace( '/\s*(?<![-\w])fetchpriority\s*=\s*["\']high["\']/i', '', $m[0] );
			},
			$html
		) ?? $html;

		return $html;
	}

	// ─── Candidates ────────────────────────────────────────────────────────────

	/**
	 * Per-page manual override: post meta `bcboost_lcp_image` (an image URL set
	 * as a custom field), then the `BCBOOST_lcp_image_url` filter. When present
	 * it wins over all detection.
	 *
	 * @return string Sanitised URL or '' when unset/invalid.
	 */
	private function manual_override_url(): string {
		$url = '';
		if ( function_exists( 'get_queried_object_id' ) ) {
			$qid = (int) get_queried_object_id();
			if ( $qid > 0 ) {
				$url = trim( (string) get_post_meta( $qid, 'bcboost_lcp_image', true ) );
			}
		}

		/**
		 * Filter the manual LCP image URL for the current page. Return a URL to
		 * force it as the preloaded hero, or '' to fall back to detection.
		 *
		 * @param string $url Post-meta override URL ('' when none is set).
		 */
		$url = trim( (string) apply_filters( 'BCBOOST_lcp_image_url', $url ) );

		if ( '' === $url || strlen( $url ) > 2000 ) {
			return '';
		}
		// Absolute or root-relative, no markup-breaking characters.
		if ( ! preg_match( '#^(?:https?:)?//|^/[^/]#i', $url ) || preg_match( '/[\s<>"\']/', $url ) ) {
			return '';
		}
		return $url;
	}

	/**
	 * First non-logo <img fetchpriority="high"> in the body — the image the
	 * Lazy_Loader (or the theme) already designated as the LCP. Used only for
	 * document-order arbitration; its preload is owned by the Lazy_Loader.
	 *
	 * Ancestor-context veto: when the first eligible image sits inside a
	 * responsive-hidden wrapper (Elementor's elementor-hidden-mobile /
	 * elementor-hidden-phone — the quatro.co.za shape, where WP core gave
	 * fetchpriority="high" to an infographic that never paints on phones), the
	 * WHOLE tier is discarded and $promoted_img_vetoed is raised so optimize()
	 * strips the now-untrustworthy Lazy_Loader preload. We discard rather than
	 * scan on: the Lazy_Loader recorded THIS image, so any later match would
	 * disagree with the preload already in <head>. Mobile is the fleet's
	 * primary CWV target — a candidate hidden on phones is never "confident".
	 *
	 * @param string $body HTML after </head>.
	 * @return array{url:string,pos:int,source:string}|null
	 */
	private function find_promoted_img( string $body ): ?array {
		if ( ! preg_match_all( '/<img\b[^>]*\bfetchpriority\s*=\s*["\']high["\'][^>]*>/i', $body, $m, PREG_OFFSET_CAPTURE ) ) {
			return null;
		}
		foreach ( $m[0] as $img ) {
			if ( Lazy_Loader::is_logo( $img[0] ) || Lazy_Loader::is_hidden( $img[0] ) || Lazy_Loader::is_insignificant( $img[0] ) ) {
				continue;
			}
			$pos = (int) $img[1];
			$ctx = substr( $body, max( 0, $pos - 600 ), min( 600, $pos ) );
			if ( preg_match( '/elementor-hidden-(?:mobile|phone)\b/i', $ctx ) ) {
				$this->promoted_img_vetoed = true;
				return null;
			}
			return [ 'url' => '', 'pos' => $pos, 'source' => 'promoted-img' ];
		}
		return null;
	}

	/**
	 * First early above-the-fold container carrying an inline
	 * style="background-image:url(…)" — the common builder hero pattern
	 * (Elementor sections, Divi slides, custom themes). Scans only the first
	 * BCBOOST_lcp_bg_scan_bytes of the body (default 150 KB) so a footer
	 * background can never be mistaken for the hero. No stylesheet parsing.
	 *
	 * @param string $body HTML after </head>.
	 * @return array{url:string,pos:int,source:string,tag:string}|null
	 */
	private function find_inline_bg_hero( string $body ): ?array {
		$window = max( 1000, (int) apply_filters( 'BCBOOST_lcp_bg_scan_bytes', 150000 ) );
		$region = substr( $body, 0, $window );

		if ( ! preg_match_all(
			'#<(?:div|section|header|figure|main|a|li|span)\b[^>]*\sstyle\s*=\s*("|\')(.*?)\1[^>]*>#is',
			$region,
			$m,
			PREG_OFFSET_CAPTURE | PREG_SET_ORDER
		) ) {
			return null;
		}

		foreach ( $m as $t ) {
			// A container hidden by its own attributes — or an INACTIVE tab panel
			// (role="tabpanel" without an active class; active panels carry
			// e-active / active in every major builder) — is not painted, so its
			// background must never win the hero preload.
			if ( Lazy_Loader::is_hidden( $t[0][0] ) ) {
				continue;
			}
			if ( preg_match( '/\brole\s*=\s*["\']tabpanel["\']/i', $t[0][0] )
				&& ! preg_match( '/\bclass\s*=\s*["\'][^"\']*\bactive\b/i', $t[0][0] ) ) {
				continue;
			}
			$style = html_entity_decode( $t[2][0], ENT_QUOTES );
			// The lookbehind rejects CSS CUSTOM PROPERTIES (--awb-background-image,
			// --e-bg…): a variable declaration is not a painted background on this
			// element — Avada/builders may override it per breakpoint, so preloading
			// its URL guessed wrong on 2summers.net (decorative header band beat the
			// real featured-image LCP). Silence beats a wrong preload.
			if ( ! preg_match( '/(?<![-\w])background(?:-image)?\s*:[^;{}]*url\(\s*([\'"]?)([^\'")]+)\1\s*\)/i', $style, $um ) ) {
				continue;
			}
			$url = trim( $um[2] );
			if ( ! $this->is_preloadable_image_url( $url ) ) {
				continue;
			}
			return [ 'url' => $url, 'pos' => (int) $t[0][1], 'source' => 'inline-bg', 'tag' => $t[0][0] ];
		}
		return null;
	}

	/**
	 * First above-the-fold <video> carrying a poster image — the self-hosted
	 * hero-background-video pattern. The video file itself can never paint
	 * first (frames arrive long after first render); its POSTER is what the
	 * browser shows immediately, so the poster IS the LCP element and gets the
	 * preload. Videos without a poster yield nothing (there is no image to
	 * prioritise — encourage a poster instead of guessing a frame).
	 *
	 * @param string $body HTML after </head>.
	 * @return array{url:string,pos:int,source:string}|null
	 */
	private function find_video_poster_hero( string $body ): ?array {
		$window = max( 1000, (int) apply_filters( 'BCBOOST_lcp_bg_scan_bytes', 150000 ) );
		$region = substr( $body, 0, $window );

		if ( ! preg_match_all( '/<video\b[^>]*>/i', $region, $m, PREG_OFFSET_CAPTURE ) ) {
			return null;
		}
		foreach ( $m[0] as $vid ) {
			$tag = $vid[0];
			if ( Lazy_Loader::is_hidden( $tag ) ) {
				continue;
			}
			if ( ! preg_match( '/(?<![-\w])poster\s*=\s*(["\'])(.*?)\1/i', $tag, $pm ) ) {
				continue;
			}
			$url = trim( html_entity_decode( $pm[2], ENT_QUOTES ) );
			if ( ! $this->is_preloadable_image_url( $url ) ) {
				continue;
			}
			return [ 'url' => $url, 'pos' => (int) $vid[1], 'source' => 'video-poster' ];
		}
		return null;
	}

	/**
	 * First slide image of the first above-the-fold slider, when it is
	 * statically present in the markup. Sliders that build their DOM entirely
	 * in JavaScript yield nothing — we never guess a URL.
	 *
	 * @param string $body HTML after </head>.
	 * @return array{url:string,pos:int,source:string}|null
	 */
	private function find_slider_hero( string $body ): ?array {
		/**
		 * Filter the slider wrapper patterns scanned for a static first-slide
		 * image. Keys are labels, values are full regex patterns.
		 *
		 * @param string[] $patterns Regexes matching slider wrapper open tags.
		 */
		$patterns = (array) apply_filters( 'BCBOOST_lcp_slider_patterns', [
			'revslider6'  => '#<(?:rs-module-wrap|rs-module)\b#i',
			'revslider5'  => '#<div\b[^>]*\bclass\s*=\s*["\'][^"\']*\brev_slider\b#i',
			'metaslider'  => '#<div\b[^>]*\bclass\s*=\s*["\'][^"\']*\bmetaslider\b#i',
			'smartslider' => '#<div\b[^>]*\bclass\s*=\s*["\'][^"\']*\bn2-ss-slider\b#i',
			'layerslider' => '#<div\b[^>]*\b(?:class|id)\s*=\s*["\'][^"\']*\b(?:layerslider|ls-wp-container)\b#i',
			'masterslider' => '#<div\b[^>]*\bclass\s*=\s*["\'][^"\']*\bmasterslider\b#i',
			'divi'        => '#<div\b[^>]*\bclass\s*=\s*["\'][^"\']*\bet_pb_slider\b#i',
			'elementor'   => '#<div\b[^>]*\bclass\s*=\s*["\'][^"\']*\belementor-slides\b#i',
			'soliloquy'   => '#<div\b[^>]*\bclass\s*=\s*["\'][^"\']*\bsoliloquy-container\b#i',
		] );

		$best = null;
		foreach ( $patterns as $pattern ) {
			if ( preg_match( (string) $pattern, $body, $m, PREG_OFFSET_CAPTURE ) ) {
				$pos = (int) $m[0][1];
				if ( null === $best || $pos < $best ) {
					$best = $pos;
				}
			}
		}
		if ( null === $best ) {
			return null;
		}

		// A hero slider sits above the fold — ignore sliders deep in the page
		// (client-logo carousels, footer galleries).
		if ( $best > max( 1000, (int) apply_filters( 'BCBOOST_lcp_bg_scan_bytes', 150000 ) ) ) {
			return null;
		}

		$slice = substr( $body, $best, max( 5000, (int) apply_filters( 'BCBOOST_lcp_slider_slice_bytes', 40000 ) ) );
		$url   = $this->first_slide_image_url( $slice );
		if ( '' === $url ) {
			return null;
		}
		return [ 'url' => $url, 'pos' => $best, 'source' => 'slider' ];
	}

	/**
	 * Extract the first statically-present slide image URL from a slider's
	 * markup slice. Checks lazy data-* attributes first (Slider Revolution
	 * parks the real image in data-lazyload behind a dummy src), then plain
	 * <img>/<rs-sbg> sources, then Smart Slider's data-desktop background,
	 * then an inline background-image on the slide element.
	 *
	 * @param string $slice Markup from the slider wrapper onward (bounded).
	 * @return string Image URL, or '' when nothing usable is static.
	 */
	private function first_slide_image_url( string $slice ): string {
		if ( preg_match_all( '/<(?:img|rs-sbg)\b[^>]*>/i', $slice, $m ) ) {
			foreach ( $m[0] as $tag ) {
				foreach ( [ 'data-lazyload', 'data-lazy-src', 'data-src', 'src' ] as $attr ) {
					if ( ! preg_match( '/(?<![-\w])' . $attr . '\s*=\s*(["\'])(.*?)\1/i', $tag, $am ) ) {
						continue;
					}
					$url = trim( html_entity_decode( $am[2], ENT_QUOTES ) );
					if ( $this->is_preloadable_image_url( $url ) ) {
						return $url;
					}
				}
			}
		}

		// Smart Slider 3 background layer: <div ... data-desktop="URL">.
		if ( preg_match( '/(?<![-\w])data-desktop\s*=\s*(["\'])(.*?)\1/i', $slice, $dm ) ) {
			$url = trim( html_entity_decode( $dm[2], ENT_QUOTES ) );
			if ( $this->is_preloadable_image_url( $url ) ) {
				return $url;
			}
		}

		// Divi / Elementor slide with an inline background-image. Same custom-
		// property guard as find_inline_bg_hero(): --var declarations don't count.
		if ( preg_match( '/\sstyle\s*=\s*(["\'])([^"\']*background[^"\']*)\1/i', $slice, $sm ) ) {
			$style = html_entity_decode( $sm[2], ENT_QUOTES );
			if ( preg_match( '/(?<![-\w])background(?:-image)?\s*:[^;{}]*url\(\s*([\'"]?)([^\'")]+)\1\s*\)/i', $style, $um ) ) {
				$url = trim( $um[2] );
				if ( $this->is_preloadable_image_url( $url ) ) {
					return $url;
				}
			}
		}

		return '';
	}

	/**
	 * Elementor external-file hero: when Elementor is in its default "external
	 * file" CSS mode, each page's section backgrounds live in
	 * wp-content/uploads/elementor/css/post-<id>.css — a file the style-block
	 * tier can never see. Read ONLY those linked post-*.css files (same
	 * disk-local uploads path, never a network fetch; ≤3 files, 300 KB cap) and
	 * run the same section-background lookup as the other CSS tiers. The
	 * per-breakpoint ambiguity veto in elementor_bg_map() still applies: a
	 * section with different desktop/mobile backgrounds yields nothing —
	 * silence beats preloading the wrong variant.
	 *
	 * @param string $head Inner <head> HTML.
	 * @param string $body HTML after </head>.
	 * @return array{url:string,pos:int,source:string}|null
	 */
	private function find_elementor_file_bg_hero( string $head, string $body ): ?array {
		if ( ! preg_match_all(
			'#<link\b[^>]*rel=["\']stylesheet["\'][^>]*href=["\']([^"\']*/wp-content/uploads/elementor/css/post-\d+[^"\'/]*?\.css)(?:\?[^"\']*)?["\']#i',
			$head,
			$lm
		) ) {
			return null;
		}
		if ( ! defined( 'WP_CONTENT_DIR' ) ) {
			return null;
		}

		$css   = '';
		$files = array_slice( array_unique( $lm[1] ), 0, 3 );
		foreach ( $files as $href ) {
			// Map the URL path back to disk via the /wp-content/ anchor. The href
			// may carry any host (origin or CDN-rewritten) — only the path matters.
			// The regex pins the filename to a flat post-N…css segment ([^"'/]
			// admits no slash, so no ../ traversal can hide inside it); the
			// explicit '..' bail below is defence in depth for the prefix.
			$path = (string) wp_parse_url( $href, PHP_URL_PATH );
			if ( strpos( $path, '..' ) !== false ) {
				continue;
			}
			$pos  = strpos( $path, '/wp-content/' );
			if ( false === $pos ) {
				continue;
			}
			$file = WP_CONTENT_DIR . substr( $path, $pos + strlen( '/wp-content' ) );
			if ( ! is_file( $file ) || filesize( $file ) > 300000 ) {
				continue;
			}
			$part = (string) @file_get_contents( $file );
			if ( '' !== $part ) {
				$css .= "\n" . $part;
			}
		}
		if ( '' === $css ) {
			return null;
		}

		return $this->first_mapped_section_bg( $body, $this->elementor_bg_map( $css ), 'elementor-file-css' );
	}

	/**
	 * Combined-CSS hero: when the CSS combiner emitted a head bundle, read it
	 * back and find the first top-level Elementor section (in document order)
	 * whose background-image is declared in that bundle. This is the original
	 * `preload_lcp_image` mechanism (verified live on monitornet), now one
	 * candidate among several instead of the only path.
	 *
	 * @param string $head Inner <head> HTML.
	 * @param string $body HTML after </head>.
	 * @return array{url:string,pos:int,source:string}|null
	 */
	private function find_bundle_bg_hero( string $head, string $body ): ?array {
		if ( ! preg_match( '#href=["\'][^"\']*?(bcboost-bundle-[a-f0-9]{8,16}\.min\.css)(?:\?[^"\']*)?["\']#i', $head, $bm ) ) {
			return null;
		}
		if ( ! defined( 'BCBOOST_MINIFY_DIR' ) ) {
			return null;
		}
		$file = BCBOOST_MINIFY_DIR . 'css/' . $bm[1];
		if ( ! is_file( $file ) ) {
			return null;
		}
		$css = (string) @file_get_contents( $file );
		if ( '' === $css ) {
			return null;
		}

		return $this->first_mapped_section_bg( $body, $this->elementor_bg_map( $css ), 'bundle-css' );
	}

	/**
	 * Inline-CSS hero: the same section-background lookup as the bundle tier,
	 * but sourced from the page's OWN <style> blocks (Elementor "internal
	 * embedding" post CSS, Boost's per-template Critical CSS, theme inline CSS).
	 * This is the cxconsulting.co.za shape: the hero container's
	 * background-image (incl. the motion-effects layer variant) is declared in
	 * an inline style block, never as a style="" attribute, so the inline-bg
	 * tier can't see it. No network/stylesheet fetch happens here — only CSS
	 * already embedded in the HTML in hand is read.
	 *
	 * @param string $head Inner <head> HTML.
	 * @param string $body HTML after </head>.
	 * @return array{url:string,pos:int,source:string}|null
	 */
	private function find_style_block_bg_hero( string $head, string $body ): ?array {
		$css = '';
		foreach ( [ $head, $body ] as $region ) {
			if ( preg_match_all( '#<style\b[^>]*>(.*?)</style>#is', $region, $sm ) ) {
				$css .= implode( "\n", $sm[1] );
			}
		}
		if ( '' === $css ) {
			return null;
		}

		return $this->first_mapped_section_bg( $body, $this->elementor_bg_map( $css ), 'style-css' );
	}

	/**
	 * Map Elementor element ids to the background-image URL their CSS declares.
	 *
	 * Confidence guards (a wrong preload is worse than none):
	 *  - `:hover` rules are skipped — a hover background never paints first.
	 *  - An id declared with TWO different URLs (per-breakpoint backgrounds) is
	 *    dropped entirely: we can't know which variant this viewport paints.
	 *
	 * @param string $css Raw CSS text (bundle file or inline style blocks).
	 * @return array<string,string> id → URL.
	 */
	private function elementor_bg_map( string $css ): array {
		$map       = [];
		$ambiguous = [];
		if ( preg_match_all( '/([^{}]+)\{([^{}]*background-image\s*:\s*url\([^{}]*)\}/i', $css, $rules, PREG_SET_ORDER ) ) {
			foreach ( $rules as $r ) {
				if ( stripos( $r[1], ':hover' ) !== false ) {
					continue;
				}
				if ( ! preg_match( '/(?<![-\w])background-image\s*:\s*url\(\s*([\'"]?)([^\'")]+)\1\s*\)/i', $r[2], $um ) ) {
					continue;
				}
				$url = trim( $um[2] );
				if ( ! $this->is_preloadable_image_url( $url ) ) {
					continue;
				}
				if ( preg_match_all( '/elementor-element-([a-z0-9]+)/i', $r[1], $ids ) ) {
					foreach ( $ids[1] as $id ) {
						$id = strtolower( $id );
						if ( isset( $map[ $id ] ) && $map[ $id ] !== $url ) {
							$ambiguous[ $id ] = true;
						} elseif ( ! isset( $map[ $id ] ) ) {
							$map[ $id ] = $url;
						}
					}
				}
			}
		}
		foreach ( array_keys( $ambiguous ) as $id ) {
			unset( $map[ $id ] );
		}
		return $map;
	}

	/**
	 * First SECTION-LEVEL element (document order, above-the-fold window) whose
	 * Elementor id has a mapped background. Only top-level sections and e-con
	 * parent containers qualify — widget-level rules (buttons, icon boxes) are
	 * never a hero. Hidden containers and inactive tab panels are skipped with
	 * the same definitions used everywhere else.
	 *
	 * @param string                $body   HTML after </head>.
	 * @param array<string,string>  $map    id → background URL.
	 * @param string                $source Candidate source label.
	 * @return array{url:string,pos:int,source:string}|null
	 */
	private function first_mapped_section_bg( string $body, array $map, string $source ): ?array {
		if ( empty( $map ) ) {
			return null;
		}
		$window = max( 1000, (int) apply_filters( 'BCBOOST_lcp_bg_scan_bytes', 150000 ) );
		$region = substr( $body, 0, $window );

		if ( ! preg_match_all(
			'/<(?:div|section|main)\b[^>]*\bclass\s*=\s*["\'][^"\']*(?<![-\w])(?:elementor-top-section|e-parent)(?![-\w])[^"\']*["\'][^>]*>/i',
			$region,
			$secs,
			PREG_OFFSET_CAPTURE
		) ) {
			return null;
		}

		foreach ( $secs[0] as $sec ) {
			$tag = $sec[0];
			if ( Lazy_Loader::is_hidden( $tag ) ) {
				continue;
			}
			if ( preg_match( '/\brole\s*=\s*["\']tabpanel["\']/i', $tag )
				&& ! preg_match( '/\bclass\s*=\s*["\'][^"\']*\bactive\b/i', $tag ) ) {
				continue;
			}
			if ( ! preg_match_all( '/elementor-element-([a-z0-9]+)/i', $tag, $ids ) ) {
				continue;
			}
			foreach ( $ids[1] as $id ) {
				$id = strtolower( $id );
				if ( isset( $map[ $id ] ) ) {
					return [ 'url' => $map[ $id ], 'pos' => (int) $sec[1], 'source' => $source ];
				}
			}
		}
		return null;
	}

	// ─── Helpers ───────────────────────────────────────────────────────────────

	/**
	 * Rewrite the winning inline-background hero to its WebP/AVIF copy (when one
	 * exists on disk) so the render and the preload fetch the same modern URL —
	 * mirrors what the CSS combiner already does for stylesheet backgrounds.
	 * Only this ONE element is touched, and only when Image_Optimizer serving is
	 * active (which already stands down under Bunny Optimizer).
	 *
	 * @param string $tag Original element open tag.
	 * @param string $url Decoded background image URL.
	 * @return array{tag:string,url:string}|null Rewritten tag+URL, or null to keep the original.
	 */
	private function modernize_inline_bg( string $tag, string $url ): ?array {
		/**
		 * Filter whether the inline-background hero may be rewritten to WebP/AVIF.
		 *
		 * @param bool $enabled Default true.
		 */
		if ( ! apply_filters( 'BCBOOST_lcp_modernize_inline_bg', true ) ) {
			return null;
		}
		if ( ! Image_Optimizer::is_enabled() ) {
			return null;
		}
		$modern = ( new Image_Optimizer() )->modernize_url( $url );
		if ( null === $modern || $modern === $url ) {
			return null;
		}
		// Swap only the URL token inside this one tag. If the attribute stores an
		// entity-encoded form we can't match exactly, fail open: the original URL
		// keeps rendering AND stays the preloaded URL (always consistent).
		$new_tag = str_replace( $url, $modern, $tag );
		if ( $new_tag === $tag ) {
			return null;
		}
		return [ 'tag' => $new_tag, 'url' => $modern ];
	}

	/**
	 * Is this URL a plausible hero image worth preloading? Rejects data URIs,
	 * gradients, SVG/GIF, relative paths, and filenames that scream "not a
	 * hero" (logos, icons, sprites, placeholders, textures…). A false negative
	 * only means "no preload" — it never breaks the page.
	 *
	 * @param string $url Candidate URL (already entity-decoded).
	 * @return bool
	 */
	private function is_preloadable_image_url( string $url ): bool {
		$url = trim( $url );
		if ( '' === $url || strlen( $url ) > 2000 ) {
			return false;
		}
		if ( stripos( $url, 'data:' ) === 0 || stripos( $url, 'gradient' ) !== false ) {
			return false;
		}
		// Absolute (http/https/protocol-relative) or root-relative only.
		if ( ! preg_match( '#^(?:https?:)?//|^/[^/]#i', $url ) ) {
			return false;
		}
		if ( ! preg_match( '/\.(?:jpe?g|png|webp|avif)(?:\?|#|$)/i', $url ) ) {
			return false;
		}
		// Obvious non-hero assets by filename. `thumb` (accreditation/gallery
		// thumbnails — the monitornet mis-preload) and `footer` can never be the
		// above-the-fold hero; a false positive only means "no preload".
		if ( preg_match( '#(?:^|/)[^/]*(?:logo|icon|sprite|avatar|gravatar|spinner|loader|loading|placeholder|pixel|blank|spacer|dummy|transparent|pattern|texture|overlay|badge|emoji|thumb|footer)[^/]*\.(?:jpe?g|png|webp|avif)#i', $url ) ) {
			return false;
		}

		/**
		 * Final veto over a detected LCP candidate URL.
		 *
		 * @param bool   $allowed Default true.
		 * @param string $url     Candidate image URL.
		 */
		return (bool) apply_filters( 'BCBOOST_lcp_image_url_allowed', true, $url );
	}

	/**
	 * MIME type attribute for the preload link (modern formats only — browsers
	 * that lack support then skip the preload instead of wasting it).
	 *
	 * @param string $url Image URL.
	 * @return string
	 */
	private function type_attr( string $url ): string {
		if ( preg_match( '/\.avif(?:\?|#|$)/i', $url ) ) {
			return ' type="image/avif"';
		}
		if ( preg_match( '/\.webp(?:\?|#|$)/i', $url ) ) {
			return ' type="image/webp"';
		}
		return '';
	}
}
