<?php
/**
 * WordPress Coding Standard.
 *
 * @package WPCS\WordPressCodingStandards
 * @link    https://github.com/WordPress/WordPress-Coding-Standards
 * @license https://opensource.org/licenses/MIT MIT
 */

namespace WordPressCS\WordPress\Helpers;

use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Util\Tokens;
use PHPCSUtils\Tokens\Collections;
use PHPCSUtils\Utils\Parentheses;
use PHPCSUtils\Utils\PassedParameters;

/**
 * Helper utilities for checking the context in which a token is used.
 *
 * ---------------------------------------------------------------------------------------------
 * This class is only intended for internal use by WordPressCS and is not part of the public API.
 * This also means that it has no promise of backward compatibility. Use at your own risk.
 * ---------------------------------------------------------------------------------------------
 *
 * @internal
 *
 * @since 3.0.0 The methods in this class were previously contained in the
 *              `WordPressCS\WordPress\Sniff` class and have been moved here.
 */
final class ContextHelper {

	/**
	 * Tokens which when they precede code indicate the value is safely casted.
	 *
	 * @since 1.1.0
	 * @since 3.0.0 - Moved from the Sniff class to this class.
	 *              - The property visibility was changed from `protected` to `private static`.
	 *
	 * @var array<int|string, true> Key is token constant, value irrelevant.
	 */
	private static $safe_casts = array(
		\T_INT_CAST    => true,
		\T_DOUBLE_CAST => true,
		\T_BOOL_CAST   => true,
		\T_UNSET_CAST  => true,
	);

	/**
	 * List of PHP native functions to test the type of a variable.
	 *
	 * Using these functions is safe in combination with superglobals without
	 * unslashing or sanitization.
	 *
	 * They should, however, not be regarded as unslashing or sanitization functions.
	 *
	 * @since 2.1.0
	 * @since 3.0.0 - Moved from the Sniff class to this class.
	 *              - The property visibility was changed from `protected` to `private static`.
	 *
	 * @var array<string, true> Key is function name, value irrelevant.
	 */
	private static $typeTestFunctions = array(
		'is_array'     => true,
		'is_bool'      => true,
		'is_callable'  => true,
		'is_countable' => true,
		'is_double'    => true,
		'is_float'     => true,
		'is_int'       => true,
		'is_integer'   => true,
		'is_iterable'  => true,
		'is_long'      => true,
		'is_null'      => true,
		'is_numeric'   => true,
		'is_object'    => true,
		'is_real'      => true,
		'is_resource'  => true,
		'is_scalar'    => true,
		'is_string'    => true,
	);

	/**
	 * List of PHP native functions to check if an array index exists.
	 *
	 * @since 3.0.0
	 *
	 * @var array<string, true> Key is function name, value irrelevant.
	 */
	private static $key_exists_functions = array(
		'array_key_exists' => true,
		'key_exists'       => true, // Alias.
	);

	/**
	 * Array functions to compare a $needle to a predefined set of values.
	 *
	 * If the value is set to an array, the parameter specified in the array is
	 * required for the function call to be considered as a comparison.
	 *
	 * @since 2.1.0
	 * @since 3.0.0 - Moved from the Sniff class to this class.
	 *              - The property visibility was changed from `protected` to `private static`.
	 *
	 * @var array<string, bool|array>
	 */
	private static $arrayCompareFunctions = array(
		'in_array'     => true,
		'array_search' => true,
		'array_keys'   => array(
			'position' => 2,
			'name'     => 'filter_value',
		),
	);

	/**
	 * Check if a particular token acts - statically or non-statically - on an object.
	 *
	 * {@internal Note: this may still mistake a namespaced function imported via a `use` statement for
	 * a global function!}
	 *
	 * @since 2.1.0
	 * @since 3.0.0 - Moved from the Sniff class to this class.
	 *              - The method was renamed from `is_class_object_call() to `has_object_operator_before()`.
	 *              - The method visibility was changed from `protected` to `public static`.
	 *              - The `$phpcsFile` parameter was added.
	 *
	 * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
	 * @param int                         $stackPtr  The index of the token in the stack.
	 *
	 * @return bool
	 */
	public static function has_object_operator_before( File $phpcsFile, $stackPtr ) {
		$tokens = $phpcsFile->getTokens();
		if ( isset( $tokens[ $stackPtr ] ) === false ) {
			return false;
		}

		$before = $phpcsFile->findPrevious( Tokens::$emptyTokens, ( $stackPtr - 1 ), null, true );

		return isset( Collections::objectOperators()[ $tokens[ $before ]['code'] ] );
	}

