<?php

namespace MongoDB\Tests\SpecTests;

use ArrayObject;
use InvalidArgumentException;
use MongoDB\BSON\BinaryInterface;
use MongoDB\BSON\DBPointer;
use MongoDB\BSON\Decimal128;
use MongoDB\BSON\Int64;
use MongoDB\BSON\Javascript;
use MongoDB\BSON\MaxKey;
use MongoDB\BSON\MinKey;
use MongoDB\BSON\ObjectId;
use MongoDB\BSON\Regex;
use MongoDB\BSON\Symbol;
use MongoDB\BSON\Timestamp;
use MongoDB\BSON\Undefined;
use MongoDB\BSON\UTCDateTime;
use MongoDB\Model\BSONArray;
use MongoDB\Model\BSONDocument;
use PHPUnit\Framework\Constraint\Constraint;
use PHPUnit\Framework\Constraint\IsInstanceOf;
use PHPUnit\Framework\Constraint\IsNull;
use PHPUnit\Framework\Constraint\IsType;
use PHPUnit\Framework\Constraint\LogicalAnd;
use PHPUnit\Framework\Constraint\LogicalNot;
use PHPUnit\Framework\Constraint\LogicalOr;
use RuntimeException;
use SebastianBergmann\Comparator\ComparisonFailure;
use SebastianBergmann\Comparator\Factory;
use stdClass;
use Symfony\Bridge\PhpUnit\ConstraintTrait;

use function array_values;
use function get_class;
use function get_debug_type;
use function in_array;
use function is_array;
use function is_object;
use function is_scalar;
use function method_exists;
use function sprintf;

use const PHP_INT_SIZE;

/**
 * Constraint that checks if one document matches another.
 *
 * The expected value is passed in the constructor.
 */
class DocumentsMatchConstraint extends Constraint
{
    use ConstraintTrait;

    /** @var boolean */
    private $ignoreExtraKeysInRoot = false;

    /** @var boolean */
    private $ignoreExtraKeysInEmbedded = false;

    /** @var array */
    private $placeholders = [];

    /**
     * TODO: This is not currently used, but was preserved from the design of
     * TestCase::assertMatchesDocument(), which would sort keys and then compare
     * documents as JSON strings. If the TODO item in matches() is implemented
     * to make document comparisons more efficient, we may consider supporting
     * this option.
     *
     * @var boolean
     */
    private $sortKeys = false;

    /** @var BSONArray|BSONDocument */
    private $value;

    /** @var ComparisonFailure|null */
    private $lastFailure;

    /** @var Factory */
    private $comparatorFactory;

    /**
     * Creates a new constraint.
     *
     * @param array|object $value
     * @param boolean      $ignoreExtraKeysInRoot     If true, ignore extra keys within the root document
     * @param boolean      $ignoreExtraKeysInEmbedded If true, ignore extra keys within embedded documents
     * @param array        $placeholders              Placeholders for any value
     */
    public function __construct($value, bool $ignoreExtraKeysInRoot = false, bool $ignoreExtraKeysInEmbedded = false, array $placeholders = [])
    {
        $this->value = $this->prepareBSON($value, true, $this->sortKeys);
        $this->ignoreExtraKeysInRoot = $ignoreExtraKeysInRoot;
        $this->ignoreExtraKeysInEmbedded = $ignoreExtraKeysInEmbedded;
        $this->placeholders = $placeholders;
        $this->comparatorFactory = Factory::getInstance();
    }

    private function doEvaluate($other, $description = '', $returnResult = false)
    {
        /* TODO: If ignoreExtraKeys and sortKeys are both false, then we may be
         * able to skip preparation, convert both documents to extended JSON,
         * and compare strings.
         *
         * If ignoreExtraKeys is false and sortKeys is true, we still be able to
         * compare JSON strings but will still require preparation to sort keys
         * in all documents and sub-documents. */
        $other = $this->prepareBSON($other, true, $this->sortKeys);

        $success = false;
        $this->lastFailure = null;

        try {
            $this->assertEquals($this->value, $other, $this->ignoreExtraKeysInRoot);
            $success = true;
        } catch (RuntimeException $e) {
            $this->lastFailure = new ComparisonFailure(
                $this->value,
                $other,
                $this->exporter()->export($this->value),
                $this->exporter()->export($other),
                false,
                $e->getMessage()
            );
        }

        if ($returnResult) {
            return $success;
        }

        if (! $success) {
            $this->fail($other, $description, $this->lastFailure);
        }
    }

