<?php
/**
 * PHPCompatibility, an external standard for PHP_CodeSniffer.
 *
 * @package   PHPCompatibility
 * @copyright 2012-2019 PHPCompatibility Contributors
 * @license   https://opensource.org/licenses/LGPL-3.0 LGPL3
 * @link      https://github.com/PHPCompatibility/PHPCompatibility
 */

namespace PHPCompatibility\Sniffs\Classes;

use PHPCompatibility\AbstractNewFeatureSniff;
use PHP_CodeSniffer_File as File;

/**
 * Detect use of new PHP native classes.
 *
 * The sniff analyses the following constructs to find usage of new classes:
 * - Class instantiation using the `new` keyword.
 * - (Anonymous) Class declarations to detect new classes being extended by userland classes.
 * - Static use of class properties, constants or functions using the double colon.
 * - Function/closure declarations to detect new classes used as parameter type declarations.
 * - Function/closure declarations to detect new classes used as return type declarations.
 * - Try/catch statements to detect new exception classes being caught.
 *
 * PHP version All
 *
 * @since 5.5
 * @since 5.6   Now extends the base `Sniff` class.
 * @since 7.1.0 Now extends the `AbstractNewFeatureSniff` class.
 */
class NewClassesSniff extends AbstractNewFeatureSniff
{

