<?php
/**
 * BaseCloud Boost — Local Critical CSS Generator (opt-in)
 *
 * Generates above-the-fold ("critical") CSS for a URL entirely in PHP — no
 * Node, no headless Chrome, no external service. The method is above-the-fold
 * SELECTOR MATCHING, the same family of approach several established plugins
 * use where a real render is unavailable:
 *
 *   1. Fetch the page's HTML (one loopback GET, transfer-capped).
 *   2. Take the above-the-fold window: the first N bytes of <body>
 *      (default 80 KB — hero + first sections on builder markup).
 *   3. Build token sets from that window: every class, id, and tag present.
 *   4. Read the page's SAME-ORIGIN stylesheets from disk (path-anchored under
 *      /wp-content/ / /wp-includes/, never a network fetch, count+size capped)
 *      in document order.
 *   5. Keep only the rules whose selectors are satisfiable by the fold's
 *      tokens (plus :root/html/body and @font-face; @media/@supports recurse;
 *      @keyframes/@import/@page are dropped). Relative url()s are made
 *      absolute so the inlined copy resolves identically.
 *   6. Lightly minify, cap the output size, return.
 *
 * Fidelity note (why this is a Beta, opt-in feature): token matching
 * over-includes (a below-fold rule whose classes also appear above the fold
 * is kept — harmless, just bytes) and can under-include on exotic selectors
 * (escaped class names à la Tailwind `md\:flex`, attribute-only selectors) —
 * an under-include shows as briefly unstyled fold content ONLY when Async CSS
 * is also enabled. That is why generation NEVER auto-enables anything:
 * the user explicitly opts in at every step (generate → enable Critical CSS →
 * optionally enable Async CSS), and the async gate still requires coverage to
 * exist per request. A configured remote endpoint (real-render service)
 * always takes precedence over this local method.
 *
 * @package BaseCloud\Boost\Optimizer
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Optimizer;

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

/**
 * PHP-only above-the-fold CSS extraction.
 */
class Critical_CSS_Generator {

	/** Hard cap on stylesheets read per page. */
	private const MAX_FILES = 40;

	/** Hard cap on total CSS bytes read from disk per page. */
	private const MAX_READ_BYTES = 5242880; // 5 MB