    /**
     * @param string $expectedType
     * @param mixed  $actualValue
     */
    private function assertBSONType(string $expectedType, $actualValue): void
    {
        switch ($expectedType) {
            case 'double':
                (new IsType('float'))->evaluate($actualValue);

                return;

            case 'string':
                (new IsType('string'))->evaluate($actualValue);

                return;

            case 'object':
                $constraints = [
                    new IsType('object'),
                    new LogicalNot(new IsInstanceOf(BSONArray::class)),
                ];

                // LogicalAnd::fromConstraints was introduced in PHPUnit 6.5.0.
                // This check can be removed when the PHPUnit dependency is bumped to that version
                if (method_exists(LogicalAnd::class, 'fromConstraints')) {
                    $constraint = LogicalAnd::fromConstraints(...$constraints);
                } else {
                    $constraint = new LogicalAnd();
                    $constraint->setConstraints($constraints);
                }

                $constraint->evaluate($actualValue);

                return;

            case 'array':
                $constraints = [
                    new IsType('array'),
                    new IsInstanceOf(BSONArray::class),
                ];

                // LogicalOr::fromConstraints was introduced in PHPUnit 6.5.0.
                // This check can be removed when the PHPUnit dependency is bumped to that version
                if (method_exists(LogicalOr::class, 'fromConstraints')) {
                    $constraint = LogicalOr::fromConstraints(...$constraints);
                } else {
                    $constraint = new LogicalOr();
                    $constraint->setConstraints($constraints);
                }

                $constraint->evaluate($actualValue);

                return;

            case 'binData':
                (new IsInstanceOf(BinaryInterface::class))->evaluate($actualValue);

                return;

            case 'undefined':
                (new IsInstanceOf(Undefined::class))->evaluate($actualValue);

                return;

            case 'objectId':
                (new IsInstanceOf(ObjectId::class))->evaluate($actualValue);

                return;

            case 'boolean':
                (new IsType('bool'))->evaluate($actualValue);

                return;

            case 'date':
                (new IsInstanceOf(UTCDateTime::class))->evaluate($actualValue);

                return;

            case 'null':
                (new IsNull())->evaluate($actualValue);

                return;

            case 'regex':
                (new IsInstanceOf(Regex::class))->evaluate($actualValue);

                return;

            case 'dbPointer':
                (new IsInstanceOf(DBPointer::class))->evaluate($actualValue);

                return;

            case 'javascript':
                (new IsInstanceOf(Javascript::class))->evaluate($actualValue);

                return;

            case 'symbol':
                (new IsInstanceOf(Symbol::class))->evaluate($actualValue);

                return;

            case 'int':
                (new IsType('int'))->evaluate($actualValue);

                return;

            case 'timestamp':
                (new IsInstanceOf(Timestamp::class))->evaluate($actualValue);

                return;

            case 'long':
                if (PHP_INT_SIZE == 4) {
                    (new IsInstanceOf(Int64::class))->evaluate($actualValue);
                } else {
                    (new IsType('int'))->evaluate($actualValue);
                }

                return;

            case 'decimal':
                (new IsInstanceOf(Decimal128::class))->evaluate($actualValue);

                return;

            case 'minKey':
                (new IsInstanceOf(MinKey::class))->evaluate($actualValue);

                return;

            case 'maxKey':
                (new IsInstanceOf(MaxKey::class))->evaluate($actualValue);

                return;
        }
    }