    /**
     * A list of new classes, not present in older versions.
     *
     * The array lists : version number with false (not present) or true (present).
     * If's sufficient to list the first version where the class appears.
     *
     * @since 5.5
     *
     * @var array(string => array(string => bool))
     */
    protected $newClasses = array(
        'ArrayObject' => array(
            '4.4' => false,
            '5.0' => true,
        ),
        'ArrayIterator' => array(
            '4.4' => false,
            '5.0' => true,
        ),
        'CachingIterator' => array(
            '4.4' => false,
            '5.0' => true,
        ),
        'DirectoryIterator' => array(
            '4.4' => false,
            '5.0' => true,
        ),
        'RecursiveDirectoryIterator' => array(
            '4.4' => false,
            '5.0' => true,
        ),
        'RecursiveIteratorIterator' => array(
            '4.4' => false,
            '5.0' => true,
        ),
        'php_user_filter' => array(
            '4.4' => false,
            '5.0' => true,
        ),
        'tidy' => array(
            '4.4' => false,
            '5.0' => true,
        ),

        'SimpleXMLElement' => array(
            '5.0.0' => false,
            '5.0.1' => true,
        ),
        'tidyNode' => array(
            '5.0.0' => false,
            '5.0.1' => true,
        ),

        'libXMLError' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'PDO' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'PDOStatement' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'AppendIterator' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'EmptyIterator' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'FilterIterator' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'InfiniteIterator' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'IteratorIterator' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'LimitIterator' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'NoRewindIterator' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'ParentIterator' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'RecursiveArrayIterator' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'RecursiveCachingIterator' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'RecursiveFilterIterator' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'SimpleXMLIterator' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'SplFileObject' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'XMLReader' => array(
            '5.0' => false,
            '5.1' => true,
        ),

        'SplFileInfo' => array(
            '5.1.1' => false,
            '5.1.2' => true,
        ),
        'SplTempFileObject' => array(
            '5.1.1' => false,
            '5.1.2' => true,
        ),
        'XMLWriter' => array(
            '5.1.1' => false,
            '5.1.2' => true,
        ),

        'DateTime' => array(
            '5.1' => false,
            '5.2' => true,
        ),
        'DateTimeZone' => array(
            '5.1' => false,
            '5.2' => true,
        ),
        'RegexIterator' => array(
            '5.1' => false,
            '5.2' => true,
        ),
        'RecursiveRegexIterator' => array(
            '5.1' => false,
            '5.2' => true,
        ),
        'ReflectionFunctionAbstract' => array(
            '5.1' => false,
            '5.2' => true,
        ),
        'ZipArchive' => array(
            '5.1' => false,
            '5.2' => true,
        ),

        'Closure' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'DateInterval' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'DatePeriod' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'finfo' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'Collator' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'NumberFormatter' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'Locale' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'Normalizer' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'MessageFormatter' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'IntlDateFormatter' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'Phar' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'PharData' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'PharFileInfo' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'FilesystemIterator' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'GlobIterator' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'MultipleIterator' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'RecursiveTreeIterator' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'SplDoublyLinkedList' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'SplFixedArray' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'SplHeap' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'SplMaxHeap' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'SplMinHeap' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'SplObjectStorage' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'SplPriorityQueue' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'SplQueue' => array(
            '5.2' => false,
            '5.3' => true,
        ),
        'SplStack' => array(
            '5.2' => false,
            '5.3' => true,
        ),

        'ResourceBundle' => array(
            '5.3.1' => false,
            '5.3.2' => true,
        ),

        'CallbackFilterIterator' => array(
            '5.3' => false,
            '5.4' => true,
        ),
        'RecursiveCallbackFilterIterator' => array(
            '5.3' => false,
            '5.4' => true,
        ),
        'ReflectionZendExtension' => array(
            '5.3' => false,
            '5.4' => true,
        ),
        'SessionHandler' => array(
            '5.3' => false,
            '5.4' => true,
        ),
        'SNMP' => array(
            '5.3' => false,
            '5.4' => true,
        ),
        'Transliterator' => array(
            '5.3' => false,
            '5.4' => true,
        ),
        'Spoofchecker' => array(
            '5.3' => false,
            '5.4' => true,
        ),

        'Generator' => array(
            '5.4' => false,
            '5.5' => true,
        ),
        'CURLFile' => array(
            '5.4' => false,
            '5.5' => true,
        ),
        'DateTimeImmutable' => array(
            '5.4' => false,
            '5.5' => true,
        ),
        'IntlCalendar' => array(
            '5.4' => false,
            '5.5' => true,
        ),
        'IntlGregorianCalendar' => array(
            '5.4' => false,
            '5.5' => true,
        ),
        'IntlTimeZone' => array(
            '5.4' => false,
            '5.5' => true,
        ),
        'IntlBreakIterator' => array(
            '5.4' => false,
            '5.5' => true,
        ),
        'IntlRuleBasedBreakIterator' => array(
            '5.4' => false,
            '5.5' => true,
        ),
        'IntlCodePointBreakIterator' => array(
            '5.4' => false,
            '5.5' => true,
        ),
        'UConverter' => array(
            '5.4' => false,
            '5.5' => true,
        ),

        'GMP' => array(
            '5.5' => false,
            '5.6' => true,
        ),

        'IntlChar' => array(
            '5.6' => false,
            '7.0' => true,
        ),
        'ReflectionType' => array(
            '5.6' => false,
            '7.0' => true,
        ),
        'ReflectionGenerator' => array(
            '5.6' => false,
            '7.0' => true,
        ),

        'ReflectionClassConstant' => array(
            '7.0' => false,
            '7.1' => true,
        ),

        'FFI' => array(
            '7.3' => false,
            '7.4' => true,
        ),
        'FFI\CData' => array(
            '7.3' => false,
            '7.4' => true,
        ),
        'FFI\CType' => array(
            '7.3' => false,
            '7.4' => true,
        ),
        'ReflectionReference' => array(
            '7.3' => false,
            '7.4' => true,
        ),
        'WeakReference' => array(
            '7.3' => false,
            '7.4' => true,
        ),
    );

