<?php
/**
 * Token-based PHP-compatibility ruleset (ADVISORY tier).
 *
 * Curated, high-precision checks only: false-positive discipline beats coverage.
 * Lexes with token_get_all() WITHOUT TOKEN_PARSE so PHP-7-only syntax can be
 * inspected even when the scanner itself runs on PHP 8 (and vice versa).
 *
 * Architecture note: this class implements the ruleset behind a stable contract
 * (scan_source) so a PHPCompatibilityWP-based engine can replace it when that
 * project ships a stable PHP-8-aware release (10.x is alpha as of 2026-07).
 *
 * @package Upgrade_Pilot
 */

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

/**
 * Class UPilot_Ruleset
 */
class UPilot_Ruleset {

	/**
	 * Functions removed from PHP, keyed by name.
	 * name => array( removed_in, hint ).
	 *
	 * @var array<string,array{0:string,1:string}>
	 */
	private $removed_functions = array(
		// Removed in PHP 7.0 — catches truly ancient code that fatals on any modern PHP.
		'mysql_connect'              => array( '7.0', 'Use mysqli or PDO (WordPress: $wpdb).' ),
		'mysql_query'                => array( '7.0', 'Use mysqli or PDO (WordPress: $wpdb).' ),
		'mysql_fetch_array'          => array( '7.0', 'Use mysqli or PDO (WordPress: $wpdb).' ),
		'mysql_fetch_assoc'          => array( '7.0', 'Use mysqli or PDO (WordPress: $wpdb).' ),
		'mysql_fetch_object'         => array( '7.0', 'Use mysqli or PDO (WordPress: $wpdb).' ),
		'mysql_num_rows'             => array( '7.0', 'Use mysqli or PDO (WordPress: $wpdb).' ),
		'mysql_real_escape_string'   => array( '7.0', 'Use $wpdb->prepare().' ),
		'mysql_error'                => array( '7.0', 'Use mysqli or PDO (WordPress: $wpdb).' ),
		'mysql_insert_id'            => array( '7.0', 'Use $wpdb->insert_id.' ),
		'mysql_select_db'            => array( '7.0', 'Use mysqli or PDO (WordPress: $wpdb).' ),
		'mysql_close'                => array( '7.0', 'Use mysqli or PDO (WordPress: $wpdb).' ),
		'split'                      => array( '7.0', 'Use explode() or preg_split().' ),
		'spliti'                     => array( '7.0', 'Use preg_split() with the i modifier.' ),
		'ereg'                       => array( '7.0', 'Use preg_match().' ),
		'eregi'                      => array( '7.0', 'Use preg_match() with the i modifier.' ),
		'ereg_replace'               => array( '7.0', 'Use preg_replace().' ),
		'eregi_replace'              => array( '7.0', 'Use preg_replace() with the i modifier.' ),
		'sql_regcase'                => array( '7.0', 'Use preg_quote().' ),
		'set_magic_quotes_runtime'   => array( '7.0', 'Remove the call; magic quotes no longer exist.' ),
		'call_user_method'           => array( '7.0', 'Use call_user_func( array( $obj, $method ) ).' ),
		'call_user_method_array'     => array( '7.0', 'Use call_user_func_array( array( $obj, $method ), $args ).' ),
		'mcrypt_encrypt'             => array( '7.2', 'Use openssl_encrypt() or sodium.' ),
		'mcrypt_decrypt'             => array( '7.2', 'Use openssl_decrypt() or sodium.' ),
		'mcrypt_generic_init'        => array( '7.2', 'Use OpenSSL or sodium.' ),
		'mcrypt_module_open'         => array( '7.2', 'Use OpenSSL or sodium.' ),
		'mcrypt_get_iv_size'         => array( '7.2', 'Use openssl_cipher_iv_length().' ),
		'mcrypt_create_iv'           => array( '7.2', 'Use random_bytes().' ),
		'create_function'            => array( '8.0', 'Use an anonymous function (closure).' ),
		'each'                       => array( '8.0', 'Use foreach or key()/current()/next().' ),
		'money_format'               => array( '8.0', 'Use NumberFormatter (intl).' ),
		'get_magic_quotes_gpc'       => array( '8.0', 'Remove the call; magic quotes no longer exist.' ),
		'get_magic_quotes_runtime'   => array( '8.0', 'Remove the call; magic quotes no longer exist.' ),
		'hebrevc'                    => array( '8.0', 'Use hebrev() with nl2br().' ),
		'convert_cyr_string'         => array( '8.0', 'Use mb_convert_encoding() or iconv().' ),
		'ezmlm_hash'                 => array( '8.0', 'No replacement; remove the call.' ),
		'restore_include_path'       => array( '8.0', "Use ini_restore( 'include_path' )." ),
		'image2wbmp'                 => array( '8.0', 'Use imagewbmp().' ),
		'png2wbmp'                   => array( '8.0', 'Use imagecreatefrompng() + imagewbmp().' ),
		'jpeg2wbmp'                  => array( '8.0', 'Use imagecreatefromjpeg() + imagewbmp().' ),
		'is_real'                    => array( '8.0', 'Use is_float().' ),
		'fgetss'                     => array( '8.0', 'Use fgets() + strip_tags().' ),
		'gzgetss'                    => array( '8.0', 'Use gzgets() + strip_tags().' ),
		'ldap_sort'                  => array( '8.0', 'Sort the result array with usort().' ),
		'read_exif_data'             => array( '8.0', 'Use exif_read_data().' ),
	);

