<?php

declare(strict_types=1);

/*
 * This file is part of PHP CS Fixer.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *     Dariusz Rumiński <dariusz.ruminski@gmail.com>
 *
 * This source file is subject to the MIT license that is bundled
 * with this source code in the file LICENSE.
 */

namespace PhpCsFixer\Fixer\ControlStructure;

use PhpCsFixer\AbstractFixer;
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
use PhpCsFixer\Fixer\ConfigurableFixerTrait;
use PhpCsFixer\FixerConfiguration\AllowedValueSubset;
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
use PhpCsFixer\FixerDefinition\CodeSample;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
use PhpCsFixer\Tokenizer\CT;
use PhpCsFixer\Tokenizer\FCT;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
use PhpCsFixer\Tokenizer\TokensAnalyzer;

/**
 * @phpstan-type _AutogeneratedInputConfiguration array{
 *  statements?: list<'break'|'clone'|'continue'|'echo_print'|'negative_instanceof'|'others'|'return'|'switch_case'|'yield'|'yield_from'>,
 * }
 * @phpstan-type _AutogeneratedComputedConfiguration array{
 *  statements: list<'break'|'clone'|'continue'|'echo_print'|'negative_instanceof'|'others'|'return'|'switch_case'|'yield'|'yield_from'>,
 * }
 *
 * @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
 *
 * @phpstan-import-type _PhpTokenPrototypePartial from Token
 *
 * @author Sullivan Senechal <soullivaneuh@gmail.com>
 * @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
 * @author Gregor Harlan <gharlan@web.de>
 *
 * @no-named-arguments Parameter names are not covered by the backward compatibility promise.
 */
final class NoUnneededControlParenthesesFixer extends AbstractFixer implements ConfigurableFixerInterface
{
    /** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
    use ConfigurableFixerTrait;

    /**
     * @var non-empty-list<int>
     */
    private const BLOCK_TYPES = [
        Tokens::BLOCK_TYPE_ARRAY_INDEX_CURLY_BRACE,
        Tokens::BLOCK_TYPE_ARRAY_SQUARE_BRACE,
        Tokens::BLOCK_TYPE_CURLY_BRACE,
        Tokens::BLOCK_TYPE_DESTRUCTURING_SQUARE_BRACE,
        Tokens::BLOCK_TYPE_DYNAMIC_PROP_BRACE,
        Tokens::BLOCK_TYPE_DYNAMIC_VAR_BRACE,
        Tokens::BLOCK_TYPE_INDEX_SQUARE_BRACE,
        Tokens::BLOCK_TYPE_PARENTHESIS_BRACE,
    ];

    private const BEFORE_TYPES = [
        ';',
        '{',
        [\T_OPEN_TAG],
        [\T_OPEN_TAG_WITH_ECHO],
        [\T_ECHO],
        [\T_PRINT],
        [\T_RETURN],
        [\T_THROW],
        [\T_YIELD],
        [\T_YIELD_FROM],
        [\T_BREAK],
        [\T_CONTINUE],
        // won't be fixed, but true in concept, helpful for fast check
        [\T_REQUIRE],
        [\T_REQUIRE_ONCE],
        [\T_INCLUDE],
        [\T_INCLUDE_ONCE],
    ];

    private const CONFIG_OPTIONS = [
        'break',
        'clone',
        'continue',
        'echo_print',
        'negative_instanceof',
        'others',
        'return',
        'switch_case',
        'yield',
        'yield_from',
    ];

    private const TOKEN_TYPE_CONFIG_MAP = [
        \T_BREAK => 'break',
        \T_CASE => 'switch_case',
        \T_CONTINUE => 'continue',
        \T_ECHO => 'echo_print',
        \T_PRINT => 'echo_print',
        \T_RETURN => 'return',
        \T_YIELD => 'yield',
        \T_YIELD_FROM => 'yield_from',
    ];

