<?php
/**
 * Defense-in-depth scanner for obvious dangerous PHP in admin-authored snippets.
 *
 * Not a sandbox: administrators with PHP snippet access already have code-execution
 * capability. This blocks common footguns / malware shapes; it cannot guarantee
 * that determined admin PHP is harmless.
 *
 * @package CodeSnippet
 */

namespace CodeSnippet;

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

final class SnippetThreatScanner {

	/**
	 * Note: exit/die are allowed — common for ABSPATH guards and custom wp_die handlers.
	 *
	 * @var list<string>
	 */
	private const BLOCKED_FUNCTIONS = [
		'eval',
		'assert',
		'create_function',
		'exec',
		'shell_exec',
		'passthru',
		'proc_open',
		'popen',
		'system',
		'pcntl_exec',
		'pcntl_fork',
		'dl',
		'call_user_func',
		'call_user_func_array',
		'forward_static_call',
		'forward_static_call_array',
		'dns_get_record',
		'fopen',
		'file_get_contents',
		'file_put_contents',
		'readfile',
		'unlink',
		'rmdir',
		'rename',
		'copy',
		'move_uploaded_file',
		'file',
		'glob',
		'scandir',
		'mkdir',
		'chmod',
		'chown',
		'tmpfile',
		'curl_exec',
		'curl_multi_exec',
		'fsockopen',
		'pfsockopen',
		'stream_socket_client',
		'unserialize',
		'maybe_unserialize',
	];

	/** @var list<string> */
	private const BLOCKED_CLASSES = [
		'reflectionfunction',
		'reflectionclass',
		'reflectionmethod',
		'reflectionobject',
		'splfileobject',
		'splfileinfo',
		'ffi',
		'directoryiterator',
		'filesystemiterator',
		'recursivedirectoryiterator',
	];

	/** @var list<string> */
	private const BLOCKED_CALLBACK_STRINGS = [
		'exec',
		'shell_exec',
		'passthru',
		'proc_open',
		'popen',
		'system',
		'pcntl_exec',
		'assert',
		'create_function',
		'eval',
		'dl',
	];

	/**
	 * Functions that accept a callable argument we inspect for blocked names.
	 * Value = 0-based callback argument index (-1 = scan every top-level string arg).
	 *
	 * @var array<string, int>
	 */
	private const CALLBACK_ARG_INDEX = [
		'array_map'                   => 0,
		'array_filter'                => 1,
		'array_walk'                  => 1,
		'array_walk_recursive'        => 1,
		'array_reduce'                => 1,
		'array_udiff'                 => 2,
		'usort'                       => 1,
		'uasort'                      => 1,
		'uksort'                      => 1,
		'preg_replace_callback'       => 1,
		'preg_replace_callback_array' => 0,
		'ob_start'                    => 0,
		'iterator_apply'              => 1,
		'spl_autoload_register'       => 0,
		'pcntl_signal'                => 1,
		'add_action'                  => 1,
		'add_filter'                  => 1,
		'add_shortcode'               => 1,
		'register_shutdown_function'  => 0,
		'register_tick_function'      => 0,
		'set_error_handler'           => 0,
		'set_exception_handler'       => 0,
		'session_set_save_handler'    => -1,
	];

	public static function isRuntimeBlocked( string $code ): bool {
		return self::getRuntimeBlockReason( $code ) !== null;
	}

	public static function getRuntimeBlockReason( string $code ): ?string {
		$code = trim( $code );
		if ( $code === '' ) {
			return null;
		}

		$token_reason = self::getTokenBlockReason( $code );
		if ( $token_reason !== null ) {
			return $token_reason;
		}

		if ( preg_match( '/`[^`]*`/', $code ) ) {
			return __( 'Backtick shell execution is not allowed.', 'wppowersuite-lite' );
		}

		if ( preg_match( '/preg_replace\s*\(\s*[\'"][^\'"]*\/e[\'"]/i', $code ) ) {
			return __( 'preg_replace() with the /e modifier can execute arbitrary code.', 'wppowersuite-lite' );
		}

		if ( preg_match_all( '/(base64_decode|error_reporting|ini_set)\s*\(/i', $code, $matches ) ) {
			if ( count( $matches[0] ) > 5 ) {
				return __( 'Excessive obfuscation or environment manipulation detected.', 'wppowersuite-lite' );
			}
		}

		$disallowed = \apply_filters( 'wpps_code_snippet_disallowed_php', [] );
		foreach ( (array) $disallowed as $pattern ) {
			if ( ! is_string( $pattern ) || $pattern === '' ) {
				continue;
			}
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Invalid site-supplied patterns must not fatally error.
			$matched = @preg_match( '#' . $pattern . '#i', $code );
			if ( $matched === 1 ) {
				return __( 'This snippet uses a disallowed PHP pattern.', 'wppowersuite-lite' );
			}
		}

		return null;
	}

