<?php
/**
 * PHPCompatibility, an external standard for PHP_CodeSniffer.
 *
 * @package   PHPCompatibility
 * @copyright 2012-2020 PHPCompatibility Contributors
 * @license   https://opensource.org/licenses/LGPL-3.0 LGPL3
 * @link      https://github.com/PHPCompatibility/PHPCompatibility
 */

namespace PHPCompatibility\Sniffs\ParameterValues;

use PHPCompatibility\AbstractFunctionCallParameterSniff;
use PHPCompatibility\Helpers\ScannedCode;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Util\Tokens;
use PHPCSUtils\BackCompat\BCTokens;
use PHPCSUtils\Tokens\Collections;
use PHPCSUtils\Utils\MessageHelper;

/**
 * Passing the `$glue` and `$pieces` parameters to `implode()` in reverse order has
 * been deprecated in PHP 7.4 and removed in PHP 8.0.
 *
 * PHP version 7.4
 * PHP version 8.0
 *
 * @link https://www.php.net/manual/en/migration74.deprecated.php#migration74.deprecated.core.implode-reverse-parameters
 * @link https://wiki.php.net/rfc/deprecations_php_7_4#implode_parameter_order_mix
 * @link https://php.net/manual/en/function.implode.php
 *
 * @since 9.3.0
 */
class RemovedImplodeFlexibleParamOrderSniff extends AbstractFunctionCallParameterSniff
{

    /**
     * Functions to check for.
     *
     * @since 9.3.0
     *
     * @var array<string, true>
     */
    protected $targetFunctions = [
        'implode' => true,
        'join'    => true,
    ];

    /**
     * List of PHP native constants which should be recognized as text strings.
     *
     * @since 9.3.0
     *
     * @var array<string, true>
     */
    private $constantStrings = [
        'DIRECTORY_SEPARATOR' => true,
        'PHP_EOL'             => true,
    ];

    /**
     * List of PHP native functions which should be recognized as returning an array.
     *
     * Note: The array_*() functions will always be taken into account.
     *
     * @since 9.3.0
     *
     * @var array<string, true>
     */
    private $arrayFunctions = [
        'compact' => true,
        'explode' => true,
        'range'   => true,
    ];

    /**
     * List of PHP native array functions which should *not* be recognized as returning an array.
     *
     * @since 9.3.0
     *
     * @var array<string, true>
     */
    private $arrayFunctionExceptions = [
        'array_key_exists'     => true,
        'array_key_first'      => true,
        'array_key_last'       => true,
        'array_multisort'      => true,
        'array_pop'            => true,
        'array_product'        => true,
        'array_push'           => true,
        'array_search'         => true,
        'array_shift'          => true,
        'array_sum'            => true,
        'array_unshift'        => true,
        'array_walk_recursive' => true,
        'array_walk'           => true,
    ];


    /**
     * Do a version check to determine if this sniff needs to run at all.
     *
     * @since 9.3.0
     *
     * @return bool
     */
    protected function bowOutEarly()
    {
        return (ScannedCode::shouldRunOnOrAbove('7.4') === false);
    }


    /**
     * Process the parameters of a matched function.
     *
     * @since 9.3.0
     *
     * @param \PHP_CodeSniffer\Files\File $phpcsFile    The file being scanned.
     * @param int                         $stackPtr     The position of the current token in the stack.
     * @param string                      $functionName The token content (function name) which was matched.
     * @param array                       $parameters   Array with information about the parameters.
     *
     * @return int|void Integer stack pointer to skip forward or void to continue
     *                  normal file processing.
     */
    public function processParameters(File $phpcsFile, $stackPtr, $functionName, $parameters)
    {
        if (isset($parameters[2]) === false) {
            // Only one parameter, this must be $pieces. Bow out.
            return;
        }

        $tokens = $phpcsFile->getTokens();

        /*
         * Examine the first parameter.
         * If there is any indication that this is an array declaration, we have an error.
         */

        $targetParam = $parameters[1];
        $start       = $targetParam['start'];
        $end         = ($targetParam['end'] + 1);
        $isOnlyText  = true;

        $firstNonEmpty = $phpcsFile->findNext(Tokens::$emptyTokens, $start, $end, true);
        if ($firstNonEmpty === false) {
            // Parse error. Shouldn't be possible.
            return;
        }

        if ($tokens[$firstNonEmpty]['code'] === \T_OPEN_PARENTHESIS) {
            $start = ($firstNonEmpty + 1);
            $end   = $tokens[$firstNonEmpty]['parenthesis_closer'];
        }

        $hasTernary = $phpcsFile->findNext(\T_INLINE_THEN, $start, $end);
        if ($hasTernary !== false
            && isset($tokens[$start]['nested_parenthesis'], $tokens[$hasTernary]['nested_parenthesis'])
            && \count($tokens[$start]['nested_parenthesis']) === \count($tokens[$hasTernary]['nested_parenthesis'])
        ) {
            $start = ($hasTernary + 1);
        }

        for ($i = $start; $i < $end; $i++) {
            $tokenCode = $tokens[$i]['code'];

            if (isset(Tokens::$emptyTokens[$tokenCode])) {
                continue;
            }

            if ($tokenCode === \T_STRING && isset($this->constantStrings[$tokens[$i]['content']])) {
                continue;
            }

            if ($hasTernary !== false && $tokenCode === \T_INLINE_ELSE) {
                continue;
            }

            if (isset(Tokens::$stringTokens[$tokenCode]) === false) {
                $isOnlyText = false;
            }

            if ($tokenCode === \T_ARRAY || $tokenCode === \T_OPEN_SHORT_ARRAY || $tokenCode === \T_ARRAY_CAST) {
                $this->throwNotice($phpcsFile, $stackPtr, $functionName);
                return;
            }

            if ($tokenCode === \T_STRING) {
                /*
                 * Check for specific functions which return an array (i.e. $pieces).
                 */
                $nextNonEmpty = $phpcsFile->findNext(Tokens::$emptyTokens, ($i + 1), $end, true);
                if ($nextNonEmpty === false || $tokens[$nextNonEmpty]['code'] !== \T_OPEN_PARENTHESIS) {
                    continue;
                }

                $nameLc = \strtolower($tokens[$i]['content']);
                if (isset($this->arrayFunctions[$nameLc]) === false
                    && (\strpos($nameLc, 'array_') !== 0
                    || isset($this->arrayFunctionExceptions[$nameLc]) === true)
                ) {
                    continue;
                }

                // Now make sure it's the PHP native function being called.
                $prevNonEmpty = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($i - 1), $start, true);
                if (isset(Collections::objectOperators()[$tokens[$prevNonEmpty]['code']]) === true) {
                    // Method call, not a call to the PHP native function.
                    continue;
                }

                if ($tokens[$prevNonEmpty]['code'] === \T_NS_SEPARATOR
                    && $tokens[$prevNonEmpty - 1]['code'] === \T_STRING
                ) {
                    // Namespaced function.
                    continue;
                }

                // Ok, so we know that there is an array function in the first param.
                // 99.9% chance that this is $pieces, not $glue.
                $this->throwNotice($phpcsFile, $stackPtr, $functionName);
                return;
            }
        }

