<?php
/**
 * BaseCloud Boost — Asset Optimizer
 *
 * Script loading strategy + asset URL hygiene.
 *
 * ── Order-safe JS engine ────────────────────────────────────────────────────
 * Defer JS and Delay JS used to add a raw `defer` attribute (or change `type`) on
 * individual *enqueued* scripts via `script_loader_tag`. That left the inline
 * initialisers a theme hard-codes in the page body (`revapi.revolution({...})`,
 * `vc_js()`, …) running during parse — BEFORE the deferred libraries they call had
 * executed — which threw `… is not a function` and broke sliders, popups and menus
 * (the classic "defer broke my site" bug). It also never touched inline scripts at all.
 *
 * The engine now treats inline + external scripts as one ordered unit. At the buffer
 * level it neutralises every eligible `<script>` (turns its type into the inert
 * `bcb-script` and parks any `src` in `data-bcb-src`) and a single sequential loader
 * re-runs them in document order, awaiting each external file's load before running the
 * next. So an inline initialiser only runs after its library has loaded AND executed,
 * while a script's `*-js-before` / `*-js-extra` config still runs before its library
 * (source order is preserved). Trigger is the first user interaction (Delay JS) or
 * DOMContentLoaded (Defer JS only).
 *
 * @package BaseCloud\Boost\Optimizer
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Optimizer;

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

/**
 * Optimises script loading strategies and asset URL hygiene.
 */
class Asset_Optimizer {

	/**
	 * Handles that must keep executing normally (never routed through the loader).
	 *
	 * jQuery is intentionally NOT here: the loader runs the queue in document order, so
	 * jQuery executes first and stays available to everything after it — routing it
	 * through the loader is the biggest Total-Blocking-Time win. `wp-polyfill` stays
	 * early because other scripts may assume it. WooCommerce_Compat appends the
	 * cart-fragments / add-to-cart handles via the never-defer / never-delay filters so
	 * the mini-cart keeps updating on cached pages.
	 *
	 * @var string[]
	 */
	private const SKIP_HANDLES = [
		'wp-polyfill',
	];

	/**
	 * Boot asset optimizer.
	 *
	 * @return void
	 */
	public function boot(): void {
		$defer = (bool) BCBOOST_get_option( 'defer_js', true );
		$delay = (bool) BCBOOST_get_option( 'delay_js', false );

		if ( $defer || $delay ) {
			// Mark handle-based opt-outs (WooCommerce cart fragments, etc.) so the
			// buffer pass can honour them — the buffer only sees markup, not handles.
			add_filter( 'script_loader_tag', [ $this, 'mark_protected_scripts' ], 8, 3 );
			// Reorder/neutralise scripts on the final document. Priority 15 runs after
			// JS inline-minify (10) and CSS combine (9) on the same BCBOOST_buffer filter.
			add_filter( 'BCBOOST_buffer', [ $this, 'optimize_scripts_in_buffer' ], 15 );
		}

		// LEGACY (off by default): "remove query strings". Actually *removing* ?ver=
		// destroys the cache-busting token — an edited asset keeps the same URL and
		// browsers/proxies keep serving the old bytes (the "I cleared the cache but
		// still see the old site" bug). When a user enables this anyway, we now
		// REPLACE ?ver= with a filemtime-derived token for local assets (the WP
		// Rocket / WP Fastest Cache pattern) and leave everything else untouched.
		if ( BCBOOST_get_option( 'remove_query_strings', false ) ) {
			add_filter( 'style_loader_src',  [ $this, 'remove_query_string' ] );
			add_filter( 'script_loader_src', [ $this, 'remove_query_string' ] );
		}

		// Lazy CSS — async-load non-critical stylesheets so they don't block the
		// first paint. When Combine CSS is also on we skip the per-stylesheet swap:
		// the buffer-level combiner folds everything into one bundle and emits that
		// bundle asynchronously instead (one non-blocking request beats dozens).
		// NOTE: only the cheap toggles are checked here. The full gate —
		// BCBOOST_async_css_active(), which resolves the per-template critical CSS
		// store — is consulted inside the callback: boot() runs at plugins_loaded,
		// before the main query exists, so the template cannot be known yet.
		if ( (bool) BCBOOST_get_option( 'lazy_css', false ) && ! BCBOOST_get_option( 'combine_css', false ) ) {
			add_filter( 'style_loader_tag', [ $this, 'async_load_stylesheet' ], 99, 2 );
		}
	}

