<?php
/**
 * Tests for the handling of properties being set via the ruleset.
 *
 * @author    Juliette Reinders Folmer <phpcs_nospam@adviesenzo.nl>
 * @copyright 2022 Juliette Reinders Folmer. All rights reserved.
 * @license   https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence
 */

namespace PHP_CodeSniffer\Tests\Core\Ruleset;

use PHP_CodeSniffer\Ruleset;
use PHP_CodeSniffer\Tests\ConfigDouble;
use PHPUnit\Framework\TestCase;
use ReflectionObject;

/**
 * These tests specifically focus on the changes made to work around the PHP 8.2 dynamic properties deprecation.
 *
 * @covers \PHP_CodeSniffer\Ruleset::setSniffProperty
 */
final class SetSniffPropertyTest extends TestCase
{


    /**
     * Test that setting a property via the ruleset works in all situations which allow for it.
     *
     * @param string $name Name of the test. Used for the sniff name, the ruleset file name etc.
     *
     * @dataProvider dataSniffPropertiesGetSetWhenAllowed
     *
     * @return void
     */
    public function testSniffPropertiesGetSetWhenAllowed($name)
    {
        $sniffCode  = "Fixtures.SetProperty.{$name}";
        $sniffClass = 'Fixtures\Sniffs\SetProperty\\'.$name.'Sniff';
        $properties = [
            'arbitrarystring' => 'arbitraryvalue',
            'arbitraryarray'  => [
                'mykey'    => 'myvalue',
                'otherkey' => 'othervalue',
            ],
        ];

        // Set up the ruleset.
        $standard = __DIR__."/SetProperty{$name}Test.xml";
        $config   = new ConfigDouble(["--standard=$standard"]);
        $ruleset  = new Ruleset($config);

        // Verify that the sniff has been registered.
        $this->assertGreaterThan(0, count($ruleset->sniffCodes), 'No sniff codes registered');

        // Verify that our target sniff has been registered.
        $this->assertArrayHasKey($sniffCode, $ruleset->sniffCodes, 'Target sniff not registered');
        $this->assertSame($sniffClass, $ruleset->sniffCodes[$sniffCode], 'Target sniff not registered with the correct class');

        // Test that the property as declared in the ruleset has been set on the sniff.
        $this->assertArrayHasKey($sniffClass, $ruleset->sniffs, 'Sniff class not listed in registered sniffs');

        $sniffObject = $ruleset->sniffs[$sniffClass];
        foreach ($properties as $name => $expectedValue) {
            $this->assertSame($expectedValue, $sniffObject->$name, 'Property value not set to expected value');
        }

    }//end testSniffPropertiesGetSetWhenAllowed()


    /**
     * Data provider.
     *
     * @see self::testSniffPropertiesGetSetWhenAllowed()
     *
     * @return array<string, array<string>>
     */
    public static function dataSniffPropertiesGetSetWhenAllowed()
    {
        return [
            'Property allowed as explicitly declared'            => ['AllowedAsDeclared'],
            'Property allowed as sniff extends stdClass'         => ['AllowedViaStdClass'],
            'Property allowed as sniff has magic __set() method' => ['AllowedViaMagicMethod'],
        ];

    }//end dataSniffPropertiesGetSetWhenAllowed()