        if ($isOnlyText === true) {
            // First parameter only contained text string tokens, i.e. glue.
            return;
        }

        /*
         * Examine the second parameter.
         */

        $targetParam = $parameters[2];
        $start       = $targetParam['start'];
        $end         = ($targetParam['end'] + 1);

        $firstNonEmpty = $phpcsFile->findNext(Tokens::$emptyTokens, $start, $end, true);
        if ($firstNonEmpty === false) {
            // Parse error. Shouldn't be possible.
            return;
        }

        if ($tokens[$firstNonEmpty]['code'] === \T_OPEN_PARENTHESIS) {
            $start = ($firstNonEmpty + 1);
            $end   = $tokens[$firstNonEmpty]['parenthesis_closer'];
        }

        $hasTernary = $phpcsFile->findNext(\T_INLINE_THEN, $start, $end);
        if ($hasTernary !== false
            && isset($tokens[$start]['nested_parenthesis'], $tokens[$hasTernary]['nested_parenthesis'])
            && \count($tokens[$start]['nested_parenthesis']) === \count($tokens[$hasTernary]['nested_parenthesis'])
        ) {
            $start = ($hasTernary + 1);
        }

        for ($i = $start; $i < $end; $i++) {
            $tokenCode = $tokens[$i]['code'];

            if (isset(Tokens::$emptyTokens[$tokenCode])) {
                continue;
            }

            if ($tokenCode === \T_ARRAY || $tokenCode === \T_OPEN_SHORT_ARRAY || $tokenCode === \T_ARRAY_CAST) {
                // Found an array, $pieces is second.
                return;
            }

            if ($tokenCode === \T_STRING && isset($this->constantStrings[$tokens[$i]['content']])) {
                // One of the special cased, PHP native string constants found.
                $this->throwNotice($phpcsFile, $stackPtr, $functionName);
                return;
            }

            if ($tokenCode === \T_STRING || $tokenCode === \T_VARIABLE) {
                // Function call, constant or variable encountered.
                // No matter what this is combined with, we won't be able to reliably determine the value.
                return;
            }

            if (isset(BCTokens::textStringTokens()[$tokenCode]) === true) {
                $this->throwNotice($phpcsFile, $stackPtr, $functionName);
                return;
            }
        }
    }


    /**
     * Throw the error/warning.
     *
     * @since 9.3.0
     *
     * @param \PHP_CodeSniffer\Files\File $phpcsFile    The file being scanned.
     * @param int                         $stackPtr     The position of the current token in the stack.
     * @param string                      $functionName The token content (function name) which was matched.
     *
     * @return void
     */
    protected function throwNotice(File $phpcsFile, $stackPtr, $functionName)
    {
        $message   = 'Passing the $glue and $pieces parameters in reverse order to %s has been deprecated since PHP 7.4';
        $isError   = false;
        $errorCode = 'Deprecated';
        $data      = [$functionName];

        if (ScannedCode::shouldRunOnOrAbove('8.0') === true) {
            $message  .= ' and is removed since PHP 8.0';
            $isError   = true;
            $errorCode = 'Removed';
        }

        $message .= '; $glue should be the first parameter and $pieces the second';

        MessageHelper::addMessage($phpcsFile, $message, $stackPtr, $isError, $errorCode, $data);
    }
}
