<?php
/**
 * PHPCSUtils, utility functions and classes for PHP_CodeSniffer sniff developers.
 *
 * @package   PHPCSUtils
 * @copyright 2019-2020 PHPCSUtils Contributors
 * @license   https://opensource.org/licenses/LGPL-3.0 LGPL3
 * @link      https://github.com/PHPCSStandards/PHPCSUtils
 */

namespace PHPCSUtils\Utils;

use PHP_CodeSniffer\Exceptions\RuntimeException;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Util\Tokens;
use PHPCSUtils\BackCompat\BCFile;
use PHPCSUtils\Internal\Cache;
use PHPCSUtils\Tokens\Collections;
use PHPCSUtils\Utils\Conditions;
use PHPCSUtils\Utils\GetTokensAsString;
use PHPCSUtils\Utils\Parentheses;

/**
 * Utility functions for use when examining T_NAMESPACE tokens and to determine the
 * namespace of arbitrary tokens.
 *
 * @link https://www.php.net/language.namespaces PHP Manual on namespaces.
 *
 * @since 1.0.0
 */
final class Namespaces
{

    /**
     * Determine what a T_NAMESPACE token is used for.
     *
     * @since 1.0.0
     *
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
     * @param int                         $stackPtr  The position of the `T_NAMESPACE` token.
     *
     * @return string Either `'declaration'`, `'operator'` or an empty string.
     *                An empty string will be returned if it couldn't be
     *                reliably determined what the `T_NAMESPACE` token is used for,
     *                which, in most cases, will mean the code contains a parse/fatal error.
     *
     * @throws \PHP_CodeSniffer\Exceptions\RuntimeException If the specified position is
     *                                                      not a `T_NAMESPACE` token.
     */
    public static function getType(File $phpcsFile, $stackPtr)
    {
        static $findAfter;

        if (isset($findAfter) === false) {
            /*
             * Set up array of tokens which can only be used in combination with the keyword as operator
             * and which cannot be confused with other keywords.
             */
            $findAfter = Tokens::$assignmentTokens
                + Tokens::$comparisonTokens
                + Tokens::$operators
                + Tokens::$castTokens
                + Tokens::$blockOpeners
                + Collections::incrementDecrementOperators()
                + Collections::objectOperators()
                + Collections::shortArrayListOpenTokensBC();

            $findAfter[\T_OPEN_CURLY_BRACKET] = \T_OPEN_CURLY_BRACKET;
        }

        $tokens = $phpcsFile->getTokens();

        if (isset($tokens[$stackPtr]) === false || $tokens[$stackPtr]['code'] !== \T_NAMESPACE) {
            throw new RuntimeException('$stackPtr must be of type T_NAMESPACE');
        }

        $next = $phpcsFile->findNext(Tokens::$emptyTokens, ($stackPtr + 1), null, true);
        if ($next === false) {
            // Live coding or parse error.
            return '';
        }

        if (empty($tokens[$stackPtr]['conditions']) === false
            || empty($tokens[$stackPtr]['nested_parenthesis']) === false
        ) {
            /*
             * Namespace declarations are only allowed at top level, so this can definitely not
             * be a namespace declaration.
             */
            if ($tokens[$next]['code'] === \T_NS_SEPARATOR) {
                return 'operator';
            }

            return '';
        }

        $start = BCFile::findStartOfStatement($phpcsFile, $stackPtr);
        if ($start === $stackPtr
            && ($tokens[$next]['code'] === \T_STRING
               || $tokens[$next]['code'] === \T_NAME_QUALIFIED
               || $tokens[$next]['code'] === \T_OPEN_CURLY_BRACKET)
        ) {
            return 'declaration';
        }

        if (($tokens[$next]['code'] === \T_NS_SEPARATOR
            || $tokens[$next]['code'] === \T_NAME_FULLY_QUALIFIED) // PHP 8.0 parse error.
            && ($start !== $stackPtr
                || $phpcsFile->findNext($findAfter, ($stackPtr + 1), null, false, null, true) !== false)
        ) {
            return 'operator';
        }

        return '';
    }

    /**
     * Determine whether a T_NAMESPACE token is the keyword for a namespace declaration.
     *
     * @since 1.0.0
     *
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
     * @param int                         $stackPtr  The position of a `T_NAMESPACE` token.
     *
     * @return bool `TRUE` if the token passed is the keyword for a namespace declaration.
     *              `FALSE` if not.
     *
     * @throws \PHP_CodeSniffer\Exceptions\RuntimeException If the specified position is
     *                                                      not a `T_NAMESPACE` token.
     */
    public static function isDeclaration(File $phpcsFile, $stackPtr)
    {
        return (self::getType($phpcsFile, $stackPtr) === 'declaration');
    }

