<?php

namespace MongoDB\Tests\SpecTests;

use MongoDB\Driver\Command;
use MongoDB\Driver\Cursor;
use MongoDB\Tests\CommandObserver;
use stdClass;

use function basename;
use function current;
use function explode;
use function file_get_contents;
use function glob;
use function parse_url;

/**
 * Atlas Data Lake spec tests.
 *
 * @see https://github.com/mongodb/specifications/tree/master/source/atlas-data-lake-testing/tests
 */
class AtlasDataLakeSpecTest extends FunctionalTestCase
{
    public function setUp(): void
    {
        parent::setUp();

        if (! $this->isAtlasDataLake()) {
            $this->markTestSkipped('Server is not Atlas Data Lake');
        }
    }

    /**
     * Assert that the expected and actual command documents match.
     *
     * @param stdClass $expected Expected command document
     * @param stdClass $actual   Actual command document
     */
    public static function assertCommandMatches(stdClass $expected, stdClass $actual): void
    {
        foreach ($expected as $key => $value) {
            if ($value === null) {
                static::assertObjectNotHasAttribute($key, $actual);
                unset($expected->{$key});
            }
        }

        static::assertDocumentsMatch($expected, $actual);
    }

    /**
     * Execute an individual test case from the specification.
     *
     * @dataProvider provideTests
     * @param stdClass $test           Individual "tests[]" document
     * @param array    $runOn          Top-level "runOn" array with server requirements
     * @param array    $data           Top-level "data" array to initialize collection
     * @param string   $databaseName   Name of database under test
     * @param string   $collectionName Name of collection under test
     */
    public function testAtlasDataLake(stdClass $test, ?array $runOn, array $data, ?string $databaseName = null, ?string $collectionName = null): void
    {
        if (isset($runOn)) {
            $this->checkServerRequirements($runOn);
        }

        if (isset($test->skipReason)) {
            $this->markTestSkipped($test->skipReason);
        }

        $databaseName = $databaseName ?? $this->getDatabaseName();
        $collectionName = $collectionName ?? $this->getCollectionName();

        $context = Context::fromCrud($test, $databaseName, $collectionName);
        $this->setContext($context);

        /* Note: Atlas Data Lake is read-only, so do not attempt to drop the
         * collection under test or insert data fixtures. Necesarry data
         * fixtures are already specified in the mongohoused configuration. */

        if (isset($test->failPoint)) {
            throw new LogicException('ADL tests are not expected to configure fail points');
        }

        if (isset($test->expectations)) {
            $commandExpectations = CommandExpectations::fromCrud($context->getClient(), (array) $test->expectations);
            $commandExpectations->startMonitoring();
        }

        foreach ($test->operations as $operation) {
            Operation::fromCrud($operation)->assert($this, $context);
        }

        if (isset($commandExpectations)) {
            $commandExpectations->stopMonitoring();
            $commandExpectations->assert($this, $context);
        }

        if (isset($test->outcome->collection->data)) {
            throw new LogicException('ADL tests are not expected to assert collection data');
        }
    }

    public function provideTests()
    {
        $testArgs = [];

        foreach (glob(__DIR__ . '/atlas_data_lake/*.json') as $filename) {
            $json = $this->decodeJson(file_get_contents($filename));
            $group = basename($filename, '.json');
            $runOn = $json->runOn ?? null;
            $data = $json->data ?? [];
            // phpcs:disable Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
            $databaseName = $json->database_name ?? null;
            $collectionName = $json->collection_name ?? null;
            // phpcs:enable Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps

            foreach ($json->tests as $test) {
                $name = $group . ': ' . $test->description;
                $testArgs[$name] = [$test, $runOn, $data, $databaseName, $collectionName];
            }
        }

        return $testArgs;
    }