    /**
     * Test that setting a property for a category will apply it correctly to those sniffs which support the
     * property, but won't apply it to sniffs which don't.
     *
     * Note: this test intentionally uses the `PEAR.Functions` category as two sniffs in that category
     * have a public property with the same name (`indent`) and one sniff doesn't, which makes it a great
     * test case for this.
     *
     * @return void
     */
    public function testSetPropertyAppliesPropertyToMultipleSniffsInCategory()
    {
        $propertyName  = 'indent';
        $expectedValue = '10';

        // Set up the ruleset.
        $standard = __DIR__.'/SetPropertyAppliesPropertyToMultipleSniffsInCategoryTest.xml';
        $config   = new ConfigDouble(["--standard=$standard"]);
        $ruleset  = new Ruleset($config);

        // Test that the two sniffs which support the property have received the value.
        $sniffClass = 'PHP_CodeSniffer\Standards\PEAR\Sniffs\Functions\FunctionCallSignatureSniff';
        $this->assertArrayHasKey($sniffClass, $ruleset->sniffs, 'Sniff class '.$sniffClass.' not listed in registered sniffs');
        $sniffObject = $ruleset->sniffs[$sniffClass];
        $this->assertSame($expectedValue, $sniffObject->$propertyName, 'Property value not set to expected value for '.$sniffClass);

        $sniffClass = 'PHP_CodeSniffer\Standards\PEAR\Sniffs\Functions\FunctionDeclarationSniff';
        $this->assertArrayHasKey($sniffClass, $ruleset->sniffs, 'Sniff class '.$sniffClass.' not listed in registered sniffs');
        $sniffObject = $ruleset->sniffs[$sniffClass];
        $this->assertSame($expectedValue, $sniffObject->$propertyName, 'Property value not set to expected value for '.$sniffClass);

        // Test that the property doesn't get set for the one sniff which doesn't support the property.
        $sniffClass = 'PHP_CodeSniffer\Standards\PEAR\Sniffs\Functions\ValidDefaultValueSniff';
        $this->assertArrayHasKey($sniffClass, $ruleset->sniffs, 'Sniff class '.$sniffClass.' not listed in registered sniffs');

        $hasProperty = (new ReflectionObject($ruleset->sniffs[$sniffClass]))->hasProperty($propertyName);
        $errorMsg    = sprintf('Property %s registered for sniff %s which does not support it', $propertyName, $sniffClass);
        $this->assertFalse($hasProperty, $errorMsg);

    }//end testSetPropertyAppliesPropertyToMultipleSniffsInCategory()


    /**
     * Test that attempting to set a non-existent property directly on a sniff will throw an error
     * when the sniff does not explicitly declare the property, extends stdClass or has magic methods.
     *
     * @return void
     */
    public function testSetPropertyThrowsErrorOnInvalidProperty()
    {
        $exceptionClass = 'PHP_CodeSniffer\Exceptions\RuntimeException';
        $exceptionMsg   = 'Ruleset invalid. Property "indentation" does not exist on sniff Generic.Arrays.ArrayIndent';
        if (method_exists($this, 'expectException') === true) {
            $this->expectException($exceptionClass);
            $this->expectExceptionMessage($exceptionMsg);
        } else {
            // PHPUnit < 5.2.0.
            $this->setExpectedException($exceptionClass, $exceptionMsg);
        }

        // Set up the ruleset.
        $standard = __DIR__.'/SetPropertyThrowsErrorOnInvalidPropertyTest.xml';
        $config   = new ConfigDouble(["--standard=$standard"]);
        new Ruleset($config);

    }//end testSetPropertyThrowsErrorOnInvalidProperty()


    /**
     * Test that attempting to set a non-existent property directly on a sniff will throw an error
     * when the sniff does not explicitly declare the property, extends stdClass or has magic methods,
     * even though the sniff has the PHP 8.2 `#[AllowDynamicProperties]` attribute set.
     *
     * @return void
     */
    public function testSetPropertyThrowsErrorWhenPropertyOnlyAllowedViaAttribute()
    {
        $exceptionClass = 'PHP_CodeSniffer\Exceptions\RuntimeException';
        $exceptionMsg   = 'Ruleset invalid. Property "arbitrarystring" does not exist on sniff Fixtures.SetProperty.NotAllowedViaAttribute';
        if (method_exists($this, 'expectException') === true) {
            $this->expectException($exceptionClass);
            $this->expectExceptionMessage($exceptionMsg);
        } else {
            // PHPUnit < 5.2.0.
            $this->setExpectedException($exceptionClass, $exceptionMsg);
        }

        // Set up the ruleset.
        $standard = __DIR__.'/SetPropertyNotAllowedViaAttributeTest.xml';
        $config   = new ConfigDouble(["--standard=$standard"]);
        new Ruleset($config);

    }//end testSetPropertyThrowsErrorWhenPropertyOnlyAllowedViaAttribute()


