<?php
namespace Consolidation\AnnotatedCommand;

use Consolidation\AnnotatedCommand\CommandData;
use Consolidation\AnnotatedCommand\CommandProcessor;
use Consolidation\AnnotatedCommand\Hooks\AlterResultInterface;
use Consolidation\AnnotatedCommand\Hooks\ExtractOutputInterface;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Consolidation\AnnotatedCommand\Hooks\ProcessResultInterface;
use Consolidation\AnnotatedCommand\Hooks\StatusDeterminerInterface;
use Consolidation\AnnotatedCommand\Hooks\ValidatorInterface;
use Consolidation\AnnotatedCommand\Options\AlterOptionsCommandEvent;
use Consolidation\AnnotatedCommand\Parser\CommandInfo;
use Consolidation\OutputFormatters\FormatterManager;
use Consolidation\TestUtils\TestTerminal;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Consolidation\TestUtils\ApplicationWithTerminalWidth;
use Consolidation\AnnotatedCommand\Options\PrepareTerminalWidthOption;
use Consolidation\AnnotatedCommand\Events\CustomEventAwareInterface;
use PHPUnit\Framework\TestCase;

/**
 * Do a test of all of the classes in this project, top-to-bottom.
 */
class FullStackTest extends TestCase
{
    protected $application;
    protected $commandFactory;

    function setup(): void {
        $this->application = new ApplicationWithTerminalWidth('TestApplication', '0.0.0');
        $this->commandFactory = new AnnotatedCommandFactory();
        $alterOptionsEventManager = new AlterOptionsCommandEvent($this->application);
        $eventDispatcher = new \Symfony\Component\EventDispatcher\EventDispatcher();
        $eventDispatcher->addSubscriber($this->commandFactory->commandProcessor()->hookManager());
        $this->commandFactory->commandProcessor()->hookManager()->addCommandEvent($alterOptionsEventManager);
        $this->application->setDispatcher($eventDispatcher);
        $this->application->setAutoExit(false);
    }

    function testValidFormats()
    {
        $formatter = new FormatterManager();
        $formatter->addDefaultFormatters();
        $formatter->addDefaultSimplifiers();
        $commandInfo = CommandInfo::create('\Consolidation\TestUtils\alpha\AlphaCommandFile', 'exampleTable');
        $this->assertEquals('example:table', $commandInfo->getName());
        $this->assertEquals('\Consolidation\OutputFormatters\StructuredData\RowsOfFields', $commandInfo->getReturnType());
    }

    function testAutomaticOptions()
    {
        $commandFileInstance = new \Consolidation\TestUtils\alpha\AlphaCommandFile;
        $formatter = new FormatterManager();
        $formatter->addDefaultFormatters();
        $formatter->addDefaultSimplifiers();

        $this->commandFactory->commandProcessor()->setFormatterManager($formatter);
        $this->assertAutomaticOptionsForCommand($commandFileInstance, 'exampleTable', 'example:table');
        $this->assertAutomaticOptionsForCommand($commandFileInstance, 'exampleTableTwo', 'example:table2');
    }

    function assertAutomaticOptionsForCommand($commandFileInstance, $functionName, $commandName)
    {
        $commandInfo = $this->commandFactory->createCommandInfo($commandFileInstance, $functionName);

        $command = $this->commandFactory->createCommand($commandInfo, $commandFileInstance);
        $this->application->add($command);

        $containsList =
        [
            '--format[=FORMAT]  Format the result data. Available formats: csv,json,list,null,php,print-r,sections,string,table,tsv,var_export,xml,yaml [default: "table"]',
            '--fields[=FIELDS]  Available fields: I (first), II (second), III (third), IV (fourth) [default: ""]',
        ];
        $this->assertRunCommandViaApplicationContains('help ' . $commandName, $containsList);
    }

