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

use PhpCsFixer\AbstractFixer;
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
use PhpCsFixer\Fixer\ConfigurableFixerTrait;
use PhpCsFixer\Fixer\WhitespacesAwareFixerInterface;
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\Token;
use PhpCsFixer\Tokenizer\Tokens;
use PhpCsFixer\Tokenizer\TokensAnalyzer;

/**
 * @phpstan-type _AutogeneratedInputConfiguration array{
 *  statements?: list<'break'|'case'|'continue'|'declare'|'default'|'do'|'exit'|'for'|'foreach'|'goto'|'if'|'include'|'include_once'|'phpdoc'|'require'|'require_once'|'return'|'switch'|'throw'|'try'|'while'|'yield'|'yield_from'>,
 * }
 * @phpstan-type _AutogeneratedComputedConfiguration array{
 *  statements: list<'break'|'case'|'continue'|'declare'|'default'|'do'|'exit'|'for'|'foreach'|'goto'|'if'|'include'|'include_once'|'phpdoc'|'require'|'require_once'|'return'|'switch'|'throw'|'try'|'while'|'yield'|'yield_from'>,
 * }
 *
 * @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
 *
 * @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
 * @author Andreas Möller <am@localheinz.com>
 *
 * @no-named-arguments Parameter names are not covered by the backward compatibility promise.
 */
final class BlankLineBeforeStatementFixer extends AbstractFixer implements ConfigurableFixerInterface, WhitespacesAwareFixerInterface
{
    /** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
    use ConfigurableFixerTrait;

    /**
     * @var array<string, int>
     */
    private const TOKEN_MAP = [
        'break' => \T_BREAK,
        'case' => \T_CASE,
        'continue' => \T_CONTINUE,
        'declare' => \T_DECLARE,
        'default' => \T_DEFAULT,
        'do' => \T_DO,
        'exit' => \T_EXIT,
        'for' => \T_FOR,
        'foreach' => \T_FOREACH,
        'goto' => \T_GOTO,
        'if' => \T_IF,
        'include' => \T_INCLUDE,
        'include_once' => \T_INCLUDE_ONCE,
        'phpdoc' => \T_DOC_COMMENT,
        'require' => \T_REQUIRE,
        'require_once' => \T_REQUIRE_ONCE,
        'return' => \T_RETURN,
        'switch' => \T_SWITCH,
        'throw' => \T_THROW,
        'try' => \T_TRY,
        'while' => \T_WHILE,
        'yield' => \T_YIELD,
        'yield_from' => \T_YIELD_FROM,
    ];

    /**
     * @var list<int>
     */
    private array $fixTokenMap = [];

    public function getDefinition(): FixerDefinitionInterface
    {
        return new FixerDefinition(
            'An empty line feed must precede any configured statement.',
            [
                new CodeSample(
                    <<<'PHP'
                        <?php
                        function A() {
                            echo 1;
                            return 1;
                        }

                        PHP,
                ),
                new CodeSample(
                    <<<'PHP'
                        <?php
                        switch ($foo) {
                            case 42:
                                $bar->process();
                                break;
                            case 44:
                                break;
                        }

                        PHP,
                    [
                        'statements' => ['break'],
                    ],
                ),
                new CodeSample(
                    <<<'PHP'
                        <?php
                        foreach ($foo as $bar) {
                            if ($bar->isTired()) {
                                $bar->sleep();
                                continue;
                            }
                        }

                        PHP,
                    [
                        'statements' => ['continue'],
                    ],
                ),
                new CodeSample(
                    <<<'PHP'
                        <?php
                        $i = 0;
                        do {
                            echo $i;
                        } while ($i > 0);

                        PHP,
                    [
                        'statements' => ['do'],
                    ],
                ),
                new CodeSample(
                    <<<'PHP'
                        <?php
                        if ($foo === false) {
                            exit(0);
                        } else {
                            $bar = 9000;
                            exit(1);
                        }

                        PHP,
                    [
                        'statements' => ['exit'],
                    ],
                ),
                new CodeSample(
                    <<<'PHP'
                        <?php
                        a:

                        if ($foo === false) {
                            goto a;
                        } else {
                            $bar = 9000;
                            goto b;
                        }

                        PHP,
                    [
                        'statements' => ['goto'],
                    ],
                ),
                new CodeSample(
                    <<<'PHP'
                        <?php
                        $a = 9000;
                        if (true) {
                            $foo = $bar;
                        }

                        PHP,
                    [
                        'statements' => ['if'],
                    ],
                ),
                new CodeSample(
                    <<<'PHP'
                        <?php

                        if (true) {
                            $foo = $bar;
                            return;
                        }

                        PHP,
                    [
                        'statements' => ['return'],
                    ],
                ),
                new CodeSample(
                    <<<'PHP'
                        <?php
                        $a = 9000;
                        switch ($a) {
                            case 42:
                                break;
                        }

                        PHP,
                    [
                        'statements' => ['switch'],
                    ],
                ),
                new CodeSample(
                    <<<'PHP'
                        <?php
                        if (null === $a) {
                            $foo->bar();
                            throw new \UnexpectedValueException("A cannot be null.");
                        }

                        PHP,
                    [
                        'statements' => ['throw'],
                    ],
                ),
                new CodeSample(
                    <<<'PHP'
                        <?php
                        $a = 9000;
                        try {
                            $foo->bar();
                        } catch (\Exception $exception) {
                            $a = -1;
                        }

                        PHP,
                    [
                        'statements' => ['try'],
                    ],
                ),
                new CodeSample(
                    <<<'PHP'
                        <?php
                        function getValues() {
                            yield 1;
                            yield 2;
                            // comment
                            yield 3;
                        }

                        PHP,
                    [
                        'statements' => ['yield'],
                    ],
                ),
            ],
        );
    }