	/**
	 * Check if a particular token is prefixed with a namespace.
	 *
	 * {@internal This will give a false positive if the file is not namespaced and the token is prefixed
	 * with `namespace\`.}
	 *
	 * @since 2.1.0
	 * @since 3.0.0 - Moved from the Sniff class to this class.
	 *              - The method visibility was changed from `protected` to `public static`.
	 *              - The `$phpcsFile` parameter was added.
	 *
	 * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
	 * @param int                         $stackPtr  The index of the token in the stack.
	 *
	 * @return bool
	 */
	public static function is_token_namespaced( File $phpcsFile, $stackPtr ) {
		$tokens = $phpcsFile->getTokens();
		if ( isset( $tokens[ $stackPtr ] ) === false ) {
			return false;
		}

		$prev = $phpcsFile->findPrevious( Tokens::$emptyTokens, ( $stackPtr - 1 ), null, true );

		if ( \T_NS_SEPARATOR !== $tokens[ $prev ]['code'] ) {
			return false;
		}

		$before_prev = $phpcsFile->findPrevious( Tokens::$emptyTokens, ( $prev - 1 ), null, true );
		if ( \T_STRING !== $tokens[ $before_prev ]['code']
			&& \T_NAMESPACE !== $tokens[ $before_prev ]['code']
		) {
			return false;
		}

		return true;
	}

	/**
	 * Check if a token is (part of) a parameter for a function call to a select list of functions.
	 *
	 * This is useful, for instance, when trying to determine the context a variable is used in.
	 *
	 * For example: this function could be used to determine if the variable `$foo` is used
	 * in a global function call to the function `is_foo()`.
	 * In that case, a call to this function would return the stackPtr to the T_STRING `is_foo`
	 * for code like: `is_foo( $foo, 'some_other_param' )`, while it would return `false` for
	 * the following code `is_bar( $foo, 'some_other_param' )`.
	 *
	 * @since 2.1.0
	 * @since 3.0.0 - Moved from the Sniff class to this class.
	 *              - The method visibility was changed from `protected` to `public static`.
	 *              - The `$phpcsFile` parameter was added.
	 *              - The `$global` parameter was renamed to `$global_function`.
	 *
	 * @param \PHP_CodeSniffer\Files\File $phpcsFile       The file being scanned.
	 * @param int                         $stackPtr        The index of the token in the stack.
	 * @param array                       $valid_functions List of valid function names.
	 *                                                     Note: The keys to this array should be the function names
	 *                                                     in lowercase. Values are irrelevant.
	 * @param bool                        $global_function Optional. Whether to make sure that the function call is
	 *                                                     to a global function. If `false`, calls to methods, be it static
	 *                                                     `Class::method()` or via an object `$obj->method()`, and
	 *                                                     namespaced function calls, like `MyNS\function_name()` will
	 *                                                     also be accepted.
	 *                                                     Defaults to `true`.
	 * @param bool                        $allow_nested    Optional. Whether to allow for nested function calls within the
	 *                                                     call to this function.
	 *                                                     I.e. when checking whether a token is within a function call
	 *                                                     to `strtolower()`, whether to accept `strtolower( trim( $var ) )`
	 *                                                     or only `strtolower( $var )`.
	 *                                                     Defaults to `false`.
	 *
	 * @return int|bool Stack pointer to the function call T_STRING token or false otherwise.
	 */
	public static function is_in_function_call( File $phpcsFile, $stackPtr, array $valid_functions, $global_function = true, $allow_nested = false ) {
		$tokens = $phpcsFile->getTokens();
		if ( ! isset( $tokens[ $stackPtr ]['nested_parenthesis'] ) ) {
			return false;
		}

		$nested_parenthesis = $tokens[ $stackPtr ]['nested_parenthesis'];
		if ( false === $allow_nested ) {
			$nested_parenthesis = array_reverse( $nested_parenthesis, true );
		}

		foreach ( $nested_parenthesis as $open => $close ) {
			$prev_non_empty = $phpcsFile->findPrevious( Tokens::$emptyTokens, ( $open - 1 ), null, true );
			if ( false === $prev_non_empty || \T_STRING !== $tokens[ $prev_non_empty ]['code'] ) {
				continue;
			}

			if ( isset( $valid_functions[ strtolower( $tokens[ $prev_non_empty ]['content'] ) ] ) === false ) {
				if ( false === $allow_nested ) {
					// Function call encountered, but not to one of the allowed functions.
					return false;
				}

				continue;
			}

			if ( false === $global_function ) {
				return $prev_non_empty;
			}

			/*
			 * Now, make sure it is a global function.
			 */
			if ( self::has_object_operator_before( $phpcsFile, $prev_non_empty ) === true ) {
				continue;
			}

			if ( self::is_token_namespaced( $phpcsFile, $prev_non_empty ) === true ) {
				continue;
			}

			return $prev_non_empty;
		}

		return false;
	}

