<?php

namespace MongoDB\Tests\SpecTests;

use ArrayIterator;
use LogicException;
use MongoDB\BSON\Int64;
use MongoDB\ChangeStream;
use MongoDB\Driver\Exception\Exception;
use MongoDB\Model\BSONDocument;
use MultipleIterator;
use stdClass;

use function basename;
use function count;
use function file_get_contents;
use function glob;

/**
 * Change Streams spec tests.
 *
 * @see https://github.com/mongodb/specifications/tree/master/source/change-streams
 */
class ChangeStreamsSpecTest extends FunctionalTestCase
{
    /** @var array */
    private static $incompleteTests = [];

    /**
     * Assert that the expected and actual command documents match.
     *
     * Note: this method may modify the $expected object.
     *
     * @param stdClass $expected Expected command document
     * @param stdClass $actual   Actual command document
     */
    public static function assertCommandMatches(stdClass $expected, stdClass $actual): void
    {
        if (isset($expected->getMore) && $expected->getMore === 42) {
            static::assertObjectHasAttribute('getMore', $actual);
            static::assertThat($actual->getMore, static::logicalOr(
                static::isInstanceOf(Int64::class),
                static::isType('integer')
            ));
            unset($expected->getMore);
        }

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

    /**
     * Assert that the expected and actual documents match.
     *
     * @param array $expectedDocuments Expected documents
     * @param array $actualDocuments   Actual documents
     */
    public static function assertResult(array $expectedDocuments, array $actualDocuments): void
    {
        static::assertCount(count($expectedDocuments), $actualDocuments);

        $mi = new MultipleIterator(MultipleIterator::MIT_NEED_ANY);
        $mi->attachIterator(new ArrayIterator($expectedDocuments));
        $mi->attachIterator(new ArrayIterator($actualDocuments));

        foreach ($mi as $documents) {
            [$expectedDocument, $actualDocument] = $documents;

            $constraint = new DocumentsMatchConstraint($expectedDocument, true, true, ['42']);

            static::assertThat($actualDocument, $constraint);
        }
    }

    /**
     * Execute an individual test case from the specification.
     *
     * @dataProvider provideTests
     * @param stdClass $test            Individual "tests[]" document
     * @param string   $databaseName    Name of database under test
     * @param string   $collectionName  Name of collection under test
     * @param string   $database2Name   Name of alternate database under test
     * @param string   $collection2Name Name of alternate collection under test
     */
    public function testChangeStreams(stdClass $test, ?string $databaseName = null, ?string $collectionName = null, ?string $database2Name = null, ?string $collection2Name = null): void
    {
        if (isset(self::$incompleteTests[$this->dataDescription()])) {
            $this->markTestIncomplete(self::$incompleteTests[$this->dataDescription()]);
        }

        if ($this->isShardedCluster() && ! $this->isShardedClusterUsingReplicasets()) {
            $this->markTestSkipped('$changeStream is only supported with replicasets');
        }

        $this->checkServerRequirements($this->createRunOn($test));

        if (! isset($databaseName, $collectionName)) {
            $this->fail('Required database and collection names are unset');
        }

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

        $this->dropDatabasesAndCreateCollection($databaseName, $collectionName);

        if (isset($database2Name, $collection2Name)) {
            $this->dropDatabasesAndCreateCollection($database2Name, $collection2Name);
        }

        if (isset($test->failPoint)) {
            $this->configureFailPoint($test->failPoint);
        }

        if (isset($test->expectations)) {
            $commandExpectations = CommandExpectations::fromChangeStreams($test->expectations);
            $commandExpectations->startMonitoring();
        }

        $errorExpectation = ErrorExpectation::fromChangeStreams($test->result);
        $resultExpectation = ResultExpectation::fromChangeStreams($test->result, [$this, 'assertResult']);

        $result = null;
        $exception = null;

        try {
            $changeStream = $this->createChangeStream($test);
        } catch (Exception $e) {
            $exception = $e;
        }

        if (isset($commandExpectations)) {
            $commandExpectations->stopMonitoring();
        }

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

        if (isset($commandExpectations)) {
            $commandExpectations->startMonitoring();
        }

        /* If the change stream was successfully created (i.e. $exception is
         * null), attempt to iterate up to the expected number of results. It's
         * possible that some errors (e.g. projecting out _id) will only be
         * thrown during iteration, so we must also try/catch here. */
        try {
            if (isset($changeStream)) {
                $limit = isset($test->result->success) ? count($test->result->success) : 0;
                $result = $this->iterateChangeStream($changeStream, $limit);
            }
        } catch (Exception $e) {
            $this->assertNull($exception);
            $exception = $e;
        }

        $errorExpectation->assert($this, $exception);
        $resultExpectation->assert($this, $result);

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

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

        foreach (glob(__DIR__ . '/change-streams/*.json') as $filename) {
            $json = $this->decodeJson(file_get_contents($filename));
            $group = basename($filename, '.json');
            // phpcs:disable Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
            $databaseName = $json->database_name ?? null;
            $database2Name = $json->database2_name ?? null;
            $collectionName = $json->collection_name ?? null;
            $collection2Name = $json->collection2_name ?? null;
            // phpcs:enable Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps

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

        return $testArgs;
    }

    /**
     * Create a change stream.
     *
     * @param stdClass $test
     * @return ChangeStream
     * @throws LogicException if the target is unsupported
     */
    private function createChangeStream(stdClass $test): ChangeStream
    {
        $context = $this->getContext();
        $pipeline = $test->changeStreamPipeline ?? [];
        $options = isset($test->changeStreamOptions) ? (array) $test->changeStreamOptions : [];

        switch ($test->target) {
            case 'client':
                return $context->getClient()->watch($pipeline, $options);

            case 'database':
                return $context->getDatabase()->watch($pipeline, $options);

            case 'collection':
                return $context->getCollection()->watch($pipeline, $options);

            default:
                throw new LogicException('Unsupported target: ' . $test->target);
        }
    }

    /**
     * Convert the server requirements to a standard "runOn" array used by other
     * specifications.
     *
     * @param stdClass $test
     * @return array
     */
    private function createRunOn(stdClass $test): array
    {
        $req = new stdClass();

        /* Append ".99" as patch version, since command monitoring tests expect
         * the minor version to be an inclusive upper bound. */
        if (isset($test->maxServerVersion)) {
            $req->maxServerVersion = $test->maxServerVersion;
        }

        if (isset($test->minServerVersion)) {
            $req->minServerVersion = $test->minServerVersion;
        }

        if (isset($test->topology)) {
            $req->topology = $test->topology;
        }

        return [$req];
    }

    /**
     * Drop the database and create the collection.
     *
     * @param string $databaseName
     * @param string $collectionName
     */
    private function dropDatabasesAndCreateCollection(string $databaseName, string $collectionName): void
    {
        $context = $this->getContext();

        $database = $context->getClient()->selectDatabase($databaseName);
        $database->drop($context->defaultWriteOptions);
        $database->createCollection($collectionName, $context->defaultWriteOptions);
    }

    /**
     * Iterate a change stream.
     *
     * @param ChangeStream $changeStream
     * @param integer      $limit
     * @return BSONDocument[]
     */
    private function iterateChangeStream(ChangeStream $changeStream, int $limit = 0): array
    {
        if ($limit < 0) {
            throw new LogicException('$limit is negative');
        }

        /* Limit iterations to guard against an infinite loop should a test fail
         * to return as many results as are expected. Require at least one
         * iteration to allow next() a chance to throw for error tests. */
        $maxIterations = $limit + 1;

        /* On sharded clusters, allow for empty getMore calls due to sharding
         * architecture */
        if ($this->isShardedCluster()) {
            $maxIterations *= 5;
        }

        $events = [];

        for ($i = 0, $changeStream->rewind(); $i < $maxIterations; $i++, $changeStream->next()) {
            if (! $changeStream->valid()) {
                continue;
            }

            $event = $changeStream->current();
            $this->assertInstanceOf(BSONDocument::class, $event);
            $events[] = $event;

            if (count($events) >= $limit) {
                break;
            }
        }

        return $events;
    }
}
