<?php
/**
 * BaseCloud Boost — HTML Minifier
 *
 * @package BaseCloud\Boost\Optimizer
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Optimizer;

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

/**
 * Minifies HTML output via output buffering.
 */
class HTML_Minifier {

	/** @var bool Whether we have started output buffering. */
	private static bool $buffering = false;

	/** @var int Output buffer level before our ob_start call. */
	private static int $ob_level = 0;

	/**
	 * Boot HTML minifier.
	 *
	 * @return void
	 */
	public function boot(): void {
		if ( ! BCBOOST_get_option( 'minify_html', true ) ) {
			return;
		}

		// template_redirect priority 1 — deliberately BETWEEN Cache_Engine (0) and
		// Lazy_Loader (99). ob_start() nesting is last-started = innermost, and when
		// two callbacks share a priority WordPress falls back to registration order —
		// so at priority 0 the "cache captures the minified output" nesting silently
		// depended on the order classes boot in Plugin.php. Distinct priorities make
		// the nesting deterministic and registration-order-proof:
		//   cache (0, outermost, runs last) ⊃ HTML minifier (1) ⊃ lazy loader (99, runs first).
		add_action( 'template_redirect', [ $this, 'start_buffer' ], 1 );
	}

	/**
	 * Start output buffering.
	 *
	 * @return void
	 */
	public function start_buffer(): void {
		if ( is_admin() || is_feed() || is_robots() || is_trackback() ) {
			return;
		}

		// Never minify inside a builder editor/preview (Divi ?et_fb=1, Elementor,
		// Customizer, post previews): collapsing whitespace and stripping comments
		// there corrupts the editor DOM. Central gate — see basecloud-boost.php.
		if ( ! BCBOOST_is_optimizable_request() ) {
			return;
		}

		self::$buffering = true;
		self::$ob_level  = ob_get_level();
		ob_start( [ __CLASS__, 'minify' ] );
		add_action( 'shutdown', [ $this, 'end_output_buffer' ], PHP_INT_MAX );
	}

	/**
	 * Explicitly close our output buffer on shutdown.
	 *
	 * @return void
	 */
	public function end_output_buffer(): void {
		if ( self::$buffering && ob_get_level() > self::$ob_level ) {
			ob_end_flush();
		}
	}

	/**
	 * Minify an HTML string.
	 *
	 * @param string $html Raw HTML.
	 * @return string Minified HTML.
	 */
	public static function minify( string $html ): string {
		if ( strlen( $html ) === 0 ) {
			return $html;
		}

		// Keep the untouched input. Every transform below falls back to this if a
		// PCRE step fails (e.g. pcre.backtrack_limit on a large page), so a failed
		// regex can NEVER turn the page blank — it just skips that optimisation.
		$original = $html;

		// Preserve protected blocks.
		$preserved = [];
		$index     = 0;

		$protected_patterns = [
			'pre'      => '/<pre[\s\S]*?<\/pre>/i',
			'textarea' => '/<textarea[\s\S]*?<\/textarea>/i',
			'script'   => '/<script[\s\S]*?<\/script>/i',
			'style'    => '/<style[\s\S]*?<\/style>/i',
			'code'     => '/<code[\s\S]*?<\/code>/i',
		];

		foreach ( $protected_patterns as $key => $pattern ) {
			$replaced = preg_replace_callback( $pattern, static function ( array $matches ) use ( &$preserved, &$index, $key ): string {
				$token              = '%%BCBOOST_' . strtoupper( $key ) . '_' . $index . '%%';
				$preserved[ $token ] = $matches[0];
				$index++;
				return $token;
			}, $html );

			// A null here means the block was NOT tokenised. Bailing out entirely is
			// the only safe move — otherwise later steps could mangle unprotected
			// <script>/<style> content and break the page.
			if ( null === $replaced ) {
				return $original;
			}
			$html = $replaced;
		}

		// Each step keeps the previous value if the regex engine bails (null).

		// Strip comments — but PRESERVE:
		//  • IE conditional openers  <!--[if lt IE 9]> …           (body starts [if)
		//  • conditional closers     <!--<![endif]-->              (body has [endif])
		//  • downlevel-revealed pair <!--[if !IE]><!--> … <!--<![endif]-->
		//    (each side matches as ONE comment and is kept whole — the old
		//    lookahead-only regex re-matched the trailing <!--> as a fresh comment
		//    opener and lazily consumed everything up to the closer's -->, deleting
		//    the revealed content and orphaning the block)
		//  • comments containing an allowlisted token (filterable), e.g. 'noptimize'
		//    markers other optimization plugins key on.
		$keep_tokens = (array) apply_filters( 'BCBOOST_html_keep_comments', [ 'noptimize' ] );
		$html        = preg_replace_callback(
			'/<!--([\s\S]*?)-->/',
			static function ( array $m ) use ( $keep_tokens ): string {
				$body = $m[1];
				if ( preg_match( '/^\s*\[if\b/i', $body ) || false !== stripos( $body, '[endif]' ) ) {
					return $m[0]; // IE conditional opener / closer — keep verbatim.
				}
				foreach ( $keep_tokens as $token ) {
					if ( '' !== (string) $token && false !== stripos( $body, (string) $token ) ) {
						return $m[0];
					}
				}
				return '';
			},
			$html
		) ?? $html;
		$html = preg_replace( '/>\s+</', '> <', $html ) ?? $html;            // Whitespace between tags.
		$html = preg_replace( '/^\s+/m', '', $html ) ?? $html;               // Leading line whitespace.
		$html = preg_replace( '/\s+$/m', '', $html ) ?? $html;               // Trailing line whitespace.
		$html = preg_replace( '/[ \t]{2,}/', ' ', $html ) ?? $html;          // Collapse runs of spaces.

		// Restore protected blocks.
		foreach ( $preserved as $token => $block ) {
			$html = str_replace( $token, $block, $html );
		}

		// Final safety net: if anything went wrong and we lost the document, serve
		// the original untouched HTML rather than a broken/blank page.
		if ( '' === trim( $html ) || strpos( $html, '%%BCBOOST_' ) !== false ) {
			return $original;
		}

		return trim( $html );
	}
}
