<?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\FunctionNotation;

use PhpCsFixer\AbstractFixer;
use PhpCsFixer\FixerDefinition\CodeSample;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
use PhpCsFixer\Tokenizer\Analyzer\ArgumentsAnalyzer;
use PhpCsFixer\Tokenizer\Analyzer\FunctionsAnalyzer;
use PhpCsFixer\Tokenizer\CT;
use PhpCsFixer\Tokenizer\Tokens;
use PhpCsFixer\Tokenizer\TokensAnalyzer;

final class LambdaNotUsedImportFixer extends AbstractFixer
{
    private ArgumentsAnalyzer $argumentsAnalyzer;

    private FunctionsAnalyzer $functionAnalyzer;

    private TokensAnalyzer $tokensAnalyzer;

    public function getDefinition(): FixerDefinitionInterface
    {
        return new FixerDefinition(
            'Lambda must not import variables it doesn\'t use.',
            [new CodeSample("<?php\n\$foo = function() use (\$bar) {};\n")]
        );
    }

    /**
     * {@inheritdoc}
     *
     * Must run before MethodArgumentSpaceFixer, NoSpacesInsideParenthesisFixer, SpacesInsideParenthesesFixer.
     */
    public function getPriority(): int
    {
        return 31;
    }

    public function isCandidate(Tokens $tokens): bool
    {
        return $tokens->isAllTokenKindsFound([\T_FUNCTION, CT::T_USE_LAMBDA]);
    }

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

