<?php
/**
 * BaseCloud Boost — CSS Minifier
 *
 * Handles:
 *  - Minification of enqueued CSS files (file-level)
 *  - Combining all CSS files into a single bundle
 *  - Minification of inline <style> blocks (via BCBOOST_buffer filter)
 *
 * @package BaseCloud\Boost\Optimizer
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Optimizer;

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

/**
 * Minifies CSS content and optimises enqueued stylesheets.
 */
class CSS_Minifier {

	/** @var string Directory for minified asset cache. */
	private string $cache_dir;

	/** @var string URL for minified asset cache. */
	private string $cache_url;

	/**
	 * @var string CDN base URL (e.g. https://zone.b-cdn.net) when the BunnyCDN URL
	 *             rewriter is active, otherwise ''. Needed because that rewriter runs
	 *             on an inner output buffer BEFORE the BCBOOST_buffer filter, so by the
	 *             time we fold the <head> every stylesheet href is already a CDN URL —
	 *             we must map those back to local paths to read and combine them.
	 */
	private string $cdn_base = '';

	/** @var bool Whether to inject font-display:swap into @font-face rules. */
	private bool $font_swap = false;

	public function __construct() {
		// Reuse the Google Fonts optimisation toggle: it already governs the
		// font-display:swap behaviour for Google Fonts links and inline @font-face,
		// so external stylesheet @font-face (icon fonts etc.) should follow the same
		// switch. swap keeps text/icons visible during font load (no invisible-text
		// FOIT), which is the "Font display" PageSpeed item.
		$this->font_swap = (bool) BCBOOST_get_option( 'google_fonts_optimize', true );

		$this->cache_dir = BCBOOST_MINIFY_DIR . 'css/';
		$this->cache_url = content_url( '/cache/basecloud-boost-min/css/' );

		if ( BCBOOST_get_option( 'bunnycdn_enabled', false ) ) {
			$host = trim( (string) BCBOOST_get_option( 'bunnycdn_hostname', '' ) );
			if ( '' !== $host ) {
				$host = rtrim( (string) preg_replace( '#^https?://#', '', $host ), '/' );
				$cdn  = 'https://' . $host;
				// Only treat it as a CDN if it actually differs from the origin.
				if ( $cdn !== rtrim( (string) site_url(), '/' ) ) {
					$this->cdn_base = $cdn;
				}
			}
		}
	}

	/**
	 * Boot CSS minifier.
	 *
	 * @return void
	 */
	public function boot(): void {
		$minify  = BCBOOST_get_option( 'minify_css', true );
		$combine = BCBOOST_get_option( 'combine_css', false );

		if ( ! $minify && ! $combine ) {
			return;
		}

		// Inline <style> blocks via HTML output-buffer filter.
		if ( $minify ) {
			add_filter( 'BCBOOST_buffer', [ $this, 'minify_inline_styles' ] );

			// Enqueued CSS files — minify each individually after everything is
			// enqueued.
			add_action( 'wp_enqueue_scripts', [ $this, 'process_enqueued_styles' ], 999999 );
		}

		// Combine stylesheets on the final HTML. Folding at the buffer level (not
		// at wp_enqueue_scripts) is the only reliable way to catch stylesheets that
		// page builders such as Elementor enqueue late in the render — those are
		// invisible to the enqueue-time pass and are the bulk of render-blocking CSS.
		if ( $combine ) {
			add_filter( 'BCBOOST_buffer', [ $this, 'combine_links_in_buffer' ], 9 );
			// Then fold body-printed stylesheets (page-builder widget/form CSS the head
			// pass can't reach) into a second, render-blocking bundle. Priority 10 so it
			// runs after the head pass on the same buffer.
			add_filter( 'BCBOOST_buffer', [ $this, 'combine_body_links_in_buffer' ], 10 );
		}
	}

	/**
	 * Find and minify all inline <style> blocks in an HTML string.
	 *
	 * @param string $html HTML content.
	 * @return string Modified HTML.
	 */
	public function minify_inline_styles( string $html ): string {
		// Builder editors / previews are never transformed (defense in depth — the
		// boot-time gate in Plugin.php cannot see is_preview()/Customizer states).
		if ( ! BCBOOST_is_optimizable_request() ) {
			return $html;
		}

		$saved = 0;
		$result = preg_replace_callback(
			'/<style(?:[^>]*)>([\s\S]*?)<\/style>/i',
			static function ( array $matches ) use ( &$saved ): string {
				$minified = self::minify( $matches[1] );
				$saved   += max( 0, strlen( $matches[1] ) - strlen( $minified ) );
				return str_replace( $matches[1], $minified, $matches[0] );
			},
			$html
		) ?? $html;
		if ( $saved > 0 ) {
			( new \BaseCloud\Boost\Core\Metrics_Collector() )->increment( 'css_saved', $saved );
		}
		return $result;
	}