	private static function getTokenBlockReason( string $code ): ?string {
		if ( ! function_exists( 'token_get_all' ) ) {
			return self::getRegexFallbackReason( $code );
		}

		$full = '<' . '?php' . "\n" . $code;
		try {
			$tokens = token_get_all( $full );
		} catch ( \Throwable $e ) {
			return self::getRegexFallbackReason( $code );
		}

		$blocked_fn    = array_fill_keys( self::BLOCKED_FUNCTIONS, true );
		$blocked_class = array_fill_keys( self::BLOCKED_CLASSES, true );
		$blocked_cb    = array_fill_keys( self::BLOCKED_CALLBACK_STRINGS, true );
		$callback_fns  = self::CALLBACK_ARG_INDEX;
		/** @var array<string, string> $var_kinds Variable name => closure|string|other */
		$var_kinds = [];
		$name_ids  = [ T_STRING ];
		if ( defined( 'T_NAME_FULLY_QUALIFIED' ) ) {
			$name_ids[] = T_NAME_FULLY_QUALIFIED;
		}
		if ( defined( 'T_NAME_QUALIFIED' ) ) {
			$name_ids[] = T_NAME_QUALIFIED;
		}

		$count = count( $tokens );
		for ( $i = 0; $i < $count; $i++ ) {
			$tok = $tokens[ $i ];

			if ( is_string( $tok ) ) {
				if ( $tok === '`' ) {
					return __( 'Backtick shell execution is not allowed.', 'wppowersuite-lite' );
				}
				// Call-result used as callable: ($fn())('id') or foo()().
				if ( $tok === ')' ) {
					$after = self::nextSignificantToken( $tokens, $i + 1 );
					if ( $after !== null && is_string( $after[0] ) && $after[0] === '(' ) {
						return __( 'Dynamic function invocation is not allowed.', 'wppowersuite-lite' );
					}
				}
				continue;
			}

			[ $id, $text ] = $tok;

			if ( in_array( $id, [ T_INCLUDE, T_INCLUDE_ONCE, T_REQUIRE, T_REQUIRE_ONCE ], true ) ) {
				return __( 'Including or requiring files is not allowed in snippets.', 'wppowersuite-lite' );
			}

			if ( $id === T_EVAL ) {
				return __( 'eval() is not allowed in snippets.', 'wppowersuite-lite' );
			}

			// Variable variables: ${'x'}(...).
			if ( defined( 'T_DOLLAR_OPEN_CURLY_BRACES' ) && $id === T_DOLLAR_OPEN_CURLY_BRACES ) {
				return __( 'Variable/dynamic function calls are not allowed in snippets.', 'wppowersuite-lite' );
			}

			if ( $id === T_VARIABLE ) {
				$assigned = self::classifyVariableAssignment( $tokens, $i );
				if ( $assigned !== null ) {
					$var_kinds[ $text ] = $assigned;
				}
				$dyn = self::getDynamicInvokeBlockReason( $tokens, $i, $var_kinds );
				if ( $dyn !== null ) {
					return $dyn;
				}
			}

			if ( $id === T_CONSTANT_ENCAPSED_STRING ) {
				$literal = strtolower( trim( $text, "'\"" ) );
				if ( isset( $blocked_cb[ $literal ] ) ) {
					$next = self::nextSignificantToken( $tokens, $i + 1 );
					if ( $next !== null && is_string( $next[0] ) && $next[0] === '(' ) {
						return __( 'Dangerous function names cannot be used as string callables in snippets.', 'wppowersuite-lite' );
					}
					if ( $next !== null && is_string( $next[0] ) && $next[0] === ')' ) {
						$after = self::nextSignificantToken( $tokens, $next[1] + 1 );
						if ( $after !== null && is_string( $after[0] ) && $after[0] === '(' ) {
							return __( 'Dangerous function names cannot be used as string callables in snippets.', 'wppowersuite-lite' );
						}
					}
				}
			}

			if ( ! in_array( $id, $name_ids, true ) ) {
				continue;
			}

			$normalized = strtolower( ltrim( $text, '\\' ) );
			if ( str_contains( $normalized, '\\' ) ) {
				$parts      = explode( '\\', $normalized );
				$normalized = (string) end( $parts );
			}

			$next = self::nextSignificantToken( $tokens, $i + 1 );

			if ( isset( $blocked_class[ $normalized ] ) ) {
				return __( 'This PHP class is not allowed in snippets.', 'wppowersuite-lite' );
			}

			if ( $next === null || ! is_string( $next[0] ) || $next[0] !== '(' ) {
				continue;
			}

			if ( $normalized === 'extract' ) {
				return __( 'extract() can rebind variables and is not allowed in snippets.', 'wppowersuite-lite' );
			}

			if ( isset( $callback_fns[ $normalized ] ) ) {
				$cb_reason = self::getCallbackArgBlockReason( $tokens, $next[1], $blocked_cb, $callback_fns[ $normalized ] );
				if ( $cb_reason !== null ) {
					return $cb_reason;
				}
			}

			if ( ! isset( $blocked_fn[ $normalized ] ) ) {
				continue;
			}

			if ( $normalized === 'eval' ) {
				return __( 'eval() is not allowed in snippets.', 'wppowersuite-lite' );
			}
			if ( in_array( $normalized, [ 'fopen', 'file_get_contents', 'file_put_contents', 'readfile', 'unlink', 'rmdir', 'rename', 'copy', 'move_uploaded_file', 'file', 'glob', 'scandir', 'mkdir', 'chmod', 'chown', 'tmpfile' ], true ) ) {
				if ( self::isSafePhpStreamCall( $tokens, $i ) ) {
					continue;
				}
				return __( 'Direct filesystem access from a snippet is not allowed.', 'wppowersuite-lite' );
			}
			if ( in_array( $normalized, [ 'exec', 'shell_exec', 'passthru', 'proc_open', 'popen', 'system', 'pcntl_exec', 'pcntl_fork', 'dl' ], true ) ) {
				return __( 'Operating-system command execution is not allowed.', 'wppowersuite-lite' );
			}
			if ( in_array( $normalized, [ 'call_user_func', 'call_user_func_array', 'forward_static_call', 'forward_static_call_array' ], true ) ) {
				return __( 'Dynamic function invocation is not allowed.', 'wppowersuite-lite' );
			}
			return __( 'This PHP function is not allowed in snippets.', 'wppowersuite-lite' );
		}

		return null;
	}

