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

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

/**
 * @phpstan-type _AutogeneratedInputConfiguration array{
 *  allow_single_line_anonymous_functions?: bool,
 *  allow_single_line_empty_anonymous_classes?: bool,
 *  anonymous_classes_opening_brace?: 'next_line_unless_newline_at_signature_end'|'same_line',
 *  anonymous_functions_opening_brace?: 'next_line_unless_newline_at_signature_end'|'same_line',
 *  classes_opening_brace?: 'next_line_unless_newline_at_signature_end'|'same_line',
 *  control_structures_opening_brace?: 'next_line_unless_newline_at_signature_end'|'same_line',
 *  functions_opening_brace?: 'next_line_unless_newline_at_signature_end'|'same_line',
 * }
 * @phpstan-type _AutogeneratedComputedConfiguration array{
 *  allow_single_line_anonymous_functions: bool,
 *  allow_single_line_empty_anonymous_classes: bool,
 *  anonymous_classes_opening_brace: 'next_line_unless_newline_at_signature_end'|'same_line',
 *  anonymous_functions_opening_brace: 'next_line_unless_newline_at_signature_end'|'same_line',
 *  classes_opening_brace: 'next_line_unless_newline_at_signature_end'|'same_line',
 *  control_structures_opening_brace: 'next_line_unless_newline_at_signature_end'|'same_line',
 *  functions_opening_brace: 'next_line_unless_newline_at_signature_end'|'same_line',
 * }
 *
 * @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
 */
final class BracesPositionFixer extends AbstractFixer implements ConfigurableFixerInterface, WhitespacesAwareFixerInterface
{
    /** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
    use ConfigurableFixerTrait;

    use Indentation;

    /**
     * @internal
     */
    public const NEXT_LINE_UNLESS_NEWLINE_AT_SIGNATURE_END = 'next_line_unless_newline_at_signature_end';

    /**
     * @internal
     */
    public const SAME_LINE = 'same_line';

    private const CONTROL_STRUCTURE_TOKENS = [\T_DECLARE, \T_DO, \T_ELSE, \T_ELSEIF, \T_FINALLY, \T_FOR, \T_FOREACH, \T_IF, \T_WHILE, \T_TRY, \T_CATCH, \T_SWITCH, FCT::T_MATCH];

    public function getDefinition(): FixerDefinitionInterface
    {
        return new FixerDefinition(
            'Braces must be placed as configured.',
            [
                new CodeSample(
                    '<?php
class Foo {
}

function foo() {
}

$foo = function()
{
};

if (foo())
{
    bar();
}

$foo = new class
{
};
'
                ),
                new CodeSample(
                    '<?php
if (foo()) {
    bar();
}
',
                    ['control_structures_opening_brace' => self::NEXT_LINE_UNLESS_NEWLINE_AT_SIGNATURE_END]
                ),
                new CodeSample(
                    '<?php
function foo()
{
}
',
                    ['functions_opening_brace' => self::SAME_LINE]
                ),
                new CodeSample(
                    '<?php
$foo = function () {
};
',
                    ['anonymous_functions_opening_brace' => self::NEXT_LINE_UNLESS_NEWLINE_AT_SIGNATURE_END]
                ),
                new CodeSample(
                    '<?php
class Foo
{
}
',
                    ['classes_opening_brace' => self::SAME_LINE]
                ),
                new CodeSample(
                    '<?php
$foo = new class {
};
',
                    ['anonymous_classes_opening_brace' => self::NEXT_LINE_UNLESS_NEWLINE_AT_SIGNATURE_END]
                ),
                new CodeSample(
                    '<?php
$foo = new class { };
$bar = new class { private $baz; };
',
                    ['allow_single_line_empty_anonymous_classes' => true]
                ),
                new CodeSample(
                    '<?php
$foo = function () { return true; };
$bar = function () { $result = true;
    return $result; };
',
                    ['allow_single_line_anonymous_functions' => true]
                ),
            ]
        );
    }

