<?php

namespace MongoDB\Tests\SpecTests;

use ArrayIterator;
use LogicException;
use MongoDB\BSON\Document;
use MongoDB\Collection;
use MongoDB\Driver\Server;
use MongoDB\Tests\FunctionalTestCase as BaseFunctionalTestCase;
use MultipleIterator;
use PHPUnit\Framework\SkippedTest;
use stdClass;
use UnexpectedValueException;

use function in_array;
use function json_encode;
use function sprintf;
use function version_compare;

use const JSON_THROW_ON_ERROR;

/**
 * Base class for spec test runners.
 *
 * @see https://github.com/mongodb/specifications
 */
class FunctionalTestCase extends BaseFunctionalTestCase
{
    public const TOPOLOGY_SINGLE = 'single';
    public const TOPOLOGY_REPLICASET = 'replicaset';
    public const TOPOLOGY_SHARDED = 'sharded';
    public const TOPOLOGY_LOAD_BALANCED = 'load-balanced';

    public const SERVERLESS_ALLOW = 'allow';
    public const SERVERLESS_FORBID = 'forbid';
    public const SERVERLESS_REQUIRE = 'require';

    private ?Context $context = null;

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

        $this->context = null;
    }

    public function tearDown(): void
    {
        $this->context = null;

        parent::tearDown();
    }

    /**
     * Assert that the expected and actual command documents match.
     *
     * Note: Spec tests that do not assert command started events may throw an
     * exception in lieu of implementing this method.
     *
     * @param stdClass $expectedCommand Expected command document
     * @param stdClass $actualCommand   Actual command document
     */
    public static function assertCommandMatches(stdClass $expected, stdClass $actual): void
    {
        throw new LogicException(sprintf('%s does not assert CommandStartedEvents', static::class));
    }

    /**
     * Assert that the expected and actual command reply documents match.
     *
     * Note: Spec tests that do not assert command started events may throw an
     * exception in lieu of implementing this method.
     *
     * @param stdClass $expected Expected command reply document
     * @param stdClass $actual   Actual command reply document
     */
    public static function assertCommandReplyMatches(stdClass $expected, stdClass $actual): void
    {
        throw new LogicException(sprintf('%s does not assert CommandSucceededEvents', static::class));
    }

    /**
     * Asserts that two given documents match.
     *
     * Extra keys in the actual value's document(s) will be ignored.
     */
    public static function assertDocumentsMatch(array|object $expectedDocument, array|object $actualDocument, string $message = ''): void
    {
        $constraint = new DocumentsMatchConstraint($expectedDocument, true, true);

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

    /**
     * Assert omitted top-level fields in command documents.
     *
     * Note: this method may modify the $expected object.
     *
     * @see https://github.com/mongodb/specifications/blob/master/source/transactions/tests/README.rst#null-values
     * @see https://github.com/mongodb/specifications/blob/09ee1ebc481f1502e3246971a9419e484d736207/source/command-monitoring/tests/README.rst#additional-values
     */
    protected static function assertCommandOmittedFields(stdClass $expected, stdClass $actual): void
    {
        foreach ($expected as $key => $value) {
            if ($value === null) {
                static::assertObjectNotHasProperty($key, $actual);
                unset($expected->{$key});
            }
        }
    }

    /**
     * Assert data within the outcome collection.
     */
    protected function assertOutcomeCollectionData(array $expectedDocuments, int $resultExpectation = ResultExpectation::ASSERT_SAME_DOCUMENT): void
    {
        $outcomeCollection = $this->getOutcomeCollection($this->getContext()->outcomeReadOptions);

        $mi = new MultipleIterator(MultipleIterator::MIT_NEED_ANY);
        $mi->attachIterator(new ArrayIterator($expectedDocuments));
        $mi->attachIterator($outcomeCollection->find([], ['sort' => ['_id' => 1]]));

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

            match ($resultExpectation) {
                ResultExpectation::ASSERT_SAME_DOCUMENT => $this->assertSameDocument($expectedDocument, $actualDocument),
                ResultExpectation::ASSERT_DOCUMENTS_MATCH => $this->assertDocumentsMatch($expectedDocument, $actualDocument),
                default => $this->fail(sprintf('Invalid result expectation "%d" for %s', $resultExpectation, __METHOD__)),
            };
        }
    }

    /**
     * Checks server version and topology requirements.
     *
     * @throws SkippedTest if the server requirements are not met
     */
    protected function checkServerRequirements(array $runOn): void
    {
        foreach ($runOn as $req) {
            $minServerVersion = $req->minServerVersion ?? null;
            $maxServerVersion = $req->maxServerVersion ?? null;
            $topologies = $req->topology ?? null;
            $serverlessMode = $req->serverless ?? null;

            if ($this->isServerRequirementSatisifed($minServerVersion, $maxServerVersion, $topologies, $serverlessMode)) {
                return;
            }
        }

        $serverVersion = $this->getServerVersion();
        $topology = $this->getTopology();

        $this->markTestSkipped(sprintf('Server version "%s" and topology "%s" do not meet test requirements: %s', $serverVersion, $topology, json_encode($runOn, JSON_THROW_ON_ERROR)));
    }

    /**
     * Decode a JSON spec test.
     *
     * This decodes the file through the driver's extended JSON parser to ensure
     * proper handling of special types.
     */
    protected function decodeJson(string $json): array|object
    {
        return Document::fromJSON($json)->toPHP();
    }

    /**
     * Return the test context.
     *
     * @throws LogicException if the context has not been set
     */
    protected function getContext(): Context
    {
        if (! $this->context instanceof Context) {
            throw new LogicException('Context has not been set');
        }

        return $this->context;
    }

    /**
     * Set the test context.
     */
    protected function setContext(Context $context): void
    {
        $this->context = $context;
    }

    /**
     * Drop the test and outcome collections by dropping them.
     */
    protected function dropTestAndOutcomeCollections(array $testCollectionDropOptions = []): void
    {
        $context = $this->getContext();

        if ($context->databaseName === 'admin') {
            return;
        }

        if ($context->bucketName !== null) {
            $bucket = $context->getGridFSBucket($context->defaultWriteOptions);
            $bucket->drop();
        }

        $collection = null;
        if ($context->collectionName !== null) {
            $collection = $context->getCollection($context->defaultWriteOptions);
            $collection->drop($testCollectionDropOptions);
        }

        if ($context->outcomeCollectionName !== null) {
            $outcomeCollection = $this->getOutcomeCollection($context->defaultWriteOptions);

            // Avoid redundant drop if the test and outcome collections are the same
            if ($collection === null || $outcomeCollection->getNamespace() !== $collection->getNamespace()) {
                $outcomeCollection->drop();
            }
        }
    }

    /**
     * Insert data fixtures into the test collection.
     */
    protected function insertDataFixtures(array $documents, ?string $collectionName = null): void
    {
        if (empty($documents)) {
            return;
        }

        $context = $this->getContext();
        $collection = $collectionName ? $context->selectCollection($context->databaseName, $collectionName) : $context->getCollection();

        $collection->insertMany($documents, $context->defaultWriteOptions);

        return;
    }

    private function getOutcomeCollection(array $collectionOptions = [])
    {
        $context = $this->getContext();

        // Outcome collection need not use the client under test
        return new Collection($this->manager, $context->databaseName, $context->outcomeCollectionName, $collectionOptions);
    }

    /**
     * Return the corresponding topology constants for the current topology.
     *
     * @throws UnexpectedValueException if topology is neither single nor RS nor sharded
     */
    private function getTopology(): string
    {
        $topologyTypeMap = [
            Server::TYPE_STANDALONE => self::TOPOLOGY_SINGLE,
            Server::TYPE_RS_PRIMARY => self::TOPOLOGY_REPLICASET,
            Server::TYPE_MONGOS => self::TOPOLOGY_SHARDED,
            Server::TYPE_LOAD_BALANCER => self::TOPOLOGY_LOAD_BALANCED,
        ];

        $primaryType = $this->getPrimaryServer()->getType();

        if (isset($topologyTypeMap[$primaryType])) {
            return $topologyTypeMap[$primaryType];
        }

        throw new UnexpectedValueException(sprintf('Cannot find topology for primary of type "%d".', $primaryType));
    }

    private function isServerlessRequirementSatisfied(?string $serverlessMode): bool
    {
        if ($serverlessMode === null) {
            return true;
        }

        return $serverlessMode !== self::SERVERLESS_REQUIRE;
    }

    /**
     * Checks if server version and topology requirements are satifised.
     */
    private function isServerRequirementSatisifed(?string $minServerVersion, ?string $maxServerVersion, ?array $topologies = null, ?string $serverlessMode = null): bool
    {
        $serverVersion = $this->getServerVersion();

        if (isset($minServerVersion) && version_compare($serverVersion, $minServerVersion, '<')) {
            return false;
        }

        if (isset($maxServerVersion) && version_compare($serverVersion, $maxServerVersion, '>')) {
            return false;
        }

        $topology = $this->getTopology();

        if (isset($topologies) && ! in_array($topology, $topologies)) {
            return false;
        }

        if (! $this->isServerlessRequirementSatisfied($serverlessMode)) {
            return false;
        }

        return true;
    }
}