	/**
	 * If $tokens[$var_index] is the LHS of an assignment, classify the RHS.
	 *
	 * @param array<int, mixed> $tokens
	 * @return 'closure'|'string'|'other'|null
	 */
	private static function classifyVariableAssignment( array $tokens, int $var_index ): ?string {
		$next = self::nextSignificantToken( $tokens, $var_index + 1 );
		if ( $next === null || ! is_string( $next[0] ) || $next[0] !== '=' ) {
			return null;
		}

		$rhs = self::nextSignificantToken( $tokens, $next[1] + 1 );
		if ( $rhs === null ) {
			return 'other';
		}

		$tok = $rhs[0];
		if ( is_string( $tok ) && $tok === '&' ) {
			return 'other';
		}
		if ( ! is_array( $tok ) ) {
			return 'other';
		}

		if ( $tok[0] === T_FUNCTION || ( defined( 'T_FN' ) && $tok[0] === T_FN ) ) {
			return 'closure';
		}
		if ( $tok[0] === T_CONSTANT_ENCAPSED_STRING || $tok[0] === T_ENCAPSED_AND_WHITESPACE || $tok[0] === T_STRING ) {
			return 'string';
		}

		return 'other';
	}

	/**
	 * Block $fn(...) unless $fn was assigned a closure; always block $a[0](...) / $GLOBALS[...](...).
	 *
	 * @param array<int, mixed>    $tokens
	 * @param array<string, string> $var_kinds
	 */
	private static function getDynamicInvokeBlockReason( array $tokens, int $var_index, array $var_kinds ): ?string {
		$var_tok = $tokens[ $var_index ];
		$var_name = is_array( $var_tok ) ? (string) $var_tok[1] : '';

		$next = self::nextSignificantToken( $tokens, $var_index + 1 );
		if ( $next === null ) {
			return null;
		}
		if ( is_string( $next[0] ) && $next[0] === '(' ) {
			// Closures stored in locals are common in admin UI snippets ($kv = function...).
			if ( ( $var_kinds[ $var_name ] ?? '' ) === 'closure' ) {
				return null;
			}
			return __( 'Variable/dynamic function calls are not allowed in snippets.', 'wppowersuite-lite' );
		}
		if ( ! is_string( $next[0] ) || $next[0] !== '[' ) {
			return null;
		}

		$bracket_at = $next[1];
		while ( true ) {
			$close = self::findMatchingSquareBracket( $tokens, $bracket_at );
			if ( $close === null ) {
				return null;
			}
			$after = self::nextSignificantToken( $tokens, $close + 1 );
			if ( $after === null ) {
				return null;
			}
			if ( is_string( $after[0] ) && $after[0] === '[' ) {
				$bracket_at = $after[1];
				continue;
			}
			if ( is_string( $after[0] ) && $after[0] === '(' ) {
				return __( 'Variable/dynamic function calls are not allowed in snippets.', 'wppowersuite-lite' );
			}
			return null;
		}
	}