        for ($index = $tokens->count() - 4; $index > 0; --$index) {
            $lambdaUseIndex = $this->getLambdaUseIndex($tokens, $index);

            if (false !== $lambdaUseIndex) {
                $this->fixLambda($tokens, $lambdaUseIndex);
            }
        }
    }

    private function fixLambda(Tokens $tokens, int $lambdaUseIndex): void
    {
        $lambdaUseOpenBraceIndex = $tokens->getNextTokenOfKind($lambdaUseIndex, ['(']);
        $lambdaUseCloseBraceIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $lambdaUseOpenBraceIndex);
        $arguments = $this->argumentsAnalyzer->getArguments($tokens, $lambdaUseOpenBraceIndex, $lambdaUseCloseBraceIndex);

        $imports = $this->filterArguments($tokens, $arguments);

        if (0 === \count($imports)) {
            return; // no imports to remove
        }

        $notUsedImports = $this->findNotUsedLambdaImports($tokens, $imports, $lambdaUseCloseBraceIndex);
        $notUsedImportsCount = \count($notUsedImports);

        if (0 === $notUsedImportsCount) {
            return; // no not used imports found
        }

        if ($notUsedImportsCount === \count($arguments)) {
            $this->clearImportsAndUse($tokens, $lambdaUseIndex, $lambdaUseCloseBraceIndex); // all imports are not used

            return;
        }

        $this->clearImports($tokens, array_reverse($notUsedImports));
    }

    /**
     * @param array<string, int> $imports
     *
     * @return array<string, int>
     */
    private function findNotUsedLambdaImports(Tokens $tokens, array $imports, int $lambdaUseCloseBraceIndex): array
    {
        // figure out where the lambda starts ...
        $lambdaOpenIndex = $tokens->getNextTokenOfKind($lambdaUseCloseBraceIndex, ['{']);
        $curlyBracesLevel = 0;

        for ($index = $lambdaOpenIndex;; ++$index) { // go through the body of the lambda and keep count of the (possible) usages of the imported variables
            $token = $tokens[$index];

            if ($token->equals('{')) {
                ++$curlyBracesLevel;

                continue;
            }

            if ($token->equals('}')) {
                --$curlyBracesLevel;

                if (0 === $curlyBracesLevel) {
                    break;
                }

                continue;
            }

            if ($token->isGivenKind(\T_STRING) && 'compact' === strtolower($token->getContent()) && $this->functionAnalyzer->isGlobalFunctionCall($tokens, $index)) {
                return []; // wouldn't touch it with a ten-foot pole
            }

            if ($token->isGivenKind([
                CT::T_DYNAMIC_VAR_BRACE_OPEN,
                \T_EVAL,
                \T_INCLUDE,
                \T_INCLUDE_ONCE,
                \T_REQUIRE,
                \T_REQUIRE_ONCE,
            ])) {
                return [];
            }

            if ($token->equals('$')) {
                $nextIndex = $tokens->getNextMeaningfulToken($index);

                if ($tokens[$nextIndex]->isGivenKind(\T_VARIABLE)) {
                    return []; // "$$a" case
                }
            }

            if ($token->isGivenKind(\T_VARIABLE)) {
                $content = $token->getContent();

                if (isset($imports[$content])) {
                    unset($imports[$content]);

                    if (0 === \count($imports)) {
                        return $imports;
                    }
                }
            }

            if ($token->isGivenKind(\T_STRING_VARNAME)) {
                $content = '$'.$token->getContent();

                if (isset($imports[$content])) {
                    unset($imports[$content]);

                    if (0 === \count($imports)) {
                        return $imports;
                    }
                }
            }

            if ($token->isClassy()) { // is anonymous class
                // check if used as argument in the constructor of the anonymous class
                $index = $tokens->getNextTokenOfKind($index, ['(', '{']);

                if ($tokens[$index]->equals('(')) {
                    $closeBraceIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $index);
                    $arguments = $this->argumentsAnalyzer->getArguments($tokens, $index, $closeBraceIndex);

                    $imports = $this->countImportsUsedAsArgument($tokens, $imports, $arguments);

                    $index = $tokens->getNextTokenOfKind($closeBraceIndex, ['{']);
                }

                // skip body
                $index = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $index);

                continue;
            }

            if ($token->isGivenKind(\T_FUNCTION)) {
                // check if used as argument
                $lambdaUseOpenBraceIndex = $tokens->getNextTokenOfKind($index, ['(']);
                $lambdaUseCloseBraceIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $lambdaUseOpenBraceIndex);
                $arguments = $this->argumentsAnalyzer->getArguments($tokens, $lambdaUseOpenBraceIndex, $lambdaUseCloseBraceIndex);

                $imports = $this->countImportsUsedAsArgument($tokens, $imports, $arguments);

                // check if used as import
                $index = $tokens->getNextTokenOfKind($index, [[CT::T_USE_LAMBDA], '{']);

                if ($tokens[$index]->isGivenKind(CT::T_USE_LAMBDA)) {
                    $lambdaUseOpenBraceIndex = $tokens->getNextTokenOfKind($index, ['(']);
                    $lambdaUseCloseBraceIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $lambdaUseOpenBraceIndex);
                    $arguments = $this->argumentsAnalyzer->getArguments($tokens, $lambdaUseOpenBraceIndex, $lambdaUseCloseBraceIndex);

                    $imports = $this->countImportsUsedAsArgument($tokens, $imports, $arguments);

                    $index = $tokens->getNextTokenOfKind($lambdaUseCloseBraceIndex, ['{']);
                }

                // skip body
                $index = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $index);

                continue;
            }
        }

        return $imports;
    }

    /**
     * @param array<string, int> $imports
     * @param array<int, int>    $arguments
     *
     * @return array<string, int>
     */
    private function countImportsUsedAsArgument(Tokens $tokens, array $imports, array $arguments): array
    {
        foreach ($arguments as $start => $end) {
            $info = $this->argumentsAnalyzer->getArgumentInfo($tokens, $start, $end);
            $content = $info->getName();

            if (isset($imports[$content])) {
                unset($imports[$content]);

                if (0 === \count($imports)) {
                    return $imports;
                }
            }
        }

        return $imports;
    }

    /**
     * @return false|int
     */
    private function getLambdaUseIndex(Tokens $tokens, int $index)
    {
        if (!$tokens[$index]->isGivenKind(\T_FUNCTION) || !$this->tokensAnalyzer->isLambda($index)) {
            return false;
        }

        $lambdaUseIndex = $tokens->getNextMeaningfulToken($index); // we are @ '(' or '&' after this

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

        $lambdaUseIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $lambdaUseIndex); // we are @ ')' after this
        $lambdaUseIndex = $tokens->getNextMeaningfulToken($lambdaUseIndex);

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

        return $lambdaUseIndex;
    }

    /**
     * @param array<int, int> $arguments
     *
     * @return array<string, int>
     */
    private function filterArguments(Tokens $tokens, array $arguments): array
    {
        $imports = [];

        foreach ($arguments as $start => $end) {
            $info = $this->argumentsAnalyzer->getArgumentInfo($tokens, $start, $end);
            $argument = $info->getNameIndex();

            if ($tokens[$tokens->getPrevMeaningfulToken($argument)]->equals('&')) {
                continue;
            }

            $argumentCandidate = $tokens[$argument];

            if ('$this' === $argumentCandidate->getContent()) {
                continue;
            }

            if ($this->tokensAnalyzer->isSuperGlobal($argument)) {
                continue;
            }

            $imports[$argumentCandidate->getContent()] = $argument;
        }

        return $imports;
    }

    /**
     * @param array<string, int> $imports
     */
    private function clearImports(Tokens $tokens, array $imports): void
    {
        foreach ($imports as $removeIndex) {
            $tokens->clearTokenAndMergeSurroundingWhitespace($removeIndex);
            $previousRemoveIndex = $tokens->getPrevMeaningfulToken($removeIndex);

            if ($tokens[$previousRemoveIndex]->equals(',')) {
                $tokens->clearTokenAndMergeSurroundingWhitespace($previousRemoveIndex);
            } elseif ($tokens[$previousRemoveIndex]->equals('(')) {
                $tokens->clearTokenAndMergeSurroundingWhitespace($tokens->getNextMeaningfulToken($removeIndex)); // next is always ',' here
            }
        }
    }

    /**
     * Remove `use` and all imported variables.
     */
    private function clearImportsAndUse(Tokens $tokens, int $lambdaUseIndex, int $lambdaUseCloseBraceIndex): void
    {
        for ($i = $lambdaUseCloseBraceIndex; $i >= $lambdaUseIndex; --$i) {
            if ($tokens[$i]->isComment()) {
                continue;
            }

            if ($tokens[$i]->isWhitespace()) {
                $previousIndex = $tokens->getPrevNonWhitespace($i);

                if ($tokens[$previousIndex]->isComment()) {
                    continue;
                }
            }

            $tokens->clearTokenAndMergeSurroundingWhitespace($i);
        }
    }
}
