<?php
/**
 * Cache module — JS Optimizer
 *
 * Two transformations on external <script src="..."> tags:
 *
 *   1. **Defer** — adds the `defer` attribute (unless the script is in the
 *      safelist or already has async/defer/type=module).
 *
 *   2. **Delay** — replaces `src=...` with `data-be-src=...` and changes the
 *      type to `text/be-delayed` so the browser does NOT execute the script.
 *      The companion `delay-js.js` file watches for the first user
 *      interaction (mouseover, scroll, touchstart, keydown) and re-attaches
 *      the original script tags so they execute then. Excellent for TBT
 *      audit but risky for analytics — safelist `gtag, analytics, gtm,
 *      fbevents` are excluded by default.
 *
 * Inline `<script>` blocks: optionally trim leading/trailing whitespace.
 * No JS minification — that requires an AST-aware parser; instead we rely
 * on plugin/theme authors having already shipped minified bundles.
 *
 * @package BlaminhorEssentials
 */

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

class Blaminhor_Essentials_Cache_Js_Optimizer {

	/** @var Blaminhor_Essentials_Module_Cache */
	private $parent;

	public function __construct( Blaminhor_Essentials_Module_Cache $parent ) {
		$this->parent = $parent;
	}

	public function register_hooks() {
		// `script_loader_tag` runs once per enqueued script, fairly low overhead.
		add_filter( 'script_loader_tag', array( $this, 'maybe_defer_or_delay' ), 10, 3 );
		// Frontend script that wakes delayed scripts on first user interaction.
		add_action( 'wp_enqueue_scripts', array( $this, 'maybe_enqueue_delay_runtime' ) );
	}

	/**
	 * Pre-process every script tag.
	 *
	 * @param string $tag    The original `<script ... src="..."></script>` tag.
	 * @param string $handle Script handle.
	 * @param string $src    Script src URL.
	 * @return string
	 */
	public function maybe_defer_or_delay( $tag, $handle, $src ) {
		// Bypass for admins / logged-in users (when cache_logged_in is OFF) and
		// non-cacheable contexts. Without this, the very person who would notice
		// a builder breakage (Elementor Pro nav menus, Avada popups...) gets the
		// delayed/deferred bundle and cannot tell the cause apart from "the site
		// is broken" — exactly the support workflow this gate exists to keep.
		// Replaces the previous is_admin()-only check.
		if ( $this->parent->should_skip_runtime_optimizations() ) {
			return $tag;
		}
		if ( ! $this->is_externally_eligible( $tag, $handle, $src ) ) {
			return $tag;
		}

		// Delay wins over defer if both are on (delayed scripts won't run at
		// document-ready anyway).
		if ( $this->parent->get_setting( 'delay_js', false ) && ! $this->is_safelisted( $handle, $src, 'delay' ) ) {
			return $this->transform_to_delay( $tag, $src );
		}

		if ( $this->parent->get_setting( 'defer_js', true ) && ! $this->is_safelisted( $handle, $src, 'defer' ) ) {
			return $this->add_defer_attr( $tag );
		}

		return $tag;
	}

	/**
	 * @param string $tag
	 * @param string $handle
	 * @param string $src
	 * @return bool
	 */
	private function is_externally_eligible( $tag, $handle, $src ) {
		if ( '' === $src || empty( $tag ) ) {
			return false;
		}
		// If already async/defer/module, don't touch.
		if ( false !== stripos( $tag, ' async' ) || false !== stripos( $tag, ' defer' ) || false !== stripos( $tag, 'type=\'module\'' ) || false !== stripos( $tag, 'type="module"' ) ) {
			return false;
		}
		// Skip jQuery-core dependencies by handle (always safelisted no matter what).
		$core_handles = array( 'jquery', 'jquery-core', 'jquery-migrate' );
		if ( in_array( $handle, $core_handles, true ) ) {
			return false;
		}
		return true;
	}

	/**
	 * Add `defer` to the <script> open tag.
	 *
	 * @param string $tag
	 * @return string
	 */
	private function add_defer_attr( $tag ) {
		return preg_replace( '#<script(\s)#i', '<script defer$1', $tag, 1 );
	}

	/**
	 * Convert <script src="x.js"> → <script type="text/be-delayed" data-be-src="x.js">.
	 *
	 * @param string $tag
	 * @param string $src
	 * @return string
	 */
	private function transform_to_delay( $tag, $src ) {
		// Strip any existing type, replace with text/be-delayed, swap src→data-be-src.
		$tag = preg_replace( '#\stype=(["\'])([^"\']*)\1#i', '', $tag );
		$tag = preg_replace_callback(
			'#\ssrc=(["\'])([^"\']*)\1#i',
			function ( $m ) {
				return ' data-be-src=' . $m[1] . esc_attr( $m[2] ) . $m[1];
			},
			$tag
		);
		return preg_replace( '#<script(\s)#i', '<script type="text/be-delayed"$1', $tag, 1 );
	}