	/**
	 * Tag scripts whose handle must keep executing immediately with a
	 * `data-bcb-no-optimize` marker. The buffer pass skips (and then strips the marker
	 * from) any tag carrying it. WooCommerce_Compat supplies the cart handles via the
	 * never-defer / never-delay filters.
	 *
	 * @param string $tag    Script HTML tag.
	 * @param string $handle Script handle.
	 * @param string $src    Script source URL.
	 * @return string Modified tag.
	 */
	public function mark_protected_scripts( string $tag, string $handle, string $src ): string {
		$protected = array_unique( array_merge(
			(array) apply_filters( 'BCBOOST_never_delay_handles', self::SKIP_HANDLES ),
			(array) apply_filters( 'BCBOOST_never_defer_handles', self::SKIP_HANDLES )
		) );

		if ( in_array( $handle, $protected, true ) && strpos( $tag, 'data-bcb-no-optimize' ) === false ) {
			$tag = preg_replace( '/<script\b/', '<script data-bcb-no-optimize', $tag, 1 ) ?? $tag;
		}

		return $tag;
	}

	/**
	 * Neutralise every eligible <script> (inline + external) so the browser will not
	 * run it during parsing, and inject the sequential loader once. Runs on the final
	 * HTML document via the BCBOOST_buffer filter.
	 *
	 * @param string $html Full page HTML.
	 * @return string
	 */
	public function optimize_scripts_in_buffer( string $html ): string {
		if ( '' === $html || stripos( $html, '<script' ) === false ) {
			return $html;
		}

		// Builder editors / previews: scripts must run normally or the editor dies.
		// (Defense in depth — the boot-time gate in Plugin.php cannot see
		// is_preview()/Customizer states.)
		if ( ! BCBOOST_is_optimizable_request() ) {
			return $html;
		}

		$delay = (bool) BCBOOST_get_option( 'delay_js', false );
		$defer = (bool) BCBOOST_get_option( 'defer_js', true );
		if ( ! $delay && ! $defer ) {
			return $html;
		}

		// SAFE MODE (default): defer/delay ONLY a curated allowlist of known
		// third-party scripts (analytics, tag managers, ad pixels, chat widgets).
		// The site's own JavaScript — sliders, testimonials, forms, page builders
		// (Elementor/Divi), the theme — is NEVER touched, so it cannot break. This
		// replaces the fragile "delay everything except an exclusion list" model,
		// which inevitably broke something new on every site. Aggressive mode (off
		// by default) keeps the old behaviour for power users who tune the excludes.
		$safe_mode = (bool) BCBOOST_get_option( 'delay_js_safe_mode', true );
		$allowlist = $safe_mode ? $this->third_party_allowlist() : [];

		// User exclusions always apply. Built-in builder/form/slider excludes are
		// only needed in aggressive mode (in safe mode nothing but the allowlist is
		// ever delayed, so they are moot).
		$excludes = $this->patterns( (string) BCBOOST_get_option( 'exclude_js', '' ) );
		if ( ! $safe_mode ) {
			$excludes = array_merge( $this->default_js_excludes(), $excludes );
		}
		if ( $delay ) {
			$excludes = array_merge( $excludes, $this->patterns( (string) BCBOOST_get_option( 'delay_js_excludes', '' ) ) );
		}

		$found  = 0;
		$result = preg_replace_callback(
			'#<script\b([^>]*)>([\s\S]*?)</script>#i',
			function ( array $m ) use ( $excludes, $safe_mode, $allowlist, &$found ): string {
				$whole = $m[0];
				$attrs = $m[1];
				$body  = $m[2];

				if ( ! $this->is_optimizable( $attrs, $body, $excludes, $safe_mode, $allowlist ) ) {
					// Strip the helper marker so it never reaches the browser.
					return strpos( $whole, 'data-bcb-no-optimize' ) !== false
						? str_replace( ' data-bcb-no-optimize', '', $whole )
						: $whole;
				}

				++$found;

				// External script: park the URL in data-bcb-src so it is not fetched.
				if ( preg_match( '/\ssrc\s*=\s*("|\')(.*?)\1/i', $attrs, $sm ) ) {
					$new_attrs = str_replace( $sm[0], ' data-bcb-src=' . $sm[1] . $sm[2] . $sm[1], $attrs );
					return '<script' . $this->set_pending_type( $new_attrs ) . '></script>';
				}

				// Inline script: neutralise type, keep the body verbatim.
				return '<script' . $this->set_pending_type( $attrs ) . '>' . $body . '</script>';
			},
			$html
		);

		// PCRE bailed (e.g. backtrack/recursion limit on a huge page) or nothing to do —
		// serve the original HTML untouched rather than risk a broken page.
		if ( null === $result || 0 === $found ) {
			return $html;
		}

		$loader = $this->loader_markup( $delay ? 'interaction' : 'dom' );
		if ( stripos( $result, '</body>' ) !== false ) {
			$result = preg_replace( '#</body>#i', $loader . '</body>', $result, 1 ) ?? ( $result . $loader );
		} else {
			$result .= $loader;
		}

		return $result;
	}