    // handled by the `include` rule
    private const TOKEN_TYPE_NO_CONFIG = [
        \T_REQUIRE,
        \T_REQUIRE_ONCE,
        \T_INCLUDE,
        \T_INCLUDE_ONCE,
    ];
    private const KNOWN_NEGATIVE_PRE_TYPES = [
        [CT::T_CLASS_CONSTANT],
        [CT::T_DYNAMIC_VAR_BRACE_CLOSE],
        [CT::T_RETURN_REF],
        [CT::T_USE_LAMBDA],
        [\T_ARRAY],
        [\T_CATCH],
        [\T_CLASS],
        [\T_DECLARE],
        [\T_ELSEIF],
        [\T_EMPTY],
        [\T_EXIT],
        [\T_EVAL],
        [\T_FN],
        [\T_FOREACH],
        [\T_FOR],
        [\T_FUNCTION],
        [\T_HALT_COMPILER],
        [\T_IF],
        [\T_ISSET],
        [\T_LIST],
        [\T_STRING],
        [\T_SWITCH],
        [\T_STATIC],
        [\T_UNSET],
        [\T_VARIABLE],
        [\T_WHILE],
        // handled by the `include` rule
        [\T_REQUIRE],
        [\T_REQUIRE_ONCE],
        [\T_INCLUDE],
        [\T_INCLUDE_ONCE],
        [FCT::T_MATCH],
    ];

    /**
     * @var list<_PhpTokenPrototypePartial>
     */
    private array $noopTypes;

    private TokensAnalyzer $tokensAnalyzer;

    public function __construct()
    {
        parent::__construct();

        $this->noopTypes = [
            '$',
            [\T_CONSTANT_ENCAPSED_STRING],
            [\T_DNUMBER],
            [\T_DOUBLE_COLON],
            [\T_LNUMBER],
            [\T_NS_SEPARATOR],
            [\T_STRING],
            [\T_VARIABLE],
            [\T_STATIC],
            // magic constants
            [\T_CLASS_C],
            [\T_DIR],
            [\T_FILE],
            [\T_FUNC_C],
            [\T_LINE],
            [\T_METHOD_C],
            [\T_NS_C],
            [\T_TRAIT_C],
        ];

        foreach (Token::getObjectOperatorKinds() as $kind) {
            $this->noopTypes[] = [$kind];
        }
    }

    public function getDefinition(): FixerDefinitionInterface
    {
        return new FixerDefinition(
            'Removes unneeded parentheses around control statements.',
            [
                new CodeSample(
                    <<<'PHP'
                        <?php
                        while ($x) { while ($y) { break (2); } }
                        clone($a);
                        while ($y) { continue (2); }
                        echo("foo");
                        print("foo");
                        return (1 + 2);
                        switch ($a) { case($x); }
                        yield(2);

                        PHP,
                ),
                new CodeSample(
                    <<<'PHP'
                        <?php
                        while ($x) { while ($y) { break (2); } }

                        clone($a);

                        while ($y) { continue (2); }

                        PHP,
                    ['statements' => ['break', 'continue']],
                ),
            ],
        );
    }

    /**
     * {@inheritdoc}
     *
     * Must run before ConcatSpaceFixer, NewExpressionParenthesesFixer, NoTrailingWhitespaceFixer.
     * Must run after ModernizeTypesCastingFixer, NoAlternativeSyntaxFixer.
     */
    public function getPriority(): int
    {
        return 30;
    }

    public function isCandidate(Tokens $tokens): bool
    {
        return $tokens->isAnyTokenKindsFound(['(', CT::T_BRACE_CLASS_INSTANTIATION_OPEN]);
    }

    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
    {
        $this->tokensAnalyzer = new TokensAnalyzer($tokens);

        foreach ($tokens as $openIndex => $token) {
            if ($token->equals('(')) {
                $closeIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $openIndex);
            } elseif ($token->isGivenKind(CT::T_BRACE_CLASS_INSTANTIATION_OPEN)) {
                $closeIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_BRACE_CLASS_INSTANTIATION, $openIndex);
            } else {
                continue;
            }

            $beforeOpenIndex = $tokens->getPrevMeaningfulToken($openIndex);
            $afterCloseIndex = $tokens->getNextMeaningfulToken($closeIndex);

            // do a cheap check for negative case: `X()`