    function testCommandsAndHooks()
    {
        // First, search for commandfiles in the 'alpha'
        // directory. Note that this same functionality
        // is tested more thoroughly in isolation in
        // testCommandFileDiscovery.php
        $discovery = new CommandFileDiscovery();
        $discovery
          ->setSearchPattern('*CommandFile.php')
          ->setIncludeFilesAtBase(false)
          ->setSearchLocations(['alpha']);

        chdir(__DIR__);
        $commandFiles = $discovery->discover('.', '\Consolidation\TestUtils');

        $formatter = new FormatterManager();
        $formatter->addDefaultFormatters();
        $formatter->addDefaultSimplifiers();
        $hookManager = new HookManager();
        $terminalWidthOption = new PrepareTerminalWidthOption();
        $terminalWidthOption->enableWrap(true);
        $terminalWidthOption->setApplication($this->application);
        $testTerminal = new TestTerminal(0);
        $terminalWidthOption->setTerminal($testTerminal);
        $commandProcessor = new CommandProcessor($hookManager);
        $commandProcessor->setFormatterManager($formatter);
        $commandProcessor->addPrepareFormatter($terminalWidthOption);

        // Create a new factory, and load all of the files
        // discovered above.
        $factory = new AnnotatedCommandFactory();
        $factory->setCommandProcessor($commandProcessor);
        // Add a listener to configure our command handler object
        $factory->addListernerCallback(function($command) use($hookManager) {
            if ($command instanceof CustomEventAwareInterface) {
                $command->setHookManager($hookManager);
            }
        } );
        $factory->setIncludeAllPublicMethods(false);
        $this->addDiscoveredCommands($factory, $commandFiles);

        $this->assertRunCommandViaApplicationContains('list', ['example:table'], ['additional:option', 'without:annotations']);

        $this->assertTrue($this->application->has('example:table'));
        $this->assertFalse($this->application->has('without:annotations'));

        // Run the use:event command that defines a custom event, my-event.
        $this->assertRunCommandViaApplicationEquals('use:event', 'one,two');
        // Watch as we dynamically add a custom event to the hook manager to change the command results:
        $hookManager->add(function () { return 'three'; }, HookManager::ON_EVENT, 'my-event');
        $this->assertRunCommandViaApplicationEquals('use:event', 'one,three,two');

        // Fetch a reference to the 'example:table' command and test its valid format types
        $exampleTableCommand = $this->application->find('example:table');
        $returnType = $exampleTableCommand->getReturnType();
        $this->assertEquals('\Consolidation\OutputFormatters\StructuredData\RowsOfFields', $returnType);
        $validFormats = $formatter->validFormats($returnType);
        $this->assertEquals('csv,json,list,null,php,print-r,sections,string,table,tsv,var_export,xml,yaml', implode(',', $validFormats));

        // Control: run commands without hooks.
        $this->assertRunCommandViaApplicationEquals('always:fail', 'This command always fails.', 13);
        $this->assertRunCommandViaApplicationEquals('simulated:status', '42');
        $this->assertRunCommandViaApplicationEquals('example:output', 'Hello, World.');
        $this->assertRunCommandViaApplicationEquals('example:cat bet alpha --flip', 'alphabet');
        $this->assertRunCommandViaApplicationEquals('example:snake bet alpha --flip-flag', 'alphabet');
        $this->assertRunCommandViaApplicationEquals('example:camel bet alpha --flip-flag', 'alphabet');
        $this->assertRunCommandViaApplicationEquals('example:echo a b c', "a\tb\tc");
        $this->assertRunCommandViaApplicationEquals('example:message', 'Shipwrecked; send bananas.');
        $this->assertRunCommandViaApplicationEquals('command:with-one-optional-argument', 'Hello, world');
        $this->assertRunCommandViaApplicationEquals('command:with-one-optional-argument Joe', 'Hello, Joe');
        $this->assertRunCommandViaApplicationEquals('command:with-annotation-data', 'Annotation parameter is dynamic');

        $this->assertRunCommandViaApplicationContains('command:with-format-options --format=json', 'The selected fields are first,second,third,fourth!');
        $this->assertRunCommandViaApplicationContains('command:with-format-options --fields=I,II', 'The selected fields are I,II');
        $this->assertRunCommandViaApplicationContains('command:with-format-options', 'The selected fields are first,third!');

        $this->assertRunCommandViaApplicationContains('command:with-format-options --format=json', 'The slow value was computed');
        $this->assertRunCommandViaApplicationContains('command:with-format-options --fields=I,III', 'The slow value was not computed');
        $this->assertRunCommandViaApplicationContains('command:with-format-options --fields=I,II,III', 'The slow value was computed');
        $this->assertRunCommandViaApplicationContains('command:with-format-options', 'The slow value was not computed');

        // Add some hooks.
        $factory->hookManager()->addValidator(new ExampleValidator());
        $factory->hookManager()->addResultProcessor(new ExampleResultProcessor());
        $factory->hookManager()->addAlterResult(new ExampleResultAlterer());
        $factory->hookManager()->addStatusDeterminer(new ExampleStatusDeterminer());
        $factory->hookManager()->addOutputExtractor(new ExampleOutputExtractor());

        // Run the same commands as before, and confirm that results
        // are different now that the hooks are in place.
        $this->assertRunCommandViaApplicationEquals('simulated:status', '', 42);
        $this->assertRunCommandViaApplicationEquals('example:output', 'Hello, World!');
        $this->assertRunCommandViaApplicationEquals('example:cat bet alpha --flip', 'alphareplaced');
        $this->assertRunCommandViaApplicationEquals('example:echo a b c', 'a,b,c');
        $this->assertRunCommandViaApplicationEquals('example:message', 'Shipwrecked; send bananas.');

        $expected = <<<EOT
 ------ ------ -------
  I      II     III
 ------ ------ -------
  One    Two    Three
  Eins   Zwei   Drei
  Ichi   Ni     San
  Uno    Dos    Tres
 ------ ------ -------
EOT;
        $this->assertRunCommandViaApplicationEquals('example:table', $expected);

        $expected = <<<EOT
 ------- ------
  III     II
 ------- ------
  Three   Two
  Drei    Zwei
  San     Ni
  Tres    Dos
 ------- ------
EOT;
        $this->assertRunCommandViaApplicationEquals('example:table --fields=III,II', $expected);

        $expectedSingleField = <<<EOT
Two
Zwei
Ni
Dos
EOT;

        // When --field is specified (instead of --fields), then the format
        // is forced to 'string'.
        $this->assertRunCommandViaApplicationEquals('example:table --field=II', $expectedSingleField);

        $expectedJson = <<<EOT
[
    {
        "first": "One",
        "second": "Two",
        "third": "Three",
        "fourth": "Four"
    },
    {
        "first": "Eins",
        "second": "Zwei",
        "third": "Drei",
        "fourth": "Vier"
    },
    {
        "first": "Ichi",
        "second": "Ni",
        "third": "San",
        "fourth": "Shi"
    },
    {
        "first": "Uno",
        "second": "Dos",
        "third": "Tres",
        "fourth": "Quatro"
    }
]
EOT;

        $this->assertRunCommandViaApplicationEquals('example:table --format=json', $expectedJson);

        // Check the help for the example table command and see if the options
        // from the alter hook were added.  We expect that we should not see
        // any of the information from the alter hook in the 'beta' folder yet.
        $this->assertRunCommandViaApplicationContains('help example:table',
            [
                'Option added by @hook option example:table',
                'example:table --french',
                'Add a row with French numbers.'
            ],
            [
                'chinese',
                'kanji',
            ]
        );

        $expectedOutputWithFrench = <<<EOT
 ------ ------ -------
  I      II     III
 ------ ------ -------
  One    Two    Three
  Eins   Zwei   Drei
  Ichi   Ni     San
  Uno    Dos    Tres
  Un     Deux   Trois
 ------ ------ -------
EOT;
        $this->assertRunCommandViaApplicationEquals('example:table --french', $expectedOutputWithFrench);

        $expectedAssociativeListTable = <<<EOT
 --------------- ----------------------------------------------------------------------------------------
  SFTP Command    sftp -o Port=2222 dev@appserver.dev.drush.in
  Git Command     git clone ssh://codeserver.dev@codeserver.dev.drush.in:2222/~/repository.git wp-update
  MySQL Command   mysql -u pantheon -p4b33cb -h dbserver.dev.drush.in -P 16191 pantheon
 --------------- ----------------------------------------------------------------------------------------
EOT;
        $this->assertRunCommandViaApplicationEquals('example:list', $expectedAssociativeListTable);
        $this->assertRunCommandViaApplicationEquals('example:list --field=sftp_command', 'sftp -o Port=2222 dev@appserver.dev.drush.in');

        $this->assertRunCommandViaApplicationEquals('get:serious', 'very serious');
        $this->assertRunCommandViaApplicationContains('get:lost', 'Command "get:lost" is not defined.', [], 1);

        $this->assertRunCommandViaApplicationContains('help example:wrap',
            [
                'Test word wrapping',
                '[default: "table"]',
            ]
        );

        $expectedUnwrappedOutput = <<<EOT
-------------------------------------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------------------------------------------------
  First                                                                                                                      Second
 -------------------------------------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------------------------------------------------
  This is a really long cell that contains a lot of data. When it is rendered, it should be wrapped across multiple lines.   This is the second column of the same table. It is also very long, and should be wrapped across multiple lines, just like the first column.
 -------------------------------------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------------------------------------------------
EOT;
        $this->application->setWidthAndHeight(0, 0);
        $this->assertRunCommandViaApplicationEquals('example:wrap', $expectedUnwrappedOutput);

        $expectedWrappedOutput = <<<EOT
 ------------------ --------------------
  First              Second
 ------------------ --------------------
  This is a really   This is the second
  long cell that     column of the same
  contains a lot     table. It is also
  of data. When it   very long, and
  is rendered, it    should be wrapped
  should be          across multiple
  wrapped across     lines, just like
  multiple lines.    the first column.
 ------------------ --------------------
EOT;
        $this->application->setWidthAndHeight(42, 24);
        $testTerminal->setWidth(42);
        $this->assertRunCommandViaApplicationEquals('example:wrap', $expectedWrappedOutput);
    }