	/**
	 * Process all enqueued CSS files — minify and/or combine them.
	 *
	 * @return void
	 */
	public function process_enqueued_styles(): void {
		if ( is_admin() ) {
			return;
		}

		// Builder editors / previews load the real theme styles — leave them alone.
		if ( ! BCBOOST_is_optimizable_request() ) {
			return;
		}

		global $wp_styles;
		if ( ! $wp_styles instanceof \WP_Styles ) {
			return;
		}

		$excludes = array_filter( array_map( 'trim', explode( "\n", (string) BCBOOST_get_option( 'exclude_css', '' ) ) ) );

		$wp_styles->all_deps( $wp_styles->queue );

		foreach ( $wp_styles->to_do as $handle ) {
			$obj = $wp_styles->registered[ $handle ] ?? null;
			if ( ! $obj || empty( $obj->src ) ) {
				continue;
			}

			// Skip conditional stylesheets (IE-only).
			if ( ! empty( $obj->extra['conditional'] ) ) {
				continue;
			}

			// Skip excluded patterns.
			if ( $this->is_excluded( (string) $obj->src, $excludes ) ) {
				continue;
			}

			$path = $this->url_to_path( (string) $obj->src );
			if ( ! $path || ! file_exists( $path ) ) {
				continue;
			}

			$min_file = $this->get_minified_file( $path, (string) $obj->src );
			if ( $min_file ) {
				$obj->src = $this->path_to_url( $min_file );
				$obj->ver = null;
			}
		}
	}

	/**
	 * Combine local stylesheets in the final HTML <head> into a single minified
	 * bundle.
	 *
	 * Runs on the BCBOOST_buffer filter (the complete document), so it also folds
	 * stylesheets that page builders enqueue late in the render — the bulk of
	 * render-blocking CSS on Elementor/Divi/etc. sites, which the enqueue-time
	 * pass never sees. Only same-origin, all-media, non-excluded stylesheets are
	 * folded; external (fonts/CDN), print/media-query, preload and already-bundled
	 * links are left untouched. The bundle is placed at the position of the first
	 * folded link and the rest are removed, preserving cascade order.
	 *
	 * @param string $html Full page HTML.
	 * @return string
	 */
	public function combine_links_in_buffer( string $html ): string {
		// Builder editors / previews must keep their individual stylesheets.
		if ( ! BCBOOST_is_optimizable_request() ) {
			return $html;
		}

		if ( stripos( $html, '<head' ) === false ) {
			return $html;
		}

		// Confine folding to <head> — that is where render-blocking stylesheets
		// live, and it keeps the cascade predictable.
		if ( ! preg_match( '#<head\b[^>]*>(.*?)</head>#is', $html, $m, PREG_OFFSET_CAPTURE ) ) {
			return $html;
		}
		$head       = $m[1][0];
		$head_start = (int) $m[1][1];
		$head_len   = strlen( $head );

		$excludes = array_filter( array_map( 'trim', explode( "\n", (string) BCBOOST_get_option( 'exclude_css', '' ) ) ) );

		// Resolved once per page: async delivery is only allowed when critical CSS
		// covers this request (per-URL/template/global — the monitornet gate).
		$async_active = BCBOOST_async_css_active();

		if ( ! preg_match_all( '#<link\b[^>]*>#i', $head, $links, PREG_OFFSET_CAPTURE ) ) {
			return $html;
		}

		$folds = [];
		foreach ( $links[0] as $lk ) {
			$tag    = $lk[0];
			$offset = (int) $lk[1];

			if ( ! preg_match( '#\srel\s*=\s*["\']?stylesheet["\']?#i', $tag ) ) {
				continue;
			}
			// Skip preload hints (rel="preload" as="style") — not render links. A
			// stray earlier bundle is re-folded (not skipped) so the page always ends
			// with exactly one bundle and never a duplicate id.
			if ( preg_match( '#\srel\s*=\s*["\']?preload#i', $tag ) ) {
				continue;
			}
			// Only fold all-media (or unspecified) stylesheets — print/media-query
			// sheets must keep their own media or they would apply everywhere.
			if ( preg_match( '#\smedia\s*=\s*["\']([^"\']+)["\']#i', $tag, $mm ) ) {
				$media = strtolower( trim( $mm[1] ) );
				if ( '' !== $media && 'all' !== $media ) {
					continue;
				}
			}
			if ( ! preg_match( '#\shref\s*=\s*["\']([^"\']+)["\']#i', $tag, $hm ) ) {
				continue;
			}
			$href = html_entity_decode( $hm[1], ENT_QUOTES );
			if ( $this->is_excluded( $href, $excludes ) ) {
				continue;
			}
			// When the bundle will be delivered async, per-page builder CSS
			// (Elementor post-*.css, Divi /et-cache/, …) must NOT ride in it —
			// those page-specific rules are never in generic critical CSS, so
			// async delivery would paint the page unstyled (monitornet Careers).
			// Leave such links untouched: individual and render-blocking.
			if ( $async_active && self::is_async_excluded( $tag . ' ' . $href ) ) {
				continue;
			}
			$path = $this->url_to_path( $href );
			if ( ! $path || ! file_exists( $path ) ) {
				continue; // External (fonts/CDN) or unresolved — leave as-is.
			}

			$folds[] = [ 'offset' => $offset, 'len' => strlen( $tag ), 'path' => $path, 'href' => $href ];
		}

		if ( count( $folds ) < 2 ) {
			// Nothing to merge. If Async CSS is on, still make a lone same-origin
			// render-blocking stylesheet non-blocking — the per-stylesheet async swap
			// in Asset_Optimizer is disabled while Combine CSS is on, so this sheet
			// would otherwise keep blocking the first paint.
			if ( 1 === count( $folds ) && $async_active ) {
				$f   = $folds[0];
				$tag = substr( $head, $f['offset'], $f['len'] );
				if ( ! self::is_async_excluded( $tag . ' ' . $f['href'] ) ) {
					$async = self::make_link_async( $tag );
					if ( $async !== $tag ) {
						$new_head = substr_replace( $head, $async, $f['offset'], $f['len'] );
						return substr_replace( $html, $new_head, $head_start, $head_len );
					}
				}
			}
			return $html;
		}

		// Build the bundle (shared with the body-region combiner).
		$built = $this->build_css_bundle( $folds, 'bcboost-bundle' );
		if ( null === $built ) {
			return $html;
		}
		$bundle_url = $built['url'];

		// When Async CSS is enabled, load the bundle without blocking first paint
		// (media="print" + onload swap, with a <noscript> fallback). The theme's
		// inline above-the-fold CSS paints immediately; the bundle styles apply as
		// soon as they arrive. This is the single biggest mobile First-Contentful-
		// Paint lever on CSS-heavy (page-builder) sites.
		if ( $async_active ) {
			$bundle_tag = "<link rel='stylesheet' id='bcboost-css-bundle-css' href='" . $bundle_url
				. "' media='print' onload=\"this.media='all';this.onload=null\" />"
				. "<noscript><link rel='stylesheet' href='" . $bundle_url . "' media='all' /></noscript>";
		} else {
			$bundle_tag = "<link rel='stylesheet' id='bcboost-css-bundle-css' href='" . $bundle_url . "' media='all' />";
		}

		// Replace the first folded link with the bundle; remove the others. Work
		// back-to-front so earlier offsets stay valid.
		$new_head = $head;
		for ( $i = count( $folds ) - 1; $i >= 0; $i-- ) {
			$replacement = ( 0 === $i ) ? $bundle_tag : '';
			$new_head    = substr_replace( $new_head, $replacement, $folds[ $i ]['offset'], $folds[ $i ]['len'] );
		}

		// Hero/LCP preload note: the combined-CSS background hero used to be
		// detected and preloaded right here, which coupled the LCP preload to
		// Combine CSS being ON. That logic now lives in LCP_Optimizer (runs on
		// BCBOOST_buffer at priority 12, i.e. after this pass, reading the
		// bundle back from disk) so it works with or without combining.

		return substr_replace( $html, $new_head, $head_start, $head_len );
	}