	/** @param array<int, mixed> $tokens */
	private static function findMatchingSquareBracket( array $tokens, int $open_index ): ?int {
		$depth = 0;
		$count = count( $tokens );
		for ( $i = $open_index; $i < $count; $i++ ) {
			$tok = $tokens[ $i ];
			if ( ! is_string( $tok ) ) {
				continue;
			}
			if ( $tok === '[' ) {
				++$depth;
			} elseif ( $tok === ']' ) {
				--$depth;
				if ( $depth === 0 ) {
					return $i;
				}
			}
		}
		return null;
	}

	/**
	 * Inspect callback arguments for statically identifiable dangerous names only.
	 *
	 * Allows closures, array callables ([$this,'method']), callable variables, and
	 * safe named functions. Blocks string/concat callbacks that resolve to denylisted names.
	 *
	 * @param array<int, mixed>   $tokens
	 * @param array<string, true> $blocked_cb
	 * @param int                 $callback_arg 0-based arg index, or -1 to scan every top-level string arg.
	 */
	private static function getCallbackArgBlockReason( array $tokens, int $open_paren_index, array $blocked_cb, int $callback_arg ): ?string {
		$depth     = 0;
		$arg_index = 0;
		$in_cb     = false;
		$count     = count( $tokens );
		for ( $j = $open_paren_index; $j < $count; $j++ ) {
			$tok = $tokens[ $j ];
			if ( is_string( $tok ) ) {
				if ( $tok === '(' ) {
					++$depth;
					if ( $depth === 1 ) {
						$in_cb = ( $callback_arg < 0 || $arg_index === $callback_arg );
					}
				} elseif ( $tok === ')' ) {
					--$depth;
					if ( $depth === 0 ) {
						break;
					}
				} elseif ( $tok === ',' && $depth === 1 ) {
					++$arg_index;
					$in_cb = ( $callback_arg < 0 || $arg_index === $callback_arg );
				} elseif ( $tok === '.' && $depth === 1 && $in_cb ) {
					return __( 'Dynamic/concatenated callbacks are not allowed in snippets.', 'wppowersuite-lite' );
				} elseif ( $tok === '[' && $depth === 1 && $in_cb ) {
					// Array callable: [$this,'method'] / [Class::class,'method'] — scan strings only.
					$array_reason = self::getDangerousStringInArrayReason( $tokens, $j, $blocked_cb );
					if ( $array_reason !== null ) {
						return $array_reason;
					}
					$close = self::findMatchingSquareBracket( $tokens, $j );
					if ( $close === null ) {
						return null;
					}
					$j = $close;
				}
				continue;
			}
			if ( ! $in_cb || $depth !== 1 || ! is_array( $tok ) ) {
				continue;
			}

			// Closures/arrow functions are normal WP callbacks — skip bodies entirely.
			if ( $tok[0] === T_FUNCTION || ( defined( 'T_FN' ) && $tok[0] === T_FN ) ) {
				$end = self::skipCallableExpression( $tokens, $j );
				if ( $end === null ) {
					return null;
				}
				$j = $end;
				continue;
			}

			// array( $obj, 'method' ) callable form.
			if (
				( $tok[0] === T_ARRAY || ( $tok[0] === T_STRING && strtolower( $tok[1] ) === 'array' ) )
			) {
				$open = self::nextSignificantToken( $tokens, $j + 1 );
				if ( $open !== null && is_string( $open[0] ) && $open[0] === '(' ) {
					$array_reason = self::getDangerousStringInParenGroupReason( $tokens, $open[1], $blocked_cb );
					if ( $array_reason !== null ) {
						return $array_reason;
					}
					$close = self::findMatchingParen( $tokens, $open[1] );
					if ( $close === null ) {
						return null;
					}
					$j = $close;
					continue;
				}
			}

			// Callable variables / $this / objects are ambiguous → allow (not a sandbox).
			if ( $tok[0] === T_VARIABLE ) {
				continue;
			}

			if ( $tok[0] === T_CONSTANT_ENCAPSED_STRING ) {
				$literal = strtolower( trim( $tok[1], "'\"" ) );
				if ( isset( $blocked_cb[ $literal ] ) ) {
					return __( 'Dangerous function names cannot be used as string callbacks in snippets.', 'wppowersuite-lite' );
				}
			}
		}
		return null;
	}

