<?php

namespace MongoDB\Tests\SpecTests;

use LogicException;
use MongoDB\BulkWriteResult;
use MongoDB\DeleteResult;
use MongoDB\Driver\WriteResult;
use MongoDB\Exception\InvalidArgumentException;
use MongoDB\InsertManyResult;
use MongoDB\InsertOneResult;
use MongoDB\UpdateResult;
use stdClass;

use function call_user_func;
use function is_array;
use function is_object;
use function property_exists;

/**
 * Spec test operation result expectation.
 */
final class ResultExpectation
{
    public const ASSERT_NOTHING = 0;
    public const ASSERT_BULKWRITE = 1;
    public const ASSERT_DELETE = 2;
    public const ASSERT_INSERTMANY = 3;
    public const ASSERT_INSERTONE = 4;
    public const ASSERT_UPDATE = 5;
    public const ASSERT_SAME = 6;
    public const ASSERT_SAME_DOCUMENT = 7;
    public const ASSERT_SAME_DOCUMENTS = 8;
    public const ASSERT_MATCHES_DOCUMENT = 9;
    public const ASSERT_NULL = 10;
    public const ASSERT_CALLABLE = 11;
    public const ASSERT_DOCUMENTS_MATCH = 12;

    private mixed $expectedValue;

    /** @var callable */
    private $assertionCallable;

    private function __construct(private int $assertionType, $expectedValue)
    {
        switch ($assertionType) {
            case self::ASSERT_BULKWRITE:
            case self::ASSERT_DELETE:
            case self::ASSERT_INSERTMANY:
            case self::ASSERT_INSERTONE:
            case self::ASSERT_UPDATE:
                if (! is_object($expectedValue)) {
                    throw InvalidArgumentException::invalidType('$expectedValue', $expectedValue, 'object');
                }

                break;

            case self::ASSERT_SAME_DOCUMENTS:
                if (! self::isArrayOfObjects($expectedValue)) {
                    throw InvalidArgumentException::invalidType('$expectedValue', $expectedValue, 'object[]');
                }

                break;
        }

        $this->expectedValue = $expectedValue;
    }

    public static function fromClientSideEncryption(stdClass $operation, $defaultAssertionType)
    {
        if (property_exists($operation, 'result') && ! self::isErrorResult($operation->result)) {
            $assertionType = $operation->result === null ? self::ASSERT_NULL : $defaultAssertionType;
            $expectedValue = $operation->result;
        } else {
            $assertionType = self::ASSERT_NOTHING;
            $expectedValue = null;
        }

        return new self($assertionType, $expectedValue);
    }

    public static function fromCrud(stdClass $operation, $defaultAssertionType)
    {
        if (property_exists($operation, 'result') && ! self::isErrorResult($operation->result)) {
            $assertionType = $operation->result === null ? self::ASSERT_NULL : $defaultAssertionType;
            $expectedValue = $operation->result;
        } else {
            $assertionType = self::ASSERT_NOTHING;
            $expectedValue = null;
        }

        return new self($assertionType, $expectedValue);
    }

    public static function fromReadWriteConcern(stdClass $operation, $defaultAssertionType)
    {
        if (property_exists($operation, 'result') && ! self::isErrorResult($operation->result)) {
            $assertionType = $operation->result === null ? self::ASSERT_NULL : $defaultAssertionType;
            $expectedValue = $operation->result;
        } else {
            $assertionType = self::ASSERT_NOTHING;
            $expectedValue = null;
        }

        return new self($assertionType, $expectedValue);
    }

    public static function fromRetryableReads(stdClass $operation, $defaultAssertionType)
    {
        if (property_exists($operation, 'result') && ! self::isErrorResult($operation->result)) {
            $assertionType = $operation->result === null ? self::ASSERT_NULL : $defaultAssertionType;
            $expectedValue = $operation->result;
        } else {
            $assertionType = self::ASSERT_NOTHING;
            $expectedValue = null;
        }

        return new self($assertionType, $expectedValue);
    }

    public static function fromTransactions(stdClass $operation, $defaultAssertionType)
    {
        if (property_exists($operation, 'result') && ! self::isErrorResult($operation->result)) {
            $assertionType = $operation->result === null ? self::ASSERT_NULL : $defaultAssertionType;
            $expectedValue = $operation->result;
        } else {
            $assertionType = self::ASSERT_NOTHING;
            $expectedValue = null;
        }

        return new self($assertionType, $expectedValue);
    }