	/**
	 * Combine same-origin stylesheets that page builders (Elementor, Astra, Gravity
	 * Forms, …) print in the <body> — which the head pass cannot reach — into a single
	 * bundle. Runs on BCBOOST_buffer after the head combiner.
	 *
	 * Unlike the head bundle, the body bundle is kept RENDER-BLOCKING even when Async
	 * CSS is on: body-printed CSS overwhelmingly styles above-the-fold elements (the
	 * nav menu, the contact form), so loading it asynchronously would flash unstyled
	 * content and shift layout (CLS). Folding it into one sheet at the FIRST body
	 * stylesheet's position — ahead of that content — removes the per-file latency of
	 * many small blocking requests AND guarantees the content is styled before it
	 * paints. Cascade order is preserved (head bundle, then this body bundle).
	 *
	 * @param string $html Full page HTML.
	 * @return string
	 */
	public function combine_body_links_in_buffer( string $html ): string {
		// Builder editors / previews must keep their individual stylesheets.
		if ( ! BCBOOST_is_optimizable_request() ) {
			return $html;
		}

		$head_end = stripos( $html, '</head>' );
		if ( false === $head_end ) {
			return $html;
		}
		$body_start = $head_end + 7;
		$body_close = stripos( $html, '</body>', $body_start );
		$region_end = ( false !== $body_close ) ? $body_close : strlen( $html );
		$region     = substr( $html, $body_start, $region_end - $body_start );

		$excludes = array_filter( array_map( 'trim', explode( "\n", (string) BCBOOST_get_option( 'exclude_css', '' ) ) ) );
		$folds    = $this->detect_css_folds( $region, $excludes );
		if ( count( $folds ) < 2 ) {
			return $html; // Nothing worth bundling in the body.
		}

		$built = $this->build_css_bundle( $folds, 'bcboost-body-bundle' );
		if ( null === $built ) {
			return $html;
		}

		$bundle_tag = "<link rel='stylesheet' id='bcboost-body-bundle-css' href='" . $built['url'] . "' media='all' />";

		// Replace the first folded link with the bundle; remove the rest. Work
		// back-to-front so earlier offsets stay valid.
		$new_region = $region;
		for ( $i = count( $folds ) - 1; $i >= 0; $i-- ) {
			$replacement = ( 0 === $i ) ? $bundle_tag : '';
			$new_region  = substr_replace( $new_region, $replacement, $folds[ $i ]['offset'], $folds[ $i ]['len'] );
		}

		return substr_replace( $html, $new_region, $body_start, $region_end - $body_start );
	}

