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

use PhpCsFixer\AbstractFixer;
use PhpCsFixer\DocBlock\Annotation;
use PhpCsFixer\DocBlock\DocBlock;
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
use PhpCsFixer\Fixer\ConfigurableFixerTrait;
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\Analyzer\Analysis\NamespaceUseAnalysis;
use PhpCsFixer\Tokenizer\Analyzer\ClassyAnalyzer;
use PhpCsFixer\Tokenizer\Analyzer\FunctionsAnalyzer;
use PhpCsFixer\Tokenizer\Analyzer\NamespaceUsesAnalyzer;
use PhpCsFixer\Tokenizer\CT;
use PhpCsFixer\Tokenizer\Processor\ImportProcessor;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
use PhpCsFixer\Tokenizer\TokensAnalyzer;

/**
 * @phpstan-type _AutogeneratedInputConfiguration array{
 *  import_classes?: bool|null,
 *  import_constants?: bool|null,
 *  import_functions?: bool|null,
 * }
 * @phpstan-type _AutogeneratedComputedConfiguration array{
 *  import_classes: bool|null,
 *  import_constants: bool|null,
 *  import_functions: bool|null,
 * }
 *
 * @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
 *
 * @author Gregor Harlan <gharlan@web.de>
 *
 * @no-named-arguments Parameter names are not covered by the backward compatibility promise.
 */