	/**
	 * Decide whether a <script> may be routed through the loader.
	 *
	 * @param string   $attrs     The tag's attribute string.
	 * @param string   $body      Inline script body (empty for external scripts).
	 * @param string[] $excludes  User exclusion patterns.
	 * @param bool     $safe_mode When true, only allowlisted third-party scripts may be delayed.
	 * @param string[] $allowlist Third-party substring patterns (safe mode only).
	 * @return bool
	 */
	private function is_optimizable( string $attrs, string $body, array $excludes, bool $safe_mode = false, array $allowlist = [] ): bool {
		// Already processed by a previous pass.
		if ( strpos( $attrs, 'bcb-script' ) !== false ) {
			return false;
		}
		// Explicit opt-outs (ours + the widely-used Cloudflare Rocket Loader one) and the
		// handle-based marker added by mark_protected_scripts().
		if ( strpos( $attrs, 'data-bcb-no-optimize' ) !== false
			|| strpos( $attrs, 'data-no-delay' ) !== false ) {
			return false;
		}
		if ( preg_match( '/data-cfasync\s*=\s*["\']false["\']/i', $attrs ) ) {
			return false;
		}
		// async scripts already load independently — leave them alone.
		if ( preg_match( '/\sasync(\s|=|\/|>|$)/i', $attrs ) ) {
			return false;
		}
		// Boost's own loaders / facades (bcboost-*) must keep running immediately.
		if ( preg_match( '/\sid\s*=\s*["\']bcboost-/i', $attrs ) ) {
			return false;
		}
		// Only optimise JavaScript: empty, text/javascript, application/javascript, module.
		if ( preg_match( '/\stype\s*=\s*["\']([^"\']*)["\']/i', $attrs, $tm ) ) {
			$type = strtolower( trim( $tm[1] ) );
			if ( ! in_array( $type, [ '', 'text/javascript', 'application/javascript', 'module' ], true ) ) {
				return false;
			}
		}

		$src = preg_match( '/\ssrc\s*=\s*("|\')(.*?)\1/i', $attrs, $sm ) ? $sm[2] : '';

		// Inline document.write() cannot survive deferral — it would overwrite the page.
		if ( '' === $src && stripos( $body, 'document.write' ) !== false ) {
			return false;
		}

		// User exclusion patterns match the src (external) or the body (inline).
		$haystack = '' !== $src ? $src : $body;
		foreach ( $excludes as $pattern ) {
			if ( '' !== $pattern && false !== strpos( $haystack, $pattern ) ) {
				return false;
			}
		}

		// Safe mode: only delay known third-party scripts. The site's own JS is left
		// completely alone, so sliders/forms/builders can never break.
		if ( $safe_mode ) {
			foreach ( $allowlist as $pattern ) {
				if ( '' !== $pattern && false !== stripos( $haystack, $pattern ) ) {
					return true;
				}
			}
			return false;
		}

		return true;
	}

	/**
	 * Known third-party scripts that are safe to defer/delay: analytics, tag
	 * managers, ad/conversion pixels, heatmaps, and external chat widgets. These
	 * never render page content or drive the site's own UI, so delaying them cannot
	 * break sliders, forms, menus or page builders. Matched case-insensitively
	 * against a script's src URL or inline body.
	 *
	 * Override via the `BCBOOST_third_party_allowlist` filter.
	 *
	 * @return string[]
	 */
	private function third_party_allowlist(): array {
		$list = [
			// Google Analytics / Ads / Tag Manager
			'googletagmanager', 'google-analytics', '/gtag/js', 'gtag(', '/ga.js', '/analytics.js',
			'googleadservices', 'googlesyndication', 'adsbygoogle', 'doubleclick.net', 'google_optimize',
			// Meta / Facebook
			'connect.facebook.net', 'fbevents', 'fbq(',
			// Microsoft Ads / Clarity
			'clarity.ms', 'bat.bing.com',
			// Other analytics / heatmaps
			'hotjar', 'fullstory', 'mouseflow', 'cdn.segment.com', 'mixpanel',
			'matomo.js', 'piwik.js', 'plausible.io', 'cdn.amplitude',
			// Social pixels
			'snap.licdn.com', '_linkedin_partner', 'analytics.tiktok.com', 'static.ads-twitter.com',
			'twq(', 'pintrk(', 'ct.pinterest.com', 'sc-static.net', 'snaptr(',
			// External chat / support widgets (SaaS, heavy, load fine on interaction)
			'tawk.to', 'widget.intercom.io', 'intercomcdn', 'client.crisp.chat', 'driftt.com',
			'static.zdassets.com', 'js.hs-scripts.com', 'hs-analytics', 'livechatinc',
			// (fw-cdn.com deliberately NOT listed: Freshworks' CDN also serves
			// content-embedded forms/CRM widgets, not just chat — delaying it could
			// blank a visible form. wchat.freshchat.com covers the chat widget.)
			'v2.zopim.com', 'code.tidio.co', 'smartsuppchat.com', 'chatra.io', 'static.olark.com',
			'salesiq.zoho', 'wchat.freshchat.com', 'userlike.com',
			// More analytics / session-replay / heatmaps
			'stats.wp.com', 'static.getclicky.com', 'script.crazyegg.com', 'luckyorange',
			'inspectlet', 'smartlook', 'heapanalytics.com', 'usefathom.com',
			'simpleanalyticscdn.com', 'matomo.cloud',
			// More ad / retargeting pixels (never render page UI). Loader-domain
			// tokens only — short inline-call tokens like `qp(`/`rdt(` are NOT safe
			// here: matched case-insensitively against WHOLE inline bodies, they hit
			// minified theme/builder bootstraps (2-char mangled locals) and would
			// delay the site's own JS. The vendors' standard snippets embed their
			// loader domain, which the entries below already match.
			// (dwin1.com / AWIN deliberately NOT listed: delaying the MasterTag can
			// lose first-click affiliate attribution — revenue risk, not breakage.)
			'static.criteo.net', 'tr.outbrain.com', 'redditstatic.com/ads',
			'a.quora.com',
		];

		/**
		 * Filter the third-party safe-delay allowlist.
		 *
		 * @param string[] $list Substring patterns matched against script src/body.
		 */
		$filtered = apply_filters( 'BCBOOST_third_party_allowlist', $list );

		return is_array( $filtered ) ? array_values( array_filter( array_map( 'strval', $filtered ) ) ) : $list;
	}

