<?php
/**
 * PHPCSUtils, utility functions and classes for PHP_CodeSniffer sniff developers.
 *
 * @package   PHPCSUtils
 * @copyright 2019-2020 PHPCSUtils Contributors
 * @license   https://opensource.org/licenses/LGPL-3.0 LGPL3
 * @link      https://github.com/PHPCSStandards/PHPCSUtils
 */

namespace PHPCSUtils\Internal;

use PHP_CodeSniffer\Exceptions\RuntimeException;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Util\Tokens;
use PHPCSUtils\BackCompat\Helper;
use PHPCSUtils\Internal\Cache;
use PHPCSUtils\Internal\IsShortArrayOrListWithCache;
use PHPCSUtils\Internal\StableCollections;
use PHPCSUtils\Tokens\Collections;
use PHPCSUtils\Utils\Arrays;
use PHPCSUtils\Utils\Context;
use PHPCSUtils\Utils\Parentheses;
use PHPCSUtils\Utils\PassedParameters;

/**
 * Determination of short array vs short list vs square brackets.
 *
 * Short list versus short array determination is done based on the following:
 * - For "outer" arrays/lists the determination is straight forward, the surrounding
 *   tokens give enough clues. This includes "outer" arrays/lists in `foreach()` conditions
 *   or in attributes.
 * - For nested short arrays/lists, it's a whole different matter.
 *   - Both arrays as well as lists can be used when setting _keys_ in arrays and lists
 *     (with array access or as a parameter in a function call etc).
 *     For arrays, a nested array used as a key will always need array access.
 *     For lists, a plain array can be used as the key. (seriously ^@!%&#?)
 *     When used as a key though, the nesting is irrelevant and if there is nesting, the "outer"
 *     set of brackets will also be part of the key.
 *   - Both arrays as well as lists can be used as _values_ in arrays.
 *   - Only nested lists (or variables) can be used as _values_ in lists.
 *
 * ---------------------------------------------------------------------------------------------
 * This class is only intended for internal use by PHPCSUtils and is not part of the public API.
 * This also means that it has no promise of backward compatibility.
 *
 * End-users should use the {@see \PHPCSUtils\Utils\Arrays::isShortArray()}
 * or the {@see \PHPCSUtils\Utils\Lists::isShortList()} method instead.
 * ---------------------------------------------------------------------------------------------
 *
 * @internal
 *
 * @since 1.0.0
 */
final class IsShortArrayOrList
{

    /**
     * Type annotation for short arrays.
     *
     * @since 1.0.0
     *
     * @var string
     */
    const SHORT_ARRAY = 'short array';

    /**
     * Type annotation for short lists.
     *
     * @since 1.0.0
     *
     * @var string
     */
    const SHORT_LIST = 'short list';

    /**
     * Type annotation for square brackets.
     *
     * @since 1.0.0
     *
     * @var string
     */
    const SQUARE_BRACKETS = 'square brackets';

    /**
     * Limit for the amount of items to retrieve from inside a nested array/list.
     *
     * @since 1.0.0
     *
     * @var int
     */
    const ITEM_LIMIT = 5;

    /**
     * Limit for recursing over inner nested arrays/lists.
     *
     * @since 1.0.0
     *
     * @var int
     */
    const RECURSION_LIMIT = 3;

    /**
     * The PHPCS file in which the current stackPtr was found.
     *
     * @since 1.0.0
     *
     * @var \PHP_CodeSniffer\Files\File
     */
    private $phpcsFile;

    /**
     * The token stack from the current file.
     *
     * @since 1.0.0
     *
     * @var array<int, array<string, mixed>>
     */
    private $tokens;

    /**
     * Stack pointer to the open bracket.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private $opener;

    /**
     * Stack pointer to the close bracket.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private $closer;

    /**
     * Stack pointer to the first non-empty token before the open bracket.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private $beforeOpener;

    /**
     * Stack pointer to the first non-empty token after the close bracket.
     *
     * @since 1.0.0
     *
     * @var int|false Will be `false` if the close bracket is the last token in the file.
     */
    private $afterCloser;