    /**
     * Test that attempting to set a non-existent property on a sniff when the property directive is
     * for the whole standard, does not yield an error.
     *
     * @doesNotPerformAssertions
     *
     * @return void
     */
    public function testSetPropertyDoesNotThrowErrorOnInvalidPropertyWhenSetForStandard()
    {
        // Set up the ruleset.
        $standard = __DIR__.'/SetPropertyDoesNotThrowErrorOnInvalidPropertyWhenSetForStandardTest.xml';
        $config   = new ConfigDouble(["--standard=$standard"]);
        new Ruleset($config);

    }//end testSetPropertyDoesNotThrowErrorOnInvalidPropertyWhenSetForStandard()


    /**
     * Test that attempting to set a non-existent property on a sniff when the property directive is
     * for a whole category, does not yield an error.
     *
     * @doesNotPerformAssertions
     *
     * @return void
     */
    public function testSetPropertyDoesNotThrowErrorOnInvalidPropertyWhenSetForCategory()
    {
        // Set up the ruleset.
        $standard = __DIR__.'/SetPropertyDoesNotThrowErrorOnInvalidPropertyWhenSetForCategoryTest.xml';
        $config   = new ConfigDouble(["--standard=$standard"]);
        new Ruleset($config);

    }//end testSetPropertyDoesNotThrowErrorOnInvalidPropertyWhenSetForCategory()


    /**
     * Test that setting a property via a direct call to the Ruleset::setSniffProperty() method
     * sets the property correctly when using the new $settings array format.
     *
     * @return void
     */
    public function testDirectCallWithNewArrayFormatSetsProperty()
    {
        $name       = 'AllowedAsDeclared';
        $sniffCode  = "Fixtures.SetProperty.{$name}";
        $sniffClass = 'Fixtures\Sniffs\SetProperty\\'.$name.'Sniff';

        // Set up the ruleset.
        $standard = __DIR__."/SetProperty{$name}Test.xml";
        $config   = new ConfigDouble(["--standard=$standard"]);
        $ruleset  = new Ruleset($config);

        $propertyName  = 'arbitrarystring';
        $propertyValue = 'new value';

        $ruleset->setSniffProperty(
            $sniffClass,
            $propertyName,
            [
                'scope' => 'sniff',
                'value' => $propertyValue,
            ]
        );

        // Verify that the sniff has been registered.
        $this->assertGreaterThan(0, count($ruleset->sniffCodes), 'No sniff codes registered');

        // Verify that our target sniff has been registered.
        $this->assertArrayHasKey($sniffCode, $ruleset->sniffCodes, 'Target sniff not registered');
        $this->assertSame($sniffClass, $ruleset->sniffCodes[$sniffCode], 'Target sniff not registered with the correct class');

        // Test that the property as declared in the ruleset has been set on the sniff.
        $this->assertArrayHasKey($sniffClass, $ruleset->sniffs, 'Sniff class not listed in registered sniffs');

        $sniffObject = $ruleset->sniffs[$sniffClass];
        $this->assertSame($propertyValue, $sniffObject->$propertyName, 'Property value not set to expected value');

    }//end testDirectCallWithNewArrayFormatSetsProperty()