	/**
	 * Scan a region of HTML for foldable stylesheet links: same-origin, all-media,
	 * non-preload, non-excluded, resolvable to a local file. Returns fold descriptors
	 * (offset within the region, tag length, local path, original href).
	 *
	 * @param string   $region   HTML region to scan.
	 * @param string[] $excludes Exclusion patterns.
	 * @return array<int, array{offset:int,len:int,path:string,href:string}>
	 */
	private function detect_css_folds( string $region, array $excludes ): array {
		if ( ! preg_match_all( '#<link\b[^>]*>#i', $region, $links, PREG_OFFSET_CAPTURE ) ) {
			return [];
		}
		$folds = [];
		foreach ( $links[0] as $lk ) {
			$tag    = $lk[0];
			$offset = (int) $lk[1];

			if ( ! preg_match( '#\srel\s*=\s*["\']?stylesheet["\']?#i', $tag ) ) {
				continue;
			}
			if ( preg_match( '#\srel\s*=\s*["\']?preload#i', $tag ) ) {
				continue;
			}
			if ( preg_match( '#\smedia\s*=\s*["\']([^"\']+)["\']#i', $tag, $mm ) ) {
				$media = strtolower( trim( $mm[1] ) );
				if ( '' !== $media && 'all' !== $media ) {
					continue;
				}
			}
			if ( ! preg_match( '#\shref\s*=\s*["\']([^"\']+)["\']#i', $tag, $hm ) ) {
				continue;
			}
			$href = html_entity_decode( $hm[1], ENT_QUOTES );
			if ( $this->is_excluded( $href, $excludes ) ) {
				continue;
			}
			$path = $this->url_to_path( $href );
			if ( ! $path || ! file_exists( $path ) ) {
				continue;
			}
			$folds[] = [ 'offset' => $offset, 'len' => strlen( $tag ), 'path' => $path, 'href' => $href ];
		}
		return $folds;
	}

	/**
	 * Build a minified CSS bundle from a set of fold descriptors: read each file, make
	 * relative url()/@import absolute, strip stray @charset, optionally add
	 * font-display:swap, rewrite background-image url()s to WebP/AVIF + CDN, minify and
	 * concatenate (de-duplicated by path, in order). Writes the bundle to the cache dir
	 * and returns its (CDN-rewritten) URL plus the combined CSS. Shared by the head and
	 * body combiners.
	 *
	 * @param array<int, array{offset:int,len:int,path:string,href:string}> $folds  Folds.
	 * @param string                                                        $prefix Bundle filename prefix.
	 * @return array{url:string,css:string}|null Null when nothing usable was produced.
	 */
	private function build_css_bundle( array $folds, string $prefix ): ?array {
		$combined = '';
		$hash_src = '';
		$seen     = [];
		foreach ( $folds as $f ) {
			if ( isset( $seen[ $f['path'] ] ) ) {
				continue;
			}
			$seen[ $f['path'] ] = true;

			$content = (string) @file_get_contents( $f['path'] );
			if ( '' === trim( $content ) ) {
				continue;
			}
			$content = self::rewrite_relative_urls( $content, $this->dir_url( $f['href'] ) );
			$content = preg_replace( '/@charset\s+["\'][^"\']*["\']\s*;/i', '', $content ) ?? $content;
			if ( $this->font_swap ) {
				$content = self::add_font_display_swap( $content );
			}
			$content = $this->rewrite_css_image_urls( $content );

			$hash_src .= $f['path'] . '|' . $content;
			$combined .= self::minify( $content ) . "\n";
		}

		if ( '' === trim( $combined ) ) {
			return null;
		}

		wp_mkdir_p( $this->cache_dir );
		$hash     = substr( md5( $hash_src ), 0, 12 );
		$bundle   = $prefix . '-' . $hash . '.min.css';
		$out_file = $this->cache_dir . $bundle;

		if ( ! file_exists( $out_file ) ) {
			// Atomic write (avoids LOCK_EX, which fails on some NFS volumes).
			$tmp = $out_file . '.tmp.' . getmypid();
			if ( false !== file_put_contents( $tmp, $combined ) ) {
				rename( $tmp, $out_file );
			} elseif ( file_exists( $tmp ) ) {
				@unlink( $tmp );
			}
		}

		$bundle_url = $this->cache_url . $bundle;
		// Serve from the CDN too (the combiner runs after the BunnyCDN URL rewriter, so a
		// freshly-emitted bundle URL is still on the origin — map it the same way).
		if ( '' !== $this->cdn_base ) {
			$origin = rtrim( (string) site_url(), '/' );
			if ( strpos( $bundle_url, $origin . '/wp-content/' ) === 0 ) {
				$bundle_url = $this->cdn_base . substr( $bundle_url, strlen( $origin ) );
			}
		}

		return [ 'url' => esc_url( $bundle_url ), 'css' => $combined ];
	}