    /**
     * Current PHPCS version being used.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private $phpcsVersion; // @phpstan-ignore-line

    /**
     * Tokens which can open a short array or short list (PHPCS cross-version compatible).
     *
     * @since 1.0.0
     *
     * @var array<int|string, int|string>
     */
    private $openBrackets;

    /**
     * Constructor.
     *
     * @since 1.0.0
     *
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
     * @param int                         $stackPtr  The position of the short array opener token.
     *
     * @return void
     *
     * @throws \PHP_CodeSniffer\Exceptions\RuntimeException If the token passed is not one of the
     *                                                      accepted types or doesn't exist.
     */
    public function __construct(File $phpcsFile, $stackPtr)
    {
        $tokens       = $phpcsFile->getTokens();
        $openBrackets = StableCollections::$shortArrayListOpenTokensBC;

        if (isset($tokens[$stackPtr]) === false
            || isset($openBrackets[$tokens[$stackPtr]['code']]) === false
        ) {
            throw new RuntimeException(
                'The IsShortArrayOrList class expects to be passed a T_OPEN_SHORT_ARRAY or T_OPEN_SQUARE_BRACKET token.'
            );
        }

        $this->phpcsFile = $phpcsFile;
        $this->tokens    = $tokens;
        $this->opener    = $stackPtr;

        $this->closer = $stackPtr;
        if (isset($this->tokens[$stackPtr]['bracket_closer'])) {
            $this->closer = $this->tokens[$stackPtr]['bracket_closer'];
        }

        $this->beforeOpener = $this->phpcsFile->findPrevious(Tokens::$emptyTokens, ($this->opener - 1), null, true);
        $this->afterCloser  = $this->phpcsFile->findNext(Tokens::$emptyTokens, ($this->closer + 1), null, true);

        $this->phpcsVersion = Helper::getVersion();
        $this->openBrackets = $openBrackets;
    }

    /**
     * Determine whether the bracket is a short array, short list or real square bracket.
     *
     * @since 1.0.0
     *
     * @return string Either 'short array', 'short list' or 'square brackets'.
     */
    public function solve()
    {
        if ($this->isSquareBracket() === true) {
            return self::SQUARE_BRACKETS;
        }

        if ($this->afterCloser === false) {
            // Live coding. Short array until told differently.
            return self::SHORT_ARRAY;
        }

        // If the bracket closer is followed by an equals sign, it's always a short list.
        if ($this->tokens[$this->afterCloser]['code'] === \T_EQUAL) {
            return self::SHORT_LIST;
        }

        // Attributes can only contain constant expressions, i.e. lists not allowed.
        if (Context::inAttribute($this->phpcsFile, $this->opener) === true) {
            return self::SHORT_ARRAY;
        }

        $type = $this->isInForeach();
        if ($type !== false) {
            return $type;
        }

        /*
         * Check if this can be a nested set of brackets used as a value.
         * That's the only "confusing" syntax left. In all other cases, it will be a short array.
         */
        $hasRiskyTokenBeforeOpener = false;
        if (isset($this->openBrackets[$this->tokens[$this->beforeOpener]['code']]) === true
            || $this->tokens[$this->beforeOpener]['code'] === \T_COMMA
            || $this->tokens[$this->beforeOpener]['code'] === \T_DOUBLE_ARROW
        ) {
            $hasRiskyTokenBeforeOpener = true;
        }

        $hasRiskyTokenAfterCloser = false;
        if ($this->tokens[$this->afterCloser]['code'] === \T_COMMA
            || $this->tokens[$this->afterCloser]['code'] === \T_CLOSE_SHORT_ARRAY
            || $this->tokens[$this->afterCloser]['code'] === \T_CLOSE_SQUARE_BRACKET
        ) {
            $hasRiskyTokenAfterCloser = true;
        }

        if ($hasRiskyTokenBeforeOpener === false || $hasRiskyTokenAfterCloser === false) {
            return self::SHORT_ARRAY;
        }

        /*
         * Check if this is the first/last item in a "parent" set of brackets.
         * If so, skip straight to the parent and determine the type of that, the type
         * of the inner set of brackets will be the same (as all other options have
         * already been eliminated).
         */
        if (isset($this->openBrackets[$this->tokens[$this->beforeOpener]['code']]) === true) {
            return IsShortArrayOrListWithCache::getType($this->phpcsFile, $this->beforeOpener);
        }

        $nextEffectiveAfterCloser = $this->afterCloser;
        if ($this->tokens[$this->afterCloser]['code'] === \T_COMMA) {
            // Skip over potential trailing commas.
            $nextEffectiveAfterCloser = $this->phpcsFile->findNext(
                Tokens::$emptyTokens,
                ($this->afterCloser + 1),
                null,
                true
            );
        }

        if ($this->tokens[$nextEffectiveAfterCloser]['code'] === \T_CLOSE_SHORT_ARRAY
            || $this->tokens[$nextEffectiveAfterCloser]['code'] === \T_CLOSE_SQUARE_BRACKET
        ) {
            return IsShortArrayOrListWithCache::getType($this->phpcsFile, $nextEffectiveAfterCloser);
        }

        /*
         * Okay, so as of here, we know this set of brackets is preceded by a comma or double arrow
         * and followed by a comma. This is the only ambiguous syntax left.
         */

        /*
         * Check if this could be a (nested) short list at all.
         * A list must have at least one variable inside and can not be empty.
         * An array, however, cannot contain empty items.
         */
        $type = $this->walkInside($this->opener);
        if ($type !== false) {
            return $type;
        }

        // Last resort: walk up in the file to see if we can find a set of parent brackets...
        $type = $this->walkOutside();
        if ($type !== false) {
            return $type;
        }

        // If everything failed, this will be a short array (shouldn't be possible).
        return self::SHORT_ARRAY; // @codeCoverageIgnore
    }

