<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * CSS scoping for LIGHT DOM rendering.
 *
 * Once sections stopped running in the Shadow DOM — which isolated styles for free — their CSS had
 * to be confined: every rule is rewritten with its selectors prefixed by a scope selector (say
 * "#aipb-abc"). That way the section's CSS does not leak out and, thanks to an id's specificity,
 * the theme's does not easily get in.
 *
 * What it handles:
 *  - ordinary rules: every selector in the list gets the scope prefix;
 *  - :root / html / body -> mapped ONTO the scope (variables and resets stay inside the section);
 *  - @media / @supports / @container / @layer: it recurses into the body (the rules inside are
 *    the ones that need scoping);
 *  - @keyframes / @font-face / @page / @property / @import / @charset: left INTACT — prefixing
 *    their "selectors" (0%, from) would break them.
 *
 * It is NOT a complete CSS parser: it covers the CSS models generate in practice, robustly across
 * nested at-rules and strings.
 */
class AIPB_CSS_Scope {

	/** The way in: scope a whole stylesheet under $scope (for example "#aipb-abc"). */
	public static function scope_css( $css, $scope ) {
		return self::scope_rules( self::strip_comments( (string) $css ), $scope );
	}

	/** Drops comments. Tolerant: no real risk inside actual CSS strings. */
	protected static function strip_comments( $css ) {
		return preg_replace( '#/\*.*?\*/#s', '', $css );
	}

	/** Scopes a sequence of top-level rules and at-rules. */
	protected static function scope_rules( $css, $scope ) {
		$out    = '';
		$length = strlen( $css );
		$i      = 0;

		while ( $i < $length ) {
			// Skip whitespace between rules.
			if ( ctype_space( $css[ $i ] ) ) {
				$i++;
				continue;
			}

			if ( '@' === $css[ $i ] ) {
				$i = self::scope_at_rule( $css, $i, $scope, $out );
				if ( false === $i ) {
					break; // malformed: drop the tail
				}
				continue;
			}

			// An ordinary rule: selectors up to '{'.
			$j        = $i;
			$selector = '';
			while ( $j < $length && '{' !== $css[ $j ] ) {
				$selector .= $css[ $j ];
				$j++;
			}
			if ( $j >= $length ) {
				break; // no block: malformed
			}

			$block = self::extract_block( $css, $j );
			$i     = $block['end'];

			$selector = trim( $selector );
			if ( '' !== $selector ) {
				$out .= self::prefix_selector_list( $selector, $scope ) . '{' . $block['inner'] . '}';
			}
		}

		return $out;
	}

	/**
	 * Handles one at-rule, appending it to $out.
	 *
	 * @return int|false the index to carry on from, or false when the CSS is malformed
	 */
	protected static function scope_at_rule( $css, $i, $scope, &$out ) {
		$length  = strlen( $css );
		$j       = $i;
		$prelude = '';

		while ( $j < $length && '{' !== $css[ $j ] && ';' !== $css[ $j ] ) {
			$prelude .= $css[ $j ];
			$j++;
		}

		if ( $j >= $length ) {
			return false;
		}

		// @import / @charset / @namespace: passed through as they are.
		if ( ';' === $css[ $j ] ) {
			$out .= trim( $prelude ) . ';';

			return $j + 1;
		}

		$name  = strtolower( preg_replace( '/^@([a-zA-Z-]+).*/s', '$1', trim( $prelude ) ) );
		$block = self::extract_block( $css, $j );

		$inner = in_array( $name, array( 'media', 'supports', 'container', 'layer' ), true )
			? self::scope_rules( $block['inner'], $scope )
			: $block['inner']; // keyframes, font-face, page, property…: intact

		$out .= trim( $prelude ) . '{' . $inner . '}';

		return $block['end'];
	}

	/**
	 * Extracts the balanced { … } block starting at $pos (where $css[$pos] === '{').
	 *
	 * Respects nesting and strings.
	 *
	 * @return array{inner:string,end:int} the contents, and the index just past the '}'
	 */
	protected static function extract_block( $css, $pos ) {
		$length   = strlen( $css );
		$depth    = 0;
		$inString = false;
		$quote    = '';

		for ( $i = $pos; $i < $length; $i++ ) {
			$char = $css[ $i ];

			if ( $inString ) {
				if ( $char === $quote && '\\' !== $css[ $i - 1 ] ) {
					$inString = false;
				}
				continue;
			}

			if ( '"' === $char || "'" === $char ) {
				$inString = true;
				$quote    = $char;
				continue;
			}

			if ( '{' === $char ) {
				$depth++;
			} elseif ( '}' === $char ) {
				$depth--;
				if ( 0 === $depth ) {
					return array( 'inner' => substr( $css, $pos + 1, $i - $pos - 1 ), 'end' => $i + 1 );
				}
			}
		}

		return array( 'inner' => substr( $css, $pos + 1 ), 'end' => $length );
	}

	/** Prefixes every selector of a comma-separated list, respecting () and []. */
	protected static function prefix_selector_list( $selectors, $scope ) {
		$out = array();

		foreach ( self::split_selectors( $selectors ) as $selector ) {
			$selector = trim( $selector );
			if ( '' !== $selector ) {
				$out[] = self::prefix_one( $selector, $scope );
			}
		}

		return implode( ',', $out );
	}

	/** Splits on top-level commas only: not on the ones inside :is(), [attr] and friends. */
	protected static function split_selectors( $selectors ) {
		$parts    = array();
		$buffer   = '';
		$parens   = 0;
		$brackets = 0;
		$length   = strlen( $selectors );

		for ( $i = 0; $i < $length; $i++ ) {
			$char = $selectors[ $i ];

			if ( '(' === $char ) {
				$parens++;
			} elseif ( ')' === $char ) {
				$parens = max( 0, $parens - 1 );
			} elseif ( '[' === $char ) {
				$brackets++;
			} elseif ( ']' === $char ) {
				$brackets = max( 0, $brackets - 1 );
			}

			if ( ',' === $char && 0 === $parens && 0 === $brackets ) {
				$parts[] = $buffer;
				$buffer  = '';
				continue;
			}

			$buffer .= $char;
		}

		$parts[] = $buffer;

		return $parts;
	}

	/** Prefixes one selector, mapping the "root" selectors onto the scope itself. */
	protected static function prefix_one( $selector, $scope ) {
		$lower = strtolower( $selector );
		if ( 'html' === $lower || 'body' === $lower || ':root' === $lower ) {
			return $scope;
		}

		// "body …", "html …", ":root …" -> the scope takes the root's place.
		$stripped = preg_replace( '/^\s*(?:html|body|:root)\b\s*/i', '', $selector, 1, $count );
		if ( $count ) {
			$stripped = trim( $stripped );

			return '' === $stripped ? $scope : $scope . ' ' . $stripped;
		}

		return $scope . ' ' . $selector;
	}
}