	/**
	 * Replace a script tag's type with the inert `bcb-script` placeholder, preserving a
	 * `module` type so the loader can recreate it.
	 *
	 * @param string $attrs Attribute string (may start with a leading space).
	 * @return string New attribute string beginning with the inert type.
	 */
	private function set_pending_type( string $attrs ): string {
		if ( preg_match( '/\stype\s*=\s*["\']module["\']/i', $attrs ) ) {
			$attrs = preg_replace( '/\stype\s*=\s*["\']module["\']/i', ' data-bcb-type="module"', $attrs, 1 ) ?? $attrs;
		} elseif ( preg_match( '/\stype\s*=\s*["\'][^"\']*["\']/i', $attrs ) ) {
			$attrs = preg_replace( '/\stype\s*=\s*["\'][^"\']*["\']/i', '', $attrs, 1 ) ?? $attrs;
		}

		return ' type="bcb-script"' . $attrs;
	}

	/**
	 * Build the sequential loader script.
	 *
	 * Executes parked scripts strictly one at a time, in document order: external files
	 * are recreated and awaited (onload/onerror) before the next runs; inline scripts run
	 * synchronously then continue. After the queue drains it re-dispatches DOMContentLoaded
	 * and load (and jQuery ready) so scripts that registered late lifecycle listeners still
	 * initialise — the same approach WP Rocket / FlyingPress use to make this safe.
	 *
	 * Delay JS adds one more piece: a capture-phase click catcher. The interaction that
	 * triggers loading is usually the user's first *click* (a menu, an accordion, a button).
	 * Without help that click is lost — the script that would have handled it only loads
	 * *because* of it — so the element appears dead until a second click (the classic
	 * "Delay JS broke my dropdown menu" report). We record that click, suppress its default
	 * so nothing navigates prematurely, and replay the full mouse sequence on the original
	 * target once the scripts have loaded and bound their handlers, making the first click
	 * "just work".
	 *
	 * @param string $trigger 'interaction' (Delay JS) or 'dom' (Defer JS only).
	 * @return string
	 */
	private function loader_markup( string $trigger ): string {
		$interaction = ( 'interaction' === $trigger ) ? 'true' : 'false';

		$js = <<<JS
(function(){
var started=false,finished=false,clicks=[],heldReady=false,jqRef,running=false;
// Pause jQuery's "ready" the instant jQuery is defined BY THIS LOADER. Delayed scripts
// run one at a time and libraries (Elementor, Divi, Avada, WPBakery, themes) self-init
// on jQuery ready the moment they execute — which in this sequential model happens
// BEFORE later scripts (element handlers, SmartMenus, slider inits) have registered, so
// menus/widgets/sliders never bind. Holding ready until the whole queue drains makes
// them all initialise together, exactly like a normal page load. jQuery schedules ready
// on a timer as soon as it loads, so we hold at definition time (not script onload) to
// win the race. Released in finish().
//
// The `running` guard is critical: we ONLY hold when jQuery is defined *during* our
// queue execution (i.e. jQuery is itself delayed). If jQuery was excluded from delay it
// loads normally and we must NOT touch its ready timing — so we also skip the
// interceptor entirely when jQuery already exists at load time.
function holdNow(jq){if(running&&jq&&jq.holdReady&&!heldReady){heldReady=true;jqRef=jq;try{jq.holdReady(true);}catch(e){}}}
if(!window.jQuery){try{Object.defineProperty(window,'jQuery',{configurable:true,get:function(){return jqRef;},set:function(v){jqRef=v;holdNow(v);}});}catch(e){}}
function run(){
 if(started)return;started=true;running=true;
 var q=[].slice.call(document.querySelectorAll('script[type="bcb-script"]'));
 (function next(i){
  if(i>=q.length){finish();return;}
  var o=q[i];
  if(!o||!o.parentNode){next(i+1);return;}
  var s=document.createElement('script');
  for(var k=0;k<o.attributes.length;k++){
   var a=o.attributes[k];
   if(a.name==='type'||a.name==='data-bcb-src'||a.name==='data-bcb-type')continue;
   try{s.setAttribute(a.name,a.value);}catch(e){}
  }
  var mt=o.getAttribute('data-bcb-type');if(mt){s.type=mt;}
  var src=o.getAttribute('data-bcb-src');
  if(src){s.src=src;s.onload=s.onerror=function(){next(i+1);};o.parentNode.replaceChild(s,o);}
  else{try{s.text=o.textContent;}catch(e){}o.parentNode.replaceChild(s,o);next(i+1);}
 })(0);
}
function finish(){
 finished=true;
 try{document.dispatchEvent(new Event('DOMContentLoaded',{bubbles:false,cancelable:false}));}catch(e){}
 // Release jQuery ready (held since jQuery loaded) so ready callbacks fire now — once,
 // with every handler registered. Fall back to a ready trigger on older jQuery.
 if(heldReady&&jqRef&&jqRef.holdReady){try{jqRef.holdReady(false);}catch(e){}}
 else if(window.jQuery){try{window.jQuery(document).trigger('ready');}catch(e){}}
 try{window.dispatchEvent(new Event('load',{bubbles:false,cancelable:false}));}catch(e){}
 replay();
}
function replay(){
 if(!clicks.length)return;
 // One tick after the lifecycle events so late binders (smartmenus, Elementor) are ready.
 setTimeout(function(){
  clicks.forEach(function(c){
   var t=c.target;if(!t||!t.isConnected)return;
   ['mouseover','mouseenter','click'].forEach(function(type){
    try{t.dispatchEvent(new MouseEvent(type,{bubbles:true,cancelable:true,view:window,clientX:c.x,clientY:c.y}));}catch(e){}
   });
  });
  clicks=[];
 },50);
}
if($interaction){
 var evs=['mouseover','mousedown','click','keydown','touchstart','pointerdown','scroll','wheel'];
 var fire=function(){evs.forEach(function(e){window.removeEventListener(e,fire,{passive:true});});run();};
 evs.forEach(function(e){window.addEventListener(e,fire,{once:true,passive:true});});
 // Capture the first real click(s) before scripts finish; replay them post-load.
 var grab=function(e){
  if(finished)return;
  if(e.button!=null&&e.button!==0)return;
  clicks.push({target:e.target,x:e.clientX||0,y:e.clientY||0});
  try{e.preventDefault();e.stopPropagation();}catch(_){}
  run();
 };
 document.addEventListener('click',grab,true);
 setTimeout(run,5000);
}else{
 if(document.readyState!=='loading'){run();}
 else{document.addEventListener('DOMContentLoaded',run);}
}
})();
JS;

		return '<script id="bcboost-js-loader">' . $js . '</script>';
	}