            if ($tokens->getNextMeaningfulToken($openIndex) === $closeIndex) {
                if ($tokens[$beforeOpenIndex]->isGivenKind(\T_EXIT)) {
                    $this->removeUselessParenthesisPair($tokens, $beforeOpenIndex, $afterCloseIndex, $openIndex, $closeIndex, 'others');
                }

                continue;
            }

            // do a cheap check for negative case: `foo(1,2)`

            if ($tokens[$beforeOpenIndex]->equalsAny(self::KNOWN_NEGATIVE_PRE_TYPES)) {
                continue;
            }

            // check for the simple useless wrapped cases

            if ($this->isUselessWrapped($tokens, $beforeOpenIndex, $afterCloseIndex)) {
                $this->removeUselessParenthesisPair($tokens, $beforeOpenIndex, $afterCloseIndex, $openIndex, $closeIndex, $this->getConfigType($tokens, $beforeOpenIndex));

                continue;
            }

            // handle `clone` statements

            if ($tokens[$beforeOpenIndex]->isGivenKind(\T_CLONE)) {
                if ($this->isWrappedCloneArgument($tokens, $beforeOpenIndex, $openIndex, $closeIndex, $afterCloseIndex)) {
                    $this->removeUselessParenthesisPair($tokens, $beforeOpenIndex, $afterCloseIndex, $openIndex, $closeIndex, 'clone');
                }

                continue;
            }

            // handle `instance of` statements

            $instanceOfIndex = $this->getIndexOfInstanceOfStatement($tokens, $openIndex, $closeIndex);

            if (null !== $instanceOfIndex) {
                if ($this->isWrappedInstanceOf($tokens, $instanceOfIndex, $beforeOpenIndex, $openIndex, $closeIndex, $afterCloseIndex)) {
                    $this->removeUselessParenthesisPair(
                        $tokens,
                        $beforeOpenIndex,
                        $afterCloseIndex,
                        $openIndex,
                        $closeIndex,
                        $tokens[$beforeOpenIndex]->equals('!') ? 'negative_instanceof' : 'others',
                    );
                }

                continue;
            }

            // last checks deal with operators, do not swap around

