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

use PhpCsFixer\Future;

/**
 * Representation of single token.
 * As a token prototype you should understand a single element generated by token_get_all.
 * Also, this class exposes PHPStan types. (hint: string in those types shall ideally not be empty - yet we are not there yet).
 *
 * @phpstan-type _PhpTokenKind int|string
 * @phpstan-type _PhpTokenArray array{0: int, 1: string}
 * @phpstan-type _PhpTokenArrayPartial array{0: int, 1?: string}
 * @phpstan-type _PhpTokenPrototype _PhpTokenArray|string
 * @phpstan-type _PhpTokenPrototypePartial _PhpTokenArrayPartial|string
 *
 * @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
 *
 * @readonly
 *
 * @no-named-arguments Parameter names are not covered by the backward compatibility promise.
 */
final class Token
{
    /**
     * Content of token prototype.
     */
    private string $content;

    /**
     * ID of token prototype, if available.
     */
    private ?int $id;

    /**
     * If token prototype is an array.
     */
    private bool $isArray;

    /**
     * @param _PhpTokenPrototype $token token prototype
     */
    public function __construct($token)
    {
        if (\is_array($token)) {
            if (!\is_int($token[0])) {
                throw new \InvalidArgumentException(\sprintf(
                    'Id must be an int, got "%s".',
                    get_debug_type($token[0]),
                ));
            }

            if (!\is_string($token[1])) {
                throw new \InvalidArgumentException(\sprintf(
                    'Content must be a string, got "%s".',
                    get_debug_type($token[1]),
                ));
            }

            if ('' === $token[1]) {
                throw new \InvalidArgumentException('Cannot set empty content for id-based Token.');
            }

            $this->isArray = true;
            $this->id = $token[0];
            $this->content = $token[1];
        } elseif (\is_string($token)) {
            $this->isArray = false;
            $this->id = null;
            $this->content = $token;
        } else {
            throw new \InvalidArgumentException(\sprintf('Cannot recognize input value as valid Token prototype, got "%s".', get_debug_type($token)));
        }
    }

    /**
     * @return non-empty-list<int>
     */
    public static function getCastTokenKinds(): array
    {
        return [\T_ARRAY_CAST, \T_BOOL_CAST, \T_DOUBLE_CAST, \T_INT_CAST, \T_OBJECT_CAST, \T_STRING_CAST, \T_UNSET_CAST, FCT::T_VOID_CAST];
    }

    /**
     * Get classy tokens kinds: T_ENUM, T_CLASS, T_INTERFACE and T_TRAIT.
     *
     * @return non-empty-list<int>
     */
    public static function getClassyTokenKinds(): array
    {
        return [\T_CLASS, \T_TRAIT, \T_INTERFACE, FCT::T_ENUM];
    }

    /**
     * Get object operator tokens kinds: T_OBJECT_OPERATOR and (if available) T_NULLSAFE_OBJECT_OPERATOR.
     *
     * @return non-empty-list<int>
     */
    public static function getObjectOperatorKinds(): array
    {
        return [\T_OBJECT_OPERATOR, FCT::T_NULLSAFE_OBJECT_OPERATOR];
    }