    function testCommandsAndHooksIncludeAllPublicMethods()
    {
        // First, search for commandfiles in the 'alpha'
        // directory. Note that this same functionality
        // is tested more thoroughly in isolation in
        // testCommandFileDiscovery.php
        $discovery = new CommandFileDiscovery();
        $discovery
          ->setSearchPattern('*CommandFile.php')
          ->setIncludeFilesAtBase(false)
          ->setSearchLocations(['alpha']);

        chdir(__DIR__);
        $commandFiles = $discovery->discover('.', '\Consolidation\TestUtils');

        $formatter = new FormatterManager();
        $formatter->addDefaultFormatters();
        $formatter->addDefaultSimplifiers();
        $hookManager = new HookManager();
        $commandProcessor = new CommandProcessor($hookManager);
        $commandProcessor->setFormatterManager($formatter);

        // Create a new factory, and load all of the files
        // discovered above.  The command factory class is
        // tested in isolation in testAnnotatedCommandFactory.php,
        // but this is the only place where
        $factory = new AnnotatedCommandFactory();
        $factory->setCommandProcessor($commandProcessor);
        // $factory->addListener(...);

        // Now we will once again add all commands, this time including all
        // public methods.  The command 'withoutAnnotations' should now be found.
        $factory->setIncludeAllPublicMethods(true);
        $this->addDiscoveredCommands($factory, $commandFiles);
        $this->assertTrue($this->application->has('without:annotations'));

        $this->assertRunCommandViaApplicationContains('list', ['example:table', 'without:annotations'], ['alter:formatters']);

        $this->assertRunCommandViaApplicationEquals('get:serious', 'very serious');
        $this->assertRunCommandViaApplicationContains('get:lost', 'Command "get:lost" is not defined.', [], 1);
        $this->assertRunCommandViaApplicationContains('get:both', 'Here is some data.', [], 3);
    }