            if ($this->isWrappedPartOfOperation($tokens, $beforeOpenIndex, $openIndex, $closeIndex, $afterCloseIndex)) {
                $this->removeUselessParenthesisPair($tokens, $beforeOpenIndex, $afterCloseIndex, $openIndex, $closeIndex, $this->getConfigType($tokens, $beforeOpenIndex));
            }
        }
    }

    protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
    {
        $defaults = array_filter(
            self::CONFIG_OPTIONS,
            static fn (string $option): bool => 'negative_instanceof' !== $option && 'others' !== $option && 'yield_from' !== $option,
        );

        return new FixerConfigurationResolver([
            (new FixerOptionBuilder('statements', 'List of control statements to fix.'))
                ->setAllowedTypes(['string[]'])
                ->setAllowedValues([new AllowedValueSubset(self::CONFIG_OPTIONS)])
                ->setDefault(array_values($defaults))
                ->getOption(),
        ]);
    }

    private function isUselessWrapped(Tokens $tokens, int $beforeOpenIndex, int $afterCloseIndex): bool
    {
        return
            $this->isSingleStatement($tokens, $beforeOpenIndex, $afterCloseIndex)
            || $this->isWrappedFnBody($tokens, $beforeOpenIndex, $afterCloseIndex)
            || $this->isWrappedForElement($tokens, $beforeOpenIndex, $afterCloseIndex)
            || $this->isWrappedLanguageConstructArgument($tokens, $beforeOpenIndex, $afterCloseIndex)
            || $this->isWrappedSequenceElement($tokens, $beforeOpenIndex, $afterCloseIndex);
    }

    private function isWrappedCloneArgument(Tokens $tokens, int $beforeOpenIndex, int $openIndex, int $closeIndex, int $afterCloseIndex): bool
    {
        $beforeOpenIndex = $tokens->getPrevMeaningfulToken($beforeOpenIndex);

        if (
            !(
                $tokens[$beforeOpenIndex]->equals('?') // For BC reasons
                || $this->isSimpleAssignment($tokens, $beforeOpenIndex, $afterCloseIndex)
                || $this->isSingleStatement($tokens, $beforeOpenIndex, $afterCloseIndex)
                || $this->isWrappedFnBody($tokens, $beforeOpenIndex, $afterCloseIndex)
                || $this->isWrappedForElement($tokens, $beforeOpenIndex, $afterCloseIndex)
                || $this->isWrappedSequenceElement($tokens, $beforeOpenIndex, $afterCloseIndex)
            )
        ) {
            return false;
        }

        $newCandidateIndex = $tokens->getNextMeaningfulToken($openIndex);

        if ($tokens[$newCandidateIndex]->isGivenKind(\T_NEW)) {
            $openIndex = $newCandidateIndex; // `clone (new X)`, `clone (new X())`, clone (new X(Y))`
        }

        return !$this->containsOperation($tokens, $openIndex, $closeIndex);
    }

    private function getIndexOfInstanceOfStatement(Tokens $tokens, int $openIndex, int $closeIndex): ?int
    {
        $instanceOfIndex = $tokens->findGivenKind(\T_INSTANCEOF, $openIndex, $closeIndex);

        return 1 === \count($instanceOfIndex) ? array_key_first($instanceOfIndex) : null;
    }

    private function isWrappedInstanceOf(Tokens $tokens, int $instanceOfIndex, int $beforeOpenIndex, int $openIndex, int $closeIndex, int $afterCloseIndex): bool
    {
        if (
            $this->containsOperation($tokens, $openIndex, $instanceOfIndex)
            || $this->containsOperation($tokens, $instanceOfIndex, $closeIndex)
        ) {
            return false;
        }

        if ($tokens[$beforeOpenIndex]->equals('!')) {
            $beforeOpenIndex = $tokens->getPrevMeaningfulToken($beforeOpenIndex);
        }

        return
            $this->isSimpleAssignment($tokens, $beforeOpenIndex, $afterCloseIndex)
            || $this->isSingleStatement($tokens, $beforeOpenIndex, $afterCloseIndex)
            || $this->isWrappedFnBody($tokens, $beforeOpenIndex, $afterCloseIndex)
            || $this->isWrappedForElement($tokens, $beforeOpenIndex, $afterCloseIndex)
            || $this->isWrappedSequenceElement($tokens, $beforeOpenIndex, $afterCloseIndex);
    }

    private function isWrappedPartOfOperation(Tokens $tokens, int $beforeOpenIndex, int $openIndex, int $closeIndex, int $afterCloseIndex): bool
    {
        if ($this->containsOperation($tokens, $openIndex, $closeIndex)) {
            return false;
        }

        $boundariesMoved = false;

        if ($this->isPreUnaryOperation($tokens, $beforeOpenIndex)) {
            $beforeOpenIndex = $this->getBeforePreUnaryOperation($tokens, $beforeOpenIndex);
            $boundariesMoved = true;
        }

        if ($this->isAccess($tokens, $afterCloseIndex)) {
            $afterCloseIndex = $this->getAfterAccess($tokens, $afterCloseIndex);
            $boundariesMoved = true;

            if ($this->tokensAnalyzer->isUnarySuccessorOperator($afterCloseIndex)) { // post unary operation are only valid here
                $afterCloseIndex = $tokens->getNextMeaningfulToken($afterCloseIndex);
            }
        }

        if ($boundariesMoved) {
            if ($tokens[$beforeOpenIndex]->equalsAny(self::KNOWN_NEGATIVE_PRE_TYPES)) {
                return false;
            }

            if ($this->isUselessWrapped($tokens, $beforeOpenIndex, $afterCloseIndex)) {
                return true;
            }
        }

        // check if part of some operation sequence

        $beforeIsBinaryOperation = $this->tokensAnalyzer->isBinaryOperator($beforeOpenIndex);
        $afterIsBinaryOperation = $this->tokensAnalyzer->isBinaryOperator($afterCloseIndex);

        if ($beforeIsBinaryOperation && $afterIsBinaryOperation) {
            return true; // `+ (x) +`
        }

        $beforeToken = $tokens[$beforeOpenIndex];
        $afterToken = $tokens[$afterCloseIndex];

        $beforeIsBlockOpenOrComma = $beforeToken->equals(',') || null !== $this->getBlock($tokens, $beforeOpenIndex, true);
        $afterIsBlockEndOrComma = $afterToken->equals(',') || null !== $this->getBlock($tokens, $afterCloseIndex, false);

        if (($beforeIsBlockOpenOrComma && $afterIsBinaryOperation) || ($beforeIsBinaryOperation && $afterIsBlockEndOrComma)) {
            // $beforeIsBlockOpenOrComma && $afterIsBlockEndOrComma is covered by `isWrappedSequenceElement`
            // `[ (x) +` or `+ (X) ]` or `, (X) +` or `+ (X) ,`

            return true;
        }

        if ($tokens[$beforeOpenIndex]->equals('}')) {
            $beforeIsStatementOpen = !$this->closeCurlyBelongsToDynamicElement($tokens, $beforeOpenIndex);
        } else {
            $beforeIsStatementOpen = $beforeToken->equalsAny(self::BEFORE_TYPES) || $beforeToken->isGivenKind(\T_CASE);
        }

        $afterIsStatementEnd = $afterToken->equalsAny([';', [\T_CLOSE_TAG]]);

        return
            ($beforeIsStatementOpen && $afterIsBinaryOperation) // `<?php (X) +`
            || ($beforeIsBinaryOperation && $afterIsStatementEnd); // `+ (X);`
    }

    // bounded `print|yield|yield from|require|require_once|include|include_once (X)`
    private function isWrappedLanguageConstructArgument(Tokens $tokens, int $beforeOpenIndex, int $afterCloseIndex): bool
    {
        if (!$tokens[$beforeOpenIndex]->isGivenKind([\T_PRINT, \T_YIELD, \T_YIELD_FROM, \T_REQUIRE, \T_REQUIRE_ONCE, \T_INCLUDE, \T_INCLUDE_ONCE])) {
            return false;
        }

        $beforeOpenIndex = $tokens->getPrevMeaningfulToken($beforeOpenIndex);

        return $this->isWrappedSequenceElement($tokens, $beforeOpenIndex, $afterCloseIndex);
    }

    // any of `<?php|<?|<?=|;|throw|return|... (X) ;|T_CLOSE`
    private function isSingleStatement(Tokens $tokens, int $beforeOpenIndex, int $afterCloseIndex): bool
    {
        if ($tokens[$beforeOpenIndex]->isGivenKind(\T_CASE)) {
            return $tokens[$afterCloseIndex]->equalsAny([':', ';']); // `switch case`
        }

        if (!$tokens[$afterCloseIndex]->equalsAny([';', [\T_CLOSE_TAG]])) {
            return false;
        }

        if ($tokens[$beforeOpenIndex]->equals('}')) {
            return !$this->closeCurlyBelongsToDynamicElement($tokens, $beforeOpenIndex);
        }

        return $tokens[$beforeOpenIndex]->equalsAny(self::BEFORE_TYPES);
    }

    private function isSimpleAssignment(Tokens $tokens, int $beforeOpenIndex, int $afterCloseIndex): bool
    {
        return $tokens[$beforeOpenIndex]->equals('=') && $tokens[$afterCloseIndex]->equalsAny([';', [\T_CLOSE_TAG]]); // `= (X) ;`
    }

    private function isWrappedSequenceElement(Tokens $tokens, int $startIndex, int $endIndex): bool
    {
        $startIsComma = $tokens[$startIndex]->equals(',');
        $endIsComma = $tokens[$endIndex]->equals(',');

        if ($startIsComma && $endIsComma) {
            return true; // `,(X),`
        }

        $blockTypeStart = $this->getBlock($tokens, $startIndex, true);
        $blockTypeEnd = $this->getBlock($tokens, $endIndex, false);

        return
            ($startIsComma && null !== $blockTypeEnd) // `,(X)]`
            || ($endIsComma && null !== $blockTypeStart) // `[(X),`
            || (null !== $blockTypeEnd && null !== $blockTypeStart); // any type of `{(X)}`, `[(X)]` and `((X))`
    }

    // any of `for( (X); ;(X)) ;` note that the middle element is covered as 'single statement' as it is `; (X) ;`
    private function isWrappedForElement(Tokens $tokens, int $beforeOpenIndex, int $afterCloseIndex): bool
    {
        $forCandidateIndex = null;

        if ($tokens[$beforeOpenIndex]->equals('(') && $tokens[$afterCloseIndex]->equals(';')) {
            $forCandidateIndex = $tokens->getPrevMeaningfulToken($beforeOpenIndex);
        } elseif ($tokens[$afterCloseIndex]->equals(')') && $tokens[$beforeOpenIndex]->equals(';')) {
            $forCandidateIndex = $tokens->findBlockStart(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $afterCloseIndex);
            $forCandidateIndex = $tokens->getPrevMeaningfulToken($forCandidateIndex);
        }

        return null !== $forCandidateIndex && $tokens[$forCandidateIndex]->isGivenKind(\T_FOR);
    }

    // `fn() => (X);`
    private function isWrappedFnBody(Tokens $tokens, int $beforeOpenIndex, int $afterCloseIndex): bool
    {
        if (!$tokens[$beforeOpenIndex]->isGivenKind(\T_DOUBLE_ARROW)) {
            return false;
        }

        $beforeOpenIndex = $tokens->getPrevMeaningfulToken($beforeOpenIndex);

        if ($tokens[$beforeOpenIndex]->isGivenKind(\T_STRING)) {
            while (true) {
                $beforeOpenIndex = $tokens->getPrevMeaningfulToken($beforeOpenIndex);

                if (!$tokens[$beforeOpenIndex]->isGivenKind([\T_STRING, CT::T_TYPE_INTERSECTION, CT::T_TYPE_ALTERNATION])) {
                    break;
                }
            }

            if (!$tokens[$beforeOpenIndex]->isGivenKind(CT::T_TYPE_COLON)) {
                return false;
            }

            $beforeOpenIndex = $tokens->getPrevMeaningfulToken($beforeOpenIndex);
        }

        if (!$tokens[$beforeOpenIndex]->equals(')')) {
            return false;
        }

        $beforeOpenIndex = $tokens->findBlockStart(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $beforeOpenIndex);
        $beforeOpenIndex = $tokens->getPrevMeaningfulToken($beforeOpenIndex);

        if ($tokens[$beforeOpenIndex]->isGivenKind(CT::T_RETURN_REF)) {
            $beforeOpenIndex = $tokens->getPrevMeaningfulToken($beforeOpenIndex);
        }

        if (!$tokens[$beforeOpenIndex]->isGivenKind(\T_FN)) {
            return false;
        }

        return $tokens[$afterCloseIndex]->equalsAny([';', ',', [\T_CLOSE_TAG]]);
    }

    private function isPreUnaryOperation(Tokens $tokens, int $index): bool
    {
        return $this->tokensAnalyzer->isUnaryPredecessorOperator($index) || $tokens[$index]->isCast();
    }

    private function getBeforePreUnaryOperation(Tokens $tokens, int $index): int
    {
        do {
            $index = $tokens->getPrevMeaningfulToken($index);
        } while ($this->isPreUnaryOperation($tokens, $index));

        return $index;
    }

    // array access `(X)[` or `(X){` or object access `(X)->` or `(X)?->`
    private function isAccess(Tokens $tokens, int $index): bool
    {
        $token = $tokens[$index];

        return $token->isObjectOperator() || $token->equals('[') || $token->isGivenKind(CT::T_ARRAY_INDEX_CURLY_BRACE_OPEN);
    }

    private function getAfterAccess(Tokens $tokens, int $index): int
    {
        while (true) {
            $block = $this->getBlock($tokens, $index, true);

            if (null !== $block) {
                $index = $tokens->findBlockEnd($block['type'], $index);
                $index = $tokens->getNextMeaningfulToken($index);

                continue;
            }

            if (
                $tokens[$index]->isObjectOperator()
                || $tokens[$index]->equalsAny(['$', [\T_PAAMAYIM_NEKUDOTAYIM], [\T_STRING], [\T_VARIABLE]])
            ) {
                $index = $tokens->getNextMeaningfulToken($index);

                continue;
            }

            break;
        }

        return $index;
    }

    /**
     * @return null|array{type: Tokens::BLOCK_TYPE_*, isStart: bool}
     */
    private function getBlock(Tokens $tokens, int $index, bool $isStart): ?array
    {
        $block = Tokens::detectBlockType($tokens[$index]);

        return null !== $block && $isStart === $block['isStart'] && \in_array($block['type'], self::BLOCK_TYPES, true) ? $block : null;
    }

    private function containsOperation(Tokens $tokens, int $startIndex, int $endIndex): bool
    {
        while (true) {
            $startIndex = $tokens->getNextMeaningfulToken($startIndex);

            if ($startIndex === $endIndex) {
                break;
            }

            $block = Tokens::detectBlockType($tokens[$startIndex]);

            if (null !== $block && $block['isStart']) {
                $startIndex = $tokens->findBlockEnd($block['type'], $startIndex);

                continue;
            }

            if (!$tokens[$startIndex]->equalsAny($this->noopTypes)) {
                return true;
            }
        }

        return false;
    }

    private function getConfigType(Tokens $tokens, int $beforeOpenIndex): ?string
    {
        if ($tokens[$beforeOpenIndex]->isGivenKind(self::TOKEN_TYPE_NO_CONFIG)) {
            return null;
        }

        foreach (self::TOKEN_TYPE_CONFIG_MAP as $type => $configItem) {
            if ($tokens[$beforeOpenIndex]->isGivenKind($type)) {
                return $configItem;
            }
        }

        return 'others';
    }

    private function removeUselessParenthesisPair(
        Tokens $tokens,
        int $beforeOpenIndex,
        int $afterCloseIndex,
        int $openIndex,
        int $closeIndex,
        ?string $configType
    ): void {
        $statements = $this->configuration['statements'];

        if (null === $configType || !\in_array($configType, $statements, true)) {
            return;
        }

        $needsSpaceAfter = !$this->isAccess($tokens, $afterCloseIndex)
            && !$tokens[$afterCloseIndex]->equalsAny([';', ',', [\T_CLOSE_TAG]])
            && null === $this->getBlock($tokens, $afterCloseIndex, false)
            && !($tokens[$afterCloseIndex]->equalsAny([':', ';']) && $tokens[$beforeOpenIndex]->isGivenKind(\T_CASE));

        $needsSpaceBefore = !$this->isPreUnaryOperation($tokens, $beforeOpenIndex)
            && !$tokens[$beforeOpenIndex]->equalsAny(['}', [\T_EXIT], [\T_OPEN_TAG]])
            && null === $this->getBlock($tokens, $beforeOpenIndex, true);

        $this->removeBrace($tokens, $closeIndex, $needsSpaceAfter);
        $this->removeBrace($tokens, $openIndex, $needsSpaceBefore);
    }

    private function removeBrace(Tokens $tokens, int $index, bool $needsSpace): void
    {
        if ($needsSpace) {
            foreach ([-1, 1] as $direction) {
                $siblingIndex = $tokens->getNonEmptySibling($index, $direction);

                if ($tokens[$siblingIndex]->isWhitespace() || $tokens[$siblingIndex]->isComment()) {
                    $needsSpace = false;

                    break;
                }
            }
        }

        if ($needsSpace) {
            $tokens[$index] = new Token([\T_WHITESPACE, ' ']);
        } else {
            $tokens->clearTokenAndMergeSurroundingWhitespace($index);
        }
    }

    private function closeCurlyBelongsToDynamicElement(Tokens $tokens, int $beforeOpenIndex): bool
    {
        $index = $tokens->findBlockStart(Tokens::BLOCK_TYPE_CURLY_BRACE, $beforeOpenIndex);
        $index = $tokens->getPrevMeaningfulToken($index);

        if ($tokens[$index]->isGivenKind(\T_DOUBLE_COLON)) {
            return true;
        }

        if ($tokens[$index]->equals(':')) {
            $index = $tokens->getPrevTokenOfKind($index, [[\T_CASE], '?']);

            return !$tokens[$index]->isGivenKind(\T_CASE);
        }

        return false;
    }
}