    /**
     * A list of new Exception classes, not present in older versions.
     *
     * The array lists : version number with false (not present) or true (present).
     * If's sufficient to list the first version where the class appears.
     *
     * {@internal Classes listed here do not need to be added to the $newClasses
     *            property as well.
     *            This list is automatically added to the $newClasses property
     *            in the `register()` method.}
     *
     * {@internal Helper to update this list: https://3v4l.org/MhlUp}
     *
     * @since 7.1.4
     *
     * @var array(string => array(string => bool))
     */
    protected $newExceptions = array(
        'com_exception' => array(
            '4.4' => false,
            '5.0' => true,
        ),
        'DOMException' => array(
            '4.4' => false,
            '5.0' => true,
        ),
        'Exception' => array(
            // According to the docs introduced in PHP 5.1, but this appears to be.
            // an error.  Class was introduced with try/catch keywords in PHP 5.0.
            '4.4' => false,
            '5.0' => true,
        ),
        'ReflectionException' => array(
            '4.4' => false,
            '5.0' => true,
        ),
        'SoapFault' => array(
            '4.4' => false,
            '5.0' => true,
        ),
        'SQLiteException' => array(
            '4.4' => false,
            '5.0' => true,
        ),

        'ErrorException' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'BadFunctionCallException' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'BadMethodCallException' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'DomainException' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'InvalidArgumentException' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'LengthException' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'LogicException' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'mysqli_sql_exception' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'OutOfBoundsException' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'OutOfRangeException' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'OverflowException' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'PDOException' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'RangeException' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'RuntimeException' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'UnderflowException' => array(
            '5.0' => false,
            '5.1' => true,
        ),
        'UnexpectedValueException' => array(
            '5.0' => false,
            '5.1' => true,
        ),

        'PharException' => array(
            '5.2' => false,
            '5.3' => true,
        ),

        'SNMPException' => array(
            '5.3' => false,
            '5.4' => true,
        ),

        'IntlException' => array(
            '5.4' => false,
            '5.5' => true,
        ),

        'Error' => array(
            '5.6' => false,
            '7.0' => true,
        ),
        'ArithmeticError' => array(
            '5.6' => false,
            '7.0' => true,
        ),
        'AssertionError' => array(
            '5.6' => false,
            '7.0' => true,
        ),
        'DivisionByZeroError' => array(
            '5.6' => false,
            '7.0' => true,
        ),
        'ParseError' => array(
            '5.6' => false,
            '7.0' => true,
        ),
        'TypeError' => array(
            '5.6' => false,
            '7.0' => true,
        ),
        'ClosedGeneratorException' => array(
            '5.6' => false,
            '7.0' => true,
        ),
        'UI\Exception\InvalidArgumentException' => array(
            '5.6' => false,
            '7.0' => true,
        ),
        'UI\Exception\RuntimeException' => array(
            '5.6' => false,
            '7.0' => true,
        ),

        'ArgumentCountError' => array(
            '7.0' => false,
            '7.1' => true,
        ),

        'SodiumException' => array(
            '7.1' => false,
            '7.2' => true,
        ),

        'CompileError' => array(
            '7.2' => false,
            '7.3' => true,
        ),
        'JsonException' => array(
            '7.2' => false,
            '7.3' => true,
        ),

        'FFI\Exception' => array(
            '7.3' => false,
            '7.4' => true,
        ),
        'FFI\ParserException' => array(
            '7.3' => false,
            '7.4' => true,
        ),
    );


    /**
     * Returns an array of tokens this test wants to listen for.
     *
     * @since 5.5
     * @since 7.0.3 - Now also targets the `class` keyword to detect extended classes.
     *              - Now also targets double colons to detect static class use.
     * @since 7.1.4 - Now also targets anonymous classes to detect extended classes.
     *              - Now also targets functions/closures to detect new classes used
     *                as parameter type declarations.
     *              - Now also targets the `catch` control structure to detect new
     *                exception classes being caught.
     * @since 8.2.0 Now also targets the `T_RETURN_TYPE` token to detect new classes used
     *              as return type declarations.
     *
     * @return array
     */
    public function register()
    {
        // Handle case-insensitivity of class names.
        $this->newClasses    = $this->arrayKeysToLowercase($this->newClasses);
        $this->newExceptions = $this->arrayKeysToLowercase($this->newExceptions);

        // Add the Exception classes to the Classes list.
        $this->newClasses = array_merge($this->newClasses, $this->newExceptions);

        $targets = array(
            \T_NEW,
            \T_CLASS,
            \T_DOUBLE_COLON,
            \T_FUNCTION,
            \T_CLOSURE,
            \T_CATCH,
        );

        if (\defined('T_ANON_CLASS')) {
            $targets[] = \T_ANON_CLASS;
        }

        if (\defined('T_RETURN_TYPE')) {
            $targets[] = \T_RETURN_TYPE;
        }

        return $targets;
    }