    /**
     * Check if token is equals to given one.
     *
     * If tokens are arrays, then only keys defined in parameter token are checked.
     *
     * @param _PhpTokenPrototypePartial|Token $other         token or it's prototype
     * @param bool                            $caseSensitive perform a case sensitive comparison
     */
    public function equals($other, bool $caseSensitive = true): bool
    {
        if ('&' === $other) {
            return '&' === $this->content && (null === $this->id || $this->isGivenKind([FCT::T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG, FCT::T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG]));
        }
        if (null === $this->id && '&' === $this->content) {
            return $other instanceof self && '&' === $other->content && (null === $other->id || $other->isGivenKind([FCT::T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG, FCT::T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG]));
        }

        if ($other instanceof self) {
            // Inlined getPrototype() on this very hot path.
            // We access the private properties of $other directly to save function call overhead.
            // This is only possible because $other is of the same class as `self`.
            if (!$other->isArray) {
                $otherPrototype = $other->content;
            } else {
                $otherPrototype = [
                    $other->id,
                    $other->content,
                ];
            }
        } else {
            $otherPrototype = $other;
        }

        if ($this->isArray !== \is_array($otherPrototype)) {
            return false;
        }

        if (!$this->isArray) {
            return $this->content === $otherPrototype;
        }

        if ($this->id !== $otherPrototype[0]) {
            return false;
        }

        if (isset($otherPrototype[1])) {
            if ($caseSensitive) {
                if ($this->content !== $otherPrototype[1]) {
                    return false;
                }
            } elseif (0 !== strcasecmp($this->content, $otherPrototype[1])) {
                return false;
            }
        }

        // detect unknown keys
        unset($otherPrototype[0], $otherPrototype[1]);

        return [] === $otherPrototype;
    }

    /**
     * Check if token is equals to one of given.
     *
     * @param list<_PhpTokenPrototypePartial|Token> $others        array of tokens or token prototypes
     * @param bool                                  $caseSensitive perform a case sensitive comparison
     */
    public function equalsAny(array $others, bool $caseSensitive = true): bool
    {
        foreach ($others as $other) {
            if ($this->equals($other, $caseSensitive)) {
                return true;
            }
        }

        return false;
    }

    /**
     * A helper method used to find out whether a certain input token has to be case-sensitively matched.
     *
     * @param array<int, bool>|bool $caseSensitive global case sensitiveness or an array of booleans, whose keys should match
     *                                             the ones used in $sequence. If any is missing, the default case-sensitive
     *                                             comparison is used
     * @param int                   $key           the key of the token that has to be looked up
     *
     * @deprecated
     */
    public static function isKeyCaseSensitive($caseSensitive, int $key): bool
    {
        Future::triggerDeprecation(new \InvalidArgumentException(\sprintf(
            'Method "%s" is deprecated and will be removed in the next major version.',
            __METHOD__,
        )));

        if (\is_array($caseSensitive)) {
            return $caseSensitive[$key] ?? true;
        }

        return $caseSensitive;
    }

    /**
     * @return array{int, non-empty-string}|string
     */
    public function getPrototype()
    {
        if (!$this->isArray) {
            return $this->content;
        }

        \assert('' !== $this->content);

        return [
            $this->id,
            $this->content,
        ];
    }

    /**
     * Get token's content.
     *
     * It shall be used only for getting the content of token, not for checking it against excepted value.
     */
    public function getContent(): string
    {
        return $this->content;
    }

    /**
     * Get token's id.
     *
     * It shall be used only for getting the internal id of token, not for checking it against excepted value.
     */
    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * Get token's name.
     *
     * It shall be used only for getting the name of token, not for checking it against excepted value.
     *
     * @return null|non-empty-string token name
     */
    public function getName(): ?string
    {
        if (null === $this->id) {
            return null;
        }

        return self::getNameForId($this->id);
    }

    /**
     * Get token's name.
     *
     * It shall be used only for getting the name of token, not for checking it against excepted value.
     *
     * @return null|non-empty-string token name
     */
    public static function getNameForId(int $id): ?string
    {
        if (CT::has($id)) {
            return CT::getName($id);
        }

        $name = token_name($id);

        return 'UNKNOWN' === $name ? null : $name;
    }