final class GlobalNamespaceImportFixer extends AbstractFixer implements ConfigurableFixerInterface, WhitespacesAwareFixerInterface
{
    /** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
    use ConfigurableFixerTrait;

    private ImportProcessor $importProcessor;

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

        $this->importProcessor = new ImportProcessor($this->whitespacesConfig);
    }

    public function getDefinition(): FixerDefinitionInterface
    {
        return new FixerDefinition(
            'Imports or fully qualifies global classes/functions/constants.',
            [
                new CodeSample(
                    <<<'PHP'
                        <?php

                        namespace Foo;

                        $d = new \DateTimeImmutable();

                        PHP,
                ),
                new CodeSample(
                    <<<'PHP'
                        <?php

                        namespace Foo;

                        if (\count($x)) {
                            /** @var \DateTimeImmutable $d */
                            $d = new \DateTimeImmutable();
                            $p = \M_PI;
                        }

                        PHP,
                    ['import_classes' => true, 'import_constants' => true, 'import_functions' => true],
                ),
                new CodeSample(
                    <<<'PHP'
                        <?php

                        namespace Foo;

                        use DateTimeImmutable;
                        use function count;
                        use const M_PI;

                        if (count($x)) {
                            /** @var DateTimeImmutable $d */
                            $d = new DateTimeImmutable();
                            $p = M_PI;
                        }

                        PHP,
                    ['import_classes' => false, 'import_constants' => false, 'import_functions' => false],
                ),
            ],
        );
    }

    /**
     * {@inheritdoc}
     *
     * Must run before NoUnusedImportsFixer, OrderedImportsFixer, StatementIndentationFixer.
     * Must run after NativeConstantInvocationFixer, NativeFunctionInvocationFixer, StringableForToStringFixer.
     */
    public function getPriority(): int
    {
        return 0;
    }

    public function isCandidate(Tokens $tokens): bool
    {
        return $tokens->isAnyTokenKindsFound([\T_DOC_COMMENT, \T_NS_SEPARATOR, \T_USE])
            && $tokens->isTokenKindFound(\T_NAMESPACE)
            && 1 === $tokens->countTokenKind(\T_NAMESPACE)
            && $tokens->isMonolithicPhp();
    }

    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
    {
        $namespaceAnalyses = $tokens->getNamespaceDeclarations();

        if (1 !== \count($namespaceAnalyses) || $namespaceAnalyses[0]->isGlobalNamespace()) {
            return;
        }

        $useDeclarations = (new NamespaceUsesAnalyzer())->getDeclarationsFromTokens($tokens);

        $newImports = [];

        if (true === $this->configuration['import_constants']) {
            $newImports['const'] = $this->importConstants($tokens, $useDeclarations);
        } elseif (false === $this->configuration['import_constants']) {
            $this->fullyQualifyConstants($tokens, $useDeclarations);
        }

        if (true === $this->configuration['import_functions']) {
            $newImports['function'] = $this->importFunctions($tokens, $useDeclarations);
        } elseif (false === $this->configuration['import_functions']) {
            $this->fullyQualifyFunctions($tokens, $useDeclarations);
        }

        if (true === $this->configuration['import_classes']) {
            $newImports['class'] = $this->importClasses($tokens, $useDeclarations);
        } elseif (false === $this->configuration['import_classes']) {
            $this->fullyQualifyClasses($tokens, $useDeclarations);
        }

        if (\count($newImports) > 0) {
            if (\count($useDeclarations) > 0) {
                $useDeclaration = end($useDeclarations);
                $atIndex = $useDeclaration->getEndIndex() + 1;
            } else {
                \assert(isset($tokens->getNamespaceDeclarations()[0]));
                $namespace = $tokens->getNamespaceDeclarations()[0];
                $atIndex = $namespace->getEndIndex() + 1;
            }

            $this->importProcessor->insertImports($tokens, $newImports, $atIndex);
        }
    }

    protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
    {
        return new FixerConfigurationResolver([
            (new FixerOptionBuilder('import_constants', 'Whether to import, not import or ignore global constants.'))
                ->setDefault(null)
                ->setAllowedTypes(['null', 'bool'])
                ->getOption(),
            (new FixerOptionBuilder('import_functions', 'Whether to import, not import or ignore global functions.'))
                ->setDefault(null)
                ->setAllowedTypes(['null', 'bool'])
                ->getOption(),
            (new FixerOptionBuilder('import_classes', 'Whether to import, not import or ignore global classes.'))
                ->setDefault(true)
                ->setAllowedTypes(['null', 'bool'])
                ->getOption(),
        ]);
    }

    /**
     * @param list<NamespaceUseAnalysis> $useDeclarations
     *
     * @return array<non-empty-string, non-empty-string>
     */
    private function importConstants(Tokens $tokens, array $useDeclarations): array
    {
        [$global, $other] = $this->filterUseDeclarations($useDeclarations, static fn (NamespaceUseAnalysis $declaration): bool => $declaration->isConstant(), true);

        // find namespaced const declarations (`const FOO = 1`)
        // and add them to the not importable names (already used)
        for ($index = 0, $count = $tokens->count(); $index < $count; ++$index) {
            $token = $tokens[$index];

            if ($token->isClassy()) {
                $index = $tokens->getNextTokenOfKind($index, ['{']);
                $index = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_BRACE, $index);

                continue;
            }

            if (!$token->isGivenKind(\T_CONST)) {
                continue;
            }

            $index = $tokens->getNextMeaningfulToken($index);
            $other[$tokens[$index]->getContent()] = true;
        }

        $analyzer = new TokensAnalyzer($tokens);
        $indices = [];

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

            if (!$token->isGivenKind(\T_STRING)) {
                continue;
            }

            $name = $token->getContent();

            if (isset($other[$name])) {
                continue;
            }

            if (!$analyzer->isConstantInvocation($index)) {
                continue;
            }

            $nsSeparatorIndex = $tokens->getPrevMeaningfulToken($index);
            if (!$tokens[$nsSeparatorIndex]->isGivenKind(\T_NS_SEPARATOR)) {
                if (!isset($global[$name])) {
                    // found an unqualified constant invocation
                    // add it to the not importable names (already used)
                    $other[$name] = true;
                }

                continue;
            }

            $prevIndex = $tokens->getPrevMeaningfulToken($nsSeparatorIndex);
            if ($tokens[$prevIndex]->isGivenKind([CT::T_NAMESPACE_OPERATOR, \T_STRING])) {
                continue;
            }

            $indices[] = $index;
        }

        return $this->prepareImports($tokens, $indices, $global, $other, true);
    }

    /**
     * @param list<NamespaceUseAnalysis> $useDeclarations
     *
     * @return array<non-empty-string, non-empty-string>
     */
    private function importFunctions(Tokens $tokens, array $useDeclarations): array
    {
        [$global, $other] = $this->filterUseDeclarations($useDeclarations, static fn (NamespaceUseAnalysis $declaration): bool => $declaration->isFunction(), false);

        // find function declarations
        // and add them to the not importable names (already used)
        foreach ($this->findFunctionDeclarations($tokens, 0, $tokens->count() - 1) as $name) {
            $other[strtolower($name)] = true;
        }

        $analyzer = new FunctionsAnalyzer();
        $indices = [];

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

            if (!$token->isGivenKind(\T_STRING)) {
                continue;
            }

            $name = strtolower($token->getContent());

            if (isset($other[$name])) {
                continue;
            }

            if (!$analyzer->isGlobalFunctionUsage($tokens, $index)) {
                continue;
            }

            $nsSeparatorIndex = $tokens->getPrevMeaningfulToken($index);
            if (!$tokens[$nsSeparatorIndex]->isGivenKind(\T_NS_SEPARATOR)) {
                if (!isset($global[$name])) {
                    $other[$name] = true;
                }

                continue;
            }

            $indices[] = $index;
        }

        return $this->prepareImports($tokens, $indices, $global, $other, false);
    }

    /**
     * @param list<NamespaceUseAnalysis> $useDeclarations
     *
     * @return array<non-empty-string, non-empty-string>
     */
    private function importClasses(Tokens $tokens, array $useDeclarations): array
    {
        [$global, $other] = $this->filterUseDeclarations($useDeclarations, static fn (NamespaceUseAnalysis $declaration): bool => $declaration->isClass(), false);

        /** @var array<int, DocBlock> $docBlocks */
        $docBlocks = [];

        // find class declarations and class usages in docblocks
        // and add them to the not importable names (already used)
        for ($index = 0, $count = $tokens->count(); $index < $count; ++$index) {
            $token = $tokens[$index];

            if ($token->isGivenKind(\T_DOC_COMMENT)) {
                $docBlocks[$index] = new DocBlock($token->getContent());

                $this->traverseDocBlockTypes($docBlocks[$index], static function (string $type) use ($global, &$other): void {
                    if (str_contains($type, '\\')) {
                        return;
                    }

                    $name = strtolower($type);

                    if (!isset($global[$name])) {
                        $other[$name] = true;
                    }
                });
            }

            if (!$token->isClassy()) {
                continue;
            }

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

            if ($tokens[$index]->isGivenKind(\T_STRING)) {
                $other[strtolower($tokens[$index]->getContent())] = true;
            }
        }

        $analyzer = new ClassyAnalyzer();
        $indices = [];

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

            if (!$token->isGivenKind(\T_STRING)) {
                continue;
            }

            $name = strtolower($token->getContent());

            if (isset($other[$name])) {
                continue;
            }

            if (!$analyzer->isClassyInvocation($tokens, $index)) {
                continue;
            }

            $nsSeparatorIndex = $tokens->getPrevMeaningfulToken($index);
            if (!$tokens[$nsSeparatorIndex]->isGivenKind(\T_NS_SEPARATOR)) {
                if (!isset($global[$name])) {
                    $other[$name] = true;
                }

                continue;
            }

            if ($tokens[$tokens->getPrevMeaningfulToken($nsSeparatorIndex)]->isGivenKind([CT::T_NAMESPACE_OPERATOR, \T_STRING])) {
                continue;
            }

            $indices[] = $index;
        }

        $imports = [];

        foreach ($docBlocks as $index => $docBlock) {
            $changed = $this->traverseDocBlockTypes($docBlock, static function (string $type) use ($global, $other, &$imports): string {
                if ('\\' !== $type[0]) {
                    return $type;
                }

                /** @var non-empty-string $name */
                $name = substr($type, 1);

                $checkName = strtolower($name);

                if (str_contains($checkName, '\\') || isset($other[$checkName])) {
                    return $type;
                }

                if (isset($global[$checkName])) {
                    return \is_string($global[$checkName]) ? $global[$checkName] : $name;
                }

                $imports[$checkName] = $name;

                return $name;
            });

            if ($changed) {
                $tokens[$index] = new Token([\T_DOC_COMMENT, $docBlock->getContent()]);
            }
        }

        return array_merge($imports, $this->prepareImports($tokens, $indices, $global, $other, false));
    }

    /**
     * Removes the leading slash at the given indices (when the name is not already used).
     *
     * @param list<int>                  $indices
     * @param array<string, string|true> $global
     * @param array<string, true>        $other
     *
     * @return array<non-empty-string, non-empty-string> array keys contain the names that must be imported
     */
    private function prepareImports(Tokens $tokens, array $indices, array $global, array $other, bool $caseSensitive): array
    {
        $imports = [];

        foreach ($indices as $index) {
            /** @var non-empty-string $name */
            $name = $tokens[$index]->getContent();
            $checkName = $caseSensitive ? $name : strtolower($name);

            if (isset($other[$checkName])) {
                continue;
            }

            if (!isset($global[$checkName])) {
                $imports[$checkName] = $name;
            } elseif (\is_string($global[$checkName])) {
                $tokens[$index] = new Token([\T_STRING, $global[$checkName]]);
            }

            $tokens->clearAt($tokens->getPrevMeaningfulToken($index));
        }

        return $imports;
    }

    /**
     * @param list<NamespaceUseAnalysis> $useDeclarations
     */
    private function fullyQualifyConstants(Tokens $tokens, array $useDeclarations): void
    {
        if (!$tokens->isTokenKindFound(CT::T_CONST_IMPORT)) {
            return;
        }

        [$global] = $this->filterUseDeclarations($useDeclarations, static fn (NamespaceUseAnalysis $declaration): bool => $declaration->isConstant() && !$declaration->isAliased(), true);

        if ([] === $global) {
            return;
        }

        $analyzer = new TokensAnalyzer($tokens);

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

            if (!$token->isGivenKind(\T_STRING)) {
                continue;
            }

            if (!isset($global[$token->getContent()])) {
                continue;
            }

            if ($tokens[$tokens->getPrevMeaningfulToken($index)]->isGivenKind(\T_NS_SEPARATOR)) {
                continue;
            }

            if (!$analyzer->isConstantInvocation($index)) {
                continue;
            }

            $tokens->insertAt($index, new Token([\T_NS_SEPARATOR, '\\']));
        }
    }

    /**
     * @param list<NamespaceUseAnalysis> $useDeclarations
     */
    private function fullyQualifyFunctions(Tokens $tokens, array $useDeclarations): void
    {
        if (!$tokens->isTokenKindFound(CT::T_FUNCTION_IMPORT)) {
            return;
        }

        [$global] = $this->filterUseDeclarations($useDeclarations, static fn (NamespaceUseAnalysis $declaration): bool => $declaration->isFunction() && !$declaration->isAliased(), false);

        if ([] === $global) {
            return;
        }

        $analyzer = new FunctionsAnalyzer();

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

            if (!$token->isGivenKind(\T_STRING)) {
                continue;
            }

            if (!isset($global[strtolower($token->getContent())])) {
                continue;
            }

            if ($tokens[$tokens->getPrevMeaningfulToken($index)]->isGivenKind(\T_NS_SEPARATOR)) {
                continue;
            }

            if (!$analyzer->isGlobalFunctionUsage($tokens, $index)) {
                continue;
            }

            $tokens->insertAt($index, new Token([\T_NS_SEPARATOR, '\\']));
        }
    }

    /**
     * @param list<NamespaceUseAnalysis> $useDeclarations
     */
    private function fullyQualifyClasses(Tokens $tokens, array $useDeclarations): void
    {
        if (!$tokens->isTokenKindFound(\T_USE)) {
            return;
        }

        [$global] = $this->filterUseDeclarations($useDeclarations, static fn (NamespaceUseAnalysis $declaration): bool => $declaration->isClass() && !$declaration->isAliased(), false);

        if ([] === $global) {
            return;
        }

        $analyzer = new ClassyAnalyzer();

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

            if ($token->isGivenKind(\T_DOC_COMMENT)) {
                $doc = new DocBlock($token->getContent());

                $changed = $this->traverseDocBlockTypes($doc, static function (string $type) use ($global): string {
                    if (!isset($global[strtolower($type)])) {
                        return $type;
                    }

                    return '\\'.$type;
                });

                if ($changed) {
                    $tokens[$index] = new Token([\T_DOC_COMMENT, $doc->getContent()]);
                }

                continue;
            }

            if (!$token->isGivenKind(\T_STRING)) {
                continue;
            }

            if (!isset($global[strtolower($token->getContent())])) {
                continue;
            }

            if ($tokens[$tokens->getPrevMeaningfulToken($index)]->isGivenKind(\T_NS_SEPARATOR)) {
                continue;
            }

            if (!$analyzer->isClassyInvocation($tokens, $index)) {
                continue;
            }

            $tokens->insertAt($index, new Token([\T_NS_SEPARATOR, '\\']));
        }
    }

    /**
     * @param list<NamespaceUseAnalysis> $declarations
     *
     * @return array{0: array<string, string|true>, 1: array<string, true>}
     */
    private function filterUseDeclarations(array $declarations, callable $callback, bool $caseSensitive): array
    {
        $global = [];
        $other = [];

        foreach ($declarations as $declaration) {
            if (!$callback($declaration)) {
                continue;
            }

            $fullName = ltrim($declaration->getFullName(), '\\');

            if (str_contains($fullName, '\\')) {
                $name = $caseSensitive ? $declaration->getShortName() : strtolower($declaration->getShortName());
                $other[$name] = true;

                continue;
            }

            $checkName = $caseSensitive ? $fullName : strtolower($fullName);
            $alias = $declaration->getShortName();
            $global[$checkName] = $alias === $fullName ? true : $alias;
        }

        return [$global, $other];
    }

    /**
     * @return iterable<string>
     */
    private function findFunctionDeclarations(Tokens $tokens, int $start, int $end): iterable
    {
        for ($index = $start; $index <= $end; ++$index) {
            $token = $tokens[$index];

            if ($token->isClassy()) {
                $classStart = $tokens->getNextTokenOfKind($index, ['{']);
                $classEnd = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_BRACE, $classStart);

                for ($index = $classStart; $index <= $classEnd; ++$index) {
                    if (!$tokens[$index]->isGivenKind(\T_FUNCTION)) {
                        continue;
                    }

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

                    if ($tokens[$methodStart]->equals(';')) {
                        $index = $methodStart;

                        continue;
                    }

                    $methodEnd = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_BRACE, $methodStart);

                    foreach ($this->findFunctionDeclarations($tokens, $methodStart, $methodEnd) as $function) {
                        yield $function;
                    }

                    $index = $methodEnd;
                }

                continue;
            }

            if (!$token->isGivenKind(\T_FUNCTION)) {
                continue;
            }

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

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

            if ($tokens[$index]->isGivenKind(\T_STRING)) {
                yield $tokens[$index]->getContent();
            }
        }
    }

    private function traverseDocBlockTypes(DocBlock $doc, callable $callback): bool
    {
        $annotations = $doc->getAnnotationsOfType(Annotation::TAGS_WITH_TYPES);

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

        $changed = false;

        foreach ($annotations as $annotation) {
            $types = $annotation->getTypes();

            $new = array_map(static function (string $fullType) use ($callback): string {
                Preg::matchAll('/[\\\\\w]+(?![\\\\\w:])/', $fullType, $matches, \PREG_OFFSET_CAPTURE);

                foreach (array_reverse($matches[0]) as [$type, $offset]) {
                    $newType = $callback($type);

                    if (null !== $newType && $type !== $newType) {
                        $fullType = substr_replace($fullType, $newType, $offset, \strlen($type));
                    }
                }

                return $fullType;
            }, $types);

            if ($types !== $new) {
                $annotation->setTypes($new);
                $changed = true;
            }
        }

        return $changed;
    }
}