    /**
     * Assert that the result expectation matches the actual outcome.
     *
     * @param FunctionalTestCase $test   Test instance for performing assertions
     * @param mixed              $result Result (if any) from the actual outcome
     * @throws LogicException if the assertion type is unsupported
     */
    public function assert(FunctionalTestCase $test, $actual): void
    {
        $expected = $this->expectedValue;

        switch ($this->assertionType) {
            case self::ASSERT_BULKWRITE:
                /* If the bulk write was successful, the actual value should be
                 * a BulkWriteResult; otherwise, expect a WriteResult extracted
                 * from the BulkWriteException. */
                $test->assertThat($actual, $test->logicalOr(
                    $test->isInstanceOf(BulkWriteResult::class),
                    $test->isInstanceOf(WriteResult::class),
                ));

                if (! $actual->isAcknowledged()) {
                    break;
                }

                if (isset($expected->deletedCount)) {
                    $test->assertSame($expected->deletedCount, $actual->getDeletedCount());
                }

                if (isset($expected->insertedCount)) {
                    $test->assertSame($expected->insertedCount, $actual->getInsertedCount());
                }

                // insertedIds are not available after BulkWriteException (see: PHPLIB-428)
                if (isset($expected->insertedIds) && $actual instanceof BulkWriteResult) {
                    $test->assertSameDocument($expected->insertedIds, $actual->getInsertedIds());
                }

                if (isset($expected->matchedCount)) {
                    $test->assertSame($expected->matchedCount, $actual->getMatchedCount());
                }

                if (isset($expected->modifiedCount)) {
                    $test->assertSame($expected->modifiedCount, $actual->getModifiedCount());
                }

                if (isset($expected->upsertedCount)) {
                    $test->assertSame($expected->upsertedCount, $actual->getUpsertedCount());
                }

                if (isset($expected->upsertedIds)) {
                    $test->assertSameDocument($expected->upsertedIds, $actual->getUpsertedIds());
                }

                break;

            case self::ASSERT_CALLABLE:
                call_user_func($this->assertionCallable, $expected, $actual);
                break;

            case self::ASSERT_DELETE:
                $test->assertInstanceOf(DeleteResult::class, $actual);

                if (isset($expected->deletedCount)) {
                    $test->assertSame($expected->deletedCount, $actual->getDeletedCount());
                }

                break;

            case self::ASSERT_INSERTMANY:
                /* If the bulk insert was successful, the actual value should be
                 * a InsertManyResult; otherwise, expect a WriteResult extracted
                 * from the BulkWriteException. */
                $test->assertThat($actual, $test->logicalOr(
                    $test->isInstanceOf(InsertManyResult::class),
                    $test->isInstanceOf(WriteResult::class),
                ));

                if (isset($expected->insertedCount)) {
                    $test->assertSame($expected->insertedCount, $actual->getInsertedCount());
                }

                // insertedIds are not available after BulkWriteException (see: PHPLIB-428)
                if (isset($expected->insertedIds) && $actual instanceof BulkWriteResult) {
                    $test->assertSameDocument($expected->insertedIds, $actual->getInsertedIds());
                }

                break;

            case self::ASSERT_INSERTONE:
                $test->assertThat($actual, $test->logicalOr(
                    $test->isInstanceOf(InsertOneResult::class),
                    $test->isInstanceOf(WriteResult::class),
                ));

                if (isset($expected->insertedCount)) {
                    $test->assertSame($expected->insertedCount, $actual->getInsertedCount());
                }

                if (property_exists($expected, 'insertedId')) {
                    $test->assertSameDocument(
                        ['insertedId' => $expected->insertedId],
                        ['insertedId' => $actual->getInsertedId()],
                    );
                }

                break;

            case self::ASSERT_MATCHES_DOCUMENT:
                $test->assertIsObject($expected);
                $test->assertThat($actual, $test->logicalOr(
                    $test->isType('array'),
                    $test->isType('object'),
                ));
                $test->assertMatchesDocument($expected, $actual);
                break;

            case self::ASSERT_NOTHING:
                break;

            case self::ASSERT_NULL:
                $test->assertNull($actual);
                break;

            case self::ASSERT_SAME:
                $test->assertSame($expected, $actual);
                break;

            case self::ASSERT_SAME_DOCUMENT:
                $test->assertIsObject($expected);
                $test->assertThat($actual, $test->logicalOr(
                    $test->isType('array'),
                    $test->isType('object'),
                ));
                $test->assertSameDocument($expected, $actual);
                break;

            case self::ASSERT_SAME_DOCUMENTS:
                $test->assertSameDocuments($expected, $actual);
                break;

            case self::ASSERT_DOCUMENTS_MATCH:
                $test->assertDocumentsMatch($expected, $actual);
                break;

            case self::ASSERT_UPDATE:
                $test->assertInstanceOf(UpdateResult::class, $actual);

                if (isset($expected->matchedCount)) {
                    $test->assertSame($expected->matchedCount, $actual->getMatchedCount());
                }

                if (isset($expected->modifiedCount)) {
                    $test->assertSame($expected->modifiedCount, $actual->getModifiedCount());
                }

                if (isset($expected->upsertedCount)) {
                    $test->assertSame($expected->upsertedCount, $actual->getUpsertedCount());
                }

                if (property_exists($expected, 'upsertedId')) {
                    $test->assertSameDocument(
                        ['upsertedId' => $expected->upsertedId],
                        ['upsertedId' => $actual->getUpsertedId()],
                    );
                }

                break;

            default:
                throw new LogicException('Unsupported assertion type: ' . $this->assertionType);
        }
    }

    public function isExpected()
    {
        return $this->assertionType !== self::ASSERT_NOTHING;
    }

    private static function isArrayOfObjects($array)
    {
        if (! is_array($array)) {
            return false;
        }

        foreach ($array as $object) {
            if (! is_object($object)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Determines whether the result is actually an error expectation.
     *
     * @see https://github.com/mongodb/specifications/blob/master/source/transactions/tests/README.rst#test-format
     */
    private static function isErrorResult(mixed $result): bool
    {
        if (! is_object($result)) {
            return false;
        }

        $keys = ['errorContains', 'errorCodeName', 'errorLabelsContain', 'errorLabelsOmit'];

        foreach ($keys as $key) {
            if (isset($result->{$key})) {
                return true;
            }
        }

        return false;
    }
}