    /**
     * Generate array containing all keywords that exists in PHP version in use.
     *
     * @return non-empty-list<int>
     */
    public static function getKeywords(): array
    {
        static $keywords = null;

        if (null === $keywords) {
            $keywords = self::getTokenKindsForNames(['T_ABSTRACT', 'T_ARRAY', 'T_AS', 'T_BREAK', 'T_CALLABLE', 'T_CASE',
                'T_CATCH', 'T_CLASS', 'T_CLONE', 'T_CONST', 'T_CONTINUE', 'T_DECLARE', 'T_DEFAULT', 'T_DO',
                'T_ECHO', 'T_ELSE', 'T_ELSEIF', 'T_EMPTY', 'T_ENDDECLARE', 'T_ENDFOR', 'T_ENDFOREACH',
                'T_ENDIF', 'T_ENDSWITCH', 'T_ENDWHILE', 'T_EVAL', 'T_EXIT', 'T_EXTENDS', 'T_FINAL',
                'T_FINALLY', 'T_FN', 'T_FOR', 'T_FOREACH', 'T_FUNCTION', 'T_GLOBAL', 'T_GOTO', 'T_HALT_COMPILER',
                'T_IF', 'T_IMPLEMENTS', 'T_INCLUDE', 'T_INCLUDE_ONCE', 'T_INSTANCEOF', 'T_INSTEADOF',
                'T_INTERFACE', 'T_ISSET', 'T_LIST', 'T_LOGICAL_AND', 'T_LOGICAL_OR', 'T_LOGICAL_XOR',
                'T_NAMESPACE', 'T_NEW', 'T_PRINT', 'T_PRIVATE', 'T_PROTECTED', 'T_PUBLIC', 'T_REQUIRE',
                'T_REQUIRE_ONCE', 'T_RETURN', 'T_STATIC', 'T_SWITCH', 'T_THROW', 'T_TRAIT', 'T_TRY',
                'T_UNSET', 'T_USE', 'T_VAR', 'T_WHILE', 'T_YIELD', 'T_YIELD_FROM',
            ]) + [
                CT::T_ARRAY_TYPEHINT => CT::T_ARRAY_TYPEHINT,
                CT::T_CLASS_CONSTANT => CT::T_CLASS_CONSTANT,
                CT::T_CONST_IMPORT => CT::T_CONST_IMPORT,
                CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PRIVATE => CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PRIVATE,
                CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PROTECTED => CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PROTECTED,
                CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PUBLIC => CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PUBLIC,
                CT::T_FUNCTION_IMPORT => CT::T_FUNCTION_IMPORT,
                CT::T_NAMESPACE_OPERATOR => CT::T_NAMESPACE_OPERATOR,
                CT::T_USE_LAMBDA => CT::T_USE_LAMBDA,
                CT::T_USE_TRAIT => CT::T_USE_TRAIT,
                FCT::T_ENUM => FCT::T_ENUM,
                FCT::T_MATCH => FCT::T_MATCH,
                FCT::T_PRIVATE_SET => FCT::T_PRIVATE_SET,
                FCT::T_PROTECTED_SET => FCT::T_PROTECTED_SET,
                FCT::T_PUBLIC_SET => FCT::T_PUBLIC_SET,
                FCT::T_READONLY => FCT::T_READONLY,
            ];
        }

        return $keywords;
    }

    /**
     * Generate array containing all predefined constants that exists in PHP version in use.
     *
     * @return non-empty-array<int, int>
     *
     * @see https://php.net/manual/en/language.constants.predefined.php
     */
    public static function getMagicConstants(): array
    {
        static $magicConstants = null;

        if (null === $magicConstants) {
            $magicConstants = self::getTokenKindsForNames(['T_CLASS_C', 'T_DIR', 'T_FILE', 'T_FUNC_C', 'T_LINE', 'T_METHOD_C', 'T_NS_C', 'T_TRAIT_C']);
        }

        return $magicConstants;
    }

    /**
     * Check if token prototype is an array.
     *
     * @return bool is array
     *
     * @phpstan-assert-if-true !=null $this->getId()
     * @phpstan-assert-if-true !='' $this->getContent()
     */
    public function isArray(): bool
    {
        return $this->isArray;
    }

    /**
     * Check if token is one of type cast tokens.
     *
     * @phpstan-assert-if-true !='' $this->getContent()
     */
    public function isCast(): bool
    {
        return $this->isGivenKind(self::getCastTokenKinds());
    }

