<?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\AbstractPhpdocToTypeDeclarationFixer;
use PhpCsFixer\DocBlock\Annotation;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
use PhpCsFixer\FixerDefinition\VersionSpecification;
use PhpCsFixer\FixerDefinition\VersionSpecificCodeSample;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;

final class PhpdocToPropertyTypeFixer extends AbstractPhpdocToTypeDeclarationFixer
{
    /**
     * @var array<string, true>
     */
    private array $skippedTypes = [
        'mixed' => true,
        'resource' => true,
        'null' => true,
    ];

    /**
     * {@inheritdoc}
     */
    public function getDefinition(): FixerDefinitionInterface
    {
        return new FixerDefinition(
            'EXPERIMENTAL: Takes `@var` annotation of non-mixed types and adjusts accordingly the property signature. Requires PHP >= 7.4.',
            [
                new VersionSpecificCodeSample(
                    '<?php
class Foo {
    /** @var int */
    private $foo;
    /** @var \Traversable */
    private $bar;
}
',
                    new VersionSpecification(70400)
                ),
                new VersionSpecificCodeSample(
                    '<?php
class Foo {
    /** @var int */
    private $foo;
    /** @var \Traversable */
    private $bar;
}
',
                    new VersionSpecification(70400),
                    ['scalar_types' => false]
                ),
            ],
            null,
            'This rule is EXPERIMENTAL and [1] is not covered with backward compatibility promise. [2] `@var` annotation is mandatory for the fixer to make changes, signatures of properties without it (no docblock) will not be fixed. [3] Manual actions might be required for newly typed properties that are read before initialization.'
        );
    }

    /**
     * {@inheritdoc}
     */
    public function isCandidate(Tokens $tokens): bool
    {
        return $tokens->isTokenKindFound(T_DOC_COMMENT);
    }

    /**
     * {@inheritdoc}
     *
     * Must run before NoSuperfluousPhpdocTagsFixer, PhpdocAlignFixer.
     * Must run after AlignMultilineCommentFixer, CommentToPhpdocFixer, PhpdocIndentFixer, PhpdocScalarFixer, PhpdocToCommentFixer, PhpdocTypesFixer.
     */
    public function getPriority(): int
    {
        return 7;
    }

    protected function isSkippedType(string $type): bool
    {
        return isset($this->skippedTypes[$type]);
    }

    /**
     * {@inheritdoc}
     */
    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
    {
        for ($index = $tokens->count() - 1; 0 < $index; --$index) {
            if ($tokens[$index]->isGivenKind([T_CLASS, T_TRAIT])) {
                $this->fixClass($tokens, $index);
            }
        }
    }

    private function fixClass(Tokens $tokens, int $index): void
    {
        $index = $tokens->getNextTokenOfKind($index, ['{']);
        $classEndIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $index);

        for (; $index < $classEndIndex; ++$index) {
            if ($tokens[$index]->isGivenKind(T_FUNCTION)) {
                $index = $tokens->getNextTokenOfKind($index, ['{', ';']);

                if ($tokens[$index]->equals('{')) {
                    $index = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $index);
                }

                continue;
            }

            if (!$tokens[$index]->isGivenKind(T_DOC_COMMENT)) {
                continue;
            }

            $docCommentIndex = $index;
            $propertyIndices = $this->findNextUntypedPropertiesDeclaration($tokens, $docCommentIndex);

            if ([] === $propertyIndices) {
                continue;
            }

            $typeInfo = $this->resolveApplicableType(
                $propertyIndices,
                $this->getAnnotationsFromDocComment('var', $tokens, $docCommentIndex)
            );

            if (null === $typeInfo) {
                continue;
            }

            [$propertyType, $isNullable] = $typeInfo;

            if (\in_array($propertyType, ['callable', 'never', 'void'], true)) {
                continue;
            }

            $newTokens = array_merge(
                $this->createTypeDeclarationTokens($propertyType, $isNullable),
                [new Token([T_WHITESPACE, ' '])]
            );

            $tokens->insertAt(current($propertyIndices), $newTokens);

            $index = max($propertyIndices) + \count($newTokens) + 1;
            $classEndIndex += \count($newTokens);
        }
    }

    /**
     * @return array<string, int>
     */
    private function findNextUntypedPropertiesDeclaration(Tokens $tokens, int $index): array
    {
        do {
            $index = $tokens->getNextMeaningfulToken($index);
        } while ($tokens[$index]->isGivenKind([
            T_PRIVATE,
            T_PROTECTED,
            T_PUBLIC,
            T_STATIC,
            T_VAR,
        ]));

        if (!$tokens[$index]->isGivenKind(T_VARIABLE)) {
            return [];
        }

        $properties = [];

        while (!$tokens[$index]->equals(';')) {
            if ($tokens[$index]->isGivenKind(T_VARIABLE)) {
                $properties[$tokens[$index]->getContent()] = $index;
            }

            $index = $tokens->getNextMeaningfulToken($index);
        }

        return $properties;
    }

    /**
     * @param array<string, int> $propertyIndices
     * @param Annotation[]       $annotations
     */
    private function resolveApplicableType(array $propertyIndices, array $annotations): ?array
    {
        $propertyTypes = [];

        foreach ($annotations as $annotation) {
            $propertyName = $annotation->getVariableName();

            if (null === $propertyName) {
                if (1 !== \count($propertyIndices)) {
                    continue;
                }

                $propertyName = key($propertyIndices);
            }

            if (!isset($propertyIndices[$propertyName])) {
                continue;
            }

            $typeInfo = $this->getCommonTypeFromAnnotation($annotation, false);

            if (!isset($propertyTypes[$propertyName])) {
                $propertyTypes[$propertyName] = [];
            } elseif ($typeInfo !== $propertyTypes[$propertyName]) {
                return null;
            }

            $propertyTypes[$propertyName] = $typeInfo;
        }

        if (\count($propertyTypes) !== \count($propertyIndices)) {
            return null;
        }

        $type = array_shift($propertyTypes);

        foreach ($propertyTypes as $propertyType) {
            if ($propertyType !== $type) {
                return null;
            }
        }

        return $type;
    }
}