	/**
	 * Built-in defer/delay exclusions for the jQuery + page-builder init chain.
	 *
	 * The sequential loader runs scripts one at a time, and libraries self-init the
	 * instant they execute. Elementor is the problem case: `elementorFrontend.init()`
	 * fires the moment elementor-frontend.js runs — before Elementor Pro's element
	 * handlers (SmartMenus nav-menu, etc.) have registered — so dropdowns/mega-menus
	 * never bind and stay dead. Re-dispatching DOMContentLoaded/load afterwards does
	 * not re-run Elementor's handlers (double-init guard). The reliable, widely-used
	 * remedy (WP Rocket/FlyingPress ship similar lists) is to let the whole jQuery +
	 * Elementor chain load normally so it initialises once with every handler present;
	 * everything else (analytics, GTM, forms, chat) still gets delayed/deferred. These
	 * are src/inline substring matches; on non-builder sites they simply never match.
	 *
	 * Override via the `BCBOOST_default_js_excludes` filter.
	 *
	 * @return string[]
	 */
	private function default_js_excludes(): array {
		$defaults = [
			'/jquery.js',
			'/jquery.min.js',
			'jquery-core',
			'jquery-migrate',
			'js.cookie',                  // `Cookies` global — Elementor Pro sticky/popup + WooCommerce
			// Whole Elementor + Elementor Pro footprint (js AND lib): the frontend
			// scripts self-init the moment they run and immediately drive their
			// bundled libraries (SmartMenus nav, sticky header, Swiper sliders,
			// dialogs). Delaying any single piece — including a lib/ helper — leaves
			// a handler firing against a library that hasn't loaded, so menus/sliders
			// silently die. Keep the whole chain loading normally; everything else
			// (analytics, tag managers, chat, forms, WooCommerce) is still delayed.
			'/elementor/assets/',
			'/elementor-pro/assets/',
			'/pro-elements/assets/',
			'elementorFrontendConfig',    // inline config consumed by elementor-frontend.js
			'ElementorProFrontendConfig', // inline config consumed by elementor-pro frontend
			'smartmenus',
			'ele-custom-skin',            // ElementsKit / ele-custom-skin
			'ecs_ajax',
			'/ecs-',
			'ECS_add_action',             // inline ElementsKit colour-scheme call
			// Forms must work on load — delaying their JS leaves the form collapsed
			// /unstyled (the framework JS sets up layout, conditional logic, masks
			// and validation) until the visitor interacts, which reads as a broken
			// or "not loading" form. Keep form runtimes (and their inline bootstrap
			// configs) loading normally.
			'/gravityforms/',             // Gravity Forms script files
			'gform',                      // inline gform bootstrap + gform_*_config blocks
			'/contact-form-7/',           // CF7
			'wpcf7',                      // CF7 inline config + scripts
			'/wpforms',                   // WPForms
			'/fluentform',                // Fluent Forms
			'/ninja-forms/',              // Ninja Forms
			'/forminator',                // Forminator
			// Hero/above-the-fold sliders render their LCP image via their own JS
			// (often lazy-loading it). Delaying that JS means the hero only appears
			// after the visitor interacts — catastrophic LCP (e.g. a Slider Revolution
			// hero measured at 21s). Keep slider runtimes loading normally so the hero
			// paints immediately; this is an LCP win, not a regression.
			'/revslider/',                // Slider Revolution
			'themepunch',                 // Slider Revolution vendor (jquery.themepunch.*)
			'revolution',                 // Slider Revolution inline init / tools
			'/layerslider/',              // LayerSlider
			'/nextend/',                  // Smart Slider 3
			'/masterslider/',             // MasterSlider
			'/ml-slider/',                // MetaSlider
		];

		/**
		 * Filter the built-in JS exclusion patterns.
		 *
		 * @param string[] $defaults Substring patterns matched against script src/body.
		 */
		$filtered = apply_filters( 'BCBOOST_default_js_excludes', $defaults );

		return is_array( $filtered ) ? array_values( array_filter( array_map( 'strval', $filtered ) ) ) : $defaults;
	}