	/**
	 * Generate critical CSS for a URL.
	 *
	 * @param string $url Page URL (same site).
	 * @return string|\WP_Error Extracted CSS (unsanitized — caller stores via
	 *                          Critical_CSS::set_template_css which sanitizes).
	 */
	public static function generate_for_url( string $url ) {
		$html = self::fetch_html( $url );
		if ( is_wp_error( $html ) ) {
			return $html;
		}

		$body_at = stripos( $html, '<body' );
		if ( false === $body_at ) {
			return new \WP_Error( 'bcboost_ccss_nobody', 'Fetched page has no <body> element' );
		}
		$head = substr( $html, 0, $body_at );
		$body = substr( $html, $body_at );

		/**
		 * Filter the size of the above-the-fold HTML window (bytes) used for
		 * token extraction. Larger = more rules kept (safer, bigger output).
		 *
		 * @param int $bytes Default 80000.
		 */
		$fold_bytes = max( 10000, (int) apply_filters( 'BCBOOST_ccss_fold_bytes', 80000 ) );
		$fold       = substr( $body, 0, $fold_bytes );

		$sets = self::token_sets( $fold );
		if ( empty( $sets['classes'] ) && empty( $sets['ids'] ) ) {
			return new \WP_Error( 'bcboost_ccss_nofold', 'Above-the-fold window yielded no class/id tokens — page markup not readable' );
		}

		$out        = '';
		$read_bytes = 0;
		$files      = 0;
		$skipped    = 0;

		foreach ( self::stylesheet_hrefs( $head ) as $href ) {
			if ( $files >= self::MAX_FILES || $read_bytes >= self::MAX_READ_BYTES ) {
				$skipped++;
				continue;
			}
			$file = self::local_css_path( $href );
			if ( null === $file ) {
				$skipped++; // External/unmapped (e.g. fonts.googleapis.com) — other Boost features handle fonts.
				continue;
			}
			$size = (int) @filesize( $file );
			if ( $size <= 0 || $read_bytes + $size > self::MAX_READ_BYTES ) {
				$skipped++;
				continue;
			}
			$css = (string) @file_get_contents( $file );
			if ( '' === $css ) {
				continue;
			}
			$files++;
			$read_bytes += $size;

			// Comments out first (string-aware: an apostrophe or "/*" inside a
			// content:"…" string must never be mistaken for comment syntax — the
			// 1.2.x Gravity Forms lesson); then absolutize url()/@import targets
			// against the stylesheet's own URL so the inlined copy resolves
			// identically.
			$css = self::strip_comments( $css );
			$css = CSS_Minifier::rewrite_relative_urls( $css, self::dir_url( $href ) );
			$css = (string) preg_replace( '/@(?:import|charset)[^;]*;/i', '', $css );

			$kept = self::extract_matching_rules( $css, $sets );
			if ( '' !== $kept ) {
				$out .= $kept;
			}
		}

		if ( '' === trim( $out ) ) {
			return new \WP_Error( 'bcboost_ccss_none', 'No local stylesheets produced matching above-the-fold rules' );
		}

		// Light minify: collapse runs of whitespace, drop space around braces.
		// ':' is deliberately NOT in the collapse class — whitespace before ':'
		// is significant in selectors (`.menu :hover` ≠ `.menu:hover`).
		$out = (string) preg_replace( '/\s+/', ' ', $out );
		$out = (string) preg_replace( '/\s*([{};,])\s*/', '$1', $out );

		/**
		 * Filter the maximum generated critical CSS size (bytes) BEFORE storing.
		 * Oversize output is truncated at the last complete rule.
		 *
		 * @param int $bytes Default 70000 (~70 KB inline is the sane ceiling).
		 */
		$max = max( 5000, (int) apply_filters( 'BCBOOST_ccss_output_max', 70000 ) );
		if ( strlen( $out ) > $max ) {
			$cut = strrpos( substr( $out, 0, $max ), '}' );
			$out = false === $cut ? '' : substr( $out, 0, $cut + 1 );
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( '[BaseCloud Boost] Critical CSS generator: output truncated to ' . size_format( strlen( $out ) ) . ' for ' . $url );
			}
		}
		if ( '' === $out ) {
			return new \WP_Error( 'bcboost_ccss_none', 'Generated CSS was empty after size capping' );
		}

		if ( $skipped > 0 && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
			error_log( '[BaseCloud Boost] Critical CSS generator: ' . $skipped . ' stylesheet(s) skipped (external or over caps) for ' . $url );
		}