    /**
     * Processes this test, when one of its tokens is encountered.
     *
     * @since 5.5
     *
     * @param \PHP_CodeSniffer_File $phpcsFile The file being scanned.
     * @param int                   $stackPtr  The position of the current token in
     *                                         the stack passed in $tokens.
     *
     * @return void
     */
    public function process(File $phpcsFile, $stackPtr)
    {
        $tokens = $phpcsFile->getTokens();

        switch ($tokens[$stackPtr]['type']) {
            case 'T_FUNCTION':
            case 'T_CLOSURE':
                $this->processFunctionToken($phpcsFile, $stackPtr);

                // Deal with older PHPCS version which don't recognize return type hints
                // as well as newer PHPCS versions (3.3.0+) where the tokenization has changed.
                $returnTypeHint = $this->getReturnTypeHintToken($phpcsFile, $stackPtr);
                if ($returnTypeHint !== false) {
                    $this->processReturnTypeToken($phpcsFile, $returnTypeHint);
                }
                break;

            case 'T_CATCH':
                $this->processCatchToken($phpcsFile, $stackPtr);
                break;

            case 'T_RETURN_TYPE':
                $this->processReturnTypeToken($phpcsFile, $stackPtr);
                break;

            default:
                $this->processSingularToken($phpcsFile, $stackPtr);
                break;
        }
    }


    /**
     * Processes this test for when a token resulting in a singular class name is encountered.
     *
     * @since 7.1.4
     *
     * @param \PHP_CodeSniffer_File $phpcsFile The file being scanned.
     * @param int                   $stackPtr  The position of the current token in
     *                                         the stack passed in $tokens.
     *
     * @return void
     */
    private function processSingularToken(File $phpcsFile, $stackPtr)
    {
        $tokens      = $phpcsFile->getTokens();
        $FQClassName = '';

        if ($tokens[$stackPtr]['type'] === 'T_NEW') {
            $FQClassName = $this->getFQClassNameFromNewToken($phpcsFile, $stackPtr);

        } elseif ($tokens[$stackPtr]['type'] === 'T_CLASS' || $tokens[$stackPtr]['type'] === 'T_ANON_CLASS') {
            $FQClassName = $this->getFQExtendedClassName($phpcsFile, $stackPtr);

        } elseif ($tokens[$stackPtr]['type'] === 'T_DOUBLE_COLON') {
            $FQClassName = $this->getFQClassNameFromDoubleColonToken($phpcsFile, $stackPtr);
        }

        if ($FQClassName === '') {
            return;
        }

        $className   = substr($FQClassName, 1); // Remove global namespace indicator.
        $classNameLc = strtolower($className);

        if (isset($this->newClasses[$classNameLc]) === false) {
            return;
        }

        $itemInfo = array(
            'name'   => $className,
            'nameLc' => $classNameLc,
        );
        $this->handleFeature($phpcsFile, $stackPtr, $itemInfo);
    }


    /**
     * Processes this test for when a function token is encountered.
     *
     * - Detect new classes when used as a parameter type declaration.
     *
     * @since 7.1.4
     *
     * @param \PHP_CodeSniffer_File $phpcsFile The file being scanned.
     * @param int                   $stackPtr  The position of the current token in
     *                                         the stack passed in $tokens.
     *
     * @return void
     */
    private function processFunctionToken(File $phpcsFile, $stackPtr)
    {
        // Retrieve typehints stripped of global NS indicator and/or nullable indicator.
        $typeHints = $this->getTypeHintsFromFunctionDeclaration($phpcsFile, $stackPtr);
        if (empty($typeHints) || \is_array($typeHints) === false) {
            return;
        }

        foreach ($typeHints as $hint) {

            $typeHintLc = strtolower($hint);

            if (isset($this->newClasses[$typeHintLc]) === true) {
                $itemInfo = array(
                    'name'   => $hint,
                    'nameLc' => $typeHintLc,
                );
                $this->handleFeature($phpcsFile, $stackPtr, $itemInfo);
            }
        }
    }