	/**
	 * Split a newline-separated textarea value into trimmed, non-empty patterns.
	 *
	 * @param string $raw Raw textarea value.
	 * @return string[]
	 */
	private function patterns( string $raw ): array {
		return array_values( array_filter( array_map( 'trim', explode( "\n", $raw ) ) ) );
	}

	/**
	 * Convert a non-critical stylesheet to async loading.
	 *
	 * Technique: set media="print" and swap to media="all" via onload.
	 * This is the most widely-compatible async CSS loading pattern.
	 * Critical stylesheets (IDs containing 'critical', 'above-fold', 'block-',
	 * or 'woocommerce-general') are never made async.
	 *
	 * @param string $html   Full <link> tag HTML.
	 * @param string $handle Stylesheet handle.
	 * @return string Modified tag.
	 */
	public function async_load_stylesheet( string $html, string $handle ): string {
		// Builder editors / previews need render-blocking CSS as-is.
		if ( ! BCBOOST_is_optimizable_request() ) {
			return $html;
		}

		// The async gate resolves the per-template critical CSS store for THIS
		// request (style_loader_tag fires inside wp_head, so the query exists).
		// No provable above-the-fold coverage ⇒ CSS stays render-blocking.
		if ( ! BCBOOST_async_css_active() ) {
			return $html;
		}

		// Never async-load these handles.
		$skip = [
			'wp-block-library',
			'wp-block-library-theme',
			'classic-theme-styles',
		];

		if ( in_array( $handle, $skip, true ) ) {
			return $html;
		}

		// Skip handles that look critical.
		$lower = strtolower( $handle );
		if ( strpos( $lower, 'critical' ) !== false
			|| strpos( $lower, 'above-fold' ) !== false
			|| strpos( $lower, 'woocommerce-general' ) !== false ) {
			return $html;
		}

		// Built-in async exclusions: per-page/per-template page-builder CSS must
		// stay render-blocking — generic critical CSS can never contain those
		// page-specific rules (the monitornet Careers lesson).
		foreach ( self::css_async_excludes() as $pattern ) {
			if ( self::css_exclude_matches( $pattern, $html ) || self::css_exclude_matches( $pattern, $handle ) ) {
				return $html;
			}
		}

		// Check exclude_css list.
		$excludes = array_filter( array_map( 'trim', explode( "\n", (string) BCBOOST_get_option( 'exclude_css', '' ) ) ) );
		foreach ( $excludes as $pattern ) {
			if ( '' !== $pattern && ( false !== strpos( $html, $pattern ) || false !== strpos( $handle, $pattern ) ) ) {
				return $html;
			}
		}

		// Convert media="all" (or no media) to print+onload async pattern.
		if ( strpos( $html, 'media=' ) !== false ) {
			$async = preg_replace( '/\smedia=["\'][^"\']*["\']/', ' media="print" onload="this.media=\'all\'"', $html ) ?? $html;
		} else {
			$async = str_replace( ' />', ' media="print" onload="this.media=\'all\'" />', $html );
		}

		return $async . '<noscript>' . $html . '</noscript>';
	}