    /**
     * Compares two documents recursively.
     *
     * @param ArrayObject $expected
     * @param ArrayObject $actual
     * @param boolean     $ignoreExtraKeys
     * @param string      $keyPrefix
     * @throws RuntimeException if the documents do not match
     */
    private function assertEquals(ArrayObject $expected, ArrayObject $actual, bool $ignoreExtraKeys, string $keyPrefix = ''): void
    {
        if (get_class($expected) !== get_class($actual)) {
            throw new RuntimeException(sprintf(
                '%s is not instance of expected class "%s"',
                $this->exporter()->shortenedExport($actual),
                get_class($expected)
            ));
        }

        foreach ($expected as $key => $expectedValue) {
            $actualHasKey = $actual->offsetExists($key);

            if (! $actualHasKey) {
                throw new RuntimeException(sprintf('$actual is missing key: "%s"', $keyPrefix . $key));
            }

            if (in_array($expectedValue, $this->placeholders, true)) {
                continue;
            }

            $actualValue = $actual[$key];

            if ($expectedValue instanceof BSONDocument && isset($expectedValue['$$type'])) {
                $this->assertBSONType($expectedValue['$$type'], $actualValue);
                continue;
            }

            if (
                ($expectedValue instanceof BSONArray && $actualValue instanceof BSONArray) ||
                ($expectedValue instanceof BSONDocument && $actualValue instanceof BSONDocument)
            ) {
                $this->assertEquals($expectedValue, $actualValue, $this->ignoreExtraKeysInEmbedded, $keyPrefix . $key . '.');
                continue;
            }

            if (is_scalar($expectedValue) && is_scalar($actualValue)) {
                if ($expectedValue !== $actualValue) {
                    throw new ComparisonFailure(
                        $expectedValue,
                        $actualValue,
                        '',
                        '',
                        false,
                        sprintf('Field path "%s": %s', $keyPrefix . $key, 'Failed asserting that two values are equal.')
                    );
                }

                continue;
            }

            $expectedType = get_debug_type($expectedValue);
            $actualType = get_debug_type($actualValue);

            // Workaround for ObjectComparator printing the whole actual object
            if ($expectedType !== $actualType) {
                throw new ComparisonFailure(
                    $expectedValue,
                    $actualValue,
                    '',
                    '',
                    false,
                    sprintf(
                        'Field path "%s": %s is not instance of expected type "%s".',
                        $keyPrefix . $key,
                        $this->exporter()->shortenedExport($actualValue),
                        $expectedType
                    )
                );
            }

            try {
                $this->comparatorFactory->getComparatorFor($expectedValue, $actualValue)->assertEquals($expectedValue, $actualValue);
            } catch (ComparisonFailure $failure) {
                throw new ComparisonFailure(
                    $expectedValue,
                    $actualValue,
                    '',
                    '',
                    false,
                    sprintf('Field path "%s": %s', $keyPrefix . $key, $failure->getMessage())
                );
            }
        }

        if ($ignoreExtraKeys) {
            return;
        }

        foreach ($actual as $key => $value) {
            if (! $expected->offsetExists($key)) {
                throw new RuntimeException(sprintf('$actual has extra key: "%s"', $keyPrefix . $key));
            }
        }
    }

    private function doAdditionalFailureDescription($other)
    {
        if ($this->lastFailure === null) {
            return '';
        }

        return $this->lastFailure->getMessage();
    }

    private function doFailureDescription($other)
    {
        return 'two BSON objects are equal';
    }

    private function doMatches($other)
    {
        /* TODO: If ignoreExtraKeys and sortKeys are both false, then we may be
         * able to skip preparation, convert both documents to extended JSON,
         * and compare strings.
         *
         * If ignoreExtraKeys is false and sortKeys is true, we still be able to
         * compare JSON strings but will still require preparation to sort keys
         * in all documents and sub-documents. */
        $other = $this->prepareBSON($other, true, $this->sortKeys);

        try {
            $this->assertEquals($this->value, $other, $this->ignoreExtraKeysInRoot);
        } catch (RuntimeException $e) {
            return false;
        }

        return true;
    }

    private function doToString()
    {
        return 'matches ' . $this->exporter()->export($this->value);
    }

    /**
     * Prepare a BSON document or array for comparison.
     *
     * The argument will be converted to a BSONArray or BSONDocument based on
     * its type and keys. Keys within documents will optionally be sorted. Each
     * value within the array or document will then be prepared recursively.
     *
     * @param array|object $bson
     * @param boolean      $isRoot   If true, ensure an array value is converted to a document
     * @param boolean      $sortKeys
     * @return BSONDocument|BSONArray
     * @throws InvalidArgumentException if $bson is not an array or object
     */
    private function prepareBSON($bson, bool $isRoot, bool $sortKeys = false)
    {
        if (! is_array($bson) && ! is_object($bson)) {
            throw new InvalidArgumentException('$bson is not an array or object');
        }

        if ($isRoot && is_array($bson)) {
            $bson = (object) $bson;
        }

        if ($bson instanceof BSONArray || (is_array($bson) && $bson === array_values($bson))) {
            if (! $bson instanceof BSONArray) {
                $bson = new BSONArray($bson);
            }
        } else {
            if (! $bson instanceof BSONDocument) {
                $bson = new BSONDocument((array) $bson);
            }

            if ($sortKeys) {
                $bson->ksort();
            }
        }

        foreach ($bson as $key => $value) {
            if ($value instanceof BSONArray || (is_array($value) && $value === array_values($value))) {
                $bson[$key] = $this->prepareBSON($value, false, $sortKeys);
                continue;
            }

            if ($value instanceof BSONDocument || $value instanceof stdClass || is_array($value)) {
                $bson[$key] = $this->prepareBSON($value, false, $sortKeys);
                continue;
            }

            /* Convert Int64 objects to integers on 64-bit platforms for
             * compatibility reasons. */
            if ($value instanceof Int64 && PHP_INT_SIZE != 4) {
                $bson[$key] = (int) ((string) $value);
            }
        }

        return $bson;
    }
}