	/**
	 * Deprecated (not yet removed) functions.
	 * name => array( deprecated_in, hint ).
	 *
	 * @var array<string,array{0:string,1:string}>
	 */
	private $deprecated_functions = array(
		'strftime'            => array( '8.1', 'Use date() or IntlDateFormatter (WordPress: wp_date()).' ),
		'gmstrftime'          => array( '8.1', 'Use gmdate() or IntlDateFormatter.' ),
		'strptime'            => array( '8.1', 'Use date_parse_from_format() or IntlDateFormatter.' ),
		'date_sunrise'        => array( '8.1', 'Use date_sun_info().' ),
		'date_sunset'         => array( '8.1', 'Use date_sun_info().' ),
		'utf8_encode'         => array( '8.2', 'Use mb_convert_encoding( $s, "UTF-8", "ISO-8859-1" ).' ),
		'utf8_decode'         => array( '8.2', 'Use mb_convert_encoding( $s, "ISO-8859-1", "UTF-8" ).' ),
		'mysqli_ping'         => array( '8.4', 'Reconnect explicitly instead of pinging.' ),
		'mysqli_kill'         => array( '8.4', 'Use a KILL query.' ),
		'mysqli_refresh'      => array( '8.4', 'Use FLUSH statements.' ),
	);

	/**
	 * Scan PHP source code and return findings.
	 *
	 * @param string $src  Source code.
	 * @param string $file Relative file path (for messages only).
	 * @return array[] findings {rule_id, line, severity, php_version, message}
	 */
	public function scan_source( $src, $file = '' ) {
		$findings = array();

		// Lex without TOKEN_PARSE: works across PHP versions for version-specific syntax.
		$tokens = @token_get_all( $src ); // phpcs:ignore WordPress.PHP.NoSilencedErrors -- lexer warnings on malformed edge-case files are expected and handled by the empty check.
		if ( ! is_array( $tokens ) || empty( $tokens ) ) {
			return $findings;
		}

		$count = count( $tokens );

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

			if ( ! is_array( $token ) ) {
				continue;
			}

			$id   = $token[0];
			$text = $token[1];
			$line = $token[2];

			// --- Rule: ${var} string interpolation (deprecated 8.2). ---
			if ( T_DOLLAR_OPEN_CURLY_BRACES === $id ) {
				$findings[] = $this->finding(
					'interp_dollar_curly',
					$line,
					'warning',
					'8.2',
					__( 'Uses the "${var}" string interpolation style, deprecated in PHP 8.2. Replace with "{$var}".', 'upgrade-pilot' )
				);
				continue;
			}

			// --- Rule: (real) cast removed in 8.0. ---
			if ( T_DOUBLE_CAST === $id && false !== stripos( $text, 'real' ) ) {
				$findings[] = $this->finding(
					'cast_real',
					$line,
					'error',
					'8.0',
					__( 'Uses the (real) cast, removed in PHP 8.0. Replace with (float).', 'upgrade-pilot' )
				);
				continue;
			}

			// --- Rule: curly-brace string/array offset $x{0} removed in 8.0. ---
			if ( T_VARIABLE === $id && isset( $tokens[ $i + 1 ] ) && '{' === $tokens[ $i + 1 ] ) {
				$findings[] = $this->finding(
					'curly_offset',
					$line,
					'error',
					'8.0',
					sprintf(
						/* translators: %s: variable name. */
						__( 'Uses curly-brace offset access (%s{...}), removed in PHP 8.0. Replace with square brackets.', 'upgrade-pilot' ),
						$text
					)
				);
				continue;
			}

			// --- Rules keyed on function-call tokens. ---
			$name = null;
			if ( T_STRING === $id ) {
				$name = strtolower( $text );
			} elseif ( defined( 'T_NAME_FULLY_QUALIFIED' ) && T_NAME_FULLY_QUALIFIED === $id ) {
				$name = strtolower( ltrim( $text, '\\' ) );
			}

			// --- Rule: (real) cast on PHP 8 lexers. PHP 8 removed 'real' from the
			// cast list, so the T_DOUBLE_CAST branch above never sees it there —
			// it lexes as '(' T_STRING(real) ')'. ---
			if ( 'real' === $name ) {
				$prev = $this->prev_meaningful( $tokens, $i );
				$next = $this->next_meaningful( $tokens, $i );
				if ( '(' === $prev['token'] && ')' === $next['token'] ) {
					$findings[] = $this->finding(
						'cast_real',
						$line,
						'error',
						'8.0',
						__( 'Uses the (real) cast, removed in PHP 8.0. Replace with (float).', 'upgrade-pilot' )
					);
					continue;
				}
			}

			if ( null !== $name && ( isset( $this->removed_functions[ $name ] ) || isset( $this->deprecated_functions[ $name ] ) ) ) {
				if ( ! $this->is_global_function_call( $tokens, $i ) ) {
					continue;
				}

				// Special case: get_class()/get_parent_class() handled separately below.
				if ( isset( $this->removed_functions[ $name ] ) ) {
					list( $ver, $hint ) = $this->removed_functions[ $name ];
					$findings[]         = $this->finding(
						'removed_' . $name,
						$line,
						'error',
						$ver,
						sprintf(
							/* translators: 1: function name, 2: PHP version, 3: hint. */
							__( 'Calls %1$s(), removed in PHP %2$s — this line fatals if executed. %3$s', 'upgrade-pilot' ),
							$name,
							$ver,
							$hint
						)
					);
				} else {
					list( $ver, $hint ) = $this->deprecated_functions[ $name ];
					$findings[]         = $this->finding(
						'deprecated_' . $name,
						$line,
						'warning',
						$ver,
						sprintf(
							/* translators: 1: function name, 2: PHP version, 3: hint. */
							__( 'Calls %1$s(), deprecated since PHP %2$s. Still works today but will be removed in a future PHP. %3$s', 'upgrade-pilot' ),
							$name,
							$ver,
							$hint
						)
					);
				}
				continue;
			}

			// --- Rule: get_class()/get_parent_class() with no arguments (deprecated 8.3). ---
			if ( null !== $name && ( 'get_class' === $name || 'get_parent_class' === $name ) ) {
				if ( $this->is_global_function_call( $tokens, $i ) && $this->call_has_no_args( $tokens, $i ) ) {
					$findings[] = $this->finding(
						'noargs_' . $name,
						$line,
						'warning',
						'8.3',
						sprintf(
							/* translators: %s: function name. */
							__( 'Calls %s() without arguments, deprecated since PHP 8.3. Pass $this explicitly.', 'upgrade-pilot' ),
							$name
						)
					);
				}
				continue;
			}

			// --- Rule: implicit nullable parameters (deprecated 8.4). ---
			if ( T_FUNCTION === $id || ( defined( 'T_FN' ) && T_FN === $id ) ) {
				$sig_findings = $this->check_implicit_nullable( $tokens, $i );
				foreach ( $sig_findings as $f ) {
					$findings[] = $f;
				}
			}
		}