    /**
     * {@inheritdoc}
     *
     * Must run after NoExtraBlankLinesFixer, NoUselessElseFixer, NoUselessReturnFixer, ReturnAssignmentFixer, YieldFromArrayToYieldsFixer.
     */
    public function getPriority(): int
    {
        return -21;
    }

    public function isCandidate(Tokens $tokens): bool
    {
        return $tokens->isAnyTokenKindsFound($this->fixTokenMap);
    }

    protected function configurePostNormalisation(): void
    {
        $fixTokenMap = [];

        foreach ($this->configuration['statements'] as $key) {
            $fixTokenMap[$key] = self::TOKEN_MAP[$key];
        }

        $this->fixTokenMap = array_values($fixTokenMap);
    }

    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
    {
        $analyzer = new TokensAnalyzer($tokens);

        for ($index = $tokens->count() - 1; $index > 0; --$index) {
            $token = $tokens[$index];

            if (!$token->isGivenKind($this->fixTokenMap)) {
                continue;
            }

            if ($token->isGivenKind(\T_WHILE) && $analyzer->isWhilePartOfDoWhile($index)) {
                continue;
            }

            if ($token->isGivenKind(\T_CASE) && $analyzer->isEnumCase($index)) {
                continue;
            }

            $insertBlankLineIndex = $this->getInsertBlankLineIndex($tokens, $index);
            $prevNonWhitespace = $tokens->getPrevNonWhitespace($insertBlankLineIndex);

            if ($this->shouldAddBlankLine($tokens, $prevNonWhitespace)) {
                $this->insertBlankLine($tokens, $insertBlankLineIndex);
            }

            $index = $prevNonWhitespace;
        }
    }

    protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
    {
        return new FixerConfigurationResolver([
            (new FixerOptionBuilder('statements', 'List of statements which must be preceded by an empty line.'))
                ->setAllowedTypes(['string[]'])
                ->setAllowedValues([new AllowedValueSubset(array_keys(self::TOKEN_MAP))])
                ->setDefault([
                    'break',
                    'continue',
                    'declare',
                    'return',
                    'throw',
                    'try',
                ])
                ->getOption(),
        ]);
    }

    private function getInsertBlankLineIndex(Tokens $tokens, int $index): int
    {
        while ($index > 0) {
            if ($tokens[$index - 1]->isWhitespace() && substr_count($tokens[$index - 1]->getContent(), "\n") > 1) {
                break;
            }

            $prevIndex = $tokens->getPrevNonWhitespace($index);

            if (!$tokens[$prevIndex]->isComment()) {
                break;
            }

            if (!$tokens[$prevIndex - 1]->isWhitespace()) {
                break;
            }

            if (1 !== substr_count($tokens[$prevIndex - 1]->getContent(), "\n")) {
                break;
            }

            $index = $prevIndex;
        }

        return $index;
    }

    private function shouldAddBlankLine(Tokens $tokens, int $prevNonWhitespace): bool
    {
        $prevNonWhitespaceToken = $tokens[$prevNonWhitespace];

        if ($prevNonWhitespaceToken->isComment()) {
            for ($j = $prevNonWhitespace - 1; $j >= 0; --$j) {
                if (str_contains($tokens[$j]->getContent(), "\n")) {
                    return false;
                }

                if ($tokens[$j]->isWhitespace() || $tokens[$j]->isComment()) {
                    continue;
                }

                return $tokens[$j]->equalsAny([';', '}']);
            }
        }

        return $prevNonWhitespaceToken->equalsAny([';', '}']);
    }

    private function insertBlankLine(Tokens $tokens, int $index): void
    {
        $prevIndex = $index - 1;
        $prevToken = $tokens[$prevIndex];
        $lineEnding = $this->whitespacesConfig->getLineEnding();

        if ($prevToken->isWhitespace()) {
            $newlinesCount = substr_count($prevToken->getContent(), "\n");

            if (0 === $newlinesCount) {
                $tokens[$prevIndex] = new Token([\T_WHITESPACE, rtrim($prevToken->getContent(), " \t").$lineEnding.$lineEnding]);
            } elseif (1 === $newlinesCount) {
                $tokens[$prevIndex] = new Token([\T_WHITESPACE, $lineEnding.$prevToken->getContent()]);
            }
        } else {
            $tokens->insertAt($index, new Token([\T_WHITESPACE, $lineEnding.$lineEnding]));
        }
    }
}