    function testCommandsAndHooksWithBetaFolder()
    {
        // First, search for commandfiles in the 'alpha'
        // directory. Note that this same functionality
        // is tested more thoroughly in isolation in
        // testCommandFileDiscovery.php
        $discovery = new CommandFileDiscovery();
        $discovery
          ->setSearchPattern('*CommandFile.php')
          ->setIncludeFilesAtBase(false)
          ->setSearchLocations(['alpha', 'beta']);

        chdir(__DIR__);
        $commandFiles = $discovery->discover('.', '\Consolidation\TestUtils');

        $formatter = new FormatterManager();
        $formatter->addDefaultFormatters();
        $formatter->addDefaultSimplifiers();
        $hookManager = new HookManager();
        $commandProcessor = new CommandProcessor($hookManager);
        $commandProcessor->setFormatterManager($formatter);

        // Create a new factory, and load all of the files
        // discovered above.  The command factory class is
        // tested in isolation in testAnnotatedCommandFactory.php,
        // but this is the only place where
        $factory = new AnnotatedCommandFactory();
        $factory->setCommandProcessor($commandProcessor);
        // $factory->addListener(...);
        $factory->setIncludeAllPublicMethods(true);
        $this->addDiscoveredCommands($factory, $commandFiles);

        // A few asserts, to make sure that our hooks all get registered.
        $allRegisteredHooks = $hookManager->getAllHooks();
        $registeredHookNames = array_keys($allRegisteredHooks);
        sort($registeredHookNames);
        $this->assertEquals('*,example:table,my-event', implode(',', $registeredHookNames));
        $allHooksForExampleTable = $allRegisteredHooks['example:table'];
        $allHookPhasesForExampleTable = array_keys($allHooksForExampleTable);
        sort($allHookPhasesForExampleTable);
        $this->assertEquals('alter,option', implode(',', $allHookPhasesForExampleTable));

        $this->assertStringContainsString('alterFormattersChinese', var_export($allHooksForExampleTable, true));

        $alterHooksForExampleTable = $this->callProtected($hookManager, 'getHooks', [['example:table'], 'alter']);
        $this->assertStringContainsString('alterFormattersKanji', var_export($alterHooksForExampleTable, true));

        $allHooksForAnyCommand = $allRegisteredHooks['*'];
        $allHookPhasesForAnyCommand = array_keys($allHooksForAnyCommand);
        sort($allHookPhasesForAnyCommand);
        $this->assertEquals('alter', implode(',', $allHookPhasesForAnyCommand));

        $this->assertStringContainsString('alterFormattersKanji', var_export($allHooksForAnyCommand, true));

        // Help should have the information from the hooks in the 'beta' folder
        $this->assertRunCommandViaApplicationContains('help example:table',
            [
                'Option added by @hook option example:table',
                'example:table --french',
                'Add a row with French numbers.',
                'chinese',
                'kanji',
            ]
        );

        // Confirm that the "unavailable" command is now available
        $this->assertTrue($this->application->has('unavailable:command'));

        $expectedOutputWithChinese = <<<EOT
 ------ ------ -------
  I      II     III
 ------ ------ -------
  One    Two    Three
  Eins   Zwei   Drei
  Ichi   Ni     San
  Uno    Dos    Tres
  壹     貳     叁
 ------ ------ -------
EOT;
        $this->assertRunCommandViaApplicationEquals('example:table --chinese', $expectedOutputWithChinese);

        $expectedOutputWithKanji = <<<EOT
 ------ ------ -------
  I      II     III
 ------ ------ -------
  One    Two    Three
  Eins   Zwei   Drei
  Ichi   Ni     San
  Uno    Dos    Tres
  一     二     三
 ------ ------ -------
EOT;
        $this->assertRunCommandViaApplicationEquals('example:table --kanji', $expectedOutputWithKanji);
    }