    /**
     * Check if the brackets are in actual fact real square brackets.
     *
     * @since 1.0.0
     *
     * @return bool TRUE if these are real square brackets; FALSE otherwise.
     */
    private function isSquareBracket()
    {
        if ($this->opener === $this->closer) {
            // Parse error (unclosed bracket) or live coding. Bow out.
            return true;
        }

        // Check if this is a bracket we need to examine or a mistokenization.
        return ($this->isShortArrayBracket() === false);
    }

    /**
     * Verify that the current set of brackets is not affected by known PHPCS cross-version tokenizer issues.
     *
     * List of current tokenizer issues which affect the short array/short list tokenization:
     * - {@link https://github.com/squizlabs/PHP_CodeSniffer/pull/3632 PHPCS#3632} (PHPCS < 3.7.2)
     *
     * List of previous tokenizer issues which affected the short array/short list tokenization for reference:
     * - {@link https://github.com/squizlabs/PHP_CodeSniffer/issues/1284 PHPCS#1284} (PHPCS < 2.8.1)
     * - {@link https://github.com/squizlabs/PHP_CodeSniffer/issues/1381 PHPCS#1381} (PHPCS < 2.9.0)
     * - {@link https://github.com/squizlabs/PHP_CodeSniffer/issues/1971 PHPCS#1971} (PHPCS 2.8.0 - 3.2.3)
     * - {@link https://github.com/squizlabs/PHP_CodeSniffer/pull/3013 PHPCS#3013} (PHPCS < 3.5.6)
     * - {@link https://github.com/squizlabs/PHP_CodeSniffer/pull/3172 PHPCS#3172} (PHPCS < 3.6.0)
     *
     * @since 1.0.0
     *
     * @return bool TRUE if this is actually a short array bracket which needs to be examined,
     *              FALSE if it is an (incorrectly tokenized) square bracket.
     */
    private function isShortArrayBracket()
    {
        if ($this->tokens[$this->opener]['code'] === \T_OPEN_SQUARE_BRACKET) {
            // Currently there are no known issues with the tokenization in PHPCS 3.9.0 and higher.
            return false;
        }

        return true;
    }