    /**
     * Determine whether a T_NAMESPACE token is used as an operator.
     *
     * @link https://www.php.net/language.namespaces.nsconstants PHP Manual about the use of the
     *                                                           namespace keyword as an operator.
     *
     * @since 1.0.0
     *
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
     * @param int                         $stackPtr  The position of a `T_NAMESPACE` token.
     *
     * @return bool `TRUE` if the namespace token passed is used as an operator. `FALSE` if not.
     *
     * @throws \PHP_CodeSniffer\Exceptions\RuntimeException If the specified position is
     *                                                      not a `T_NAMESPACE` token.
     */
    public static function isOperator(File $phpcsFile, $stackPtr)
    {
        return (self::getType($phpcsFile, $stackPtr) === 'operator');
    }

    /**
     * Get the complete namespace name as declared.
     *
     * For hierarchical namespaces, the namespace name will be composed of several tokens,
     * i.e. "MyProject\Sub\Level", which will be returned together as one string.
     *
     * @since 1.0.0
     *
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
     * @param int                         $stackPtr  The position of a `T_NAMESPACE` token.
     * @param bool                        $clean     Optional. Whether to get the name stripped
     *                                               of potentially interlaced whitespace and/or
     *                                               comments. Defaults to `true`.
     *
     * @return string|false The namespace name; or `FALSE` if the specified position is not a
     *                      `T_NAMESPACE` token, the token points to a namespace operator
     *                      or when parse errors are encountered/during live coding.
     *                      > Note: The name can be an empty string for a valid global
     *                      namespace declaration.
     */
    public static function getDeclaredName(File $phpcsFile, $stackPtr, $clean = true)
    {
        try {
            if (self::isDeclaration($phpcsFile, $stackPtr) === false) {
                // Not a namespace declaration.
                return false;
            }
        } catch (RuntimeException $e) {
            // Non-existent token or not a namespace keyword token.
            return false;
        }

        $endOfStatement = $phpcsFile->findNext(Collections::namespaceDeclarationClosers(), ($stackPtr + 1));
        if ($endOfStatement === false) {
            // Live coding or parse error.
            return false;
        }

        $next = $phpcsFile->findNext(Tokens::$emptyTokens, ($stackPtr + 1), ($endOfStatement + 1), true);
        if ($next === $endOfStatement) {
            // Declaration of global namespace. I.e.: namespace {}.
            // If not a scoped {} namespace declaration, no name/global declarations are invalid
            // and result in parse errors, but that's not our concern.
            return '';
        }

        if ($clean === false) {
            return \trim(GetTokensAsString::origContent($phpcsFile, $next, ($endOfStatement - 1)));
        }

        return \trim(GetTokensAsString::noEmpties($phpcsFile, $next, ($endOfStatement - 1)));
    }