	/**
	 * Whether a stylesheet (link tag + href haystack) matches the built-in
	 * async-CSS exclusion list (per-page builder CSS that must stay
	 * render-blocking). Single source: Asset_Optimizer::css_async_excludes(),
	 * filterable via BCBOOST_async_css_excludes; entries are substrings or
	 * `regex:`-prefixed patterns, resolved by the shared matcher
	 * Asset_Optimizer::css_exclude_matches().
	 *
	 * @param string $haystack Link tag and/or href to test.
	 * @return bool
	 */
	private static function is_async_excluded( string $haystack ): bool {
		foreach ( Asset_Optimizer::css_async_excludes() as $pattern ) {
			if ( Asset_Optimizer::css_exclude_matches( $pattern, $haystack ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Convert a render-blocking <link rel=stylesheet> tag to the async print+onload
	 * pattern, with a <noscript> fallback. Used for the single-stylesheet case where
	 * there is nothing to combine but Async CSS is requested.
	 *
	 * @param string $tag Original <link> tag.
	 * @return string Async tag (+ noscript), or the original if it is already async.
	 */
	private static function make_link_async( string $tag ): string {
		// Already async (print media swap)?
		if ( preg_match( '#\smedia\s*=\s*["\']print["\']#i', $tag ) ) {
			return $tag;
		}

		$swap = " media='print' onload=\"this.media='all';this.onload=null\"";

		if ( preg_match( '#\smedia\s*=\s*["\'][^"\']*["\']#i', $tag ) ) {
			$async = preg_replace( '#\smedia\s*=\s*["\'][^"\']*["\']#i', $swap, $tag, 1 ) ?? $tag;
		} else {
			$async = preg_replace( '#\s*/?>\s*$#', $swap . ' />', $tag, 1 ) ?? $tag;
		}

		if ( $async === $tag ) {
			return $tag;
		}

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

	/**
	 * Return the path to a minified version of a CSS file, creating it if needed.
	 *
	 * @param string $source_path Absolute path to the source CSS file.
	 * @return string|null Path to minified file, or null if already minified / unreadable.
	 */
	private function get_minified_file( string $source_path, string $source_url = '' ): ?string {
		// Skip already-minified files.
		if ( preg_match( '/\.min\.css$/i', $source_path ) ) {
			return null;
		}

		$content = file_get_contents( $source_path );
		if ( false === $content || '' === trim( $content ) ) {
			return null;
		}

		// The minified copy lives in our cache dir, so any relative url()/@import in
		// the source must be made absolute or it would resolve to the wrong place.
		if ( '' !== $source_url ) {
			$content = self::rewrite_relative_urls( $content, $this->dir_url( $source_url ) );
		}

		if ( $this->font_swap ) {
			$content = self::add_font_display_swap( $content );
		}

		wp_mkdir_p( $this->cache_dir );

		$hash     = substr( md5( $content ), 0, 12 );
		$basename = pathinfo( $source_path, PATHINFO_FILENAME );
		$out_file = $this->cache_dir . $basename . '-' . $hash . '.min.css';

		if ( ! file_exists( $out_file ) ) {
			$minified = self::minify( $content );
			file_put_contents( $out_file, $minified, LOCK_EX );
		}

		return $out_file;
	}

	/**
	 * Return the directory URL (with trailing slash) of a stylesheet URL.
	 *
	 * @param string $url Stylesheet URL (may include a query string).
	 * @return string
	 */
	private function dir_url( string $url ): string {
		$url = (string) strtok( $url, '?#' );
		return preg_replace( '#/[^/]*$#', '/', $url ) ?? $url;
	}

	/**
	 * Rewrite relative url() and @import targets in a CSS string to absolute URLs,
	 * resolved against the stylesheet's own directory. Absolute, root-relative,
	 * protocol-relative, and data: URIs are left untouched.
	 *
	 * @param string $css      CSS source.
	 * @param string $base_url Directory URL of the source stylesheet (trailing slash).
	 * @return string
	 */
	public static function rewrite_relative_urls( string $css, string $base_url ): string {
		if ( '' === $base_url ) {
			return $css;
		}

		// url( ... )
		$css = preg_replace_callback(
			'/url\(\s*([\'"]?)([^\'")]+)\1\s*\)/i',
			static function ( array $m ) use ( $base_url ): string {
				return 'url(' . $m[1] . self::resolve_relative( $base_url, trim( $m[2] ) ) . $m[1] . ')';
			},
			$css
		) ?? $css;

		// @import "..." / @import '...'
		$css = preg_replace_callback(
			'/@import\s+([\'"])([^\'"]+)\1/i',
			static function ( array $m ) use ( $base_url ): string {
				return '@import ' . $m[1] . self::resolve_relative( $base_url, trim( $m[2] ) ) . $m[1];
			},
			$css
		) ?? $css;

		return $css;
	}

	/**
	 * Resolve a possibly-relative URL against a base directory URL.
	 *
	 * @param string $base Base directory URL (with trailing slash).
	 * @param string $rel  URL found in the CSS.
	 * @return string Absolute URL (or the original when already absolute / data:).
	 */
	private static function resolve_relative( string $base, string $rel ): string {
		if ( '' === $rel ) {
			return $rel;
		}
		// Already absolute, root-relative, protocol-relative, data:, or a bare fragment.
		if ( preg_match( '#^(?:data:|https?://|//|/|\#)#i', $rel ) ) {
			return $rel;
		}

		$parts = wp_parse_url( $base );
		if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return $rel;
		}

		$origin = $parts['scheme'] . '://' . $parts['host']
			. ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' );

		// Normalise the path: base path + relative, collapsing ./ and ../ segments.
		$full     = ( $parts['path'] ?? '/' ) . $rel;
		$segments = [];
		foreach ( explode( '/', $full ) as $seg ) {
			if ( '..' === $seg ) {
				array_pop( $segments );
			} elseif ( '.' !== $seg && '' !== $seg ) {
				$segments[] = $seg;
			}
		}

		return $origin . '/' . implode( '/', $segments );
	}

	/**
	 * Rewrite image url() targets inside CSS to the best available modern format
	 * (AVIF → WebP, when the converted file exists on disk) and to the CDN host —
	 * mirroring what the <img> optimiser and the BunnyCDN HTML rewriter already do
	 * for markup. CSS background images (notably page-builder hero/section
	 * backgrounds, which are frequently the LCP element) are reached by neither of
	 * those passes, so without this they stay as full-size origin JPG/PNGs.
	 *
	 * url()s are expected to already be absolute origin URLs ({@see
	 * rewrite_relative_urls} has run). Non-raster, external and data: URLs are left
	 * untouched. When the modern file does not exist the original URL is kept and
	 * only the CDN host swap (if any) is applied — so this never points at a
	 * missing file.
	 *
	 * @param string $css Absolute-URL CSS.
	 * @return string
	 */
	private function rewrite_css_image_urls( string $css ): string {
		$img_opt = Image_Optimizer::is_enabled() ? new Image_Optimizer() : null;

		// Nothing to do if neither modern formats nor a CDN are in play.
		if ( null === $img_opt && '' === $this->cdn_base ) {
			return $css;
		}

		$origin = rtrim( (string) site_url(), '/' );

		return preg_replace_callback(
			'/url\(\s*([\'"]?)([^\'")]+)\1\s*\)/i',
			function ( array $m ) use ( $img_opt, $origin ): string {
				$url   = trim( $m[2] );
				$quote = $m[1];

				// Only touch raster images we can convert / serve from the CDN.
				if ( ! preg_match( '/\.(?:jpe?g|png|gif)(?:\?|#|$)/i', $url ) ) {
					return $m[0];
				}

				// 1) Modern format — returns an origin WebP/AVIF URL if the file exists.
				if ( null !== $img_opt ) {
					$modern = $img_opt->modernize_url( $url );
					if ( $modern ) {
						$url = $modern;
					}
				}

				// 2) Push to the CDN, using the same /wp-content/ and /wp-includes/
				//    mapping the CDN rewriter applies to every other asset.
				if ( '' !== $this->cdn_base && strpos( $url, $origin . '/' ) === 0 ) {
					$rel = substr( $url, strlen( $origin ) );
					if ( strpos( $rel, '/wp-content/' ) === 0 || strpos( $rel, '/wp-includes/' ) === 0 ) {
						$url = $this->cdn_base . $rel;
					}
				}

				return 'url(' . $quote . $url . $quote . ')';
			},
			$css
		) ?? $css;
	}

	/**
	 * Check whether a URL matches any exclusion pattern.
	 *
	 * @param string   $url      Asset URL.
	 * @param string[] $excludes List of patterns.
	 * @return bool
	 */
	private function is_excluded( string $url, array $excludes ): bool {
		foreach ( $excludes as $pattern ) {
			if ( '' !== $pattern && false !== strpos( $url, $pattern ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Convert a public URL to an absolute server filesystem path.
	 *
	 * @param string $url Asset URL.
	 * @return string|null Absolute path, or null if not resolvable.
	 */
	private function url_to_path( string $url ): ?string {
		// Normalise protocol-relative URLs.
		$url = preg_replace( '#^//#', 'https://', $url ) ?? $url;

		// Map CDN-rewritten URLs back to the origin so the local path resolves. The
		// BunnyCDN rewriter only ever touches /wp-content/ and /wp-includes/ paths.
		if ( '' !== $this->cdn_base && strpos( $url, $this->cdn_base . '/' ) === 0 ) {
			$url = rtrim( (string) site_url(), '/' ) . substr( $url, strlen( $this->cdn_base ) );
		}

		$content_url = content_url();
		$site_url    = site_url();

		if ( strpos( $url, $content_url ) === 0 ) {
			return WP_CONTENT_DIR . substr( $url, strlen( $content_url ) );
		}
		if ( strpos( $url, $site_url ) === 0 ) {
			return untrailingslashit( ABSPATH ) . substr( $url, strlen( $site_url ) );
		}

		return null;
	}

	/**
	 * Convert an absolute filesystem path back to a public URL.
	 *
	 * @param string $path Absolute path.
	 * @return string Public URL.
	 */
	private function path_to_url( string $path ): string {
		if ( strpos( $path, WP_CONTENT_DIR ) === 0 ) {
			return content_url( substr( $path, strlen( WP_CONTENT_DIR ) ) );
		}
		return site_url( '/' . ltrim( substr( $path, strlen( ABSPATH ) ), '/' ) );
	}

	/**
	 * Inject `font-display: swap` into @font-face rules that don't already declare a
	 * font-display descriptor. Keeps text and icon fonts visible during load (avoids
	 * the invisible-text FOIT period) — the "Font display" PageSpeed item. Applies to
	 * external stylesheets (icon-font packs, self-hosted fonts) that the Google Fonts
	 * optimiser can't reach because their @font-face lives in a .css file, not inline.
	 *
	 * @param string $css CSS source.
	 * @return string
	 */
	/**
	 * Heuristic: does this @font-face block define an ICON font (glyphs mapped in
	 * the Unicode private-use area) rather than a text font? Icon fonts break under
	 * `font-display:swap` — during the swap window the fallback face renders a
	 * stand-in glyph (a wrong letter or tofu box) where an icon should be — so
	 * callers give them `block` (brief invisibility, then the real icon) instead.
	 *
	 * Signals (any one is enough): a known icon-pack family name or source URL
	 * token, or a `unicode-range` that lies in the PUA (U+E000–F8FF, where icon
	 * packs place their glyphs). A false positive only downgrades a text font to
	 * `block` (a short FOIT) — never breaks anything.
	 *
	 * Shared with Google_Fonts::inject_font_face_swap() so "what is an icon font"
	 * has a single definition.
	 *
	 * @param string $block Inner @font-face declaration text (no braces).
	 * @return bool
	 */
	public static function is_icon_font_face( string $block ): bool {
		$family = '';
		if ( preg_match( '/font-family\s*:\s*([^;]+)/i', $block, $fm ) ) {
			$family = strtolower( trim( $fm[1], " \t\"'" ) );
		}
		// Match src tokens against file BASENAMES only — never directory paths. A
		// TEXT font that merely lives under /themes/themify-*/fonts/ or a
		// WooCommerce plugin dir must not be classified as an icon font (that
		// would give real copy `block` = invisible text on slow mobile).
		$names = '';
		if ( preg_match_all( '/url\(\s*[\'"]?([^\'")]+)/i', $block, $um ) ) {
			$bases = [];
			foreach ( $um[1] as $u ) {
				$path    = (string) strtok( $u, '?#' );
				$bases[] = strtolower( basename( $path ) );
			}
			$names = implode( ' ', $bases );
		}
		$hay = $family . ' ' . $names;

		// Distinctive icon-pack names — matched against family + src basenames.
		static $tokens = [
			'fontawesome', 'font awesome', 'fa-brands', 'fa-solid', 'fa-regular', 'fontawe',
			'dashicons', 'eicons', 'etmodules', 'et-modules', 'themify',
			'ionicons', 'icomoon', 'genericons', 'glyphicon', 'bootstrap-icons',
			'material icons', 'material-icons', 'materialicons', 'material symbols',
			'simple-line-icons', 'line-awesome', 'lineawesome', 'flaticon', 'socicon',
			'typicons', 'foundation-icons', 'foundation icons', 'revicons', 'entypo',
			'linearicons', 'elusive', 'fontello', 'pe-icon', 'iconfont', 'weathericons',
		];
		foreach ( $tokens as $t ) {
			if ( '' !== $t && strpos( $hay, $t ) !== false ) {
				return true;
			}
		}

		// Generic words too common in ordinary filenames — exact family match only.
		static $family_tokens = [ 'woocommerce', 'star', 'slick' ];
		foreach ( $family_tokens as $t ) {
			if ( $family === $t ) {
				return true;
			}
		}

		// A unicode-range sitting in the private-use area (U+E000–F8FF) is an icon
		// font regardless of its name. Text/CJK/emoji ranges start elsewhere
		// (Latin U+00xx, CJK U+4Exx, emoji U+1Fxxx), so this rarely false-positives.
		if ( preg_match( '/unicode-range[^;{}]*u\+0*(?:e[0-9a-f]|f[0-8])[0-9a-f]{2}/i', $block ) ) {
			return true;
		}

		return false;
	}

	public static function add_font_display_swap( string $css ): string {
		if ( stripos( $css, '@font-face' ) === false ) {
			return $css;
		}
		return preg_replace_callback(
			'/@font-face\s*\{([^}]*)\}/i',
			static function ( array $m ): string {
				if ( stripos( $m[1], 'font-display' ) !== false ) {
					return $m[0]; // Already declared — respect it.
				}
				// Icon fonts must NOT get `swap`: their glyphs live in the Unicode
				// private-use area, so a fallback face paints a wrong character / tofu
				// box during the swap window — broken FontAwesome/Dashicons/Divi icons
				// on first paint, worst on throttled mobile. `block` keeps them briefly
				// invisible, then correct (what those packs ship). Text fonts keep
				// `swap` so real copy is never invisible (no FOIT).
				$display = self::is_icon_font_face( $m[1] ) ? 'block' : 'swap';
				return '@font-face{' . $m[1] . ';font-display:' . $display . '}';
			},
			$css
		) ?? $css;
	}

	// ─── Core Minification Engine ─────────────────────────────────────────────

	/**
	 * Minify a CSS string.
	 *
	 * @param string $css Raw CSS.
	 * @return string Minified CSS.
	 */
	public static function minify( string $css ): string {
		if ( '' === trim( $css ) ) {
			return $css;
		}

		// Strip comments AND preserve strings in a SINGLE left-to-right pass.
		// These must be handled together: a quote can appear inside a comment
		// (e.g. /* Elementor's font */) and a comment sequence can appear inside a
		// string (e.g. content:"/* x */"). Processing strings first (the old code)
		// let an apostrophe in a comment open a bogus "string" that swallowed the
		// CSS up to the next quote — then comment removal deleted the token, losing
		// large spans of real CSS. Matching comments/strings in one alternation,
		// left to right, means whichever opens first wins, so neither can corrupt
		// the other. /*! ... */ license comments are kept; other comments removed.
		// Non-greedy [\s\S]*? also avoids the catastrophic backtracking the previous
		// comment regex was prone to on large stylesheets.
		$strings = [];
		$idx     = 0;
		$css     = preg_replace_callback(
			'#/\*![\s\S]*?\*/|/\*[\s\S]*?\*/|"[^"]*"|\'[^\']*\'#',
			static function ( array $m ) use ( &$strings, &$idx ): string {
				$s = $m[0];
				if ( 0 === strpos( $s, '/*!' ) ) {
					return $s; // keep license comment verbatim
				}
				if ( 0 === strpos( $s, '/*' ) ) {
					return ''; // strip ordinary comment (including any quotes inside it)
				}
				$token             = '"__BCBOOST_STR_' . $idx . '__"';
				$strings[ $token ] = $s;
				$idx++;
				return $token;
			},
			$css
		) ?? $css;

		// Preserve calc() / min() / max() / clamp() expressions.
		// CSS requires spaces around + and - inside these — stripping them makes
		// the expression invalid (e.g. calc(100% - 20px) → calc(100%-20px)).
		$math_tokens = [];
		$math_idx    = 0;
		$css = preg_replace_callback(
			'/\b(calc|min|max|clamp|env|var)\s*\((?:[^()]*|\((?:[^()]*|\([^()]*\))*\))*\)/i',
			static function ( array $m ) use ( &$math_tokens, &$math_idx ): string {
				$token                  = '__BCBOOST_MATH_' . $math_idx . '__';
				$math_tokens[ $token ] = $m[0];
				$math_idx++;
				return $token;
			},
			$css
		) ?? $css;

		// (Comments were already stripped in the combined string/comment pass above.)

		// Remove whitespace around structural characters.
		// Note: + is intentionally excluded — it is used in calc() and adjacent-sibling
		// selectors both require spaces to be safe (calc spaces are protected above).
		$css = preg_replace( '/\s*([{};:,>~])\s*/', '$1', $css ) ?? $css;

		// Strip spaces around + only when it looks like a selector combinator
		// (preceded/followed by a non-digit, non-unit, non-. character).
		$css = preg_replace( '/(?<![0-9a-z%])\s*\+\s*(?![0-9])/i', '+', $css ) ?? $css;

		// Collapse whitespace.
		$css = preg_replace( '/\s{2,}/', ' ', $css ) ?? $css;

		// Remove newlines and tabs.
		$css = str_replace( [ "\n", "\r", "\t" ], ' ', $css );

		// Remove spaces inside parentheses (only opening — closing handled below).
		$css = preg_replace( '/\(\s+/', '(', $css ) ?? $css;
		$css = preg_replace( '/\s+\)/', ')', $css ) ?? $css;

		// Remove trailing semicolons before closing braces.
		$css = str_replace( ';}', '}', $css );

		// Shorten hex colours: #aabbcc → #abc.
		$css = preg_replace( '/#([0-9a-f])\1([0-9a-f])\2([0-9a-f])\3(?=[^0-9a-f])/i', '#$1$2$3', $css ) ?? $css;

		// Remove units on zero values (0px → 0).
		$css = preg_replace( '/(?<=[:\s,])0(px|em|rem|%|vw|vh|vmin|vmax)\b/', '0', $css ) ?? $css;

		// Restore calc/math expressions.
		foreach ( $math_tokens as $token => $original ) {
			$css = str_replace( $token, $original, $css );
		}

		// Restore strings.
		foreach ( $strings as $token => $original ) {
			$css = str_replace( $token, $original, $css );
		}

		return trim( $css );
	}
}