    /**
     * Check is this set of brackets is used within a foreach expression.
     *
     * @since 1.0.0
     *
     * @return string|false The determined type or FALSE if undetermined.
     */
    private function isInForeach()
    {
        $inForeach = Context::inForeachCondition($this->phpcsFile, $this->opener);
        if ($inForeach === false) {
            return false;
        }

        switch ($inForeach) {
            case 'beforeAs':
                if ($this->tokens[$this->afterCloser]['code'] === \T_AS) {
                    return self::SHORT_ARRAY;
                }

                break;

            case 'afterAs':
                if ($this->tokens[$this->afterCloser]['code'] === \T_CLOSE_PARENTHESIS) {
                    $owner = Parentheses::getOwner($this->phpcsFile, $this->afterCloser);
                    if ($owner !== false && $this->tokens[$owner]['code'] === \T_FOREACH) {
                        return self::SHORT_LIST;
                    }
                }

                break;
        }

        /*
         * Everything else will be a nested set of brackets (provided we're talking valid PHP),
         * so disregard as it can not be determined yet.
         */
        return false;
    }

    /**
     * Walk the first part of the contents between the brackets to see if we can determine if this
     * is a short array or short list based on its contents.
     *
     * Short lists can only have another (nested) list or variable assignments, including property assignments
     * and array index assignment, as the value inside the brackets.
     *
     * This won't walk the complete contents as that could be a huge performance drain. Just the first x items.
     *
     * @since 1.0.0
     *
     * @param int $opener     The position of the short array open bracket token.
     * @param int $recursions Optional. Keep track of how often we've recursed into this methd.
     *                        Prevent infinite loops for extremely deeply nested arrays.
     *                        Defaults to 0.
     *
     * @return string|false The determined type or FALSE if undetermined.
     */
    private function walkInside($opener, $recursions = 0)
    {
        // Get the first 5 "parameters" and ignore the "is short array" check.
        $items = PassedParameters::getParameters($this->phpcsFile, $opener, self::ITEM_LIMIT, true);

        if ($items === []) {
            /*
             * A list can not be empty, so this must be an array, however as this is a nested
             * set of brackets, let the outside brackets be the decider as it may be
             * a coding error which a sniff needs to flag.
             */
            return false;
        }

        // Make sure vars assigned by reference are handled correctly.
        $skip   = Tokens::$emptyTokens;
        $skip[] = \T_BITWISE_AND;

        $skipNames = Collections::namespacedNameTokens() + Collections::ooHierarchyKeywords();

        foreach ($items as $item) {
            /*
             * If we encounter a completely empty item, this must be a short list as arrays cannot contain
             * empty items.
             */
            if ($item['clean'] === '') {
                return self::SHORT_LIST;
            }

            /*
             * If the "value" part of the entry doesn't start with a variable, a (nested) short list/array,
             * or a static property assignment, we know for sure that the outside brackets will be an array.
             */
            $arrow = Arrays::getDoubleArrowPtr($this->phpcsFile, $item['start'], $item['end']);
            if ($arrow === false) {
                $firstNonEmptyInValue = $this->phpcsFile->findNext($skip, $item['start'], ($item['end'] + 1), true);
            } else {
                $firstNonEmptyInValue = $this->phpcsFile->findNext($skip, ($arrow + 1), ($item['end'] + 1), true);
            }

            if ($this->tokens[$firstNonEmptyInValue]['code'] !== \T_VARIABLE
                && isset(Collections::namespacedNameTokens()[$this->tokens[$firstNonEmptyInValue]['code']]) === false
                && isset(Collections::ooHierarchyKeywords()[$this->tokens[$firstNonEmptyInValue]['code']]) === false
                && isset($this->openBrackets[$this->tokens[$firstNonEmptyInValue]['code']]) === false
            ) {
                return self::SHORT_ARRAY;
            }

            /*
             * Check if this is a potential list assignment to a static variable.
             * If not, again, we can be sure it will be a short array.
             */
            if (isset(Collections::namespacedNameTokens()[$this->tokens[$firstNonEmptyInValue]['code']]) === true
                || isset(Collections::ooHierarchyKeywords()[$this->tokens[$firstNonEmptyInValue]['code']]) === true
            ) {
                $nextAfter = $this->phpcsFile->findNext($skipNames, ($firstNonEmptyInValue + 1), null, true);

                if ($this->tokens[$nextAfter]['code'] !== \T_DOUBLE_COLON) {
                    return self::SHORT_ARRAY;
                } else {
                    /*
                     * Double colon, so make sure there is a variable after it.
                     * If not, it's constant or function call, i.e. a short array.
                     */
                    $nextNextAfter = $this->phpcsFile->findNext(Tokens::$emptyTokens, ($nextAfter + 1), null, true);
                    if ($this->tokens[$nextNextAfter]['code'] !== \T_VARIABLE) {
                        return self::SHORT_ARRAY;
                    }
                }

                continue;
            }

            if (isset($this->openBrackets[$this->tokens[$firstNonEmptyInValue]['code']]) === true) {
                /*
                 * If the "value" part starts with an open bracket, but has other tokens after it, the current,
                 * outside set of brackets will always be an array (the brackets in the value can still be both,
                 * but that's not the concern of the current determination).
                 */
                $lastNonEmptyInValue = $this->phpcsFile->findPrevious(
                    Tokens::$emptyTokens,
                    $item['end'],
                    $item['start'],
                    true
                );
                if (isset($this->tokens[$firstNonEmptyInValue]['bracket_closer']) === true
                    && $this->tokens[$firstNonEmptyInValue]['bracket_closer'] !== $lastNonEmptyInValue
                ) {
                    return self::SHORT_ARRAY;
                }

                /*
                 * Recursively check the inner set of brackets for contents indicating this is not a short list.
                 */
                if ($recursions < self::RECURSION_LIMIT) {
                    $innerType = $this->walkInside($firstNonEmptyInValue, ($recursions + 1));
                    if ($innerType !== false) {
                        return $innerType;
                    }
                }
            }
        }

        // Undetermined.
        return false;
    }