		return $findings;
	}

	/**
	 * Is the token at $i a plain global function call?
	 * Rejects method calls, static calls, definitions, namespaced calls and class instantiation.
	 *
	 * @param array $tokens Token stream.
	 * @param int   $i      Index of the name token.
	 * @return bool
	 */
	private function is_global_function_call( $tokens, $i ) {
		// Next meaningful token must be an opening parenthesis.
		$next = $this->next_meaningful( $tokens, $i );
		if ( '(' !== $next['token'] ) {
			return false;
		}

		$prev = $this->prev_meaningful( $tokens, $i );
		$pid  = is_array( $prev['token'] ) ? $prev['token'][0] : $prev['token'];

		// Method / static / definition / instantiation contexts.
		$blocked = array( T_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW, T_CONST );
		if ( defined( 'T_NULLSAFE_OBJECT_OPERATOR' ) ) {
			$blocked[] = T_NULLSAFE_OBJECT_OPERATOR;
		}
		if ( in_array( $pid, $blocked, true ) ) {
			return false;
		}

		// Namespaced call on PHP 7 tokenizers: T_STRING preceded by T_NS_SEPARATOR preceded by T_STRING.
		if ( T_NS_SEPARATOR === $pid ) {
			$prev2 = $this->prev_meaningful( $tokens, $prev['index'] );
			$p2id  = is_array( $prev2['token'] ) ? $prev2['token'][0] : $prev2['token'];
			if ( T_STRING === $p2id ) {
				return false; // Foo\create_function() is not the global function.
			}
		}

		return true;
	}

	/**
	 * Does the call at name-token $i have an empty argument list?
	 *
	 * @param array $tokens Token stream.
	 * @param int   $i      Index of the name token.
	 * @return bool
	 */
	private function call_has_no_args( $tokens, $i ) {
		$open = $this->next_meaningful( $tokens, $i );
		if ( '(' !== $open['token'] ) {
			return false;
		}
		$close = $this->next_meaningful( $tokens, $open['index'] );
		return ')' === $close['token'];
	}

	/**
	 * Detect implicit nullable parameters in the signature starting at T_FUNCTION token $i.
	 * Pattern: <Type> $param = null   (type present, not prefixed with "?").
	 *
	 * @param array $tokens Token stream.
	 * @param int   $i      Index of T_FUNCTION.
	 * @return array[]
	 */
	private function check_implicit_nullable( $tokens, $i ) {
		$findings = array();
		$count    = count( $tokens );

		// Find the opening parenthesis of the parameter list.
		$j = $i + 1;
		while ( $j < $count && '(' !== $tokens[ $j ] ) {
			// Abort on '{' or ';' — signature never started (e.g. "function" keyword in a string context won't reach here).
			if ( '{' === $tokens[ $j ] || ';' === $tokens[ $j ] ) {
				return $findings;
			}
			$j++;
		}
		if ( $j >= $count ) {
			return $findings;
		}

		$depth       = 0;
		$has_type    = false;
		$is_nullable = false;
		$param_var   = '';
		$param_line  = 0;
		$saw_equals  = false;

		for ( $k = $j; $k < $count; $k++ ) {
			$tok = $tokens[ $k ];

			if ( '(' === $tok ) {
				$depth++;
				continue;
			}
			if ( ')' === $tok ) {
				$depth--;
				if ( 0 === $depth ) {
					// Close out the final parameter.
					if ( $has_type && ! $is_nullable && $saw_equals && '' !== $param_var ) {
						$findings[] = $this->implicit_nullable_finding( $param_var, $param_line );
					}
					break;
				}
				continue;
			}
			if ( 1 !== $depth ) {
				continue;
			}

			if ( ',' === $tok ) {
				if ( $has_type && ! $is_nullable && $saw_equals && '' !== $param_var ) {
					$findings[] = $this->implicit_nullable_finding( $param_var, $param_line );
				}
				$has_type    = false;
				$is_nullable = false;
				$param_var   = '';
				$param_line  = 0;
				$saw_equals  = false;
				continue;
			}

			if ( '?' === $tok ) {
				$is_nullable = true;
				continue;
			}
			if ( '=' === $tok ) {
				$saw_equals = false; // Reset; only set when default is literally null.
				// Look ahead for the default value.
				$def = $this->next_meaningful( $tokens, $k );
				if ( is_array( $def['token'] ) && T_STRING === $def['token'][0] && 'null' === strtolower( $def['token'][1] ) ) {
					$saw_equals = true;
				}
				continue;
			}

			if ( is_array( $tok ) ) {
				$tid = $tok[0];
				if ( T_VARIABLE === $tid && '' === $param_var ) {
					$param_var  = $tok[1];
					$param_line = $tok[2];
					continue;
				}
				// Type tokens before the variable (union types with |null are fine — treated as nullable).
				if ( '' === $param_var ) {
					if ( T_STRING === $tid && 'null' === strtolower( $tok[1] ) ) {
						$is_nullable = true; // Union type containing null.
					} elseif ( in_array( $tid, array( T_STRING, T_ARRAY, T_CALLABLE, T_NS_SEPARATOR ), true )
						|| ( defined( 'T_NAME_QUALIFIED' ) && T_NAME_QUALIFIED === $tid )
						|| ( defined( 'T_NAME_FULLY_QUALIFIED' ) && T_NAME_FULLY_QUALIFIED === $tid ) ) {
						$has_type = true;
					}
				}
			}
		}

		return $findings;
	}

	/**
	 * Build the implicit-nullable finding.
	 *
	 * @param string $var  Parameter variable name.
	 * @param int    $line Line number.
	 * @return array
	 */
	private function implicit_nullable_finding( $var, $line ) {
		return $this->finding(
			'implicit_nullable',
			$line,
			'warning',
			'8.4',
			sprintf(
				/* translators: %s: parameter name. */
				__( 'Parameter %s has a type but defaults to null without being declared nullable — deprecated since PHP 8.4. Prefix the type with "?".', 'upgrade-pilot' ),
				$var
			)
		);
	}

	/**
	 * Next non-whitespace/comment token.
	 *
	 * @param array $tokens Token stream.
	 * @param int   $i      Current index.
	 * @return array{token:mixed,index:int}
	 */
	private function next_meaningful( $tokens, $i ) {
		$count = count( $tokens );
		for ( $j = $i + 1; $j < $count; $j++ ) {
			if ( is_array( $tokens[ $j ] ) && in_array( $tokens[ $j ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
				continue;
			}
			return array(
				'token' => $tokens[ $j ],
				'index' => $j,
			);
		}
		return array(
			'token' => null,
			'index' => $count,
		);
	}

	/**
	 * Previous non-whitespace/comment token.
	 *
	 * @param array $tokens Token stream.
	 * @param int   $i      Current index.
	 * @return array{token:mixed,index:int}
	 */
	private function prev_meaningful( $tokens, $i ) {
		for ( $j = $i - 1; $j >= 0; $j-- ) {
			if ( is_array( $tokens[ $j ] ) && in_array( $tokens[ $j ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
				continue;
			}
			return array(
				'token' => $tokens[ $j ],
				'index' => $j,
			);
		}
		return array(
			'token' => null,
			'index' => -1,
		);
	}

	/**
	 * Build a finding row.
	 *
	 * @param string $rule_id     Rule id.
	 * @param int    $line        Line number.
	 * @param string $severity    error|warning.
	 * @param string $php_version PHP version the rule concerns.
	 * @param string $message     Human message.
	 * @return array
	 */
	private function finding( $rule_id, $line, $severity, $php_version, $message ) {
		return array(
			'rule_id'     => $rule_id,
			'line'        => (int) $line,
			'severity'    => $severity,
			'php_version' => $php_version,
			'message'     => $message,
		);
	}
}
