<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Component\Console\Tests\Command;

use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\ArgumentResolver\ArgumentResolver;
use Symfony\Component\Console\ArgumentResolver\ValueResolver\ValueResolverInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\MapDateTime;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Completion\Suggestion;
use Symfony\Component\Console\Cursor;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\InvalidOptionException;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Console\Tests\Fixtures\InvokableTestCommand;
use Symfony\Component\Console\Tests\Fixtures\InvokableWithCustomValidatorTestCommand;
use Symfony\Component\Console\Tests\Fixtures\InvokableWithInputFileAndConstraintsTestCommand;

class InvokableCommandTest extends TestCase
{
    public function testCommandInputArgumentDefinition()
    {
        $command = new Command('foo');
        $command->setCode(function (
            #[Argument(name: 'very-first-name')] string $name,
            #[Argument] ?string $firstName,
            #[Argument] string $lastName = '',
            #[Argument(description: 'Short argument description')] string $bio = '',
            // In this test case, we declare the callback in static context, even when the method is NOT static.
            // PHP doesn't allow using `$this` here, and the callback is later modified on-the-fly
            // to be called on the instance instead, and this test case validates if this mechanism works.
            //
            // @see \Symfony\Component\Console\Attribute\Argument
            #[Argument(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'],
        ): int {
            \assert(null !== $this); // so PHP CS Fixer knows this callback is actually coupled with `$this` and `static_lambda` rule shall not be applied

            return 0;
        });

        $nameInputArgument = $command->getDefinition()->getArgument('very-first-name');
        self::assertSame('very-first-name', $nameInputArgument->getName());
        self::assertTrue($nameInputArgument->isRequired());

        $lastNameInputArgument = $command->getDefinition()->getArgument('first-name');
        self::assertSame('first-name', $lastNameInputArgument->getName());
        self::assertFalse($lastNameInputArgument->isRequired());
        self::assertNull($lastNameInputArgument->getDefault());

        $lastNameInputArgument = $command->getDefinition()->getArgument('last-name');
        self::assertSame('last-name', $lastNameInputArgument->getName());
        self::assertFalse($lastNameInputArgument->isRequired());
        self::assertSame('', $lastNameInputArgument->getDefault());

        $bioInputArgument = $command->getDefinition()->getArgument('bio');
        self::assertSame('bio', $bioInputArgument->getName());
        self::assertFalse($bioInputArgument->isRequired());
        self::assertSame('Short argument description', $bioInputArgument->getDescription());
        self::assertSame('', $bioInputArgument->getDefault());

        $rolesInputArgument = $command->getDefinition()->getArgument('roles');
        self::assertSame('roles', $rolesInputArgument->getName());
        self::assertFalse($rolesInputArgument->isRequired());
        self::assertTrue($rolesInputArgument->isArray());
        self::assertSame(['ROLE_USER'], $rolesInputArgument->getDefault());
        self::assertTrue($rolesInputArgument->hasCompletion());
        $rolesInputArgument->complete(new CompletionInput(), $suggestions = new CompletionSuggestions());
        self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], array_map(static fn (Suggestion $s) => $s->getValue(), $suggestions->getValueSuggestions()));
    }

    public function testCommandInputOptionDefinition()
    {
        $command = new Command('foo');
        $command->setCode(function (
            #[Option(name: 'idle')] ?int $timeout = null,
            #[Option] string $type = 'USER_TYPE',
            #[Option(shortcut: 'v')] bool $verbose = false,
            #[Option(description: 'User groups')] array $groups = [],
            #[Option(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'],
            #[Option] string|bool $opt = false,
        ): int {
            \assert(null !== $this); // so PHP CS Fixer knows this callback is actually coupled with `$this` and `static_lambda` rule shall not be applied

            return 0;
        });

        $timeoutInputOption = $command->getDefinition()->getOption('idle');
        self::assertSame('idle', $timeoutInputOption->getName());
        self::assertNull($timeoutInputOption->getShortcut());
        self::assertTrue($timeoutInputOption->isValueRequired());
        self::assertFalse($timeoutInputOption->isValueOptional());
        self::assertFalse($timeoutInputOption->isNegatable());
        self::assertNull($timeoutInputOption->getDefault());

        $typeInputOption = $command->getDefinition()->getOption('type');
        self::assertSame('type', $typeInputOption->getName());
        self::assertTrue($typeInputOption->isValueRequired());
        self::assertFalse($typeInputOption->isNegatable());
        self::assertSame('USER_TYPE', $typeInputOption->getDefault());

        $verboseInputOption = $command->getDefinition()->getOption('verbose');
        self::assertSame('verbose', $verboseInputOption->getName());
        self::assertSame('v', $verboseInputOption->getShortcut());
        self::assertFalse($verboseInputOption->isValueRequired());
        self::assertFalse($verboseInputOption->isValueOptional());
        self::assertFalse($verboseInputOption->isNegatable());
        self::assertFalse($verboseInputOption->getDefault());

        $groupsInputOption = $command->getDefinition()->getOption('groups');
        self::assertSame('groups', $groupsInputOption->getName());
        self::assertTrue($groupsInputOption->isArray());
        self::assertSame('User groups', $groupsInputOption->getDescription());
        self::assertFalse($groupsInputOption->isNegatable());
        self::assertSame([], $groupsInputOption->getDefault());

        $rolesInputOption = $command->getDefinition()->getOption('roles');
        self::assertSame('roles', $rolesInputOption->getName());
        self::assertTrue($rolesInputOption->isValueRequired());
        self::assertFalse($rolesInputOption->isNegatable());
        self::assertTrue($rolesInputOption->isArray());
        self::assertSame(['ROLE_USER'], $rolesInputOption->getDefault());
        self::assertTrue($rolesInputOption->hasCompletion());
        $rolesInputOption->complete(new CompletionInput(), $suggestions = new CompletionSuggestions());
        self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], array_map(static fn (Suggestion $s) => $s->getValue(), $suggestions->getValueSuggestions()));

        $optInputOption = $command->getDefinition()->getOption('opt');
        self::assertSame('opt', $optInputOption->getName());
        self::assertNull($optInputOption->getShortcut());
        self::assertFalse($optInputOption->isValueRequired());
        self::assertTrue($optInputOption->isValueOptional());
        self::assertFalse($optInputOption->isNegatable());
        self::assertFalse($optInputOption->getDefault());
    }

    public function testEnumArgument()
    {
        $command = new Command('foo');
        $command->setCode(static function (
            #[Argument] StringEnum $enum,
            #[Argument] StringEnum $enumWithDefault = StringEnum::Image,
            #[Argument] ?StringEnum $nullableEnum = null,
        ): int {
            Assert::assertSame(StringEnum::Image, $enum);
            Assert::assertSame(StringEnum::Image, $enumWithDefault);
            Assert::assertNull($nullableEnum);

            return 0;
        });

        $enumInputArgument = $command->getDefinition()->getArgument('enum');
        self::assertTrue($enumInputArgument->isRequired());
        self::assertNull($enumInputArgument->getDefault());
        self::assertTrue($enumInputArgument->hasCompletion());

        $enumWithDefaultInputArgument = $command->getDefinition()->getArgument('enum-with-default');
        self::assertFalse($enumWithDefaultInputArgument->isRequired());
        self::assertSame('image', $enumWithDefaultInputArgument->getDefault());
        self::assertTrue($enumWithDefaultInputArgument->hasCompletion());

        $nullableEnumInputArgument = $command->getDefinition()->getArgument('nullable-enum');
        self::assertFalse($nullableEnumInputArgument->isRequired());
        self::assertNull($nullableEnumInputArgument->getDefault());
        self::assertTrue($nullableEnumInputArgument->hasCompletion());

        $enumInputArgument->complete(CompletionInput::fromTokens([], 0), $suggestions = new CompletionSuggestions());
        self::assertEquals([new Suggestion('image'), new Suggestion('video')], $suggestions->getValueSuggestions());

        $command->run(new ArrayInput(['enum' => 'image']), new NullOutput());

        self::expectException(InvalidArgumentException::class);
        self::expectExceptionMessage('The value "incorrect" is not valid for the "enum" argument. Supported values are "image", "video".');

        $command->run(new ArrayInput(['enum' => 'incorrect']), new NullOutput());
    }

    public function testEnumOption()
    {
        $command = new Command('foo');
        $command->setCode(static function (
            #[Option] StringEnum $enum = StringEnum::Video,
            #[Option] StringEnum $enumWithDefault = StringEnum::Image,
            #[Option] ?StringEnum $nullableEnum = null,
        ): int {
            Assert::assertSame(StringEnum::Image, $enum);
            Assert::assertSame(StringEnum::Image, $enumWithDefault);
            Assert::assertNull($nullableEnum);

            return 0;
        });

        $enumInputOption = $command->getDefinition()->getOption('enum');
        self::assertTrue($enumInputOption->isValueRequired());
        self::assertSame('video', $enumInputOption->getDefault());
        self::assertTrue($enumInputOption->hasCompletion());

        $enumWithDefaultInputOption = $command->getDefinition()->getOption('enum-with-default');
        self::assertTrue($enumWithDefaultInputOption->isValueRequired());
        self::assertSame('image', $enumWithDefaultInputOption->getDefault());
        self::assertTrue($enumWithDefaultInputOption->hasCompletion());

        $nullableEnumInputOption = $command->getDefinition()->getOption('nullable-enum');
        self::assertTrue($nullableEnumInputOption->isValueRequired());
        self::assertNull($nullableEnumInputOption->getDefault());
        self::assertTrue($nullableEnumInputOption->hasCompletion());

        $enumInputOption->complete(CompletionInput::fromTokens([], 0), $suggestions = new CompletionSuggestions());
        self::assertEquals([new Suggestion('image'), new Suggestion('video')], $suggestions->getValueSuggestions());

        $command->run(new ArrayInput(['--enum' => 'image']), new NullOutput());

        self::expectException(InvalidOptionException::class);
        self::expectExceptionMessage('The value "incorrect" is not valid for the "enum" option. Supported values are "image", "video".');

        $command->run(new ArrayInput(['--enum' => 'incorrect']), new NullOutput());
    }

    public function testExecuteHasPriorityOverInvokeMethod()
    {
        $command = new class extends Command {
            public string $called;

            protected function execute(InputInterface $input, OutputInterface $output): int
            {
                $this->called = __FUNCTION__;

                return 0;
            }

            public function __invoke(): int
            {
                $this->called = __FUNCTION__;

                return 0;
            }
        };

        $command->run(new ArrayInput([]), new NullOutput());
        $this->assertSame('execute', $command->called);
    }

    public function testCallInvokeMethodWhenExtendingCommandClass()
    {
        $command = new class extends Command {
            public string $called;

            public function __invoke(): int
            {
                $this->called = __FUNCTION__;

                return 0;
            }
        };

        $command->run(new ArrayInput([]), new NullOutput());
        $this->assertSame('__invoke', $command->called);
    }

    public function testInvalidReturnType()
    {
        $command = new Command('foo');
        $command->setCode(new class {
            public function __invoke()
            {
            }
        });

        $this->expectException(\TypeError::class);
        $this->expectExceptionMessage('The command "foo" must return an integer value in the "__invoke" method, but "null" was returned.');

        $command->run(new ArrayInput([]), new NullOutput());
    }

    public function testGetCode()
    {
        $invokableTestCommand = new InvokableTestCommand();
        $command = new Command(null, $invokableTestCommand);

        $this->assertSame($invokableTestCommand, $command->getCode());
    }

    #[DataProvider('provideInputArguments')]
    public function testInputArguments(array $parameters, array $expected)
    {
        $command = new Command('foo');
        $command->setCode(function (
            #[Argument] string $a,
            #[Argument] ?string $b,
            #[Argument] string $c = '',
            #[Argument] array $d = [],
        ) use ($expected): int {
            $this->assertSame($expected[0], $a);
            $this->assertSame($expected[1], $b);
            $this->assertSame($expected[2], $c);
            $this->assertSame($expected[3], $d);

            return 0;
        });

        $command->run(new ArrayInput($parameters), new NullOutput());
    }

    public static function provideInputArguments(): \Generator
    {
        yield 'required & defaults' => [['a' => 'x'], ['x', null, '', []]];
        yield 'required & with-value' => [['a' => 'x', 'b' => 'y', 'c' => 'z', 'd' => ['d']], ['x', 'y', 'z', ['d']]];
        yield 'required & without-value' => [['a' => 'x', 'b' => null, 'c' => null, 'd' => null], ['x', null, '', []]];
    }

    #[DataProvider('provideBinaryInputOptions')]
    public function testBinaryInputOptions(array $parameters, array $expected)
    {
        $command = new Command('foo');
        $command->setCode(function (
            #[Option] bool $a = true,
            #[Option] bool $b = false,
            #[Option] ?bool $c = null,
        ) use ($expected): int {
            $this->assertSame($expected[0], $a);
            $this->assertSame($expected[1], $b);
            $this->assertSame($expected[2], $c);

            return 0;
        });

        $command->run(new ArrayInput($parameters), new NullOutput());
    }

    public static function provideBinaryInputOptions(): \Generator
    {
        yield 'defaults' => [[], [true, false, null]];
        yield 'positive' => [['--a' => null, '--b' => null, '--c' => null], [true, true, true]];
        yield 'negative' => [['--no-a' => null, '--no-c' => null], [false, false, false]];
    }

    #[DataProvider('provideNonBinaryInputOptions')]
    public function testNonBinaryInputOptions(array $parameters, array $expected)
    {
        $command = new Command('foo');
        $command->setCode(function (
            #[Option] string $a = '',
            #[Option] array $b = [],
            #[Option] array $c = ['a', 'b'],
            #[Option] bool|string $d = false,
            #[Option] ?string $e = null,
            #[Option] ?array $f = null,
            #[Option] int $g = 0,
            #[Option] ?int $h = null,
            #[Option] float $i = 0.0,
            #[Option] ?float $j = null,
            #[Option] bool|int $k = false,
            #[Option] bool|float $l = false,
        ) use ($expected): int {
            $this->assertSame($expected[0], $a);
            $this->assertSame($expected[1], $b);
            $this->assertSame($expected[2], $c);
            $this->assertSame($expected[3], $d);
            $this->assertSame($expected[4], $e);
            $this->assertSame($expected[5], $f);
            $this->assertSame($expected[6], $g);
            $this->assertSame($expected[7], $h);
            $this->assertSame($expected[8], $i);
            $this->assertSame($expected[9], $j);
            $this->assertSame($expected[10], $k);
            $this->assertSame($expected[11], $l);

            return 0;
        });

        $command->run(new ArrayInput($parameters), new NullOutput());
    }

    public static function provideNonBinaryInputOptions(): \Generator
    {
        yield 'defaults' => [
            [],
            ['', [], ['a', 'b'], false, null, null, 0, null, 0.0, null, false, false],
        ];
        yield 'with-value' => [
            ['--a' => 'x', '--b' => ['z'], '--c' => ['c', 'd'], '--d' => 'v', '--e' => 'w', '--f' => ['q'], '--g' => 1, '--h' => 2, '--i' => 3.1, '--j' => 4.2, '--k' => 5, '--l' => 6.3],
            ['x', ['z'], ['c', 'd'], 'v', 'w', ['q'], 1, 2, 3.1, 4.2, 5, 6.3],
        ];
        yield 'without-value' => [
            ['--d' => null, '--k' => null, '--l' => null],
            ['', [], ['a', 'b'], true, null, null, 0, null, 0.0, null, true, true],
        ];
    }

    #[DataProvider('provideInvalidOptionDefinitions')]
    public function testInvalidOptionDefinition(callable $code)
    {
        $command = new Command('foo');
        $command->setCode($code);

        $this->expectException(LogicException::class);

        $command->getDefinition();
    }

    public static function provideInvalidOptionDefinitions(): \Generator
    {
        yield 'no-default' => [
            static function (#[Option] string $a) {},
        ];
        yield 'nullable-bool-default-true' => [
            static function (#[Option] ?bool $a = true) {},
        ];
        yield 'nullable-bool-default-false' => [
            static function (#[Option] ?bool $a = false) {},
        ];
        yield 'invalid-union-type' => [
            static function (#[Option] array|bool $a = false) {},
        ];
        yield 'union-type-cannot-allow-null' => [
            static function (#[Option] string|bool|null $a = null) {},
        ];
        yield 'union-type-default-true' => [
            static function (#[Option] string|bool $a = true) {},
        ];
        yield 'union-type-default-string' => [
            static function (#[Option] string|bool $a = 'foo') {},
        ];
        yield 'nullable-string-not-null-default' => [
            static function (#[Option] ?string $a = 'foo') {},
        ];
        yield 'nullable-array-not-null-default' => [
            static function (#[Option] ?array $a = []) {},
        ];
    }

    public function testInvalidRequiredValueOptionEvenWithDefault()
    {
        $command = new Command('foo');
        $command->setCode(static function (#[Option] string $a = 'a') {});

        $this->expectException(InvalidOptionException::class);
        $this->expectExceptionMessage('The "--a" option requires a value.');

        $command->run(new ArrayInput(['--a' => null]), new NullOutput());
    }

    public function testHelpersInjection()
    {
        $command = new Command('foo');
        $command->setApplication(new Application());
        $command->setCode(function (
            InputInterface $input,
            OutputInterface $output,
            Cursor $cursor,
            SymfonyStyle $io,
            Application $application,
            Command $command,
        ): int {
            $this->addToAssertionCount(1);

            return 0;
        });

        $command->run(new ArrayInput([]), new NullOutput());
    }

    public function testDefaultArgumentResolversWithoutApplication()
    {
        $command = new Command('foo');
        $command->setCode(static function (
            \DateTime $date,
            #[Argument] string $name = 'default',
        ): int {
            Assert::assertInstanceOf(\DateTime::class, $date);
            Assert::assertSame('test', $name);

            return 0;
        });

        $tester = new CommandTester($command);
        $tester->execute(['name' => 'test']);

        $tester->assertCommandIsSuccessful();
    }

    public function testDefaultArgumentResolversWithMapDateTimeArgument()
    {
        $command = new Command('foo');
        $command->setCode(static function (
            #[Argument, MapDateTime]
            \DateTimeImmutable $date,
        ): int {
            Assert::assertInstanceOf(\DateTimeImmutable::class, $date);
            Assert::assertSame('2026-01-15', $date->format('Y-m-d'));

            return 0;
        });

        $tester = new CommandTester($command);
        $tester->execute(['date' => '2026-01-15']);

        $tester->assertCommandIsSuccessful();
    }

    public function testCustomArgumentResolverViaApplication()
    {
        $customArgumentResolver = new ArgumentResolver([
            new CustomTypeValueResolver(),
            ...ArgumentResolver::getDefaultArgumentValueResolvers(),
        ]);

        $application = new Application();
        $application->setArgumentResolver($customArgumentResolver);

        $command = new Command('foo');
        $command->setCode(static function (
            CustomType $custom,
            #[Argument] string $name = 'default',
        ): int {
            Assert::assertInstanceOf(CustomType::class, $custom);
            Assert::assertSame('resolved:from-app-test', $custom->value);
            Assert::assertSame('app-test', $name);

            return 0;
        });

        $application->addCommand($command);

        $tester = new CommandTester($command);
        $tester->execute(['name' => 'app-test']);

        $tester->assertCommandIsSuccessful();
    }

    public function testCommandInjection()
    {
        $application = new Application();

        $command = new Command('test-cmd');
        $command->setCode(static function (
            Command $cmd,
            #[Argument] string $arg = 'default',
        ): int {
            Assert::assertInstanceOf(Command::class, $cmd);
            Assert::assertSame('test-cmd', $cmd->getName());
            Assert::assertSame('value', $arg);

            return 0;
        });

        $application->addCommand($command);

        $tester = new CommandTester($command);
        $tester->execute(['arg' => 'value']);

        $tester->assertCommandIsSuccessful();
    }

    public function getSuggestedRoles(CompletionInput $input): array
    {
        return ['ROLE_ADMIN', 'ROLE_USER'];
    }

    public function testAskWithInputFileAndConstraints()
    {
        if (!\extension_loaded('fileinfo')) {
            $this->markTestSkipped('The "fileinfo" extension is required for this test.');
        }

        $pngData = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
        $tempFile = sys_get_temp_dir().'/test_image_'.uniqid().'.png';
        file_put_contents($tempFile, $pngData);

        try {
            $tester = new CommandTester(new InvokableWithInputFileAndConstraintsTestCommand());
            $tester->setInputs([$tempFile]);
            $tester->execute([], ['interactive' => true]);
            $tester->assertCommandIsSuccessful();

            self::assertStringContainsString('Provide an image file:', $tester->getDisplay());
            self::assertStringContainsString('Filename:', $tester->getDisplay());
            self::assertStringContainsString('Valid: yes', $tester->getDisplay());
        } finally {
            @unlink($tempFile);
        }
    }

    public function testAskWithCustomValidatorIsNotOverwritten()
    {
        $tester = new CommandTester(new InvokableWithCustomValidatorTestCommand());
        $tester->setInputs(['invalid', 'valid']);
        $tester->execute([], ['interactive' => true]);
        $tester->assertCommandIsSuccessful();

        self::assertStringContainsString('Enter a value:', $tester->getDisplay());
        self::assertStringContainsString('Value must be "valid"', $tester->getDisplay());
        self::assertStringContainsString('Value: valid', $tester->getDisplay());
    }
}

enum StringEnum: string
{
    case Image = 'image';
    case Video = 'video';
}

class CustomType
{
    public function __construct(public string $value)
    {
    }
}

class CustomTypeValueResolver implements ValueResolverInterface
{
    public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable
    {
        $type = $member->getType();

        if (!$type instanceof \ReflectionNamedType || CustomType::class !== $type->getName()) {
            return [];
        }

        $name = $input->hasArgument('name') ? $input->getArgument('name') : 'default';

        yield new CustomType('resolved:from-'.$name);
    }
}