    /**
     * Check if token is one of classy tokens: T_CLASS, T_INTERFACE, T_TRAIT or T_ENUM.
     *
     * @phpstan-assert-if-true !='' $this->getContent()
     */
    public function isClassy(): bool
    {
        return $this->isGivenKind(self::getClassyTokenKinds());
    }

    /**
     * Check if token is one of comment tokens: T_COMMENT or T_DOC_COMMENT.
     *
     * @phpstan-assert-if-true !='' $this->getContent()
     */
    public function isComment(): bool
    {
        return $this->isGivenKind([\T_COMMENT, \T_DOC_COMMENT]);
    }

    /**
     * Check if token is one of object operator tokens: T_OBJECT_OPERATOR or T_NULLSAFE_OBJECT_OPERATOR.
     *
     * @phpstan-assert-if-true !='' $this->getContent()
     */
    public function isObjectOperator(): bool
    {
        return $this->isGivenKind(self::getObjectOperatorKinds());
    }

    /**
     * Check if token is one of given kind.
     *
     * @param int|list<int> $possibleKind kind or array of kinds
     *
     * @phpstan-assert-if-true !=null $this->getId()
     * @phpstan-assert-if-true !='' $this->getContent()
     */
    public function isGivenKind($possibleKind): bool
    {
        return $this->isArray && (\is_array($possibleKind) ? \in_array($this->id, $possibleKind, true) : $this->id === $possibleKind);
    }

    /**
     * Check if token is a keyword.
     *
     * @phpstan-assert-if-true !='' $this->getContent()
     */
    public function isKeyword(): bool
    {
        $keywords = self::getKeywords();

        return $this->isArray && isset($keywords[$this->id]);
    }

    /**
     * Check if token is a native PHP constant: true, false or null.
     *
     * @phpstan-assert-if-true !='' $this->getContent()
     */
    public function isNativeConstant(): bool
    {
        return $this->isArray && \in_array(strtolower($this->content), ['true', 'false', 'null'], true);
    }

    /**
     * Returns if the token is of a Magic constants type.
     *
     * @phpstan-assert-if-true !='' $this->getContent()
     *
     * @see https://php.net/manual/en/language.constants.predefined.php
     */
    public function isMagicConstant(): bool
    {
        $magicConstants = self::getMagicConstants();

        return $this->isArray && isset($magicConstants[$this->id]);
    }

    /**
     * Check if token is whitespace.
     *
     * @param null|string $whitespaces whitespace characters, default is " \t\n\r\0\x0B"
     */
    public function isWhitespace(?string $whitespaces = " \t\n\r\0\x0B"): bool
    {
        if (null === $whitespaces) {
            $whitespaces = " \t\n\r\0\x0B";
        }

        if ($this->isArray && !$this->isGivenKind(\T_WHITESPACE)) {
            return false;
        }

        return '' === trim($this->content, $whitespaces);
    }

    /**
     * @return array{
     *     id: null|int,
     *     name: null|non-empty-string,
     *     content: string,
     *     isArray: bool,
     *     changed: bool,
     * }
     */
    public function toArray(): array
    {
        return [
            'id' => $this->id,
            'name' => $this->getName(),
            'content' => $this->content,
            'isArray' => $this->isArray,
            'changed' => false, // @TODO v4: remove index
        ];
    }

    /**
     * @return non-empty-string
     */
    public function toJson(): string
    {
        try {
            return json_encode($this->toArray(), \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_NUMERIC_CHECK);
        } catch (\JsonException $e) {
            return json_encode(
                [
                    'errorDescription' => 'Cannot encode Tokens to JSON.',
                    'rawErrorMessage' => $e->getMessage(),
                ],
                \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_NUMERIC_CHECK,
            );
        }
    }

    /**
     * @param non-empty-list<string> $tokenNames
     *
     * @return non-empty-array<int, int>
     */
    private static function getTokenKindsForNames(array $tokenNames): array
    {
        $keywords = [];
        foreach ($tokenNames as $keywordName) {
            $keyword = \constant($keywordName);
            $keywords[$keyword] = $keyword;
        }

        return $keywords;
    }
}