    public function addDiscoveredCommands($factory, $commandFiles) {
        foreach ($commandFiles as $path => $commandClass) {
            $this->assertFileExists($path);
            if (!class_exists($commandClass)) {
                include $path;
            }
            $commandInstance = new $commandClass();
            $commandList = $factory->createCommandsFromClass($commandInstance);
            foreach ($commandList as $command) {
                $this->application->add($command);
            }
        }
    }

    function assertRunCommandViaApplicationEquals($cmd, $expectedOutput, $expectedStatusCode = 0)
    {
        $input = new StringInput($cmd);
        $output = new BufferedOutput();

        $statusCode = $this->application->run($input, $output);
        $commandOutput = trim($output->fetch());

        $expectedOutput = $this->simplifyWhitespace($expectedOutput);
        $commandOutput = $this->simplifyWhitespace($commandOutput);

        $this->assertEquals($expectedOutput, $commandOutput);
        $this->assertEquals($expectedStatusCode, $statusCode);
    }

    function assertRunCommandViaApplicationContains($cmd, $containsList, $doesNotContainList = [], $expectedStatusCode = 0)
    {
        $input = new StringInput($cmd);
        $output = new BufferedOutput();
        $containsList = (array) $containsList;

        $statusCode = $this->application->run($input, $output);
        $commandOutput = trim($output->fetch());

        $commandOutput = $this->simplifyWhitespace($commandOutput);

        foreach ($containsList as $expectedToContain) {
            $this->assertStringContainsString($this->simplifyWhitespace($expectedToContain), $commandOutput);
        }
        foreach ($doesNotContainList as $expectedToNotContain) {
            $this->assertStringNotContainsString($this->simplifyWhitespace($expectedToNotContain), $commandOutput);
        }
        $this->assertEquals($expectedStatusCode, $statusCode);
    }