    /**
     * Processes this test for when a catch token is encountered.
     *
     * - Detect exceptions when used in a catch statement.
     *
     * @since 7.1.4
     *
     * @param \PHP_CodeSniffer_File $phpcsFile The file being scanned.
     * @param int                   $stackPtr  The position of the current token in
     *                                         the stack passed in $tokens.
     *
     * @return void
     */
    private function processCatchToken(File $phpcsFile, $stackPtr)
    {
        $tokens = $phpcsFile->getTokens();

        // Bow out during live coding.
        if (isset($tokens[$stackPtr]['parenthesis_opener'], $tokens[$stackPtr]['parenthesis_closer']) === false) {
            return;
        }

        $opener = $tokens[$stackPtr]['parenthesis_opener'];
        $closer = ($tokens[$stackPtr]['parenthesis_closer'] + 1);
        $name   = '';
        $listen = array(
            // Parts of a (namespaced) class name.
            \T_STRING              => true,
            \T_NS_SEPARATOR        => true,
            // End/split tokens.
            \T_VARIABLE            => false,
            \T_BITWISE_OR          => false,
            \T_CLOSE_CURLY_BRACKET => false, // Shouldn't be needed as we expect a var before this.
        );

        for ($i = ($opener + 1); $i < $closer; $i++) {
            if (isset($listen[$tokens[$i]['code']]) === false) {
                continue;
            }

            if ($listen[$tokens[$i]['code']] === true) {
                $name .= $tokens[$i]['content'];
                continue;
            } else {
                if (empty($name) === true) {
                    // Weird, we should have a name by the time we encounter a variable or |.
                    // So this may be the closer.
                    continue;
                }

                $name   = ltrim($name, '\\');
                $nameLC = strtolower($name);

                if (isset($this->newExceptions[$nameLC]) === true) {
                    $itemInfo = array(
                        'name'   => $name,
                        'nameLc' => $nameLC,
                    );
                    $this->handleFeature($phpcsFile, $i, $itemInfo);
                }

                // Reset for a potential multi-catch.
                $name = '';
            }
        }
    }


    /**
     * Processes this test for when a return type token is encountered.
     *
     * - Detect new classes when used as a return type declaration.
     *
     * @since 8.2.0
     *
     * @param \PHP_CodeSniffer_File $phpcsFile The file being scanned.
     * @param int                   $stackPtr  The position of the current token in
     *                                         the stack passed in $tokens.
     *
     * @return void
     */
    private function processReturnTypeToken(File $phpcsFile, $stackPtr)
    {
        $returnTypeHint = $this->getReturnTypeHintName($phpcsFile, $stackPtr);
        if (empty($returnTypeHint)) {
            return;
        }

        $returnTypeHint   = ltrim($returnTypeHint, '\\');
        $returnTypeHintLc = strtolower($returnTypeHint);

        if (isset($this->newClasses[$returnTypeHintLc]) === false) {
            return;
        }

        // Still here ? Then this is a return type declaration using a new class.
        $itemInfo = array(
            'name'   => $returnTypeHint,
            'nameLc' => $returnTypeHintLc,
        );
        $this->handleFeature($phpcsFile, $stackPtr, $itemInfo);
    }


    /**
     * Get the relevant sub-array for a specific item from a multi-dimensional array.
     *
     * @since 7.1.0
     *
     * @param array $itemInfo Base information about the item.
     *
     * @return array Version and other information about the item.
     */
    public function getItemArray(array $itemInfo)
    {
        return $this->newClasses[$itemInfo['nameLc']];
    }


    /**
     * Get the error message template for this sniff.
     *
     * @since 7.1.0
     *
     * @return string
     */
    protected function getErrorMsgTemplate()
    {
        return 'The built-in class ' . parent::getErrorMsgTemplate();
    }
}