<?php

// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <https://www.gnu.org/licenses/>.

namespace MoodleHQ\MoodleCS\moodle\Sniffs\PHPUnit;

use MoodleHQ\MoodleCS\moodle\Util\Attributes;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Util\Tokens;
use PHPCSUtils\Utils\ObjectDeclarations;

/**
 * Checks that a test file has the @coversxxx annotations properly defined.
 *
 * @copyright  2022 onwards Eloy Lafuente (stronk7) {@link https://stronk7.com}
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class TestCaseCoversSniff extends AbstractTestCaseSniff
{
    /**
     * Register for open tag (only process once per file).
     */
    public function register() {
        return [T_OPEN_TAG];
    }

    /**
     * Processes php files and perform various checks with file.
     *
     * @param File $file The file being scanned.
     * @param int $pointer The position in the stack.
     */
    public function process(File $file, $pointer) {
        if (!$this->shouldCheckFile($file)) {
            // Nothing to check.
            return; // @codeCoverageIgnore
        }

        $supportsAttributes = $this->shouldCheckTestCaseAttributes($file);

        // We have all we need from core, let's start processing the file.

        // Get the file tokens, for ease of use.
        $tokens = $file->getTokens();

        // In various places we are going to ignore class/method prefixes (private, abstract...)
        // and whitespace, create an array for all them.
        $skipTokens = Tokens::$methodPrefixes + [T_WHITESPACE => T_WHITESPACE];

        // We only want to do this once per file.
        $prevopentag = $file->findPrevious(T_OPEN_TAG, $pointer - 1);
        if ($prevopentag !== false) {
            return; // @codeCoverageIgnore
        }

        foreach ($this->getTestCasesInFile($file, $pointer) as $cStart => $className) {
            $classInfo = ObjectDeclarations::getClassProperties($file, $cStart);
            if ($classInfo['is_abstract']) {
                // Abstract classes are not tested.
                // Coverage information belongs to the concrete classes that extend them.
                continue;
            }


            $class = $file->getDeclarationName($cStart);
            $classCovers = false; // To control when the class has a @covers tag.
            $classCoversNothing = false; // To control when the class has a @coversNothing tag.
            $classCoversDefaultClass = []; // To annotate all the existing @coversDefaultClass tags.

            if ($supportsAttributes) {
                // From PHPUnit 10 onwards, the class may be annotated with attributes.
                // The following attributes exist:
                // - #[\PHPUnit\Framework\Attributes\CoversClass]
                // - #[\PHPUnit\Framework\Attributes\CoversTrait]
                // - #[\PHPUnit\Framework\Attributes\CoversMethod]
                // - #[\PHPUnit\Framework\Attributes\CoversFunction]
                // - #[\PHPUnit\Framework\Attributes\CoversNothing]

                // All of these may be at the class level.
                // Only the `CoversNothing` attribute is allowed to be used at the method level.

                // Check if any valid Covers attributes are defined for this class.
                $validAttributes = [
                    \PHPUnit\Framework\Attributes\CoversClass::class,
                    \PHPUnit\Framework\Attributes\CoversTrait::class,
                    \PHPUnit\Framework\Attributes\CoversMethod::class,
                    \PHPUnit\Framework\Attributes\CoversFunction::class,
                    \PHPUnit\Framework\Attributes\CoversNothing::class,
                ];
                if ($this->containsCoversAttribute($file, $cStart, $validAttributes)) {
                    // If the class has any of the valid attributes, we can skip the rest of the checks.
                    continue;
                }
            }

            // Let's see if the class has any phpdoc block (first non skip token must be end of phpdoc comment).
            $docPointer = $file->findPrevious($skipTokens, $cStart - 1, null, true);

            // Found a phpdoc block, let's look for @covers, @coversNothing and @coversDefaultClass tags.
            if ($tokens[$docPointer]['code'] === T_DOC_COMMENT_CLOSE_TAG) {
                $docStart = $tokens[$docPointer]['comment_opener'];
                while ($docPointer) { // Let's look upwards, until the beginning of the phpdoc block.
                    $docPointer = $file->findPrevious(T_DOC_COMMENT_TAG, $docPointer - 1, $docStart);
                    if ($docPointer) {
                        $docTag = trim($tokens[$docPointer]['content']);
                        switch ($docTag) {
                            case '@covers':
                                $classCovers = $docPointer;
                                // Validate basic syntax (FQCN or ::).
                                $this->checkCoversTagsSyntax($file, $docPointer, '@covers');
                                break;
                            case '@coversNothing':
                                $classCoversNothing = $docPointer;
                                // Validate basic syntax (empty).
                                $this->checkCoversTagsSyntax($file, $docPointer, '@coversNothing');
                                break;
                            case '@coversDefaultClass':
                                // Validate basic syntax (FQCN).
                                $this->checkCoversTagsSyntax($file, $docPointer, '@coversDefaultClass');
                                $classCoversDefaultClass[] = $docPointer; // Annotated for later checks.
                                break;
                        }
                    }
                }
            }

            // If we have found more than one @coversDefaultClass, that's an error.
            if (count($classCoversDefaultClass) > 1) {
                // We have to reverse the array to get them in correct order and then
                // remove the 1st one that is correct/allowed.
                $classCoversDefaultClass = array_reverse($classCoversDefaultClass);
                array_shift($classCoversDefaultClass);
                // Report the remaining ones.
                foreach ($classCoversDefaultClass as $classCoversDefaultClassPointer) {
                    $file->addError(
                        'Class %s has more than one @coversDefaultClass tag, only one allowed',
                        $classCoversDefaultClassPointer,
                        'MultipleDefaultClass',
                        [$class]
                    );
                }
            }

            // Both @covers and @coversNothing, that's a mistake. 2 errors.
            if ($classCovers && $classCoversNothing) {
                $file->addError(
                    'Class %s has both @covers and @coversNothing tags, good contradiction',
                    $classCovers,
                    'ContradictoryClass',
                    [$class]
                );
                $file->addError(
                    'Class %s has both @covers and @coversNothing tags, good contradiction',
                    $classCoversNothing,
                    'ContradictoryClass',
                    [$class]
                );
            }

            // Iterate over all the methods in the class.
            // From PHPUnit 10 onwards, the class may be annotated with attributes.
            // The following method-level attributes exist:
            // - #[\PHPUnit\Framework\Attributes\CoversNothing]
            $validAttributes = [
                \PHPUnit\Framework\Attributes\CoversNothing::class,
            ];
            foreach ($this->getTestMethodsInClass($file, $cStart) as $method => $mStart) {
                if ($supportsAttributes) {
                    // Check if any valid Covers attributes are defined for this class.
                    if ($this->containsCoversAttribute($file, $mStart, $validAttributes)) {
                        // If the class has any of the valid attributes, we can skip the rest of the checks.
                        continue;
                    }
                }

                $methodCovers = false; // To control when the method has a @covers tag.
                $methodCoversNothing = false; // To control when the method has a @coversNothing tag.


                // Let's see if the method has any phpdoc block (first non skip token must be end of phpdoc comment).
                $docPointer = $file->findPrevious($skipTokens, $mStart - 1, null, true);

                // Found a phpdoc block, let's look for @covers and @coversNothing tags.
                if ($tokens[$docPointer]['code'] === T_DOC_COMMENT_CLOSE_TAG) {
                    $docStart = $tokens[$docPointer]['comment_opener'];
                    while ($docPointer) { // Let's look upwards, until the beginning of the phpdoc block.
                        $docPointer = $file->findPrevious(T_DOC_COMMENT_TAG, $docPointer - 1, $docStart);
                        if ($docPointer) {
                            $docTag = trim($tokens[$docPointer]['content']);
                            switch ($docTag) {
                                case '@covers':
                                    $methodCovers = $docPointer;
                                    // Validate basic syntax (FQCN or ::).
                                    $this->checkCoversTagsSyntax($file, $docPointer, '@covers');
                                    break;
                                case '@coversNothing':
                                    $methodCoversNothing = $docPointer;
                                    // Validate basic syntax (empty).
                                    $this->checkCoversTagsSyntax($file, $docPointer, '@coversNothing');
                                    break;
                                case '@coversDefaultClass':
                                    // Not allowed in methods.
                                    $file->addError(
                                        'Method %s() has @coversDefaultClass tag, only allowed in classes',
                                        $docPointer,
                                        'DefaultClassNotAllowed',
                                        [$method]
                                    );
                                    break;
                            }
                        }
                    }
                }

                // No @covers or @coversNothing at any level, that's a missing one.
                if (!$classCovers && !$classCoversNothing && !$methodCovers && !$methodCoversNothing) {
                    $file->addWarning(
                        'Test method %s() is missing any coverage information, own or at class level',
                        $mStart,
                        'Missing',
                        [$method]
                    );
                }

                // Both @covers and @coversNothing, that's a mistake. 2 errors.
                if ($methodCovers && $methodCoversNothing) {
                    $file->addError(
                        'Method %s() has both @covers and @coversNothing tags, good contradiction',
                        $methodCovers,
                        'ContradictoryMethod',
                        [$method]
                    );
                    $file->addError(
                        'Method %s() has both @covers and @coversNothing tags, good contradiction',
                        $methodCoversNothing,
                        'ContradictoryMethod',
                        [$method]
                    );
                }

                // Found @coversNothing at class, and @covers at method, strange. Warning.
                if ($classCoversNothing && $methodCovers) {
                    $file->addWarning(
                        'Class %s has @coversNothing, but there are methods covering stuff',
                        $classCoversNothing,
                        'ContradictoryMixed',
                        [$class]
                    );
                    $file->addWarning(
                        'Test method %s() is covering stuff, but class has @coversNothing',
                        $methodCovers,
                        'ContradictoryMixed',
                        [$method]
                    );
                }

                // Found @coversNothing at class and method, redundant. Warning.
                if ($classCoversNothing && $methodCoversNothing) {
                    $file->addWarning(
                        'Test method %s() has @coversNothing, but class also has it, redundant',
                        $methodCoversNothing,
                        'Redundant',
                        [$method]
                    );
                }

                // Advance until the end of the method, if possible, to find the next one quicker.
                $mStart = $tokens[$mStart]['scope_closer'] ?? $mStart + 1;
            }
        }
    }

    /**
     * Perform a basic syntax cheking of the values of the @coversXXX tags.
     *
     * @param File $file The file being scanned
     * @param int $pointer pointer to the token that contains the tag. Calculations are based on that.
     * @param string $tag $coversXXX tag to be checked. Verifications are different based on that.
     * @return void
     */
    protected function checkCoversTagsSyntax(File $file, int $pointer, string $tag) {
        // Get the file tokens, for ease of use.
        $tokens = $file->getTokens();

        if ($tag === '@coversNothing') {
            // Check that there isn't whitespace and string.
            if (
                $tokens[$pointer + 1]['code'] === T_DOC_COMMENT_WHITESPACE &&
                $tokens[$pointer + 2]['code'] === T_DOC_COMMENT_STRING
            ) {
                $file->addError(
                    'Wrong %s annotation, it must be empty',
                    $pointer,
                    'NotEmpty',
                    [$tag]
                );
            }
        }

        if ($tag === '@covers' || $tag === '@coversDefaultClass') {
            // Check that there is whitespace and string.
            if (
                $tokens[$pointer + 1]['code'] !== T_DOC_COMMENT_WHITESPACE ||
                $tokens[$pointer + 2]['code'] !== T_DOC_COMMENT_STRING
            ) {
                $file->addError(
                    'Wrong %s annotation, it must contain some value',
                    $pointer,
                    'Empty',
                    [$tag]
                );
                // No value, nothing else to check.
                return;
            }
        }

        if ($tag === '@coversDefaultClass') {
            // Check that value begins with \ (FQCN).
            if (strpos($tokens[$pointer + 2]['content'], '\\') !== 0) {
                $file->addError(
                    'Wrong %s annotation, it must be FQCN (\\ prefixed)',
                    $pointer,
                    'NoFQCN',
                    [$tag]
                );
            }
            // Check that value does not contain :: (method).
            if (strpos($tokens[$pointer + 2]['content'], '::') !== false) {
                $file->addError(
                    'Wrong %s annotation, cannot point to a method (contains ::)',
                    $pointer,
                    'WrongMethod',
                    [$tag]
                );
            }
        }

        if ($tag === '@covers') {
            // Check value begins with \ (FQCN) or :: (method).
            if (
                strpos($tokens[$pointer + 2]['content'], '\\') !== 0 &&
                strpos($tokens[$pointer + 2]['content'], '::') !== 0
            ) {
                $file->addError(
                    'Wrong %s annotation, it must be FQCN (\\ prefixed) or point to method (:: prefixed)',
                    $pointer,
                    'NoFQCNOrMethod',
                    [$tag]
                );
            }
        }
    }

    /**
     * Checks if the class contains any of the valid @coversXXX attributes.
     *
     * @param File $file The file being scanned.
     * @param int $pointer The position in the stack.
     * @param array $validAttributes List of valid attributes to check against.
     * @return bool True if any valid attribute is found, false otherwise.
     */
    protected function containsCoversAttribute(
        File $file,
        int $pointer,
        array $validAttributes
    ): bool {
        // Find all the attributes for this class.
        $attributes = Attributes::getAttributePointers($file, $pointer);
        foreach ($attributes as $attributePtr) {
            $attribute = Attributes::getAttributeProperties($file, $attributePtr);
            if ($attribute === null) {
                // No attribute found, skip.
                continue; // @codeCoverageIgnore
            }

            if (in_array($attribute['qualified_name'], $validAttributes)) {
                // Valid attribute found.
                return true;
            }
        }

        return false;
    }
}