	/**
	 * Check if a token is inside of an is_...() statement.
	 *
	 * @since 2.1.0
	 * @since 3.0.0 - Moved from the Sniff class to this class.
	 *              - The method visibility was changed from `protected` to `public static`.
	 *              - The `$phpcsFile` parameter was added.
	 *
	 * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
	 * @param int                         $stackPtr  The index of the token in the stack.
	 *
	 * @return bool Whether the token is being type tested.
	 */
	public static function is_in_type_test( File $phpcsFile, $stackPtr ) {
		/*
		 * Casting the potential integer stack pointer return value to boolean here is fine.
		 * The return can never be `0` as there will always be a PHP open tag before the
		 * function call.
		 */
		return (bool) self::is_in_function_call( $phpcsFile, $stackPtr, self::$typeTestFunctions );
	}

	/**
	 * Check if a token is inside of an isset(), empty() or array_key_exists() statement.
	 *
	 * @since 0.5.0
	 * @since 2.1.0 Now checks for the token being used as the array parameter
	 *              in function calls to array_key_exists() and key_exists() as well.
	 * @since 3.0.0 - Moved from the Sniff class to this class.
	 *              - The method visibility was changed from `protected` to `public static`.
	 *              - The `$phpcsFile` parameter was added.
	 *
	 * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
	 * @param int                         $stackPtr  The index of the token in the stack.
	 *
	 * @return bool Whether the token is inside an isset() or empty() statement.
	 */
	public static function is_in_isset_or_empty( File $phpcsFile, $stackPtr ) {
		if ( Parentheses::lastOwnerIn( $phpcsFile, $stackPtr, array( \T_ISSET, \T_EMPTY ) ) !== false ) {
			return true;
		}

		$functionPtr = self::is_in_function_call( $phpcsFile, $stackPtr, self::$key_exists_functions );
		if ( false !== $functionPtr ) {
			/*
			 * Both functions being checked have the same parameters. If the function list would
			 * be expanded, this needs to be revisited.
			 */
			$array_param = PassedParameters::getParameter( $phpcsFile, $functionPtr, 2, 'array' );
			if ( false !== $array_param
				&& ( $stackPtr >= $array_param['start'] && $stackPtr <= $array_param['end'] )
			) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Retrieve a list of the tokens which are regarded as "safe casts".
	 *
	 * @since 3.0.0
	 *
	 * @return array<int|string, true>
	 */
	public static function get_safe_cast_tokens() {
		return self::$safe_casts;
	}

	/**
	 * Check if something is being casted to a safe value.
	 *
	 * @since 0.5.0
	 * @since 3.0.0 - Moved from the Sniff class to this class.
	 *              - The method visibility was changed from `protected` to `public static`.
	 *              - The `$phpcsFile` parameter was added.
	 *
	 * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
	 * @param int                         $stackPtr  The index of the token in the stack.
	 *
	 * @return bool Whether the token being casted.
	 */
	public static function is_safe_casted( File $phpcsFile, $stackPtr ) {
		$tokens = $phpcsFile->getTokens();
		if ( isset( $tokens[ $stackPtr ] ) === false ) {
			return false;
		}

		$prev = $phpcsFile->findPrevious( Tokens::$emptyTokens, ( $stackPtr - 1 ), null, true );

		return isset( self::$safe_casts[ $tokens[ $prev ]['code'] ] );
	}

	/**
	 * Check if a token is inside of an array-value comparison function.
	 *
	 * @since 2.1.0
	 * @since 3.0.0 - Moved from the Sniff class to this class.
	 *              - The method visibility was changed from `protected` to `public static`.
	 *              - The `$phpcsFile` parameter was added.
	 *
	 * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
	 * @param int                         $stackPtr  The index of the token in the stack.
	 *
	 * @return bool Whether the token is (part of) a parameter to an
	 *              array-value comparison function.
	 */
	public static function is_in_array_comparison( File $phpcsFile, $stackPtr ) {
		$function_ptr = self::is_in_function_call( $phpcsFile, $stackPtr, self::$arrayCompareFunctions, true, true );
		if ( false === $function_ptr ) {
			return false;
		}

		$tokens        = $phpcsFile->getTokens();
		$function_name = strtolower( $tokens[ $function_ptr ]['content'] );
		if ( true === self::$arrayCompareFunctions[ $function_name ] ) {
			return true;
		}

		$target_param = self::$arrayCompareFunctions[ $function_name ];
		$found_param  = PassedParameters::getParameter( $phpcsFile, $function_ptr, $target_param['position'], $target_param['name'] );
		if ( false !== $found_param ) {
			return true;
		}

		return false;
	}
}
