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

declare( strict_types=1 );

namespace BaseCloud\Boost\Optimizer;

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

/**
 * Minifies JavaScript content and optimises enqueued scripts.
 */
class JS_Minifier {

	/** @var string[] Handles that must never be minified or combined. */
	private const SKIP_HANDLES = [
		'jquery',
		'jquery-core',
		'jquery-migrate',
		'wp-polyfill',
		'wp-i18n',
	];

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

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

	public function __construct() {
		$this->cache_dir = BCBOOST_MINIFY_DIR . 'js/';
		$this->cache_url = content_url( '/cache/basecloud-boost-min/js/' );
	}

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

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

		// Inline <script> blocks via HTML output-buffer filter — OFF by default.
		// Inline minification is all risk, no reward: post-gzip it saves near-zero
		// bytes, yet it runs EVERY inline script on the page (builder inits, form
		// configs, theme bootstraps) through the lexer, so any lexer edge case
		// breaks the site. File-level minification stays governed by minify_js;
		// inline is a separate opt-in for hands-on tuners only.
		if ( $minify && BCBOOST_get_option( 'minify_inline_js', false ) ) {
			add_filter( 'BCBOOST_buffer', [ $this, 'minify_inline_scripts' ] );
		}

		// Enqueued JS files — run after all scripts are enqueued.
		add_action( 'wp_enqueue_scripts', [ $this, 'process_enqueued_scripts' ], 999999 );
	}

	/**
	 * Find and minify inline <script> blocks.
	 *
	 * @param string $html HTML content.
	 * @return string Modified HTML.
	 */
	public function minify_inline_scripts( 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(
			'/<script(?:[^>]*)>([\s\S]*?)<\/script>/i',
			static function ( array $matches ) use ( &$saved ): string {
				// Skip non-JS script tags (e.g. type="application/json", "text/template").
				if ( preg_match( '/type=["\'](?!text\/javascript|application\/javascript)[^"\']+["\']/', $matches[0] ) ) {
					return $matches[0];
				}
				// Skip empty scripts.
				if ( '' === trim( $matches[1] ) ) {
					return $matches[0];
				}
				$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( 'js_saved', $saved );
		}
		return $result;
	}

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

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

		global $wp_scripts;
		if ( ! $wp_scripts instanceof \WP_Scripts ) {
			return;
		}

		$minify   = (bool) BCBOOST_get_option( 'minify_js', true );
		$combine  = (bool) BCBOOST_get_option( 'combine_js', false );
		$excludes = array_filter( array_map( 'trim', explode( "\n", (string) BCBOOST_get_option( 'exclude_js', '' ) ) ) );

		$wp_scripts->all_deps( $wp_scripts->queue );
		$combine_handles = [];

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

			// Never touch core jQuery or safe-listed handles.
			if ( in_array( $handle, self::SKIP_HANDLES, true ) ) {
				continue;
			}

			// Skip scripts with inline extra data (wp_localize_script, etc.) when combining.
			if ( $combine && ! empty( $obj->extra['data'] ) ) {
				continue;
			}

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

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

			if ( $minify && ! $combine ) {
				$min_file = $this->get_minified_file( $path );
				if ( $min_file ) {
					$obj->src = $this->path_to_url( $min_file );
					$obj->ver = null;
				}
			}

			if ( $combine ) {
				$combine_handles[] = $handle;
			}
		}

		if ( $combine && ! empty( $combine_handles ) ) {
			$this->combine_scripts( $wp_scripts, $combine_handles, $excludes );
		}
	}

	/**
	 * Combine multiple JS handles into a single minified bundle.
	 *
	 * @param \WP_Scripts $wp_scripts WP scripts registry.
	 * @param string[]    $handles    Handles to combine.
	 * @param string[]    $excludes   Patterns to exclude.
	 * @return void
	 */
	private function combine_scripts( \WP_Scripts $wp_scripts, array $handles, array $excludes ): void {
		$combined = '';
		$hash_src = '';
		$deps     = [];

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

			if ( $this->is_excluded( (string) $obj->src, $handle, $excludes ) ) {
				continue;
			}

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

			$content  = (string) file_get_contents( $path );
			$hash_src .= $content;
			$combined .= self::minify( $content ) . ";\n";

			// Collect external deps not in the bundle.
			foreach ( ( $obj->deps ?? [] ) as $dep ) {
				if ( ! in_array( $dep, $handles, true ) ) {
					$deps[] = $dep;
				}
			}

			wp_dequeue_script( $handle );
		}

		if ( empty( $combined ) ) {
			return;
		}

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

		if ( ! file_exists( $out_file ) ) {
			file_put_contents( $out_file, $combined, LOCK_EX );
		}

		wp_enqueue_script( 'bcboost-js-bundle', $this->cache_url . 'bcboost-bundle-' . $hash . '.min.js', array_unique( $deps ), null, true );
	}

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

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

		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.js';

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

		return $out_file;
	}

	/**
	 * Check whether a URL or handle matches any exclusion pattern.
	 *
	 * @param string   $url      Asset URL.
	 * @param string   $handle   Script handle.
	 * @param string[] $excludes List of patterns.
	 * @return bool
	 */
	private function is_excluded( string $url, string $handle, array $excludes ): bool {
		foreach ( $excludes as $pattern ) {
			if ( '' !== $pattern && ( false !== strpos( $url, $pattern ) || false !== strpos( $handle, $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;

		$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 ) ), '/' ) );
	}

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

	/**
	 * Minify a JavaScript string.
	 *
	 * Implemented as a single-pass character lexer rather than a chain of regular
	 * expressions. A regex approach cannot reliably tell apart a quote inside a
	 * comment (e.g. the apostrophe in "we don't") from a real string delimiter,
	 * which previously caused it to swallow real code and emit broken JavaScript —
	 * breaking themes/plugins outright. The lexer tracks string, template-literal
	 * (including nested `${ … }` interpolations), regular-expression, and comment
	 * context exactly, so the only things removed are comments and redundant
	 * whitespace. Newlines are preserved so Automatic Semicolon Insertion can
	 * never be broken.
	 *
	 * Template literals use an explicit context stack: a backtick pushes a
	 * template context (body copied VERBATIM — its whitespace is content); inside
	 * it `${` pushes a code context which itself lexes strings, comments, regexes,
	 * nested templates and tracks brace depth, so the matching `}` — and only that
	 * one — pops back to the template. The previous lexer treated ` like a plain
	 * quote and terminated the "string" at the first nested backtick, so
	 * `Hello ${isVip ? `VIP ${name}` : name}!` came out as corrupted code — a top
	 * cause of "File Optimization broke the site".
	 *
	 * FAIL-OPEN GUARD: if lexing ends inside an unterminated string, comment, or
	 * template (malformed/truncated input, or any state we cannot prove out), the
	 * input is returned UNMINIFIED. A skipped minification costs a few KB; a
	 * corrupted script kills the site.
	 *
	 * @param string $js Raw JavaScript.
	 * @return string Minified JavaScript (or the input verbatim when unsafe).
	 */
	public static function minify( string $js ): string {
		if ( '' === trim( $js ) ) {
			return $js;
		}

		$len     = strlen( $js );
		$out     = '';
		$i       = 0;
		$prevSig = ''; // Last significant (non-whitespace) source char — used for regex/division detection.

		// Template-literal context stack. Entries are either the string 'template'
		// (inside `…`) or an int (inside a `${ … }` interpolation; the value is the
		// current open-brace depth, so only the brace-balanced closing `}` pops).
		// The base code context is the empty stack.
		$stack  = [];
		$broken = false;

		while ( $i < $len ) {
			$depth = count( $stack );

			// ── Template-literal body: copy VERBATIM ────────────────────────
			// Whitespace inside a template is rendered content — never collapse it.
			if ( $depth > 0 && 'template' === $stack[ $depth - 1 ] ) {
				$c = $js[ $i ];
				if ( '\\' === $c ) { // Escapes (incl. \` and \$) — copy both chars.
					$out .= $c;
					if ( $i + 1 < $len ) {
						$out .= $js[ $i + 1 ];
					}
					$i += 2;
					continue;
				}
				if ( '`' === $c ) { // Template closes.
					$out .= '`';
					$i++;
					array_pop( $stack );
					$prevSig = '`'; // A template is an expression — a following / is division.
					continue;
				}
				if ( '$' === $c && $i + 1 < $len && '{' === $js[ $i + 1 ] ) { // Interpolation opens.
					$out    .= '${';
					$i      += 2;
					$stack[] = 0;   // Code context, brace depth 0.
					$prevSig = '{'; // Expression position — a / here starts a regex.
					continue;
				}
				$out .= $c;
				$i++;
				continue;
			}

			// ── Code context (top level or inside a ${ … } interpolation) ───
			$c    = $js[ $i ];
			$next = $i + 1 < $len ? $js[ $i + 1 ] : '';

			// ── Line comment ────────────────────────────────────────────────
			if ( $c === '/' && $next === '/' ) {
				$i += 2;
				while ( $i < $len && $js[ $i ] !== "\n" && $js[ $i ] !== "\r" ) {
					$i++;
				}
				continue;
			}

			// ── Block comment (keep /*! license blocks) ─────────────────────
			if ( $c === '/' && $next === '*' ) {
				$keep   = ( isset( $js[ $i + 2 ] ) && $js[ $i + 2 ] === '!' );
				$start  = $i;
				$i     += 2;
				$closed = false;
				while ( $i < $len ) {
					if ( $js[ $i ] === '*' && isset( $js[ $i + 1 ] ) && $js[ $i + 1 ] === '/' ) {
						$closed = true;
						$i     += 2; // Skip the closing */.
						break;
					}
					$i++;
				}
				if ( ! $closed ) {
					$broken = true; // Unterminated comment — input is not sane JS.
					break;
				}
				if ( $keep ) {
					$out    .= substr( $js, $start, $i - $start );
					$prevSig = '/';
				} elseif ( '' !== $out && $i < $len
					&& preg_match( '/[\w$]/', $out[ strlen( $out ) - 1 ] )
					&& preg_match( '/[\w$]/', $js[ $i ] ) ) {
					// `let/**/x` — the comment was the only separator between two
					// identifier tokens; dropping it must not glue them together.
					$out .= ' ';
				}
				continue;
			}

			// ── String literal (' or ") ─────────────────────────────────────
			if ( $c === '"' || $c === "'" ) {
				$quote  = $c;
				$out   .= $c;
				$i++;
				$closed = false;
				while ( $i < $len ) {
					$ch   = $js[ $i ];
					$out .= $ch;
					if ( $ch === '\\' ) {
						if ( $i + 1 < $len ) {
							$out .= $js[ $i + 1 ];
							$i   += 2;
							continue;
						}
						$i++;
						break; // Trailing backslash at EOF — unterminated.
					}
					$i++;
					if ( $ch === $quote ) {
						$closed = true;
						break;
					}
				}
				if ( ! $closed ) {
					$broken = true; // Unterminated string — bail unminified.
					break;
				}
				$prevSig = $quote;
				continue;
			}

			// ── Template literal opens: push context ────────────────────────
			if ( '`' === $c ) {
				$out    .= '`';
				$i++;
				$stack[] = 'template';
				continue;
			}

			// ── Regular-expression literal ──────────────────────────────────
			if ( $c === '/' && self::regex_allowed( $prevSig, $out ) ) {
				$out .= $c;
				$i++;
				$in_class = false;
				while ( $i < $len ) {
					$ch   = $js[ $i ];
					$out .= $ch;
					if ( $ch === '\\' ) {
						if ( $i + 1 < $len ) {
							$out .= $js[ $i + 1 ];
							$i   += 2;
							continue;
						}
					} elseif ( $ch === '[' ) {
						$in_class = true;
					} elseif ( $ch === ']' ) {
						$in_class = false;
					} elseif ( $ch === '/' && ! $in_class ) {
						$i++;
						break;
					} elseif ( $ch === "\n" || $ch === "\r" ) {
						break; // Not a valid regex literal — bail out safely.
					}
					$i++;
				}
				// Regex flags.
				while ( $i < $len && ctype_alpha( $js[ $i ] ) ) {
					$out .= $js[ $i ];
					$i++;
				}
				$prevSig = '/';
				continue;
			}

			// ── Horizontal whitespace — collapse to a single space ──────────
			// Whitespace management happens HERE, in code context only. The old
			// version ran tidy-up regexes over the finished output, which also
			// collapsed runs of spaces INSIDE string/template literals — silently
			// changing rendered content. Literal bodies are copied verbatim above
			// and never revisited.
			if ( $c === ' ' || $c === "\t" ) {
				while ( $i < $len && ( $js[ $i ] === ' ' || $js[ $i ] === "\t" ) ) {
					$i++;
				}
				$last = '' === $out ? '' : $out[ strlen( $out ) - 1 ];
				if ( '' !== $last && ' ' !== $last && "\n" !== $last ) {
					$out .= ' ';
				}
				continue;
			}

			// ── Newlines — collapse runs to one (kept for ASI safety) ───────
			if ( $c === "\n" || $c === "\r" ) {
				while ( $i < $len && ( $js[ $i ] === "\n" || $js[ $i ] === "\r" || $js[ $i ] === ' ' || $js[ $i ] === "\t" ) ) {
					$i++;
				}
				$out_len = strlen( $out );
				if ( $out_len > 0 && ' ' === $out[ $out_len - 1 ] ) {
					$out = substr( $out, 0, -1 ); // Drop a space left hugging the newline.
					$out_len--;
				}
				if ( $out_len > 0 && "\n" !== $out[ $out_len - 1 ] ) {
					$out .= "\n";
				}
				continue;
			}

			// ── Braces inside a ${ … } interpolation (depth tracking) ───────
			// Strings/comments/regexes/templates were consumed above, so any brace
			// reaching here is real code (object literal, arrow body, block).
			if ( $depth > 0 && '{' === $c ) {
				$stack[ $depth - 1 ]++;
				$out    .= $c;
				$prevSig = $c;
				$i++;
				continue;
			}
			if ( $depth > 0 && '}' === $c ) {
				if ( $stack[ $depth - 1 ] > 0 ) {
					$stack[ $depth - 1 ]--;      // Closes a brace opened inside the interpolation.
				} else {
					array_pop( $stack );          // Closes the ${ … } — back into the template body.
				}
				$out    .= $c;
				$prevSig = $c;
				$i++;
				continue;
			}

			// ── Ordinary code character ─────────────────────────────────────
			$out    .= $c;
			$prevSig = $c;
			$i++;
		}

		// Fail open: unbalanced lexer state means we cannot guarantee the output
		// is equivalent JavaScript. Serve the original bytes instead.
		if ( $broken || ! empty( $stack ) ) {
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( '[BaseCloud Boost] JS_Minifier: unterminated string/comment/template detected — script served unminified.' );
			}
			return $js;
		}

		return trim( $out );
	}

	/**
	 * Whether a `/` following the given significant character begins a regex
	 * literal (expression position) rather than a division operator.
	 *
	 * @param string $prev Last significant source character.
	 * @param string $out  Output emitted so far (used to detect a preceding keyword).
	 * @return bool
	 */
	private static function regex_allowed( string $prev, string $out ): bool {
		if ( $prev === '' ) {
			return true;
		}
		// In these positions a `/` starts a regex; otherwise it is usually division.
		if ( strpos( '(,=:[!&|?{};~+-*%^<>', $prev ) !== false ) {
			return true;
		}
		// A regex can also follow a keyword that expects an expression, e.g.
		// `return /x/`, `typeof /x/`, `case /x/:`. Detect the trailing word.
		if ( preg_match( '/([A-Za-z_$][\w$]*)\s*$/', $out, $m ) ) {
			return in_array( $m[1], [
				'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete',
				'void', 'do', 'else', 'yield', 'await', 'case', 'throw',
			], true );
		}
		return false;
	}
}
