<?php

namespace MongoDB\Tests\UnifiedSpecTests\Constraint;

use LogicException;
use MongoDB\BSON\BinaryInterface;
use MongoDB\BSON\DBPointer;
use MongoDB\BSON\Decimal128Interface;
use MongoDB\BSON\Int64;
use MongoDB\BSON\JavascriptInterface;
use MongoDB\BSON\MaxKeyInterface;
use MongoDB\BSON\MinKeyInterface;
use MongoDB\BSON\ObjectIdInterface;
use MongoDB\BSON\RegexInterface;
use MongoDB\BSON\Serializable;
use MongoDB\BSON\Symbol;
use MongoDB\BSON\TimestampInterface;
use MongoDB\BSON\Type;
use MongoDB\BSON\Undefined;
use MongoDB\BSON\UTCDateTimeInterface;
use MongoDB\Model\BSONArray;
use MongoDB\Model\BSONDocument;
use PHPUnit\Framework\Constraint\Constraint;
use PHPUnit\Framework\Constraint\LogicalOr;
use RuntimeException;
use Symfony\Bridge\PhpUnit\ConstraintTrait;

use function array_keys;
use function array_map;
use function count;
use function in_array;
use function is_array;
use function is_bool;
use function is_float;
use function is_int;
use function is_object;
use function is_string;
use function range;
use function sprintf;

final class IsBsonType extends Constraint
{
    use ConstraintTrait;

    /** @var array */
    private static $types = [
        'double',
        'string',
        'object',
        'array',
        'binData',
        'undefined',
        'objectId',
        'bool',
        'date',
        'null',
        'regex',
        'dbPointer',
        'javascript',
        'symbol',
        'javascriptWithScope',
        'int',
        'timestamp',
        'long',
        'decimal',
        'minKey',
        'maxKey',
        'number',
    ];

    /** @var string */
    private $type;

    public function __construct(string $type)
    {
        if (! in_array($type, self::$types)) {
            throw new RuntimeException(sprintf('Type specified for %s <%s> is not a valid type', self::class, $type));
        }

        $this->type = $type;
    }

    public static function any(): LogicalOr
    {
        return self::anyOf(...self::$types);
    }

    public static function anyOf(string ...$types): Constraint
    {
        if (count($types) === 1) {
            return new self(...$types);
        }

        return LogicalOr::fromConstraints(...array_map(function ($type) {
            return new self($type);
        }, $types));
    }

    private function doMatches($other): bool
    {
        switch ($this->type) {
            case 'double':
                return is_float($other);

            case 'string':
                return is_string($other);

            case 'object':
                return self::isObject($other);

            case 'array':
                return self::isArray($other);

            case 'binData':
                return $other instanceof BinaryInterface;

            case 'undefined':
                return $other instanceof Undefined;

            case 'objectId':
                return $other instanceof ObjectIdInterface;

            case 'bool':
                return is_bool($other);

            case 'date':
                return $other instanceof UTCDateTimeInterface;

            case 'null':
                return $other === null;

            case 'regex':
                return $other instanceof RegexInterface;

            case 'dbPointer':
                return $other instanceof DBPointer;

            case 'javascript':
                return $other instanceof JavascriptInterface && $other->getScope() === null;

            case 'symbol':
                return $other instanceof Symbol;

            case 'javascriptWithScope':
                return $other instanceof JavascriptInterface && $other->getScope() !== null;

            case 'int':
                return is_int($other);

            case 'timestamp':
                return $other instanceof TimestampInterface;

            case 'long':
                return is_int($other) || $other instanceof Int64;

            case 'decimal':
                return $other instanceof Decimal128Interface;

            case 'minKey':
                return $other instanceof MinKeyInterface;

            case 'maxKey':
                return $other instanceof MaxKeyInterface;

            case 'number':
                return is_int($other) || $other instanceof Int64 || is_float($other) || $other instanceof Decimal128Interface;

            default:
                // This should already have been caught in the constructor
                throw new LogicException('Unsupported type: ' . $this->type);
        }
    }

    private function doToString(): string
    {
        return sprintf('is of BSON type "%s"', $this->type);
    }

    private static function isArray($other): bool
    {
        if ($other instanceof BSONArray) {
            return true;
        }

        // Serializable can produce an array or object, so recurse on its output
        if ($other instanceof Serializable) {
            return self::isArray($other->bsonSerialize());
        }

        if (! is_array($other)) {
            return false;
        }

        // Empty and indexed arrays serialize as BSON arrays
        return self::isArrayEmptyOrIndexed($other);
    }

    private static function isObject($other): bool
    {
        if ($other instanceof BSONDocument) {
            return true;
        }

        // Serializable can produce an array or object, so recurse on its output
        if ($other instanceof Serializable) {
            return self::isObject($other->bsonSerialize());
        }

        // Non-empty, associative arrays serialize as BSON objects
        if (is_array($other)) {
            return ! self::isArrayEmptyOrIndexed($other);
        }

        if (! is_object($other)) {
            return false;
        }

        /* Serializable has already been handled, so any remaining instances of
         * Type will not serialize as BSON objects */
        return ! $other instanceof Type;
    }

    private static function isArrayEmptyOrIndexed(array $a): bool
    {
        if (empty($a)) {
            return true;
        }

        return array_keys($a) === range(0, count($a) - 1);
    }
}