    /**
     * Test that setting a property via a direct call to the Ruleset::setSniffProperty() method
     * sets the property correctly when using the old $settings array format.
     *
     * Tested by silencing the deprecation notice as otherwise the test would fail on the deprecation notice.
     *
     * @param mixed $propertyValue Value for the property to set.
     *
     * @dataProvider dataDirectCallWithOldArrayFormatSetsProperty
     *
     * @return void
     */
    public function testDirectCallWithOldArrayFormatSetsProperty($propertyValue)
    {
        $name       = 'AllowedAsDeclared';
        $sniffCode  = "Fixtures.SetProperty.{$name}";
        $sniffClass = 'Fixtures\Sniffs\SetProperty\\'.$name.'Sniff';

        // Set up the ruleset.
        $standard = __DIR__."/SetProperty{$name}Test.xml";
        $config   = new ConfigDouble(["--standard=$standard"]);
        $ruleset  = new Ruleset($config);

        $propertyName = 'arbitrarystring';

        @$ruleset->setSniffProperty(
            $sniffClass,
            $propertyName,
            $propertyValue
        );

        // Verify that the sniff has been registered.
        $this->assertGreaterThan(0, count($ruleset->sniffCodes), 'No sniff codes registered');

        // Verify that our target sniff has been registered.
        $this->assertArrayHasKey($sniffCode, $ruleset->sniffCodes, 'Target sniff not registered');
        $this->assertSame($sniffClass, $ruleset->sniffCodes[$sniffCode], 'Target sniff not registered with the correct class');

        // Test that the property as declared in the ruleset has been set on the sniff.
        $this->assertArrayHasKey($sniffClass, $ruleset->sniffs, 'Sniff class not listed in registered sniffs');

        $sniffObject = $ruleset->sniffs[$sniffClass];
        $this->assertSame($propertyValue, $sniffObject->$propertyName, 'Property value not set to expected value');

    }//end testDirectCallWithOldArrayFormatSetsProperty()


    /**
     * Data provider.
     *
     * @see self::testDirectCallWithOldArrayFormatSetsProperty()
     *
     * @return array<string, array<string, mixed>>
     */
    public static function dataDirectCallWithOldArrayFormatSetsProperty()
    {
        return [
            'Property value is not an array (boolean)'                       => [
                'propertyValue' => false,
            ],
            'Property value is not an array (string)'                        => [
                'propertyValue' => 'a string',
            ],
            'Property value is an empty array'                               => [
                'propertyValue' => [],
            ],
            'Property value is an array without keys'                        => [
                'propertyValue' => [
                    'value',
                    false,
                ],
            ],
            'Property value is an array without the "scope" or "value" keys' => [
                'propertyValue' => [
                    'key1' => 'value',
                    'key2' => false,
                ],
            ],
            'Property value is an array without the "scope" key'             => [
                'propertyValue' => [
                    'key1'  => 'value',
                    'value' => true,
                ],
            ],
            'Property value is an array without the "value" key'             => [
                'propertyValue' => [
                    'scope' => 'value',
                    'key2'  => 1234,
                ],
            ],
        ];

    }//end dataDirectCallWithOldArrayFormatSetsProperty()


    /**
     * Test that setting a property via a direct call to the Ruleset::setSniffProperty() method
     * throws a deprecation notice when using the old $settings array format.
     *
     * Note: as PHPUnit stops as soon as it sees the deprecation notice, the setting of the property
     * value is not tested here.
     *
     * @return void
     */
    public function testDirectCallWithOldArrayFormatThrowsDeprecationNotice()
    {
        $exceptionClass = 'PHPUnit\Framework\Error\Deprecated';
        if (class_exists($exceptionClass) === false) {
            $exceptionClass = 'PHPUnit_Framework_Error_Deprecated';
        }

        $exceptionMsg = 'the format of the $settings parameter has changed from (mixed) $value to array(\'scope\' => \'sniff|standard\', \'value\' => $value). Please update your integration code. See PR #3629 for more information.';

        if (method_exists($this, 'expectException') === true) {
            $this->expectException($exceptionClass);
            $this->expectExceptionMessage($exceptionMsg);
        } else {
            // PHPUnit < 5.2.0.
            $this->setExpectedException($exceptionClass, $exceptionMsg);
        }

        $name       = 'AllowedAsDeclared';
        $sniffClass = 'Fixtures\Sniffs\SetProperty\\'.$name.'Sniff';

        // Set up the ruleset.
        $standard = __DIR__."/SetProperty{$name}Test.xml";
        $config   = new ConfigDouble(["--standard=$standard"]);
        $ruleset  = new Ruleset($config);

        $ruleset->setSniffProperty(
            $sniffClass,
            'arbitrarystring',
            ['key' => 'value']
        );

    }//end testDirectCallWithOldArrayFormatThrowsDeprecationNotice()


}//end class
