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

use PhpCsFixer\AbstractFixer;
use PhpCsFixer\ConfigurationException\InvalidFixerConfigurationException;
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\PregException;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
use ECSPrefix202510\Symfony\Component\OptionsResolver\Options;
/**
 * @phpstan-type _AutogeneratedInputConfiguration array{
 *  comment_type?: 'PHPDoc'|'comment',
 *  header: string,
 *  location?: 'after_declare_strict'|'after_open',
 *  separate?: 'both'|'bottom'|'none'|'top',
 *  validator?: null|string,
 * }
 * @phpstan-type _AutogeneratedComputedConfiguration array{
 *  comment_type: 'PHPDoc'|'comment',
 *  header: string,
 *  location: 'after_declare_strict'|'after_open',
 *  separate: 'both'|'bottom'|'none'|'top',
 *  validator: null|string,
 * }
 *
 * @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
 *
 * @author Antonio J. García Lagar <aj@garcialagar.es>
 *
 * @no-named-arguments Parameter names are not covered by the backward compatibility promise.
 */
final class HeaderCommentFixer extends AbstractFixer implements ConfigurableFixerInterface, WhitespacesAwareFixerInterface
{
    /** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
    use ConfigurableFixerTrait;
    /**
     * @internal
     */
    public const HEADER_PHPDOC = 'PHPDoc';
    /**
     * @internal
     */
    public const HEADER_COMMENT = 'comment';
    public function getDefinition() : FixerDefinitionInterface
    {
        return new FixerDefinition('Add, replace or remove header comment.', [new CodeSample(<<<'PHP'
<?php

declare (strict_types=1);
namespace ECSPrefix202510\A\B;

echo 1;

PHP
, ['header' => 'Made with love.']), new CodeSample(<<<'PHP'
<?php

declare (strict_types=1);
namespace ECSPrefix202510\A\B;

echo 1;

PHP
, ['header' => 'Made with love.', 'comment_type' => self::HEADER_PHPDOC, 'location' => 'after_open', 'separate' => 'bottom']), new CodeSample(<<<'PHP'
<?php

declare (strict_types=1);
namespace ECSPrefix202510\A\B;

echo 1;

PHP
, ['header' => 'Made with love.', 'comment_type' => self::HEADER_COMMENT, 'location' => 'after_declare_strict']), new CodeSample(<<<'PHP'
<?php

declare (strict_types=1);
/*
* Made with love.
*
* Extra content.
*/
namespace ECSPrefix202510\A\B;

echo 1;

PHP
, ['header' => 'Made with love.', 'validator' => '/Made with love(?P<EXTRA>.*)??/s', 'comment_type' => self::HEADER_COMMENT, 'location' => 'after_declare_strict']), new CodeSample(<<<'PHP'
<?php

declare (strict_types=1);
/*
* Comment is not wanted here.
*/
namespace ECSPrefix202510\A\B;

echo 1;

PHP
, ['header' => ''])]);
    }
    public function isCandidate(Tokens $tokens) : bool
    {
        return $tokens->isMonolithicPhp() && !$tokens->isTokenKindFound(\T_OPEN_TAG_WITH_ECHO);
    }
    /**
     * {@inheritdoc}
     *
     * Must run before BlankLinesBeforeNamespaceFixer, SingleBlankLineBeforeNamespaceFixer, SingleLineCommentStyleFixer.
     * Must run after DeclareStrictTypesFixer, NoBlankLinesAfterPhpdocFixer.
     */
    public function getPriority() : int
    {
        // When this fixer is configured with ["separate" => "bottom", "comment_type" => "PHPDoc"]
        // and the target file has no namespace or declare() construct,
        // the fixed header comment gets trimmed by NoBlankLinesAfterPhpdocFixer if we run before it.
        return -30;
    }
    protected function applyFix(\SplFileInfo $file, Tokens $tokens) : void
    {
        $headerAsComment = $this->getHeaderAsComment();
        $location = $this->configuration['location'];
        $locationIndices = [];
        foreach (['after_open', 'after_declare_strict'] as $possibleLocation) {
            $locationIndex = $this->findHeaderCommentInsertionIndex($tokens, $possibleLocation);
            if (!isset($locationIndices[$locationIndex]) || $possibleLocation === $location) {
                $locationIndices[$locationIndex] = $possibleLocation;
            }
        }
        // pre-run to find existing comment, if dynamic content is allowed
        if (null !== $this->configuration['validator']) {
            foreach ($locationIndices as $possibleLocation) {
                // figure out where the comment should be placed
                $headerNewIndex = $this->findHeaderCommentInsertionIndex($tokens, $possibleLocation);
                // check if there is already a comment
                $headerCurrentIndex = $this->findHeaderCommentCurrentIndex($tokens, $headerAsComment, $headerNewIndex - 1);
                if (null === $headerCurrentIndex) {
                    continue;
                }
                $currentHeaderComment = $tokens[$headerCurrentIndex]->getContent();
                if ($this->doesTokenFulfillValidator($tokens[$headerCurrentIndex])) {
                    $headerAsComment = $currentHeaderComment;
                }
            }
        }
        foreach ($locationIndices as $possibleLocation) {
            // figure out where the comment should be placed
            $headerNewIndex = $this->findHeaderCommentInsertionIndex($tokens, $possibleLocation);
            // check if there is already a comment
            $headerCurrentIndex = $this->findHeaderCommentCurrentIndex($tokens, $headerAsComment, $headerNewIndex - 1);
            if (null === $headerCurrentIndex) {
                if ('' === $this->configuration['header'] || $possibleLocation !== $location) {
                    continue;
                }
                $this->insertHeader($tokens, $headerAsComment, $headerNewIndex);
                continue;
            }
            $currentHeaderComment = $tokens[$headerCurrentIndex]->getContent();
            $sameComment = $headerAsComment === $currentHeaderComment;
            $expectedLocation = $possibleLocation === $location;
            if (!$sameComment || !$expectedLocation) {
                if ($expectedLocation xor $sameComment) {
                    $this->removeHeader($tokens, $headerCurrentIndex);
                }
                if ('' === $this->configuration['header']) {
                    continue;
                }
                if ($possibleLocation === $location) {
                    $this->insertHeader($tokens, $headerAsComment, $headerNewIndex);
                }
                continue;
            }
            $this->fixWhiteSpaceAroundHeader($tokens, $headerCurrentIndex);
        }
    }
    protected function createConfigurationDefinition() : FixerConfigurationResolverInterface
    {
        $fixerName = $this->getName();
        return new FixerConfigurationResolver([(new FixerOptionBuilder('header', 'Proper header content.'))->setAllowedTypes(['string'])->setNormalizer(static function (Options $options, string $value) use($fixerName) : string {
            if ('' === \trim($value)) {
                return '';
            }
            if (\strpos($value, '*/') !== \false) {
                throw new InvalidFixerConfigurationException($fixerName, 'Cannot use \'*/\' in header.');
            }
            return $value;
        })->getOption(), (new FixerOptionBuilder('validator', 'RegEx validator for header content.'))->setAllowedTypes(['string', 'null'])->setNormalizer(static function (Options $options, ?string $value) use($fixerName) : ?string {
            if (null !== $value) {
                try {
                    Preg::match($value, '');
                } catch (PregException $exception) {
                    throw new InvalidFixerConfigurationException($fixerName, 'Provided RegEx is not valid.');
                }
            }
            return $value;
        })->setDefault(null)->getOption(), (new FixerOptionBuilder('comment_type', 'Comment syntax type.'))->setAllowedValues([self::HEADER_PHPDOC, self::HEADER_COMMENT])->setDefault(self::HEADER_COMMENT)->getOption(), (new FixerOptionBuilder('location', 'The location of the inserted header.'))->setAllowedValues(['after_open', 'after_declare_strict'])->setDefault('after_declare_strict')->getOption(), (new FixerOptionBuilder('separate', 'Whether the header should be separated from the file content with a new line.'))->setAllowedValues(['both', 'top', 'bottom', 'none'])->setDefault('both')->getOption()]);
    }
    private function doesTokenFulfillValidator(Token $token) : bool
    {
        if (null === $this->configuration['validator']) {
            throw new \LogicException(\sprintf("Cannot call '%s' method while missing config:validator.", __METHOD__));
        }
        $currentHeaderComment = $token->getContent();
        $lines = \implode("\n", \array_map(static function (string $line) : string {
            return ' *' === $line ? '' : (\strncmp($line, ' * ', \strlen(' * ')) === 0 ? (string) \substr($line, 3) : $line);
        }, \array_slice(\explode("\n", $currentHeaderComment), 1, -1)));
        return Preg::match($this->configuration['validator'], $lines);
    }
    /**
     * Enclose the given text in a comment block.
     */
    private function getHeaderAsComment() : string
    {
        $lineEnding = $this->whitespacesConfig->getLineEnding();
        $comment = (self::HEADER_COMMENT === $this->configuration['comment_type'] ? '/*' : '/**') . $lineEnding;
        $lines = \explode("\n", \str_replace("\r", '', $this->configuration['header']));
        foreach ($lines as $line) {
            $comment .= \rtrim(' * ' . $line) . $lineEnding;
        }
        return $comment . ' */';
    }
    private function findHeaderCommentCurrentIndex(Tokens $tokens, string $headerAsComment, int $headerNewIndex) : ?int
    {
        $index = $tokens->getNextNonWhitespace($headerNewIndex);
        if (null === $index || !$tokens[$index]->isComment()) {
            return null;
        }
        $next = $index + 1;
        if (!isset($tokens[$next]) || \in_array($this->configuration['separate'], ['top', 'none'], \true) || !$tokens[$index]->isGivenKind(\T_DOC_COMMENT)) {
            return $index;
        }
        if ($tokens[$next]->isWhitespace()) {
            if (!Preg::match('/^\\h*\\R\\h*$/D', $tokens[$next]->getContent())) {
                return $index;
            }
            ++$next;
        }
        if (!isset($tokens[$next]) || !$tokens[$next]->isClassy() && !$tokens[$next]->isGivenKind(\T_FUNCTION)) {
            return $index;
        }
        if ($headerAsComment === $tokens[$index]->getContent() || null !== $this->configuration['validator'] && $this->doesTokenFulfillValidator($tokens[$index])) {
            return $index;
        }
        return null;
    }
    /**
     * Find the index where the header comment must be inserted.
     */
    private function findHeaderCommentInsertionIndex(Tokens $tokens, string $location) : int
    {
        $openTagIndex = $tokens[0]->isGivenKind(\T_INLINE_HTML) ? 1 : 0;
        if ('after_open' === $location) {
            return $openTagIndex + 1;
        }
        $index = $tokens->getNextMeaningfulToken($openTagIndex);
        if (null === $index) {
            return $openTagIndex + 1;
            // file without meaningful tokens but an open tag, comment should always be placed directly after the open tag
        }
        if (!$tokens[$index]->isGivenKind(\T_DECLARE)) {
            return $openTagIndex + 1;
        }
        $next = $tokens->getNextMeaningfulToken($index);
        if (null === $next || !$tokens[$next]->equals('(')) {
            return $openTagIndex + 1;
        }
        $next = $tokens->getNextMeaningfulToken($next);
        if (null === $next || !$tokens[$next]->equals([\T_STRING, 'strict_types'], \false)) {
            return $openTagIndex + 1;
        }
        $next = $tokens->getNextMeaningfulToken($next);
        if (null === $next || !$tokens[$next]->equals('=')) {
            return $openTagIndex + 1;
        }
        $next = $tokens->getNextMeaningfulToken($next);
        if (null === $next || !$tokens[$next]->isGivenKind(\T_LNUMBER)) {
            return $openTagIndex + 1;
        }
        $next = $tokens->getNextMeaningfulToken($next);
        if (null === $next || !$tokens[$next]->equals(')')) {
            return $openTagIndex + 1;
        }
        $next = $tokens->getNextMeaningfulToken($next);
        if (null === $next || !$tokens[$next]->equals(';')) {
            // don't insert after close tag
            return $openTagIndex + 1;
        }
        return $next + 1;
    }
    private function fixWhiteSpaceAroundHeader(Tokens $tokens, int $headerIndex) : void
    {
        $lineEnding = $this->whitespacesConfig->getLineEnding();
        // fix lines after header comment
        if (('both' === $this->configuration['separate'] || 'bottom' === $this->configuration['separate']) && null !== $tokens->getNextMeaningfulToken($headerIndex)) {
            $expectedLineCount = 2;
        } else {
            $expectedLineCount = 1;
        }
        if ($headerIndex === \count($tokens) - 1) {
            $tokens->insertAt($headerIndex + 1, new Token([\T_WHITESPACE, \str_repeat($lineEnding, $expectedLineCount)]));
        } else {
            $lineBreakCount = $this->getLineBreakCount($tokens, $headerIndex, 1);
            if ($lineBreakCount < $expectedLineCount) {
                $missing = \str_repeat($lineEnding, $expectedLineCount - $lineBreakCount);
                if ($tokens[$headerIndex + 1]->isWhitespace()) {
                    $tokens[$headerIndex + 1] = new Token([\T_WHITESPACE, $missing . $tokens[$headerIndex + 1]->getContent()]);
                } else {
                    $tokens->insertAt($headerIndex + 1, new Token([\T_WHITESPACE, $missing]));
                }
            } elseif ($lineBreakCount > $expectedLineCount && $tokens[$headerIndex + 1]->isWhitespace()) {
                $newLinesToRemove = $lineBreakCount - $expectedLineCount;
                $tokens[$headerIndex + 1] = new Token([\T_WHITESPACE, Preg::replace("/^\\R{{$newLinesToRemove}}/", '', $tokens[$headerIndex + 1]->getContent())]);
            }
        }
        // fix lines before header comment
        $expectedLineCount = 'both' === $this->configuration['separate'] || 'top' === $this->configuration['separate'] ? 2 : 1;
        $prev = $tokens->getPrevNonWhitespace($headerIndex);
        $regex = '/\\h$/';
        if ($tokens[$prev]->isGivenKind(\T_OPEN_TAG) && Preg::match($regex, $tokens[$prev]->getContent())) {
            $tokens[$prev] = new Token([\T_OPEN_TAG, Preg::replace($regex, $lineEnding, $tokens[$prev]->getContent())]);
        }
        $lineBreakCount = $this->getLineBreakCount($tokens, $headerIndex, -1);
        if ($lineBreakCount < $expectedLineCount) {
            // because of the way the insert index was determined for header comment there cannot be an empty token here
            $tokens->insertAt($headerIndex, new Token([\T_WHITESPACE, \str_repeat($lineEnding, $expectedLineCount - $lineBreakCount)]));
        }
    }
    private function getLineBreakCount(Tokens $tokens, int $index, int $direction) : int
    {
        $whitespace = '';
        for ($index += $direction; isset($tokens[$index]); $index += $direction) {
            $token = $tokens[$index];
            if ($token->isWhitespace()) {
                $whitespace .= $token->getContent();
                continue;
            }
            if (-1 === $direction && $token->isGivenKind(\T_OPEN_TAG)) {
                $whitespace .= $token->getContent();
            }
            if ('' !== $token->getContent()) {
                break;
            }
        }
        return \substr_count($whitespace, "\n");
    }
    private function removeHeader(Tokens $tokens, int $index) : void
    {
        $prevIndex = $index - 1;
        $prevToken = $tokens[$prevIndex];
        $newlineRemoved = \false;
        if ($prevToken->isWhitespace()) {
            $content = $prevToken->getContent();
            if (Preg::match('/\\R/', $content)) {
                $newlineRemoved = \true;
            }
            $content = Preg::replace('/\\R?\\h*$/', '', $content);
            $tokens->ensureWhitespaceAtIndex($prevIndex, 0, $content);
        }
        $nextIndex = $index + 1;
        $nextToken = $tokens[$nextIndex] ?? null;
        if (!$newlineRemoved && null !== $nextToken && $nextToken->isWhitespace()) {
            $content = Preg::replace('/^\\R/', '', $nextToken->getContent());
            $tokens->ensureWhitespaceAtIndex($nextIndex, 0, $content);
        }
        $tokens->clearTokenAndMergeSurroundingWhitespace($index);
    }
    private function insertHeader(Tokens $tokens, string $headerAsComment, int $index) : void
    {
        $tokens->insertAt($index, new Token([self::HEADER_COMMENT === $this->configuration['comment_type'] ? \T_COMMENT : \T_DOC_COMMENT, $headerAsComment]));
        $this->fixWhiteSpaceAroundHeader($tokens, $index);
    }
}
