<?php
/**
 * Deterministic PHP runtime blocking for user-written snippets.
 *
 * @package CodeSnippet
 */

namespace CodeSnippet;

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

final class SnippetThreatScanner {

	/**
	 * Patterns blocked at PHP snippet runtime (silent no-op).
	 */
	public static function isRuntimeBlocked( string $code ): bool {
		return self::getRuntimeBlockReason( $code ) !== null;
	}

	/**
	 * Human-readable reason when runtime execution is blocked.
	 */
	public static function getRuntimeBlockReason( string $code ): ?string {
		$code = trim( $code );
		if ( $code === '' ) {
			return null;
		}

		$blocked = [
			'/\b(exit|die)\s*\(/i'                    => __( 'exit() and die() terminate the request and can blank the site.', 'wp-powersuite' ),
			'/\beval\s*\(/i'                          => __( 'eval() is not allowed in snippets.', 'wp-powersuite' ),
			'/dns_get_record/i'                       => __( 'dns_get_record() is blocked in snippets.', 'wp-powersuite' ),
			'/\b(?:shell_exec|passthru|proc_open|popen|system)\s*\(/i' => __( 'Operating-system command execution is not allowed.', 'wp-powersuite' ),
			'/\bexec\s*\(/i'                          => __( 'exec() is not allowed in snippets.', 'wp-powersuite' ),
			'/`[^`]*`/'                               => __( 'Backtick shell execution is not allowed.', 'wp-powersuite' ),
			'/\b(?:assert|create_function)\s*\(/i'    => __( 'assert() and create_function() can execute arbitrary code.', 'wp-powersuite' ),
			'/\b(?:call_user_func|call_user_func_array)\s*\(/i' => __( 'Dynamic function invocation (call_user_func) is not allowed.', 'wp-powersuite' ),
			'/\bReflectionFunction\b/i'                => __( 'ReflectionFunction is not allowed in snippets.', 'wp-powersuite' ),
			'/preg_replace\s*\(\s*[\'"][^\'"]*\/e[\'"]/i' => __( 'preg_replace() with the /e modifier can execute arbitrary code.', 'wp-powersuite' ),
		];

		foreach ( $blocked as $pattern => $message ) {
			if ( preg_match( $pattern, $code ) ) {
				return $message;
			}
		}

		// Checked against code with comments/strings removed so prose such as
		// "// don't include the header" does not trip the keyword match.
		if ( preg_match( '/\b(?:include_once|require_once|include|require)\b/i', self::stripCommentsAndStrings( $code ) ) ) {
			return __( 'Including or requiring files is not allowed in snippets.', 'wp-powersuite' );
		}

		$filesystem_reason = self::getFilesystemBlockReason( $code );
		if ( $filesystem_reason !== null ) {
			return $filesystem_reason;
		}

		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.', 'wp-powersuite' );
			}
		}

		$disallowed = apply_filters( 'wpps_code_snippet_disallowed_php', [] );
		foreach ( (array) $disallowed as $pattern ) {
			if ( is_string( $pattern ) && $pattern !== '' && preg_match( '#' . $pattern . '#i', $code ) ) {
				return __( 'This snippet uses a disallowed PHP pattern.', 'wp-powersuite' );
			}
		}

		return null;
	}

	/**
	 * Replace comments and quoted string bodies with empty placeholders.
	 *
	 * Keyword checks run against this so text inside comments or strings cannot
	 * trigger a block, while the surrounding code structure is preserved.
	 */
	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;
	}

	/**
	 * Block local filesystem reads/writes while allowing in-memory PHP stream wrappers
	 * (e.g. fopen( 'php://output', 'w' ) for CSV downloads).
	 */
	private static function getFilesystemBlockReason( string $code ): ?string {
		$message = __( 'Direct filesystem access from a snippet is not allowed.', 'wp-powersuite' );

		// Safe: php://output|input|memory|temp (no disk path).
		$safe_stream = '[\'"]php:\/\/(?:output|input|memory|temp)[\'"]';

		$stripped = preg_replace(
			'/\b(?:fopen|file_get_contents|file_put_contents|readfile|unlink|rmdir|rename|copy|move_uploaded_file)\s*\(\s*' . $safe_stream . '[^)]*\)/i',
			'',
			$code
		);
		if ( ! is_string( $stripped ) ) {
			$stripped = $code;
		}

		if ( preg_match( '/\b(?:fopen|file_get_contents|file_put_contents|readfile|unlink|rmdir|rename|copy|move_uploaded_file)\s*\(/i', $stripped ) ) {
			return $message;
		}

		return null;
	}
}