    /**
     * Prose test 1: Connect without authentication
     */
    public function testKillCursors(): void
    {
        $cursorId = null;
        $cursorNamespace = null;

        (new CommandObserver())->observe(
            function (): void {
                $client = static::createTestClient();
                $client->test->driverdata->find([], ['batchSize' => 2, 'limit' => 3]);
            },
            function (array $event) use (&$cursorId, &$cursorNamespace): void {
                if ($event['started']->getCommandName() === 'find') {
                    $this->assertArrayHasKey('succeeded', $event);

                    $reply = $event['succeeded']->getReply();
                    $this->assertObjectHasAttribute('cursor', $reply);
                    $this->assertIsObject($reply->cursor);
                    $this->assertObjectHasAttribute('id', $reply->cursor);
                    $this->assertIsInt($reply->cursor->id);
                    $this->assertObjectHasAttribute('ns', $reply->cursor);
                    $this->assertIsString($reply->cursor->ns);

                    /* Note: MongoDB\Driver\CursorId is not used here; however,
                     * we shouldn't have to worry about encoutnering a 64-bit
                     * cursor IDs on a 32-bit platform mongohoused allocates IDs
                     * sequentially (starting from 1). */
                    $cursorId = $reply->cursor->id;
                    $cursorNamespace = $reply->cursor->ns;

                    return;
                }

                /* After the initial find command, expect that killCursors is
                 * next and that a cursor ID and namespace were collected. */
                $this->assertSame('killCursors', $event['started']->getCommandName());
                $this->assertIsInt($cursorId);
                $this->assertIsString($cursorNamespace);

                [$databaseName, $collectionName] = explode('.', $cursorNamespace, 2);
                $command = $event['started']->getCommand();

                /* Assert that the killCursors command uses the namespace and
                 * cursor ID from the find command reply. */
                $this->assertSame($databaseName, $event['started']->getDatabaseName());
                $this->assertSame($databaseName, $command->{'$db'});
                $this->assertObjectHasAttribute('killCursors', $command);
                $this->assertSame($collectionName, $command->killCursors);
                $this->assertObjectHasAttribute('cursors', $command);
                $this->assertIsArray($command->cursors);
                $this->assertArrayHasKey(0, $command->cursors);
                $this->assertSame($cursorId, $command->cursors[0]);

                /* Assert that the killCursors command reply indicates that the
                 * expected cursor ID was killed. */
                $reply = $event['succeeded']->getReply();
                $this->assertObjectHasAttribute('cursorsKilled', $reply);
                $this->assertIsArray($reply->cursorsKilled);
                $this->assertArrayHasKey(0, $reply->cursorsKilled);
                $this->assertSame($cursorId, $reply->cursorsKilled[0]);
            }
        );
    }

    /**
     * Prose test 2: Connect without authentication
     */
    public function testConnectWithoutAuth(): void
    {
        /* Parse URI to remove userinfo component. The query string is left
         * as-is and must not include authMechanism or credentials. */
        $parts = parse_url(static::getUri());
        $port = isset($parts['port']) ? ':' . $parts['port'] : '';
        $path = $parts['path'] ?? '/';
        $query = isset($parts['query']) ? '?' . $parts['query'] : '';

        $uri = $parts['scheme'] . '://' . $parts['host'] . $port . $path . $query;

        $client = static::createTestClient($uri);
        $cursor = $client->selectDatabase($this->getDatabaseName())->command(['ping' => 1]);

        $this->assertInstanceOf(Cursor::class, $cursor);
        $this->assertCommandSucceeded(current($cursor->toArray()));
    }

    /**
     * Prose test 3: Connect with SCRAM-SHA-1 authentication
     */
    public function testConnectwithSCRAMSHA1(): void
    {
        $client = static::createTestClient(null, ['authMechanism' => 'SCRAM-SHA-1']);
        $cursor = $client->selectDatabase($this->getDatabaseName())->command(['ping' => 1]);

        $this->assertInstanceOf(Cursor::class, $cursor);
        $this->assertCommandSucceeded(current($cursor->toArray()));
    }

    /**
     * Prose test 4: Connect with SCRAM-SHA-256 authentication
     */
    public function testConnectwithSCRAMSHA256(): void
    {
        $client = static::createTestClient(null, ['authMechanism' => 'SCRAM-SHA-256']);
        $cursor = $client->selectDatabase($this->getDatabaseName())->command(['ping' => 1]);

        $this->assertInstanceOf(Cursor::class, $cursor);
        $this->assertCommandSucceeded(current($cursor->toArray()));
    }

    private function isAtlasDataLake(): bool
    {
        $cursor = $this->manager->executeCommand(
            $this->getDatabaseName(),
            new Command(['buildInfo' => 1])
        );

        $document = current($cursor->toArray());

        return ! empty($document->dataLake);
    }
}