    /**
     * Walk up in the file to try and find an "outer" set of brackets for an ambiguous, potentially
     * nested set of brackets.
     *
     * This should really be the last resort, if all else fails to determine the type of the brackets.
     *
     * @since 1.0.0
     *
     * @return string|false The determined type or FALSE if undetermined.
     */
    private function walkOutside()
    {
        $stopPoints               = Collections::phpOpenTags();
        $stopPoints[\T_SEMICOLON] = \T_SEMICOLON;

        for ($i = ($this->opener - 1); $i >= 0; $i--) {
            // Skip over block comments (just in case).
            if ($this->tokens[$i]['code'] === \T_DOC_COMMENT_CLOSE_TAG) {
                $i = $this->tokens[$i]['comment_opener'];
                continue;
            }

            if (isset(Tokens::$emptyTokens[$this->tokens[$i]['code']]) === true) {
                continue;
            }

            // Stop on an end of statement.
            if (isset($stopPoints[$this->tokens[$i]['code']]) === true) {
                // End of previous statement or start of document.
                return self::SHORT_ARRAY;
            }

            if (isset($this->tokens[$i]['scope_opener'], $this->tokens[$i]['scope_closer']) === true) {
                if ($i === $this->tokens[$i]['scope_opener']
                    && $this->tokens[$i]['scope_closer'] > $this->closer
                ) {
                    // Found a scope wrapping this set of brackets before finding a outer set of brackets.
                    // This will be a short array.
                    return self::SHORT_ARRAY;
                }

                if ($i === $this->tokens[$i]['scope_closer']
                    && isset($this->tokens[$i]['scope_condition']) === true
                ) {
                    $i = $this->tokens[$i]['scope_condition'];
                    continue;
                }

                // Scope opener without scope condition shouldn't be possible, but just in case.
                // @codeCoverageIgnoreStart
                $i = $this->tokens[$i]['scope_opener'];
                continue;
                // @codeCoverageIgnoreEnd
            }

            if (isset($this->tokens[$i]['parenthesis_opener'], $this->tokens[$i]['parenthesis_closer']) === true) {
                if ($i === $this->tokens[$i]['parenthesis_opener']
                    && $this->tokens[$i]['parenthesis_closer'] > $this->closer
                ) {
                    $beforeParensOpen = $this->phpcsFile->findPrevious(Tokens::$emptyTokens, ($i - 1), null, true);
                    if ($this->tokens[$beforeParensOpen]['code'] === \T_LIST) {
                        // Parse error, mixing long and short list, but that's not our concern.
                        return self::SHORT_LIST;
                    }

                    // Found parentheses wrapping this set of brackets before finding a outer set of brackets.
                    // This will be a short array.
                    return self::SHORT_ARRAY;
                }

                if ($i === $this->tokens[$i]['parenthesis_closer']) {
                    if (isset($this->tokens[$i]['parenthesis_owner']) === true) {
                        $i = $this->tokens[$i]['parenthesis_owner'];
                        continue;
                    }
                }

                // Parenthesis closer without owner (function call and such).
                $i = $this->tokens[$i]['parenthesis_opener'];
                continue;
            }

            /*
             * Skip over attributes.
             * No special handling needed, brackets within attributes won't reach this
             * method as they are already handled within the solve() method.
             */
            if (isset($this->tokens[$i]['attribute_opener'], $this->tokens[$i]['attribute_closer']) === true
                && $i === $this->tokens[$i]['attribute_closer']
            ) {
                $i = $this->tokens[$i]['attribute_opener'];
                continue;
            }

            /*
             * This is a close bracket, but it's not the outer wrapper.
             * As we skip over parentheses and curlies above, we *know* this will be a
             * set of brackets at the same bracket "nesting level" as the set we are examining.
             */
            if (isset($this->tokens[$i]['bracket_opener'], $this->tokens[$i]['bracket_closer']) === true
                && $i === $this->tokens[$i]['bracket_closer']
            ) {
                /*
                 * Now, if the set of brackets follows the same code pattern (comma or double arrow before,
                 * comma after), this will be an adjacent set of potentially nested brackets.
                 * If so, check if the type of the previous set of brackets has already been determined
                 * as adjacent sets of brackets will have the same type.
                 */
                $adjOpener    = $this->tokens[$i]['bracket_opener'];
                $prevNonEmpty = $this->phpcsFile->findPrevious(Tokens::$emptyTokens, ($adjOpener - 1), null, true);
                $nextNonEmpty = $this->phpcsFile->findNext(Tokens::$emptyTokens, ($i + 1), null, true);

                if ($this->tokens[$prevNonEmpty]['code'] === $this->tokens[$this->beforeOpener]['code']
                    && $this->tokens[$nextNonEmpty]['code'] === $this->tokens[$this->afterCloser]['code']
                    && Cache::isCached($this->phpcsFile, IsShortArrayOrListWithCache::CACHE_KEY, $adjOpener) === true
                ) {
                    return Cache::get($this->phpcsFile, IsShortArrayOrListWithCache::CACHE_KEY, $adjOpener);
                }

                // If not, skip over it.
                $i = $this->tokens[$i]['bracket_opener'];
                continue;
            }

            // Open bracket.
            if (isset($this->openBrackets[$this->tokens[$i]['code']]) === true) {
                if (isset($this->tokens[$i]['bracket_closer']) === false
                    || $this->tokens[$i]['code'] === \T_OPEN_SQUARE_BRACKET
                ) {
                    /*
                     * If the type of the unclosed "outer" brackets cannot be determined or
                     * they are identified as plain square brackets, the inner brackets
                     * we are examining should be regarded as a short array.
                     */
                    return self::SHORT_ARRAY;
                }

                if ($this->tokens[$i]['bracket_closer'] > $this->closer) {
                    // This is one we have to examine further as an outer set of brackets.
                    // As all the other checks have already failed to get a result, we know that
                    // whatever the outer set is, the inner set will be the same.
                    return IsShortArrayOrListWithCache::getType($this->phpcsFile, $i);
                }
            }
        }

        // Reached the start of the file without finding an outer set of brackets.
        // Shouldn't be possible, but just in case.
        return false; // @codeCoverageIgnore
    }
}