    /**
     * Find the stack pointer to the namespace declaration applicable for an arbitrary token.
     *
     * Take note:
     * 1. When a namespace declaration token or a token which is part of the namespace
     *    name is passed to this method, the result will be `false` as technically, these tokens
     *    are not _within_ a namespace.
     * 2. This method has no opinion on whether the token passed is actually _subject_
     *    to namespacing.
     *
     * @since 1.0.0
     *
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
     * @param int                         $stackPtr  The token for which to determine
     *                                               the namespace.
     *
     * @return int|false Token pointer to the namespace keyword for the applicable namespace
     *                   declaration; or `FALSE` if it couldn't be determined or
     *                   if no namespace applies.
     */
    public static function findNamespacePtr(File $phpcsFile, $stackPtr)
    {
        $tokens = $phpcsFile->getTokens();

        // Check for the existence of the token.
        if (isset($tokens[$stackPtr]) === false) {
            return false;
        }

        // The namespace keyword in a namespace declaration is itself not namespaced.
        if ($tokens[$stackPtr]['code'] === \T_NAMESPACE
            && self::isDeclaration($phpcsFile, $stackPtr) === true
        ) {
            return false;
        }

        // Check for scoped namespace {}.
        $namespacePtr = Conditions::getCondition($phpcsFile, $stackPtr, \T_NAMESPACE);
        if ($namespacePtr !== false) {
            return $namespacePtr;
        }

        /*
         * Not in a scoped namespace, so let's see if we can find a non-scoped namespace instead.
         * Keeping in mind that:
         * - there can be multiple non-scoped namespaces in a file (bad practice, but is allowed);
         * - the namespace keyword can also be used as an operator;
         * - a non-named namespace resolves to the global namespace;
         * - and that namespace declarations can't be nested in anything, so we can skip over any
         *   nesting structures.
         */
        if (Cache::isCached($phpcsFile, __METHOD__, $stackPtr) === true) {
            return Cache::get($phpcsFile, __METHOD__, $stackPtr);
        }

        // Start by breaking out of any scoped structures this token is in.
        $prev           = $stackPtr;
        $firstCondition = Conditions::getFirstCondition($phpcsFile, $stackPtr);
        if ($firstCondition !== false) {
            $prev = $firstCondition;
        }

        // And break out of any surrounding parentheses as well.
        $firstParensOpener = Parentheses::getFirstOpener($phpcsFile, $prev);
        if ($firstParensOpener !== false) {
            $prev = $firstParensOpener;
        }

        $find        = [
            \T_NAMESPACE,
            \T_CLOSE_CURLY_BRACKET,
            \T_CLOSE_PARENTHESIS,
            \T_CLOSE_SHORT_ARRAY,
            \T_CLOSE_SQUARE_BRACKET,
            \T_DOC_COMMENT_CLOSE_TAG,
            \T_ATTRIBUTE_END,
        ];
        $returnValue = false;

        do {
            $prev = $phpcsFile->findPrevious($find, ($prev - 1));
            if ($prev === false) {
                break;
            }

            if ($tokens[$prev]['code'] === \T_CLOSE_CURLY_BRACKET) {
                // Stop if we encounter a scoped namespace declaration as we already know we're not in one.
                if (isset($tokens[$prev]['scope_condition']) === true
                    && $tokens[$tokens[$prev]['scope_condition']]['code'] === \T_NAMESPACE
                ) {
                    break;
                }

                // Skip over other scoped structures for efficiency.
                if (isset($tokens[$prev]['scope_condition']) === true) {
                    $prev = $tokens[$prev]['scope_condition'];
                } elseif (isset($tokens[$prev]['scope_opener']) === true) {
                    // Shouldn't be possible, but just in case.
                    $prev = $tokens[$prev]['scope_opener']; // @codeCoverageIgnore
                }

                continue;
            }

            // Skip over other nesting structures for efficiency.
            if (isset($tokens[$prev]['bracket_opener']) === true) {
                $prev = $tokens[$prev]['bracket_opener'];
                continue;
            }

            if (isset($tokens[$prev]['parenthesis_owner']) === true) {
                $prev = $tokens[$prev]['parenthesis_owner'];
                continue;
            } elseif (isset($tokens[$prev]['parenthesis_opener']) === true) {
                $prev = $tokens[$prev]['parenthesis_opener'];
                continue;
            }

            // Skip over potentially large attributes.
            if (isset($tokens[$prev]['attribute_opener'])) {
                $prev = $tokens[$prev]['attribute_opener'];
                continue;
            }

            // Skip over potentially large docblocks.
            if (isset($tokens[$prev]['comment_opener'])) {
                $prev = $tokens[$prev]['comment_opener'];
                continue;
            }

            // So this is a namespace keyword, check if it's a declaration.
            if ($tokens[$prev]['code'] === \T_NAMESPACE
                && self::isDeclaration($phpcsFile, $prev) === true
            ) {
                // Now make sure the token was not part of the declaration.
                $endOfStatement = $phpcsFile->findNext(Collections::namespaceDeclarationClosers(), ($prev + 1));
                if ($endOfStatement > $stackPtr) {
                    // Token is part of the declaration, return false.
                    break;
                }

                $returnValue = $prev;
                break;
            }
        } while (true);

        Cache::set($phpcsFile, __METHOD__, $stackPtr, $returnValue);
        return $returnValue;
    }

    /**
     * Determine the namespace name an arbitrary token lives in.
     *
     * Note: this method has no opinion on whether the token passed is actually _subject_
     * to namespacing.
     *
     * @since 1.0.0
     *
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
     * @param int                         $stackPtr  The token for which to determine
     *                                               the namespace.
     *
     * @return string Namespace name; or an empty string if the namespace couldn't be
     *                determined or when no namespace applies.
     */
    public static function determineNamespace(File $phpcsFile, $stackPtr)
    {
        $namespacePtr = self::findNamespacePtr($phpcsFile, $stackPtr);
        if ($namespacePtr === false) {
            return '';
        }

        $namespace = self::getDeclaredName($phpcsFile, $namespacePtr);
        if ($namespace !== false) {
            return $namespace;
        }

        return '';
    }
}