    public function isCandidate(Tokens $tokens): bool
    {
        return $tokens->isTokenKindFound('{');
    }

    /**
     * {@inheritdoc}
     *
     * Must run before SingleLineEmptyBodyFixer, StatementIndentationFixer.
     * Must run after ControlStructureBracesFixer, MultilinePromotedPropertiesFixer, NoMultipleStatementsPerLineFixer.
     */
    public function getPriority(): int
    {
        return -2;
    }

    /** @protected */
    public function createConfigurationDefinition(): FixerConfigurationResolverInterface
    {
        return new FixerConfigurationResolver([
            (new FixerOptionBuilder('control_structures_opening_brace', 'The position of the opening brace of control structures‘ body.'))
                ->setAllowedValues([self::NEXT_LINE_UNLESS_NEWLINE_AT_SIGNATURE_END, self::SAME_LINE])
                ->setDefault(self::SAME_LINE)
                ->getOption(),
            (new FixerOptionBuilder('functions_opening_brace', 'The position of the opening brace of functions‘ body.'))
                ->setAllowedValues([self::NEXT_LINE_UNLESS_NEWLINE_AT_SIGNATURE_END, self::SAME_LINE])
                ->setDefault(self::NEXT_LINE_UNLESS_NEWLINE_AT_SIGNATURE_END)
                ->getOption(),
            (new FixerOptionBuilder('anonymous_functions_opening_brace', 'The position of the opening brace of anonymous functions‘ body.'))
                ->setAllowedValues([self::NEXT_LINE_UNLESS_NEWLINE_AT_SIGNATURE_END, self::SAME_LINE])
                ->setDefault(self::SAME_LINE)
                ->getOption(),
            (new FixerOptionBuilder('classes_opening_brace', 'The position of the opening brace of classes‘ body.'))
                ->setAllowedValues([self::NEXT_LINE_UNLESS_NEWLINE_AT_SIGNATURE_END, self::SAME_LINE])
                ->setDefault(self::NEXT_LINE_UNLESS_NEWLINE_AT_SIGNATURE_END)
                ->getOption(),
            (new FixerOptionBuilder('anonymous_classes_opening_brace', 'The position of the opening brace of anonymous classes‘ body.'))
                ->setAllowedValues([self::NEXT_LINE_UNLESS_NEWLINE_AT_SIGNATURE_END, self::SAME_LINE])
                ->setDefault(self::SAME_LINE)
                ->getOption(),
            (new FixerOptionBuilder('allow_single_line_empty_anonymous_classes', 'Allow anonymous classes to have opening and closing braces on the same line.'))
                ->setAllowedTypes(['bool'])
                ->setDefault(true)
                ->getOption(),
            (new FixerOptionBuilder('allow_single_line_anonymous_functions', 'Allow anonymous functions to have opening and closing braces on the same line.'))
                ->setAllowedTypes(['bool'])
                ->setDefault(true)
                ->getOption(),
        ]);
    }

    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
    {
        $classyTokens = Token::getClassyTokenKinds();
        $tokensAnalyzer = new TokensAnalyzer($tokens);

        $allowSingleLineUntil = null;

        foreach ($tokens as $index => $token) {
            $allowSingleLine = false;
            $allowSingleLineIfEmpty = false;

            if ($token->isGivenKind($classyTokens)) {
                $openBraceIndex = $tokens->getNextTokenOfKind($index, ['{']);

                if ($tokensAnalyzer->isAnonymousClass($index)) {
                    $allowSingleLineIfEmpty = true === $this->configuration['allow_single_line_empty_anonymous_classes'];
                    $positionOption = 'anonymous_classes_opening_brace';
                } else {
                    $positionOption = 'classes_opening_brace';
                }
            } elseif ($token->isGivenKind(\T_FUNCTION)) {
                $openBraceIndex = $tokens->getNextTokenOfKind($index, ['{', ';', [CT::T_PROPERTY_HOOK_BRACE_OPEN]]);

                if (!$tokens[$openBraceIndex]->equals('{')) {
                    continue;
                }

                if ($tokensAnalyzer->isLambda($index)) {
                    $allowSingleLine = true === $this->configuration['allow_single_line_anonymous_functions'];
                    $positionOption = 'anonymous_functions_opening_brace';
                } else {
                    $positionOption = 'functions_opening_brace';
                }
            } elseif ($token->isGivenKind(self::CONTROL_STRUCTURE_TOKENS)) {
                $parenthesisEndIndex = $this->findParenthesisEnd($tokens, $index);
                $openBraceIndex = $tokens->getNextMeaningfulToken($parenthesisEndIndex);

                if (!$tokens[$openBraceIndex]->equals('{')) {
                    continue;
                }

                $positionOption = 'control_structures_opening_brace';
            } elseif ($token->isGivenKind(\T_VARIABLE)) {
                // handle default value - explicitly skip array as default value
                $nextMeaningfulIndex = $tokens->getNextMeaningfulToken($index);
                if ($tokens[$nextMeaningfulIndex]->equals('=')) {
                    $nextMeaningfulIndex = $tokens->getNextMeaningfulToken($nextMeaningfulIndex);

                    if ($tokens[$nextMeaningfulIndex]->isGivenKind(CT::T_ARRAY_SQUARE_BRACE_OPEN)) {
                        $index = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_ARRAY_SQUARE_BRACE, $nextMeaningfulIndex);
                    } elseif ($tokens[$nextMeaningfulIndex]->isGivenKind(\T_ARRAY)) {
                        $nextMeaningfulIndex = $tokens->getNextMeaningfulToken($nextMeaningfulIndex);
                        if ($tokens[$nextMeaningfulIndex]->equals('(')) {
                            $index = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $nextMeaningfulIndex);
                        }
                    }
                }

                $openBraceIndex = $tokens->getNextTokenOfKind($index, ['{', ';', '.', [CT::T_CURLY_CLOSE], [CT::T_PROPERTY_HOOK_BRACE_OPEN], [\T_ENCAPSED_AND_WHITESPACE], [\T_CLOSE_TAG]]);

                if (!$tokens[$openBraceIndex]->isGivenKind(CT::T_PROPERTY_HOOK_BRACE_OPEN)) {
                    continue;
                }

                $closeBraceIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PROPERTY_HOOK, $openBraceIndex);
                if (!$tokens->isPartialCodeMultiline($openBraceIndex, $closeBraceIndex)) {
                    continue;
                }