	/**
	 * Built-in async-CSS exclusions: stylesheets that must NEVER be delivered
	 * asynchronously (they stay render-blocking, individually).
	 *
	 * These are the page builders' PER-PAGE / PER-TEMPLATE generated files. Their
	 * rules style specific elements on specific pages (an Elementor heading's
	 * font-size/colour, a Divi section's background overlay) and are therefore
	 * never present in generic critical CSS — deferring them past first paint
	 * renders those elements with theme defaults until the CSS arrives: broken
	 * headings, missing overlays, mis-sized widgets (monitornet Careers,
	 * 2026-06-18). Matched case-insensitively against the <link> tag/href and the
	 * style handle via css_exclude_matches(): entries are plain substrings unless
	 * prefixed `regex:`, in which case the remainder is a case-insensitive
	 * regular-expression body (needed where a substring cannot express the rule —
	 * e.g. Oxygen's NUMERIC per-page files only). Consumers: the per-stylesheet
	 * swap here and CSS_Minifier's combine pass (which leaves matching sheets out
	 * of an async bundle).
	 *
	 * Override via the `BCBOOST_async_css_excludes` filter.
	 *
	 * @return string[]
	 */
	public static function css_async_excludes(): array {
		$defaults = [
			'/elementor/css/post-',  // Elementor per-page CSS (uploads/elementor/css/post-123.css)
			'/elementor/css/loop-',  // Elementor loop-template CSS
			'elementor-post-',       // Elementor per-page style handle/id
			'/et-cache/',            // Divi generated static CSS (per-page + global)
			'/bb-plugin/cache/',     // Beaver Builder per-layout CSS
			// Oxygen PER-PAGE CSS only (uploads/oxygen/css/331.css — a post-id
			// filename). Deliberately NOT the whole directory: Oxygen's site-wide
			// sheets (universal.css, oxygen.css) are theme-CSS-equivalent and must
			// stay eligible for minify/combine/async (12cutssteakhouse, 2026-07-17).
			'regex:/uploads/oxygen/css/\d+\.css(?![\w-])',
			'/uploads/bricks/css/',  // Bricks per-page CSS
			'/uploads/js_composer/', // WPBakery generated custom CSS
		];

		/**
		 * Filter the built-in async-CSS exclusion patterns.
		 *
		 * @param string[] $defaults Patterns matched against link tag/handle/href:
		 *                           plain case-insensitive substrings, or — when an
		 *                           entry is prefixed `regex:` — a case-insensitive
		 *                           regular-expression body.
		 */
		$filtered = apply_filters( 'BCBOOST_async_css_excludes', $defaults );

		return is_array( $filtered ) ? array_values( array_filter( array_map( 'strval', $filtered ) ) ) : $defaults;
	}

	/**
	 * Test one css_async_excludes() entry against a haystack (link tag, href, or
	 * style handle). The single matcher shared by every consumer of the exclusion
	 * list — the async swap above and CSS_Minifier's combine pass.
	 *
	 * Two entry forms:
	 * - Plain string: case-insensitive substring match (the historical behaviour).
	 * - `regex:<body>`: <body> is applied as a case-insensitive regular expression
	 *   (`~` delimited; literal `~` in the body is escaped here). An invalid body
	 *   fails CLOSED as "matches" (the sheet STAYS excluded / render-blocking) and
	 *   is logged under WP_DEBUG_LOG: these entries exist to protect per-page
	 *   builder CSS from async delivery, so a typo must degrade to slower-but-
	 *   correct, never to silently un-protecting the exact sheet the entry names
	 *   (correctness beats speed — the monitornet Careers lesson).
	 *
	 * @param string $pattern  Exclusion entry (substring or `regex:`-prefixed).
	 * @param string $haystack Text to test against.
	 * @return bool True when the entry matches.
	 */
	public static function css_exclude_matches( string $pattern, string $haystack ): bool {
		if ( '' === $pattern || '' === $haystack ) {
			return false;
		}

		if ( 0 === strpos( $pattern, 'regex:' ) ) {
			$body = str_replace( '~', '\~', substr( $pattern, 6 ) );
			if ( '' === $body ) {
				return false;
			}
			$result = @preg_match( '~' . $body . '~i', $haystack );
			if ( false === $result ) {
				// Fail CLOSED: an unparseable protection pattern keeps its sheet
				// excluded (render-blocking) rather than silently un-protecting it.
				if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
					error_log( '[BaseCloud Boost] Invalid regex in css_async_excludes entry (treated as EXCLUDED, fail-closed): ' . $pattern );
				}
				return true;
			}
			return 1 === $result;
		}