	/**
	 * @param array<int, mixed>   $tokens
	 * @param array<string, true> $blocked_cb
	 */
	private static function getDangerousStringInArrayReason( array $tokens, int $open_bracket_index, array $blocked_cb ): ?string {
		$close = self::findMatchingSquareBracket( $tokens, $open_bracket_index );
		if ( $close === null ) {
			return null;
		}
		return self::getDangerousStringsBetweenReason( $tokens, $open_bracket_index + 1, $close, $blocked_cb );
	}

	/**
	 * @param array<int, mixed>   $tokens
	 * @param array<string, true> $blocked_cb
	 */
	private static function getDangerousStringInParenGroupReason( array $tokens, int $open_paren_index, array $blocked_cb ): ?string {
		$close = self::findMatchingParen( $tokens, $open_paren_index );
		if ( $close === null ) {
			return null;
		}
		return self::getDangerousStringsBetweenReason( $tokens, $open_paren_index + 1, $close, $blocked_cb );
	}

	/**
	 * @param array<int, mixed>   $tokens
	 * @param array<string, true> $blocked_cb
	 */
	private static function getDangerousStringsBetweenReason( array $tokens, int $start, int $end, array $blocked_cb ): ?string {
		for ( $i = $start; $i < $end; $i++ ) {
			$tok = $tokens[ $i ];
			if ( ! is_array( $tok ) || $tok[0] !== T_CONSTANT_ENCAPSED_STRING ) {
				continue;
			}
			$literal = strtolower( trim( $tok[1], "'\"" ) );
			if ( isset( $blocked_cb[ $literal ] ) ) {
				return __( 'Dangerous function names cannot be used as string callbacks in snippets.', 'wppowersuite-lite' );
			}
		}
		return null;
	}