		return $out;
	}

	// ─── Fetch & parse inputs ─────────────────────────────────────────────────

	/**
	 * Fetch the page HTML via loopback GET. sslverify off mirrors the cache
	 * preloader (self-signed local certs on managed hosts).
	 *
	 * @param string $url Page URL.
	 * @return string|\WP_Error
	 */
	private static function fetch_html( string $url ) {
		$response = wp_remote_get( $url, [
			'timeout'             => 20,
			'redirection'         => 2,
			'limit_response_size' => 3145728, // 3 MB
			'sslverify'           => false,
			'user-agent'          => 'BaseCloud-Boost-CCSS/' . BCBOOST_VERSION . ' (+critical-css-generator)',
		] );
		if ( is_wp_error( $response ) ) {
			return $response;
		}
		$code = (int) wp_remote_retrieve_response_code( $response );
		if ( 200 !== $code ) {
			return new \WP_Error( 'bcboost_ccss_fetch', 'Page returned HTTP ' . $code );
		}
		$html = (string) wp_remote_retrieve_body( $response );
		if ( strlen( $html ) < 500 ) {
			return new \WP_Error( 'bcboost_ccss_fetch', 'Page body too small to analyse' );
		}
		return $html;
	}

	/**
	 * Stylesheet hrefs from <head>, document order. Skips print-only sheets and
	 * non-stylesheet links; keeps media="all"/"screen"/absent and the async
	 * media="print" onload pattern OUT (those are already deferred by design).
	 *
	 * @param string $head HTML before <body>.
	 * @return string[]
	 */
	private static function stylesheet_hrefs( string $head ): array {
		$hrefs = [];
		if ( ! preg_match_all( '#<link\b[^>]*rel=["\']stylesheet["\'][^>]*>#i', $head, $links ) ) {
			return $hrefs;
		}
		foreach ( $links[0] as $tag ) {
			if ( preg_match( '/\bmedia\s*=\s*["\']([^"\']+)["\']/i', $tag, $mm ) ) {
				$media = strtolower( trim( $mm[1] ) );
				if ( 'all' !== $media && 'screen' !== $media && ! str_starts_with_polyfill( $media, '(' ) ) {
					continue; // print / speech / already-async pattern.
				}
			}
			if ( preg_match( '/\bhref\s*=\s*["\']([^"\']+)["\']/i', $tag, $hm ) ) {
				$hrefs[] = html_entity_decode( $hm[1], ENT_QUOTES );
			}
		}
		return $hrefs;
	}

	/**
	 * Map a stylesheet URL to a local file path via the /wp-content/ or
	 * /wp-includes/ anchor (host-agnostic: origin and CDN-rewritten URLs both
	 * resolve). Returns null for anything unmapped or suspicious.
	 *
	 * @param string $href Stylesheet URL.
	 * @return string|null Absolute file path, or null.
	 */
	private static function local_css_path( string $href ): ?string {
		$path = (string) wp_parse_url( $href, PHP_URL_PATH );
		if ( '' === $path || strpos( $path, '..' ) !== false || ! preg_match( '/\.css$/i', $path ) ) {
			return null;
		}
		$content_at = strpos( $path, '/wp-content/' );
		if ( false !== $content_at && defined( 'WP_CONTENT_DIR' ) ) {
			$file = WP_CONTENT_DIR . substr( $path, $content_at + strlen( '/wp-content' ) );
			return is_file( $file ) ? $file : null;
		}
		$includes_at = strpos( $path, '/wp-includes/' );
		if ( false !== $includes_at && defined( 'ABSPATH' ) ) {
			$file = rtrim( ABSPATH, '/\\' ) . substr( $path, $includes_at );
			return is_file( $file ) ? $file : null;
		}
		return null;
	}

	/**
	 * Directory URL of a stylesheet href (base for relative url() rewriting).
	 *
	 * @param string $href Stylesheet URL.
	 * @return string
	 */
	private static function dir_url( string $href ): string {
		$no_query = (string) strtok( $href, '?#' );
		$slash    = strrpos( $no_query, '/' );
		return false === $slash ? $no_query : substr( $no_query, 0, $slash + 1 );
	}

	/**
	 * Token sets present in the fold HTML: classes, ids, tag names (lowercased
	 * hash sets for O(1) lookups).
	 *
	 * @param string $fold Above-the-fold HTML window.
	 * @return array{classes:array<string,bool>, ids:array<string,bool>, tags:array<string,bool>}
	 */
	private static function token_sets( string $fold ): array {
		$classes = [];
		if ( preg_match_all( '/\bclass\s*=\s*(["\'])(.*?)\1/is', $fold, $cm ) ) {
			foreach ( $cm[2] as $list ) {
				foreach ( preg_split( '/\s+/', $list ) ?: [] as $cls ) {
					if ( '' !== $cls ) {
						$classes[ strtolower( $cls ) ] = true;
					}
				}
			}
		}
		$ids = [];
		if ( preg_match_all( '/\bid\s*=\s*(["\'])(.*?)\1/is', $fold, $im ) ) {
			foreach ( $im[2] as $id ) {
				$id = trim( $id );
				if ( '' !== $id ) {
					$ids[ strtolower( $id ) ] = true;
				}
			}
		}
		$tags = [];
		if ( preg_match_all( '/<([a-z][a-z0-9-]*)/i', $fold, $tm ) ) {
			foreach ( $tm[1] as $tag ) {
				$tags[ strtolower( $tag ) ] = true;
			}
		}
		return [ 'classes' => $classes, 'ids' => $ids, 'tags' => $tags ];
	}

	// ─── Rule extraction ──────────────────────────────────────────────────────

	/**
	 * Walk a comment-free CSS string and keep the rules the fold can use.
	 * Brace-depth walker: handles one-or-more levels of at-rule nesting.
	 *
	 * @param string $css  CSS (comments already stripped).
	 * @param array  $sets Fold token sets.
	 * @return string Kept rules, original order.
	 */
	private static function extract_matching_rules( string $css, array $sets ): string {
		$out = '';
		$len = strlen( $css );
		$i   = 0;

		while ( $i < $len ) {
			$brace = strpos( $css, '{', $i );
			if ( false === $brace ) {
				break;
			}
			$selector = trim( substr( $css, $i, $brace - $i ) );
			// A stray semicolon-terminated at-rule between rules (@import already
			// stripped) — skip past it.
			$semi = strrpos( $selector, ';' );
			if ( false !== $semi ) {
				$selector = trim( substr( $selector, $semi + 1 ) );
			}
			if ( '' === $selector ) {
				$i = $brace + 1;
				continue;
			}

			if ( '@' === $selector[0] ) {
				$end = self::find_block_end( $css, $brace );
				if ( false === $end ) {
					break; // Malformed tail — keep what we have (fail open).
				}
				$inner = substr( $css, $brace + 1, $end - $brace - 1 );
				$at    = strtolower( $selector );

				if ( preg_match( '/^@(media|supports)\b/', $at ) ) {
					$kept = self::extract_matching_rules( $inner, $sets );
					if ( '' !== $kept ) {
						$out .= $selector . '{' . $kept . '}';
					}
				} elseif ( str_starts_with_polyfill( $at, '@font-face' ) ) {
					$out .= $selector . '{' . $inner . '}';
				}
				// @keyframes / @page / other at-rules: dropped — animations and
				// print rules never gate first paint.
				$i = $end + 1;
			} else {
				// Depth-aware end here too: a `}` inside content:"…" or a data-URI
				// url() must not terminate the rule, and native CSS nesting
				// (a nested @media inside a style rule) must be swallowed whole.
				$end = self::find_block_end( $css, $brace );
				if ( false === $end ) {
					break;
				}
				if ( self::selector_matches( $selector, $sets ) ) {
					$out .= $selector . '{' . substr( $css, $brace + 1, $end - $brace - 1 ) . '}';
				}
				$i = $end + 1;
			}
		}

		return $out;
	}

	/**
	 * End offset of the '}' closing the block opened at $open ('{'), depth-aware
	 * AND string/url-aware: braces inside "…" / '…' strings and inside unquoted
	 * url(…) tokens (SVG data URIs) never count toward nesting depth.
	 *
	 * @param string $css  CSS text.
	 * @param int    $open Offset of the opening '{'.
	 * @return int|false
	 */
	private static function find_block_end( string $css, int $open ) {
		$depth = 0;
		$len   = strlen( $css );
		for ( $j = $open; $j < $len; $j++ ) {
			$ch = $css[ $j ];
			if ( '"' === $ch || "'" === $ch ) {
				$j = self::find_string_end( $css, $j );
				continue;
			}
			if ( ( 'u' === $ch || 'U' === $ch ) && 0 === strncasecmp( substr( $css, $j, 4 ), 'url(', 4 ) ) {
				// Peek past 'url(' + optional whitespace. QUOTED value: consume the
				// string (its value may legally contain ')' — e.g. rotate(45) in an
				// unencoded SVG data URI) and let the trailing ')' fall through as a
				// plain char. UNQUOTED value: fast-skip to the closing ')' so a '}'
				// in a data URI never counts toward depth. No ')' at all is
				// malformed input — fall through to plain brace counting (fail open).
				$k = $j + 4;
				while ( $k < $len && ( ' ' === $css[ $k ] || "\t" === $css[ $k ] || "\n" === $css[ $k ] || "\r" === $css[ $k ] ) ) {
					$k++;
				}
				if ( $k < $len && ( '"' === $css[ $k ] || "'" === $css[ $k ] ) ) {
					$j = self::find_string_end( $css, $k );
					continue;
				}
				$close = strpos( $css, ')', $k );
				if ( false !== $close ) {
					$j = $close;
					continue;
				}
			}
			if ( '{' === $ch ) {
				$depth++;
			} elseif ( '}' === $ch ) {
				$depth--;
				if ( 0 === $depth ) {
					return $j;
				}
			}
		}
		return false;
	}

	/**
	 * End offset of the string opened at $open (its quote char), honouring
	 * backslash escapes. An unterminated string swallows the rest of the text
	 * (matching how a browser's tokenizer would treat it — fail open).
	 *
	 * @param string $css  CSS text.
	 * @param int    $open Offset of the opening quote.
	 * @return int
	 */
	private static function find_string_end( string $css, int $open ): int {
		$quote = $css[ $open ];
		$len   = strlen( $css );
		for ( $j = $open + 1; $j < $len; $j++ ) {
			if ( '\\' === $css[ $j ] ) {
				$j++;
				continue;
			}
			if ( $css[ $j ] === $quote ) {
				return $j;
			}
		}
		return $len - 1;
	}

	/**
	 * Strip /* … *\/ comments without ever entering strings — an apostrophe or
	 * comment-like sequence inside content:"…" must never derail parsing (the
	 * 1.2.x minifier comment-quote bug class).
	 *
	 * @param string $css Raw CSS.
	 * @return string
	 */
	private static function strip_comments( string $css ): string {
		$out = '';
		$len = strlen( $css );
		$i   = 0;
		while ( $i < $len ) {
			$ch = $css[ $i ];
			if ( '/' === $ch && $i + 1 < $len && '*' === $css[ $i + 1 ] ) {
				$end = strpos( $css, '*/', $i + 2 );
				if ( false === $end ) {
					break; // Unterminated comment: drop the rest (fail open).
				}
				$i = $end + 2;
				continue;
			}
			if ( '"' === $ch || "'" === $ch ) {
				$close = self::find_string_end( $css, $i );
				$out  .= substr( $css, $i, $close - $i + 1 );
				$i     = $close + 1;
				continue;
			}
			$out .= $ch;
			$i++;
		}
		return $out;
	}

	/**
	 * Can any selector in this (comma-separated) selector list be satisfied by
	 * the fold's tokens? Conservative direction: keeping too much only costs
	 * bytes; dropping a needed rule shows as briefly-unstyled fold content when
	 * Async CSS is on — so ambiguity resolves toward KEEP.
	 *
	 * @param string $selector Full selector list.
	 * @param array  $sets     Fold token sets.
	 * @return bool
	 */
	private static function selector_matches( string $selector, array $sets ): bool {
		foreach ( explode( ',', $selector ) as $sel ) {
			$sel = trim( $sel );
			if ( '' === $sel ) {
				continue;
			}
			// Document-level and universal selectors always apply.
			if ( '*' === $sel[0] || 0 === stripos( $sel, ':root' )
				|| preg_match( '/^(html|body)\b/i', $sel ) ) {
				return true;
			}
			// Escaped characters in selectors (Tailwind's md\:flex) defeat plain
			// token parsing — resolve toward KEEP rather than risk dropping.
			if ( strpos( $sel, '\\' ) !== false ) {
				return true;
			}

			// Strip pseudo-classes/elements and attribute selectors; the base
			// compound tokens decide.
			$clean = (string) preg_replace( '/::?[a-z-]+(\([^()]*\))?/i', '', $sel );
			$clean = (string) preg_replace( '/\[[^\]]*\]/', '', $clean );

			preg_match_all( '/\.([A-Za-z0-9_-]+)/', $clean, $cm );
			preg_match_all( '/#([A-Za-z0-9_-]+)/', $clean, $im );

			$ok = true;
			foreach ( $cm[1] as $cls ) {
				if ( ! isset( $sets['classes'][ strtolower( $cls ) ] ) ) {
					$ok = false;
					break;
				}
			}
			if ( $ok ) {
				foreach ( $im[1] as $id ) {
					if ( ! isset( $sets['ids'][ strtolower( $id ) ] ) ) {
						$ok = false;
						break;
					}
				}
			}
			if ( $ok && empty( $cm[1] ) && empty( $im[1] ) ) {
				// Tag-only selector: at least one referenced tag must exist above
				// the fold.
				$ok = false;
				if ( preg_match_all( '/(?:^|[\s>+~])([a-z][a-z0-9-]*)/i', ' ' . $clean, $tm ) ) {
					foreach ( $tm[1] as $tag ) {
						if ( isset( $sets['tags'][ strtolower( $tag ) ] ) ) {
							$ok = true;
							break;
						}
					}
				}
			}
			if ( $ok ) {
				return true;
			}
		}
		return false;
	}
}

// ─── PHP 7.4 str_starts_with polyfill (namespaced, no global pollution) ───────

/**
 * str_starts_with for the PHP 7.4 floor.
 *
 * @param string $haystack Haystack.
 * @param string $needle   Needle.
 * @return bool
 */
function str_starts_with_polyfill( string $haystack, string $needle ): bool {
	return 0 === strncmp( $haystack, $needle, strlen( $needle ) );
}