		return false !== stripos( $haystack, $pattern );
	}

	/**
	 * Rewrite the ?ver= query string on asset URLs (legacy "remove query strings").
	 *
	 * Correctness rules (CLAUDE.md §6.1):
	 * - The ?ver= token is the browser-cache buster. It is NEVER simply removed.
	 * - Local, path-resolvable assets get ?ver=<filemtime> — stable while the file
	 *   is unchanged (proxy/CDN friendly), and it changes the moment the file is
	 *   edited, so the very next visitor fetches the fresh bytes.
	 * - Divi's generated stylesheets under /et-cache/ are never touched: Divi
	 *   manages its own versioning and its files are regenerated in place.
	 * - External or unresolvable URLs are returned unchanged (never stripped).
	 *
	 * @param string $src Asset source URL.
	 * @return string URL with a safe, self-busting version query param.
	 */
	public function remove_query_string( string $src ): string {
		if ( '' === $src || strpos( $src, '?' ) === false ) {
			return $src;
		}

		// Divi generates its CSS into /et-cache/ and rebuilds files in place —
		// its own version token must survive exactly as Divi emitted it.
		if ( false !== strpos( $src, '/et-cache/' ) ) {
			return $src;
		}

		$parts = explode( '?', $src, 2 );
		parse_str( $parts[1] ?? '', $query );

		// No ?ver= present — nothing to normalise; leave the URL untouched.
		if ( ! isset( $query['ver'] ) ) {
			return $src;
		}

		// Only local, path-resolvable files can get an mtime token. Anything else
		// (external CDNs, dynamic endpoints) keeps its original version string —
		// stripping it would freeze the asset in every cache between us and the
		// visitor.
		$path = $this->local_asset_path( $parts[0] );
		if ( null === $path ) {
			return $src;
		}

		$mtime = @filemtime( $path );
		if ( false === $mtime || $mtime <= 0 ) {
			return $src;
		}

		$query['ver'] = (string) $mtime;

		return $parts[0] . '?' . http_build_query( $query );
	}

	/**
	 * Resolve a same-site asset URL to its absolute filesystem path.
	 *
	 * Handles scheme-relative and root-relative URLs, and maps content/site URLs
	 * onto WP_CONTENT_DIR / ABSPATH. Returns null for external hosts or paths
	 * that do not resolve to an existing file (fail open: caller leaves the URL
	 * untouched).
	 *
	 * @param string $url Asset URL without query string.
	 * @return string|null Absolute path to an existing file, or null.
	 */
	private function local_asset_path( string $url ): ?string {
		if ( '' === $url ) {
			return null;
		}

		// Normalise to a comparable //host/path form (scheme-agnostic: sites are
		// routinely enqueued as http:// while serving https://).
		$normalize = static function ( string $u ): string {
			if ( strpos( $u, 'https://' ) === 0 ) {
				return substr( $u, 6 );
			}
			if ( strpos( $u, 'http://' ) === 0 ) {
				return substr( $u, 5 );
			}
			return $u;
		};

		// Root-relative URL → same host by definition.
		if ( strpos( $url, '//' ) !== 0 && strpos( $url, '/' ) === 0 ) {
			$url = $normalize( (string) home_url() ) . $url;
		} else {
			$url = $normalize( $url );
			if ( strpos( $url, '//' ) !== 0 ) {
				return null; // Relative or malformed — don't guess.
			}
		}

		$bases = [
			$normalize( (string) content_url() )            => WP_CONTENT_DIR,
			$normalize( (string) includes_url() )           => rtrim( ABSPATH, '/\\' ) . '/' . WPINC,
			$normalize( (string) site_url( '/wp-admin' ) )  => rtrim( ABSPATH, '/\\' ) . '/wp-admin',
			$normalize( (string) site_url() )               => rtrim( ABSPATH, '/\\' ),
		];

		foreach ( $bases as $base_url => $base_dir ) {
			$base_url = rtrim( $base_url, '/' );
			if ( '' === $base_url || strpos( $url, $base_url . '/' ) !== 0 ) {
				continue;
			}
			$relative = rawurldecode( substr( $url, strlen( $base_url ) ) );
			// Path traversal guard: our own enqueued URLs never contain "..".
			if ( false !== strpos( $relative, '..' ) ) {
				return null;
			}
			$path = rtrim( $base_dir, '/\\' ) . $relative;
			if ( @is_file( $path ) ) {
				return $path;
			}
		}

		return null;
	}
}
