<?php

namespace MongoDB\Tests\Collection;

use LogicException;
use MongoDB\BulkWriteResult;
use MongoDB\Collection;
use MongoDB\DeleteResult;
use MongoDB\Driver\Exception\BulkWriteException;
use MongoDB\Driver\Exception\RuntimeException;
use MongoDB\InsertManyResult;
use MongoDB\InsertOneResult;
use MongoDB\Operation\FindOneAndReplace;
use MongoDB\UpdateResult;
use MultipleIterator;
use PHPUnit_Framework_SkippedTestError;

use function array_diff_key;
use function array_key_exists;
use function array_map;
use function file_get_contents;
use function glob;
use function json_decode;
use function MongoDB\is_last_pipeline_operator_write;
use function sprintf;
use function str_replace;
use function strtolower;
use function version_compare;

/**
 * CRUD spec functional tests.
 *
 * @see https://github.com/mongodb/specifications/tree/master/source/crud/tests
 *
 * @group serverless
 * @group matrix-testing-exclude-server-5.0-driver-4.0
 */
class CrudSpecFunctionalTest extends FunctionalTestCase
{
    public const SERVERLESS_ALLOW = 'allow';
    public const SERVERLESS_FORBID = 'forbid';
    public const SERVERLESS_REQUIRE = 'require';

    /** @var Collection */
    private $expectedCollection;

    public function setUp(): void
    {
        parent::setUp();

        $this->expectedCollection = new Collection($this->manager, $this->getDatabaseName(), $this->getCollectionName() . '.expected');
        $this->expectedCollection->drop();
    }

    /**
     * @dataProvider provideSpecificationTests
     */
    public function testSpecification(array $initialData, array $test, $minServerVersion, $maxServerVersion, $serverless): void
    {
        if (isset($minServerVersion) || isset($maxServerVersion)) {
            $this->checkServerVersion($minServerVersion, $maxServerVersion);
        }

        $this->checkServerlessRequirement($serverless);

        $expectedData = $test['outcome']['collection']['data'] ?? null;
        $this->initializeData($initialData, $expectedData);

        if (isset($test['outcome']['collection']['name'])) {
            $outputCollection = new Collection($this->manager, $this->getDatabaseName(), $test['outcome']['collection']['name']);
            $outputCollection->drop();
        }

        $result = null;
        $exception = null;

        try {
            $result = $this->executeOperation($test['operation']);
        } catch (RuntimeException $e) {
            $exception = $e;
        }

        $this->executeOutcome($test['operation'], $test['outcome'], $result, $exception);
    }

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

        foreach (glob(__DIR__ . '/spec-tests/*/*.json') as $filename) {
            $json = json_decode(file_get_contents($filename), true);

            foreach ($json['tests'] as $test) {
                $name = str_replace(' ', '_', $test['description']);
                $testArgs[$name] = [
                    $json['data'],
                    $test,
                    $json['minServerVersion'] ?? null,
                    $json['maxServerVersion'] ?? null,
                    $json['serverless'] ?? null,
                ];
            }
        }