	/**
	 * Skip a function/closure/arrow-function expression starting at $fn_index.
	 *
	 * @param array<int, mixed> $tokens
	 * @return int|null Index of the last token in the expression.
	 */
	private static function skipCallableExpression( array $tokens, int $fn_index ): ?int {
		$count = count( $tokens );
		$is_fn = is_array( $tokens[ $fn_index ] )
			&& defined( 'T_FN' )
			&& $tokens[ $fn_index ][0] === T_FN;

		// Find parameter list "(".
		$open = self::nextSignificantToken( $tokens, $fn_index + 1 );
		// Named function: function name (
		if ( $open !== null && is_array( $open[0] ) && $open[0][0] === T_STRING ) {
			$open = self::nextSignificantToken( $tokens, $open[1] + 1 );
		}
		if ( $open === null || ! is_string( $open[0] ) || $open[0] !== '(' ) {
			return $fn_index;
		}

		$close_params = self::findMatchingParen( $tokens, $open[1] );
		if ( $close_params === null ) {
			return null;
		}

		$after = self::nextSignificantToken( $tokens, $close_params + 1 );
		if ( $after === null ) {
			return $close_params;
		}

		// function (...) use (...) { ... }
		if ( is_array( $after[0] ) && $after[0][0] === T_USE ) {
			$use_open = self::nextSignificantToken( $tokens, $after[1] + 1 );
			if ( $use_open === null || ! is_string( $use_open[0] ) || $use_open[0] !== '(' ) {
				return $close_params;
			}
			$use_close = self::findMatchingParen( $tokens, $use_open[1] );
			if ( $use_close === null ) {
				return null;
			}
			$after = self::nextSignificantToken( $tokens, $use_close + 1 );
			if ( $after === null ) {
				return $use_close;
			}
		}

		// Arrow: fn (...) => expr  (consume until , or ) at caller depth — handled by returning => expr start;
		// for simplicity, skip a short expression until `;` is impossible inside args; stop at depth balance in caller.
		if ( $is_fn ) {
			if ( is_array( $after[0] ) && defined( 'T_DOUBLE_ARROW' ) && $after[0][0] === T_DOUBLE_ARROW ) {
				return self::skipArrowFunctionBody( $tokens, $after[1] + 1 );
			}
			return $close_params;
		}

		// Classic closure/function body: { ... }
		if ( is_string( $after[0] ) && $after[0] === '{' ) {
			$brace_close = self::findMatchingBrace( $tokens, $after[1] );
			return $brace_close ?? $after[1];
		}

		return $close_params;
	}

	/**
	 * @param array<int, mixed> $tokens
	 */
	private static function findMatchingParen( array $tokens, int $open_index ): ?int {
		$depth = 0;
		$count = count( $tokens );
		for ( $i = $open_index; $i < $count; $i++ ) {
			$tok = $tokens[ $i ];
			if ( ! is_string( $tok ) ) {
				continue;
			}
			if ( $tok === '(' ) {
				++$depth;
			} elseif ( $tok === ')' ) {
				--$depth;
				if ( $depth === 0 ) {
					return $i;
				}
			}
		}
		return null;
	}

	/**
	 * @param array<int, mixed> $tokens
	 */
	private static function findMatchingBrace( array $tokens, int $open_index ): ?int {
		$depth = 0;
		$count = count( $tokens );
		for ( $i = $open_index; $i < $count; $i++ ) {
			$tok = $tokens[ $i ];
			if ( ! is_string( $tok ) ) {
				continue;
			}
			if ( $tok === '{' ) {
				++$depth;
			} elseif ( $tok === '}' ) {
				--$depth;
				if ( $depth === 0 ) {
					return $i;
				}
			}
		}
		return null;
	}

	/**
	 * Skip arrow-function RHS until a top-level comma/closing paren of the outer call would apply.
	 * Tracks nested () [] {}.
	 *
	 * @param array<int, mixed> $tokens
	 */
	private static function skipArrowFunctionBody( array $tokens, int $start ): int {
		$count  = count( $tokens );
		$paren  = 0;
		$bracket = 0;
		$brace  = 0;
		$end    = $start;
		for ( $i = $start; $i < $count; $i++ ) {
			$tok = $tokens[ $i ];
			$end = $i;
			if ( is_string( $tok ) ) {
				if ( $tok === '(' ) {
					++$paren;
				} elseif ( $tok === ')' ) {
					if ( $paren === 0 && $bracket === 0 && $brace === 0 ) {
						// Outer call's closing paren — body ended before this token.
						return $i - 1;
					}
					--$paren;
				} elseif ( $tok === '[' ) {
					++$bracket;
				} elseif ( $tok === ']' ) {
					--$bracket;
				} elseif ( $tok === '{' ) {
					++$brace;
				} elseif ( $tok === '}' ) {
					--$brace;
				} elseif ( $tok === ',' && $paren === 0 && $bracket === 0 && $brace === 0 ) {
					return $i - 1;
				}
			}
		}
		return $end;
	}