    function simplifyWhitespace($data)
    {
        $data = preg_replace('#\r\n#ms', "\n", $data);

        return trim(preg_replace('#\s+$#m', '', $data));
    }

    function callProtected($object, $method, $args = [])
    {
        $r = new \ReflectionMethod($object, $method);
        $r->setAccessible(true);
        return $r->invokeArgs($object, $args);
    }

}

class ExampleValidator implements ValidatorInterface
{
    public function validate(CommandData $commandData)
    {
        $args = $commandData->arguments();
        if (isset($args['one']) && ($args['one'] == 'bet')) {
            $commandData->input()->setArgument('one', 'replaced');
            return $args;
        }
    }
}

class ExampleResultProcessor implements ProcessResultInterface
{
    public function process($result, CommandData $commandData)
    {
        if (is_array($result) && array_key_exists('item-list', $result)) {
            return implode(',', $result['item-list']);
        }
    }
}

class ExampleResultAlterer implements AlterResultInterface
{
    public function process($result, CommandData $commandData)
    {
        if (is_string($result) && ($result == 'Hello, World.')) {
            return 'Hello, World!';
        }
    }
}

class ExampleStatusDeterminer implements StatusDeterminerInterface
{
    public function determineStatusCode($result)
    {
        if (is_array($result) && array_key_exists('status-code', $result)) {
            return $result['status-code'];
        }
    }
}

class ExampleOutputExtractor implements ExtractOutputInterface
{
    public function extractOutput($result)
    {
        if (is_array($result) && array_key_exists('message', $result)) {
            return $result['message'];
        }
    }
}