	/**
	 * Built-in safelist of script patterns we know break when delayed. This
	 * ships embedded so a fresh activation has a sane safety net even before
	 * the user ever opens the settings. Merged at runtime with the user list
	 * (`delay_js_exclude` setting) — anything matching either list is left
	 * alone.
	 *
	 * Curated from the canonical patterns in WP Rocket's
	 * `dynamic-lists-delayjs.json` (jQuery, payment SDKs, recaptcha, common
	 * page builders & e-commerce front-ends, language switchers). Keep small
	 * (~50 entries) to avoid linear-scan cost on every script tag.
	 *
	 * @return string[]
	 */
	private function builtin_delay_safelist() {
		return array(
			// Core libraries that other code expects available synchronously.
			'jquery', 'jquery-core', 'jquery-migrate', 'jquery-ui', 'jquery.min',
			// Analytics / tag managers — must execute on page load to capture pageviews.
			'gtag', 'analytics', 'gtm', 'google-analytics', 'googletagmanager',
			'matomo', 'hotjar', 'clarity',
			// Marketing / pixels.
			'fbevents', 'connect.facebook.net', 'pinterest', 'snap.licdn',
			// Recaptcha (must run early or forms break).
			'recaptcha', 'grecaptcha',
			// Payments — never delay or checkout breaks.
			'stripe', 'paypal', 'braintree', 'adyen', 'checkout.com',
			// Frameworks heavily used by themes.
			'bootstrap', 'popper', 'foundation', 'tailwind',
			// Page builders front-ends (their widgets initialise on DOMReady).
			'elementor', 'fusion-', 'avada', 'oxygen', 'beaver-builder', 'divi',
			// E-commerce front-ends.
			'woocommerce', 'wc-', 'edd', 'easy-digital-downloads',
			// I18n / language switchers.
			'wpml', 'polylang', 'qtranslate',
			// Sliders / lightboxes / heavy UI typically expected at first paint.
			'swiper', 'slick', 'glightbox', 'fancybox', 'magnific',
			// Lazy-load libs (would self-suppress if delayed past viewport scroll).
			'lazysizes', 'lozad',
			// Cookies / consent banners (must paint asap).
			'cookie', 'consent', 'gdpr', 'iubenda', 'cookieyes',
		);
	}

	/**
	 * Built-in defer safelist — scripts WordPress, themes and major plugins
	 * print inline before defer would fire, so we never defer them.
	 *
	 * @return string[]
	 */
	private function builtin_defer_safelist() {
		return array(
			'jquery', 'jquery-core', 'jquery-migrate', 'jquery-ui',
			'wp-', 'admin-bar', 'backbone', 'marionette', 'underscore', 'lodash',
			'imagesloaded', 'masonry',
			'elementor', 'fusion-', 'avada',
		);
	}

	/**
	 * Whether a handle (or its src URL) matches the safelist for a given mode.
	 *
	 * The effective safelist is `builtin ∪ user_setting`. The user list is
	 * authoritative for additions; we never let it remove a built-in pattern
	 * (to keep a bug-resistant floor for new users who blanked the field).
	 *
	 * @param string $handle
	 * @param string $mode 'defer'|'delay'
	 * @return bool
	 */
	private function is_safelisted( $handle, $src, $mode ) {
		// Check both the WordPress handle (e.g. "elementor-frontend") AND the
		// raw src URL (e.g. ".../elementor/assets/lib/smartmenus/...js").
		// Path-style entries from theme adapters like "/elementor/assets/lib/"
		// can never match a handle — they only make sense against the src.
		// And handle-style entries like "smartmenus" still match correctly
		// against the handle. Checking both keeps backwards compatibility
		// while finally honouring path-style adapter rules.
		$haystacks = array( (string) $handle, (string) $src );

		$builtin = 'delay' === $mode ? $this->builtin_delay_safelist() : $this->builtin_defer_safelist();
		foreach ( $builtin as $needle ) {
			if ( '' === $needle ) {
				continue;
			}
			foreach ( $haystacks as $h ) {
				if ( false !== strpos( $h, $needle ) ) {
					return true;
				}
			}
		}

		$key   = 'delay' === $mode ? 'delay_js_exclude' : 'js_exclude';
		$raw   = (string) $this->parent->get_setting( $key, '' );
		$lines = preg_split( '/[\r\n]+/', $raw );
		foreach ( $lines as $needle ) {
			$needle = trim( $needle );
			if ( '' === $needle || 0 === strpos( $needle, '#' ) ) {
				continue;
			}
			foreach ( $haystacks as $h ) {
				if ( false !== strpos( $h, $needle ) ) {
					return true;
				}
			}
		}

		// Theme/builder adapter exclusions (since 1.9.8.6).
		$filter_name = 'delay' === $mode
			? 'blaminhor_essentials_cache_delay_js_exclusions'
			: 'blaminhor_essentials_cache_defer_js_exclusions';
		$dynamic = (array) apply_filters( $filter_name, array() );
		foreach ( $dynamic as $needle ) {
			$needle = (string) $needle;
			if ( '' === $needle ) {
				continue;
			}
			// Regex form: '#pattern#flags'.
			if ( strlen( $needle ) > 2 && '#' === $needle[0] ) {
				foreach ( $haystacks as $h ) {
					if ( @preg_match( $needle, $h ) === 1 ) {
						return true;
					}
				}
				continue;
			}
			foreach ( $haystacks as $h ) {
				if ( false !== strpos( $h, $needle ) ) {
					return true;
				}
			}
		}
		return false;
	}

	/**
	 * Enqueue the runtime that wakes delayed scripts on first user interaction.
	 */
	public function maybe_enqueue_delay_runtime() {
		// Bypass for admins / logged-in users: when no script gets the delay
		// transformation (because the filter is skipped), delay-js.js has
		// nothing to wake up — no point loading it. Replaces the previous
		// is_admin()-only check.
		if ( $this->parent->should_skip_runtime_optimizations() ) {
			return;
		}
		if ( ! $this->parent->get_setting( 'delay_js', false ) ) {
			return;
		}
		wp_enqueue_script(
			'blaminhor-essentials-cache-delay-js',
			BLAMINHOR_ESSENTIALS_PLUGIN_URL . 'assets/js/frontend/delay-js.js',
			array(),
			BLAMINHOR_ESSENTIALS_VERSION,
			array( 'in_footer' => false, 'strategy' => 'defer' )
		);
	}
}