	/** @param array<int, mixed> $tokens */
	private static function isSafePhpStreamCall( array $tokens, int $fn_index ): bool {
		$open = self::nextSignificantToken( $tokens, $fn_index + 1 );
		if ( $open === null || ! is_string( $open[0] ) || $open[0] !== '(' ) {
			return false;
		}
		$arg = self::nextSignificantToken( $tokens, $open[1] + 1 );
		if ( $arg === null || ! is_array( $arg[0] ) ) {
			return false;
		}
		[ $id, $text ] = $arg[0];
		if ( $id !== T_CONSTANT_ENCAPSED_STRING ) {
			return false;
		}
		$literal = trim( $text, "'\"" );
		return (bool) preg_match( '#^php://(output|input|memory|temp)$#i', $literal );
	}

	/**
	 * @param array<int, mixed> $tokens
	 * @return array{0: mixed, 1: int}|null Token and index.
	 */
	private static function nextSignificantToken( array $tokens, int $start ): ?array {
		$count = count( $tokens );
		for ( $i = $start; $i < $count; $i++ ) {
			$tok = $tokens[ $i ];
			if ( is_array( $tok ) && in_array( $tok[0], [ T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ], true ) ) {
				continue;
			}
			return [ $tok, $i ];
		}
		return null;
	}

	private static function getRegexFallbackReason( string $code ): ?string {
		$stripped = self::stripCommentsAndStrings( $code );
		$blocked  = [
			'/\beval\s*\(/i'                          => __( 'eval() is not allowed in snippets.', 'wppowersuite-lite' ),
			'/\b(?:shell_exec|passthru|proc_open|popen|system|pcntl_exec)\s*\(/i' => __( 'Operating-system command execution is not allowed.', 'wppowersuite-lite' ),
			'/\bexec\s*\(/i'                          => __( 'exec() is not allowed in snippets.', 'wppowersuite-lite' ),
			'/\b(?:assert|create_function)\s*\(/i'    => __( 'assert() and create_function() can execute arbitrary code.', 'wppowersuite-lite' ),
			'/\b(?:call_user_func|call_user_func_array)\s*\(/i' => __( 'Dynamic function invocation (call_user_func) is not allowed.', 'wppowersuite-lite' ),
			'/\b(?:ReflectionFunction|ReflectionClass|FFI|SplFileObject)\b/i' => __( 'This PHP class is not allowed in snippets.', 'wppowersuite-lite' ),
			// Variable calls are handled in the token path (closures allowed). Fallback only catches $a[0](...).
			'/\$\w+\s*\[[^\]]*\]\s*\(/'               => __( 'Variable/dynamic function calls are not allowed in snippets.', 'wppowersuite-lite' ),
			'/\b(?:include_once|require_once|include|require)\b/i' => __( 'Including or requiring files is not allowed in snippets.', 'wppowersuite-lite' ),
			'/\b(?:fopen|file_get_contents|file_put_contents|readfile|unlink|rmdir|rename|copy|move_uploaded_file)\s*\(/i' => __( 'Direct filesystem access from a snippet is not allowed.', 'wppowersuite-lite' ),
		];
		foreach ( $blocked as $pattern => $message ) {
			if ( preg_match( $pattern, $stripped ) ) {
				return $message;
			}
		}
		return null;
	}

	private static function stripCommentsAndStrings( string $code ): string {
		$steps = [
			'#/\*.*?\*/#s'                 => ' ',
			'#//[^\r\n]*#'                 => ' ',
			'/#(?!\[)[^\r\n]*/'            => ' ',
			'/"(?:\\\\.|[^"\\\\])*"/'      => '""',
			"/'(?:\\\\.|[^'\\\\])*'/"      => "''",
		];
		$out = $code;
		foreach ( $steps as $pattern => $replacement ) {
			$next = preg_replace( $pattern, $replacement, $out );
			if ( is_string( $next ) ) {
				$out = $next;
			}
		}
		return $out;
	}
}