        return $testArgs;
    }

    /**
     * Assert that the collections contain equivalent documents.
     *
     * @param Collection $expectedCollection
     * @param Collection $actualCollection
     */
    private function assertEquivalentCollections(Collection $expectedCollection, Collection $actualCollection): void
    {
        $mi = new MultipleIterator(MultipleIterator::MIT_NEED_ANY);
        $mi->attachIterator($expectedCollection->find());
        $mi->attachIterator($actualCollection->find());

        foreach ($mi as $documents) {
            [$expectedDocument, $actualDocument] = $documents;
            $this->assertSameDocument($expectedDocument, $actualDocument);
        }
    }

    private function checkServerlessRequirement(?string $serverless): void
    {
        switch ($serverless) {
            case null:
            case self::SERVERLESS_ALLOW:
                return;

            case self::SERVERLESS_FORBID:
                if ($this->isServerless()) {
                    $this->markTestSkipped('Test does not apply on serverless');
                }

                return;

            case self::SERVERLESS_REQUIRE:
                if (! $this->isServerless()) {
                    $this->markTestSkipped('Test requires serverless');
                }

                return;

            default:
                $this->fail(sprintf('Unknown serverless requirement "%s".', $serverless));
        }
    }

    /**
     * Checks that the server version is within the allowed bounds (if any).
     *
     * @param string|null $minServerVersion
     * @param string|null $maxServerVersion
     * @throws PHPUnit_Framework_SkippedTestError
     */
    private function checkServerVersion(?string $minServerVersion, ?string $maxServerVersion): void
    {
        $serverVersion = $this->getServerVersion();

        if (isset($minServerVersion) && version_compare($serverVersion, $minServerVersion, '<')) {
            $this->markTestSkipped(sprintf('Server version "%s" < minServerVersion "%s"', $serverVersion, $minServerVersion));
        }

        if (isset($maxServerVersion) && version_compare($serverVersion, $maxServerVersion, '>=')) {
            $this->markTestSkipped(sprintf('Server version "%s" >= maxServerVersion "%s"', $serverVersion, $maxServerVersion));
        }
    }

    /**
     * Executes an "operation" block.
     *
     * @param array $operation
     * @return mixed
     * @throws LogicException if the operation is unsupported
     */
    private function executeOperation(array $operation)
    {
        switch ($operation['name']) {
            case 'aggregate':
                return $this->collection->aggregate(
                    $operation['arguments']['pipeline'],
                    array_diff_key($operation['arguments'], ['pipeline' => 1])
                );

            case 'bulkWrite':
                return $this->collection->bulkWrite(
                    array_map([$this, 'prepareBulkWriteRequest'], $operation['arguments']['requests']),
                    $operation['arguments']['options'] ?? []
                );

            case 'count':
            case 'countDocuments':
            case 'find':
                return $this->collection->{$operation['name']}(
                    $operation['arguments']['filter'] ?? [],
                    array_diff_key($operation['arguments'], ['filter' => 1])
                );

            case 'estimatedDocumentCount':
                return $this->collection->estimatedDocumentCount($operation['arguments']);

            case 'deleteMany':
            case 'deleteOne':
            case 'findOneAndDelete':
                return $this->collection->{$operation['name']}(
                    $operation['arguments']['filter'],
                    array_diff_key($operation['arguments'], ['filter' => 1])
                );

            case 'distinct':
                return $this->collection->distinct(
                    $operation['arguments']['fieldName'],
                    $operation['arguments']['filter'] ?? [],
                    array_diff_key($operation['arguments'], ['fieldName' => 1, 'filter' => 1])
                );

            case 'findOneAndReplace':
                $operation['arguments'] = $this->prepareFindAndModifyArguments($operation['arguments']);
                // Fall through

            case 'replaceOne':
                return $this->collection->{$operation['name']}(
                    $operation['arguments']['filter'],
                    $operation['arguments']['replacement'],
                    array_diff_key($operation['arguments'], ['filter' => 1, 'replacement' => 1])
                );

            case 'findOneAndUpdate':
                $operation['arguments'] = $this->prepareFindAndModifyArguments($operation['arguments']);
                // Fall through

            case 'updateMany':
            case 'updateOne':
                return $this->collection->{$operation['name']}(
                    $operation['arguments']['filter'],
                    $operation['arguments']['update'],
                    array_diff_key($operation['arguments'], ['filter' => 1, 'update' => 1])
                );

            case 'insertMany':
                return $this->collection->insertMany(
                    $operation['arguments']['documents'],
                    $operation['arguments']['options'] ?? []
                );

            case 'insertOne':
                return $this->collection->insertOne(
                    $operation['arguments']['document'],
                    array_diff_key($operation['arguments'], ['document' => 1])
                );

            default:
                throw new LogicException('Unsupported operation: ' . $operation['name']);
        }
    }

    /**
     * Executes an "outcome" block.
     *
     * @param array            $operation
     * @param array            $outcome
     * @param mixed            $result
     * @param RuntimeException $exception
     * @return mixed
     * @throws LogicException if the operation is unsupported
     */
    private function executeOutcome(array $operation, array $outcome, $result, ?RuntimeException $exception = null)
    {
        $expectedError = array_key_exists('error', $outcome) ? $outcome['error'] : false;

        if ($expectedError) {
            $this->assertNull($result);
            $this->assertNotNull($exception);

            $result = $this->extractResultFromException($operation, $outcome, $exception);
        }

        if (array_key_exists('result', $outcome)) {
            $this->executeAssertResult($operation, $outcome['result'], $result);
        }

        if (isset($outcome['collection'])) {
            $actualCollection = isset($outcome['collection']['name'])
                ? new Collection($this->manager, $this->getDatabaseName(), $outcome['collection']['name'])
                : $this->collection;

            $this->assertEquivalentCollections($this->expectedCollection, $actualCollection);
        }
    }

    /**
     * Extracts a result from an exception.
     *
     * Errors for bulkWrite and insertMany operations may still report a write
     * result. This method will attempt to extract such a result so that it can
     * be used in executeAssertResult().
     *
     * If no result can be extracted, null will be returned.
     *
     * @param array            $operation
     * @param RuntimeException $exception
     * @return mixed
     */
    private function extractResultFromException(array $operation, array $outcome, RuntimeException $exception)
    {
        switch ($operation['name']) {
            case 'bulkWrite':
                $insertedIds = $outcome['result']['insertedIds'] ?? [];

                if ($exception instanceof BulkWriteException) {
                    return new BulkWriteResult($exception->getWriteResult(), $insertedIds);
                }

                break;

            case 'insertMany':
                $insertedIds = $outcome['result']['insertedIds'] ?? [];

                if ($exception instanceof BulkWriteException) {
                    return new InsertManyResult($exception->getWriteResult(), $insertedIds);
                }

                break;
        }

        return null;
    }

    /**
     * Executes the "result" section of an "outcome" block.
     *
     * @param array $operation
     * @param mixed $expectedResult
     * @param mixed $actualResult
     * @throws LogicException if the operation is unsupported
     */
    private function executeAssertResult(array $operation, $expectedResult, $actualResult): void
    {
        switch ($operation['name']) {
            case 'aggregate':
                /* Returning a cursor for the $out collection is optional per
                 * the CRUD specification and is not implemented in the library
                 * since we have no concept of lazy cursors. We will not assert
                 * the result here; however, assertEquivalentCollections() will
                 * assert the output collection's contents later.
                 */
                if (! is_last_pipeline_operator_write($operation['arguments']['pipeline'])) {
                    $this->assertSameDocuments($expectedResult, $actualResult);
                }

                break;

            case 'bulkWrite':
                $this->assertIsArray($expectedResult);
                $this->assertInstanceOf(BulkWriteResult::class, $actualResult);

                if (isset($expectedResult['deletedCount'])) {
                    $this->assertSame($expectedResult['deletedCount'], $actualResult->getDeletedCount());
                }

                if (isset($expectedResult['insertedCount'])) {
                    $this->assertSame($expectedResult['insertedCount'], $actualResult->getInsertedCount());
                }

                if (isset($expectedResult['insertedIds'])) {
                    $this->assertSameDocument(
                        ['insertedIds' => $expectedResult['insertedIds']],
                        ['insertedIds' => $actualResult->getInsertedIds()]
                    );
                }

                if (isset($expectedResult['matchedCount'])) {
                    $this->assertSame($expectedResult['matchedCount'], $actualResult->getMatchedCount());
                }

                if (isset($expectedResult['modifiedCount'])) {
                    $this->assertSame($expectedResult['modifiedCount'], $actualResult->getModifiedCount());
                }

                if (isset($expectedResult['upsertedCount'])) {
                    $this->assertSame($expectedResult['upsertedCount'], $actualResult->getUpsertedCount());
                }

                if (isset($expectedResult['upsertedIds'])) {
                    $this->assertSameDocument(
                        ['upsertedIds' => $expectedResult['upsertedIds']],
                        ['upsertedIds' => $actualResult->getUpsertedIds()]
                    );
                }

                break;

            case 'count':
            case 'countDocuments':
            case 'estimatedDocumentCount':
                $this->assertSame($expectedResult, $actualResult);
                break;

            case 'distinct':
                $this->assertSameDocument(
                    ['values' => $expectedResult],
                    ['values' => $actualResult]
                );
                break;

            case 'find':
                $this->assertSameDocuments($expectedResult, $actualResult);
                break;

            case 'deleteMany':
            case 'deleteOne':
                $this->assertIsArray($expectedResult);
                $this->assertInstanceOf(DeleteResult::class, $actualResult);

                if (isset($expectedResult['deletedCount'])) {
                    $this->assertSame($expectedResult['deletedCount'], $actualResult->getDeletedCount());
                }

                break;

            case 'findOneAndDelete':
            case 'findOneAndReplace':
            case 'findOneAndUpdate':
                $this->assertSameDocument(
                    ['result' => $expectedResult],
                    ['result' => $actualResult]
                );
                break;

            case 'insertMany':
                $this->assertIsArray($expectedResult);
                $this->assertInstanceOf(InsertManyResult::class, $actualResult);

                if (isset($expectedResult['insertedCount'])) {
                    $this->assertSame($expectedResult['insertedCount'], $actualResult->getInsertedCount());
                }

                if (isset($expectedResult['insertedIds'])) {
                    $this->assertSameDocument(
                        ['insertedIds' => $expectedResult['insertedIds']],
                        ['insertedIds' => $actualResult->getInsertedIds()]
                    );
                }

                break;

            case 'insertOne':
                $this->assertIsArray($expectedResult);
                $this->assertInstanceOf(InsertOneResult::class, $actualResult);

                if (isset($expectedResult['insertedCount'])) {
                    $this->assertSame($expectedResult['insertedCount'], $actualResult->getInsertedCount());
                }

                if (isset($expectedResult['insertedId'])) {
                    $this->assertSameDocument(
                        ['insertedId' => $expectedResult['insertedId']],
                        ['insertedId' => $actualResult->getInsertedId()]
                    );
                }

                break;

            case 'replaceOne':
            case 'updateMany':
            case 'updateOne':
                $this->assertIsArray($expectedResult);
                $this->assertInstanceOf(UpdateResult::class, $actualResult);

                if (isset($expectedResult['matchedCount'])) {
                    $this->assertSame($expectedResult['matchedCount'], $actualResult->getMatchedCount());
                }

                if (isset($expectedResult['modifiedCount'])) {
                    $this->assertSame($expectedResult['modifiedCount'], $actualResult->getModifiedCount());
                }

                if (isset($expectedResult['upsertedCount'])) {
                    $this->assertSame($expectedResult['upsertedCount'], $actualResult->getUpsertedCount());
                }

                if (array_key_exists('upsertedId', $expectedResult)) {
                    $this->assertSameDocument(
                        ['upsertedId' => $expectedResult['upsertedId']],
                        ['upsertedId' => $actualResult->getUpsertedId()]
                    );
                }

                break;

            default:
                throw new LogicException('Unsupported operation: ' . $operation['name']);
        }
    }

    /**
     * Initializes data in the test collections.
     *
     * @param array $initialData
     * @param array $expectedData
     */
    private function initializeData(array $initialData, ?array $expectedData = null): void
    {
        if (! empty($initialData)) {
            $this->collection->insertMany($initialData);
        }

        if (! empty($expectedData)) {
            $this->expectedCollection->insertMany($expectedData);
        }
    }

    /**
     * Prepares a request element for a bulkWrite operation.
     *
     * @param array $request
     * @return array
     */
    private function prepareBulkWriteRequest(array $request): array
    {
        switch ($request['name']) {
            case 'deleteMany':
            case 'deleteOne':
                return [
                    $request['name'] => [
                        $request['arguments']['filter'],
                        array_diff_key($request['arguments'], ['filter' => 1]),
                    ],
                ];

            case 'insertOne':
                return ['insertOne' => [$request['arguments']['document']]];

            case 'replaceOne':
                return [
                    'replaceOne' => [
                        $request['arguments']['filter'],
                        $request['arguments']['replacement'],
                        array_diff_key($request['arguments'], ['filter' => 1, 'replacement' => 1]),
                    ],
                ];

            case 'updateMany':
            case 'updateOne':
                return [
                    $request['name'] => [
                        $request['arguments']['filter'],
                        $request['arguments']['update'],
                        array_diff_key($request['arguments'], ['filter' => 1, 'update' => 1]),
                    ],
                ];

            default:
                throw new LogicException('Unsupported bulk write request: ' . $request['name']);
        }
    }

    /**
     * Prepares arguments for findOneAndReplace and findOneAndUpdate operations.
     *
     * @param array $arguments
     * @return array
     */
    private function prepareFindAndModifyArguments(array $arguments): array
    {
        if (isset($arguments['returnDocument'])) {
            $arguments['returnDocument'] = 'after' === strtolower($arguments['returnDocument'])
                ? FindOneAndReplace::RETURN_DOCUMENT_AFTER
                : FindOneAndReplace::RETURN_DOCUMENT_BEFORE;
        }

        return $arguments;
    }
}