                $positionOption = 'control_structures_opening_brace';
            } elseif ($token->isGivenKind(\T_STRING)) {
                $parenthesisEndIndex = $this->findParenthesisEnd($tokens, $index);
                $openBraceIndex = $tokens->getNextMeaningfulToken($parenthesisEndIndex);
                if (!$tokens[$openBraceIndex]->equals('{')) {
                    continue;
                }

                $prevIndex = $tokens->getPrevMeaningfulToken($index);
                if (!$tokens[$prevIndex]->equalsAny(['}', ';', [CT::T_ATTRIBUTE_CLOSE], [CT::T_PROPERTY_HOOK_BRACE_OPEN]])) {
                    continue;
                }
                $allowSingleLine = true === $this->configuration['allow_single_line_anonymous_functions'];
                $positionOption = 'control_structures_opening_brace';
            } else {
                continue;
            }

            $closeBraceIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $openBraceIndex);

            $addNewlinesInsideBraces = true;
            if ($allowSingleLine || $allowSingleLineIfEmpty || $index < $allowSingleLineUntil) {
                $addNewlinesInsideBraces = false;

                for ($indexInsideBraces = $openBraceIndex + 1; $indexInsideBraces < $closeBraceIndex; ++$indexInsideBraces) {
                    $tokenInsideBraces = $tokens[$indexInsideBraces];

                    if (
                        ($allowSingleLineIfEmpty && !$tokenInsideBraces->isWhitespace() && !$tokenInsideBraces->isComment())
                        || ($tokenInsideBraces->isWhitespace() && Preg::match('/\R/', $tokenInsideBraces->getContent()))
                    ) {
                        $addNewlinesInsideBraces = true;

                        break;
                    }
                }

                if (!$addNewlinesInsideBraces && null === $allowSingleLineUntil) {
                    $allowSingleLineUntil = $closeBraceIndex;
                }
            }

            if (
                $addNewlinesInsideBraces
                && !$this->isFollowedByNewLine($tokens, $openBraceIndex)
                && !$this->hasCommentOnSameLine($tokens, $openBraceIndex)
                && !$tokens[$tokens->getNextMeaningfulToken($openBraceIndex)]->isGivenKind(\T_CLOSE_TAG)
            ) {
                $whitespace = $this->whitespacesConfig->getLineEnding().$this->getLineIndentation($tokens, $openBraceIndex);
                if ($tokens->ensureWhitespaceAtIndex($openBraceIndex + 1, 0, $whitespace)) {
                    ++$closeBraceIndex;
                }
            }

            $whitespace = ' ';
            if (self::NEXT_LINE_UNLESS_NEWLINE_AT_SIGNATURE_END === $this->configuration[$positionOption]) {
                $whitespace = $this->whitespacesConfig->getLineEnding().$this->getLineIndentation($tokens, $index);

                $previousTokenIndex = $openBraceIndex;
                do {
                    $previousTokenIndex = $tokens->getPrevMeaningfulToken($previousTokenIndex);
                } while ($tokens[$previousTokenIndex]->isGivenKind([CT::T_TYPE_COLON, CT::T_NULLABLE_TYPE, \T_STRING, \T_NS_SEPARATOR, CT::T_ARRAY_TYPEHINT, \T_STATIC, CT::T_TYPE_ALTERNATION, CT::T_TYPE_INTERSECTION, \T_CALLABLE, CT::T_DISJUNCTIVE_NORMAL_FORM_TYPE_PARENTHESIS_OPEN, CT::T_DISJUNCTIVE_NORMAL_FORM_TYPE_PARENTHESIS_CLOSE]));

                if ($tokens[$previousTokenIndex]->equals(')')) {
                    if ($tokens[--$previousTokenIndex]->isComment()) {
                        --$previousTokenIndex;
                    }
                    if (
                        $tokens[$previousTokenIndex]->isWhitespace()
                        && Preg::match('/\R/', $tokens[$previousTokenIndex]->getContent())
                    ) {
                        $whitespace = ' ';
                    }
                }
            }

            $moveBraceToIndex = null;

            if (' ' === $whitespace) {
                $previousMeaningfulIndex = $tokens->getPrevMeaningfulToken($openBraceIndex);
                for ($indexBeforeOpenBrace = $openBraceIndex - 1; $indexBeforeOpenBrace > $previousMeaningfulIndex; --$indexBeforeOpenBrace) {
                    if (!$tokens[$indexBeforeOpenBrace]->isComment()) {
                        continue;
                    }

                    $tokenBeforeOpenBrace = $tokens[--$indexBeforeOpenBrace];
                    if ($tokenBeforeOpenBrace->isWhitespace()) {
                        $moveBraceToIndex = $indexBeforeOpenBrace;
                    } elseif ($indexBeforeOpenBrace === $previousMeaningfulIndex) {
                        $moveBraceToIndex = $previousMeaningfulIndex + 1;
                    }
                }
            } elseif (!$tokens[$openBraceIndex - 1]->isWhitespace() || !Preg::match('/\R/', $tokens[$openBraceIndex - 1]->getContent())) {
                for ($indexAfterOpenBrace = $openBraceIndex + 1; $indexAfterOpenBrace < $closeBraceIndex; ++$indexAfterOpenBrace) {
                    if ($tokens[$indexAfterOpenBrace]->isWhitespace() && Preg::match('/\R/', $tokens[$indexAfterOpenBrace]->getContent())) {
                        break;
                    }

                    if ($tokens[$indexAfterOpenBrace]->isComment() && !str_starts_with($tokens[$indexAfterOpenBrace]->getContent(), '/*')) {
                        $moveBraceToIndex = $indexAfterOpenBrace + 1;
                    }
                }
            }

            if (null !== $moveBraceToIndex) {
                $movedToken = clone $tokens[$openBraceIndex];

                $delta = $openBraceIndex < $moveBraceToIndex ? 1 : -1;

                if ($tokens[$openBraceIndex + $delta]->isWhitespace()) {
                    if (-1 === $delta && Preg::match('/\R/', $tokens[$openBraceIndex - 1]->getContent())) {
                        $content = Preg::replace('/^(\h*?\R)?\h*/', '', $tokens[$openBraceIndex + 1]->getContent());
                        if ('' !== $content) {
                            $tokens[$openBraceIndex + 1] = new Token([\T_WHITESPACE, $content]);
                        } else {
                            $tokens->clearAt($openBraceIndex + 1);
                        }
                    } elseif ($tokens[$openBraceIndex - 1]->isWhitespace()) {
                        $tokens->clearAt($openBraceIndex - 1);
                    }
                }

                for ($i = $openBraceIndex; $i !== $moveBraceToIndex; $i += $delta) {
                    $siblingToken = $tokens[$i + $delta];
                    $tokens[$i] = $siblingToken;
                }

                $tokens[$i] = $movedToken;

                if ($tokens[$openBraceIndex]->isWhitespace() && $tokens[$openBraceIndex + 1]->isWhitespace()) {
                    $tokens[$openBraceIndex] = new Token([
                        \T_WHITESPACE,
                        $tokens[$openBraceIndex]->getContent().$tokens[$openBraceIndex + 1]->getContent(),
                    ]);
                    $tokens->clearAt($openBraceIndex + 1);
                }

                $openBraceIndex = $moveBraceToIndex;
            }

            if ($tokens->ensureWhitespaceAtIndex($openBraceIndex - 1, 1, $whitespace)) {
                ++$closeBraceIndex;
                if (null !== $allowSingleLineUntil) {
                    ++$allowSingleLineUntil;
                }
            }

            if (
                !$addNewlinesInsideBraces
                || $tokens[$tokens->getPrevMeaningfulToken($closeBraceIndex)]->isGivenKind(\T_OPEN_TAG)
            ) {
                continue;
            }

            $prevIndex = $closeBraceIndex - 1;
            while ($tokens->isEmptyAt($prevIndex)) {
                --$prevIndex;
            }

            $prevToken = $tokens[$prevIndex];

            if ($prevToken->isWhitespace() && Preg::match('/\R/', $prevToken->getContent())) {
                continue;
            }

            $whitespace = $this->whitespacesConfig->getLineEnding().$this->getLineIndentation($tokens, $openBraceIndex);
            $tokens->ensureWhitespaceAtIndex($prevIndex, 1, $whitespace);
        }
    }

    private function findParenthesisEnd(Tokens $tokens, int $structureTokenIndex): int
    {
        $nextIndex = $tokens->getNextMeaningfulToken($structureTokenIndex);
        $nextToken = $tokens[$nextIndex];

        // return if next token is not opening parenthesis
        if (!$nextToken->equals('(')) {
            return $structureTokenIndex;
        }

        return $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $nextIndex);
    }

    private function isFollowedByNewLine(Tokens $tokens, int $index): bool
    {
        for (++$index, $max = \count($tokens) - 1; $index < $max; ++$index) {
            $token = $tokens[$index];
            if (!$token->isComment()) {
                return $token->isWhitespace() && Preg::match('/\R/', $token->getContent());
            }
        }

        return false;
    }

    private function hasCommentOnSameLine(Tokens $tokens, int $index): bool
    {
        $token = $tokens[$index + 1];

        if ($token->isWhitespace() && !Preg::match('/\R/', $token->getContent())) {
            $token = $tokens[$index + 2];
        }

        return $token->isComment();
    }
}
