<?php declare(strict_types=1);
/*
 * This file is part of PHPUnit.
 *
 * (c) Sebastian Bergmann <sebastian@phpunit.de>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
namespace PHPUnit\Framework\Constraint;

use function array_fill;
use function array_map;
use function array_slice;
use function array_sum;
use function count;
use function decbin;
use function implode;
use function sprintf;
use function str_split;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\ExpectationFailedException;
use PHPUnit\TestFixture\BooleanConstraint;
use PHPUnit\TestFixture\CountConstraint;
use PHPUnit\TestFixture\NamedConstraint;
use ReflectionClass;
use ReflectionMethod;

abstract class BinaryOperatorTestCase extends OperatorTestCase
{
    final public static function providerConnectiveTruthTable(): array
    {
        $inputs = self::getBooleanTuples(0, 5);

        return array_map(
            static fn (array $input) => [$input, static::evaluateExpectedResult($input)],
            $inputs
        );
    }

    public static function providerToStringWithNamedConstraints(): array
    {
        return [
            [
            ],
            [
                'is healthy',
            ],
            [
                'is healthy',
                'is rich in amino acids',
            ],
            [
                'is healthy',
                'is rich in amino acids',
                'is rich in unsaturated fats',
            ],
        ];
    }

    /**
     * Shall return the name of the operator under test.
     */
    abstract public static function getOperatorName(): string;

    /**
     * Shall return the precedence of the operator under test.
     */
    abstract public static function getOperatorPrecedence(): int;

    /**
     * Takes an array or boolean values and returns the expected evaluation
     * result for the logical operator under test.
     */
    abstract public static function evaluateExpectedResult(array $input): bool;

    final public function testIsSubclassOfOperator(): void
    {
        $className = $this->className();

        $reflection = new ReflectionClass($className);

        $this->assertTrue($reflection->isSubclassOf(Operator::class), sprintf(
            'Failed to assert that "%s" is subclass of "%s".',
            $className,
            Operator::class
        ));
    }

    final public function testOperatorName(): void
    {
        $className  = $this->className();
        $constraint = $className::fromConstraints();
        $this->assertSame($this->getOperatorName(), $constraint->operator());
    }

    final public function testOperatorPrecedence(): void
    {
        $className  = $this->className();
        $constraint = $className::fromConstraints();
        $this->assertSame($this->getOperatorPrecedence(), $constraint->precedence());
    }

    final public function testOperatorCount(): void
    {
        $counts = [
            3,
            5,
            8,
        ];

        $constraints = array_map(static fn ($count) => CountConstraint::fromCount($count), $counts);

        $className  = $this->className();
        $constraint = $className::fromConstraints(...$constraints);

        $expected = array_sum($counts);

        $this->assertSame($expected, $constraint->count());
    }

    final public function testOperatorArity(): void
    {
        $constraints = [
            CountConstraint::fromCount(3),
            CountConstraint::fromCount(5),
            CountConstraint::fromCount(8),
        ];

        $className  = $this->className();
        $constraint = $className::fromConstraints(...$constraints);

        $expected = count($constraints);

        $this->assertSame($expected, $constraint->arity());
    }

    public function testFromConstraints(): void
    {
        $operand   = $this->getMockBuilder(Constraint::class)->getMock();
        $className = $this->className();

        for ($arity = 0; $arity <= 3; $arity++) {
            $constraints = array_fill(0, $arity, $operand);
            $constraint  = $className::fromConstraints(...$constraints);

            $this->assertInstanceOf($className, $constraint);

            $this->assertSame($arity, $constraint->arity());
        }
    }

    public function testSetConstraintsHandlesNonConstraintArguments(): void
    {
        $className = $this->className();

        $constraint = $className::fromConstraints('whatever');

        $this->assertTrue($constraint->evaluate('whatever', '', true));

        $this->assertSame("is equal to 'whatever'", $constraint->toString());
    }

    #[DataProvider('providerConnectiveTruthTable')]
    final public function testEvaluateReturnsCorrectBooleanResult(array $inputs, bool $expected): void
    {
        $constraints = array_map(static fn (bool $input) => BooleanConstraint::fromBool($input), $inputs);

        $className = $this->className();

        $constraint = $className::fromConstraints(...$constraints);

        $message = 'Failed asserting that ' . $constraint->toString() . ' is ' . ($expected ? 'true' : 'false');
        $this->assertSame($expected, $constraint->evaluate(null, '', true), $message);
    }

    #[DataProvider('providerConnectiveTruthTable')]
    final public function testEvaluateReturnsNullOnSuccessAndThrowsExceptionOnFailure(array $inputs, bool $expected): void
    {
        $constraints = array_map(static fn (bool $input) => BooleanConstraint::fromBool($input), $inputs);

        $className = $this->className();

        $constraint = $className::fromConstraints(...$constraints);

        if ($expected) {
            $this->assertNull($constraint->evaluate(null));
        } else {
            $expectedString = self::operatorJoinStrings(
                array_map(
                    static fn (Constraint $operand) => $operand->toString(),
                    $constraints
                )
            );
            $message = "Failed asserting that 'the following expression is true' " . $expectedString;
            $this->expectException(ExpectationFailedException::class);
            $this->expectExceptionMessage($message);
            $constraint->evaluate('the following expression is true');
        }
    }

    #[DataProvider('providerToStringWithNamedConstraints')]
    public function testToStringWithNamedConstraints(string ...$names): void
    {
        $constraints = array_map(static fn (string $name) => NamedConstraint::fromName($name), $names);

        $className = $this->className();

        $constraint = $className::fromConstraints(...$constraints);

        $expected = static::operatorJoinStrings($names);

        $this->assertSame($expected, $constraint->toString());
    }

    public function testToStringWithoutOperands(): void
    {
        $className = $this->className();

        $operator = $className::fromConstraints();

        $this->assertSame('', $operator->toString());
    }

    public function testToStringWithSingleOperand(): void
    {
        $methods = [
            'arity',
            'precedence',
            'toStringInContext',
            'toString',
        ];

        $operand = $this->getMockBuilder(Operator::class)
                        ->onlyMethods($methods)
                        ->getMockForAbstractClass();

        $className = $this->className();

        $operator = $className::fromConstraints($operand);

        // A non-contextual operator
        $operand->expects($this->never())
                ->method('arity');
        $operand->expects($this->never())
                ->method('precedence');
        $operand->expects($this->never())
                ->method('toStringInContext');
        $operand->expects($this->once())
                ->method('toString')
                ->with()
                ->willReturn('is the only');

        $this->assertSame('is the only', $operator->toString());
    }

    public function testToStringWithMultipleOperands(): void
    {
        $constraintMethods = [
            'toStringInContext',
            'toString',
        ];

        $operatorMethods = [
            'arity',
            'precedence',
            'toStringInContext',
            'toString',
        ];

        $constraints = [
            $this->getMockBuilder(Constraint::class)
                 ->onlyMethods($constraintMethods)
                 ->getMockForAbstractClass(),
            $this->getMockBuilder(Constraint::class)
                 ->onlyMethods($constraintMethods)
                 ->getMockForAbstractClass(),
            $this->getMockBuilder(Operator::class)
                 ->onlyMethods($operatorMethods)
                 ->getMockForAbstractClass(),
            $this->getMockBuilder(Operator::class)
                 ->onlyMethods($operatorMethods)
                 ->getMockForAbstractClass(),
            $this->getMockBuilder(Operator::class)
                 ->onlyMethods($operatorMethods)
                 ->getMockForAbstractClass(),
        ];

        $className = $this->className();

        $constraint = $className::fromConstraints(...$constraints);

        // A non-contextual non-operator constraint
        $constraints[0]->expects($this->once())
                       ->method('toStringInContext')
                       ->with($this->identicalTo($constraint), 0)
                       ->willReturn('');
        $constraints[0]->expects($this->once())
                       ->method('toString')
                       ->with()
                       ->willReturn('is first');

        // A contextual non-operator constraint
        $constraints[1]->expects($this->once())
                       ->method('toStringInContext')
                       ->with($this->identicalTo($constraint), 1)
                       ->willReturn('is second');
        $constraints[1]->expects($this->never())
                       ->method('toString');

        // An non-contextual operator constraint with arity = 2 and high precedence (no braces needed)
        $constraints[2]->expects($this->once())
                       ->method('arity')
                       ->with()
                       ->willReturn(2);
        $constraints[2]->expects($this->once())
                       ->method('precedence')
                       ->with()
                       ->willReturn(-1);
        $constraints[2]->expects($this->once())
                       ->method('toStringInContext')
                       ->with($this->identicalTo($constraint), 2)
                       ->willReturn('');
        $constraints[2]->expects($this->once())
                       ->method('toString')
                       ->with()
                       ->willReturn('is third');

        // A contextual operator constraint with arity = 1 (no braces needed)
        $constraints[3]->expects($this->once())
                       ->method('arity')
                       ->with()
                       ->willReturn(1);
        $constraints[3]->expects($this->never())
                       ->method('precedence');
        $constraints[3]->expects($this->once())
                       ->method('toStringInContext')
                       ->with($this->identicalTo($constraint), 3)
                       ->willReturn('is fourth');
        $constraints[3]->expects($this->never())
                       ->method('toString');

        // An operator constraint with arity = 2 and low precedence (braces needed)
        $constraints[4]->expects($this->once())
                       ->method('arity')
                       ->with()
                       ->willReturn(2);
        $constraints[4]->expects($this->once())
                       ->method('precedence')
                       ->with()
                       ->willReturn(10000);
        $constraints[4]->expects($this->never())
                       ->method('toStringInContext');
        $constraints[4]->expects($this->once())
                       ->method('toString')
                       ->with()
                       ->willReturn('is fifth or later');

        $expected = self::operatorJoinStrings([
            'is first',
            'is second',
            'is third',
            'is fourth',
            '( is fifth or later )',
        ]);

        $this->assertSame($expected, $constraint->toString());
    }

    public function testFailureDescriptionWithSingleOperand(): void
    {
        $methods = [
            'arity',
            'precedence',
            'toStringInContext',
            'toString',
        ];

        $operand = $this->getMockBuilder(Operator::class)
                        ->onlyMethods($methods)
                        ->getMockForAbstractClass();

        $className = $this->className();

        $operator = $className::fromConstraints($operand);

        // A non-contextual operator with toString()
        $operand->expects($this->never())
                ->method('arity');
        $operand->expects($this->never())
                ->method('precedence');
        $operand->expects($this->never())
                ->method('toStringInContext');
        $operand->expects($this->once())
                ->method('toString')
                ->with()
                ->willReturn('is the only');

        $method = new ReflectionMethod($className, 'failureDescription');
        $method->setAccessible(true);

        $this->assertSame("'whatever' is the only", $method->invoke($operator, 'whatever'));
    }

    public function testFailureDescriptionWithMultipleOperands(): void
    {
        $constraintMethods = [
            'toStringInContext',
            'toString',
        ];

        $operatorMethods = [
            'arity',
            'precedence',
            'toStringInContext',
            'toString',
        ];

        $constraints = [
            $this->getMockBuilder(Constraint::class)
                 ->onlyMethods($constraintMethods)
                 ->getMockForAbstractClass(),
            $this->getMockBuilder(Constraint::class)
                 ->onlyMethods($constraintMethods)
                 ->getMockForAbstractClass(),
            $this->getMockBuilder(Operator::class)
                 ->onlyMethods($operatorMethods)
                 ->getMockForAbstractClass(),
            $this->getMockBuilder(Operator::class)
                 ->onlyMethods($operatorMethods)
                 ->getMockForAbstractClass(),
            $this->getMockBuilder(Operator::class)
                 ->onlyMethods($operatorMethods)
                 ->getMockForAbstractClass(),
        ];

        $className = $this->className();

        $constraint = $className::fromConstraints(...$constraints);

        // A non-contextual non-operator constraint
        $constraints[0]->expects($this->once())
                       ->method('toStringInContext')
                       ->with($this->identicalTo($constraint), 0)
                       ->willReturn('');
        $constraints[0]->expects($this->once())
                       ->method('toString')
                       ->with()
                       ->willReturn('is first');

        // A contextual non-operator constraint
        $constraints[1]->expects($this->once())
                       ->method('toStringInContext')
                       ->with($this->identicalTo($constraint), 1)
                       ->willReturn('is second');
        $constraints[1]->expects($this->never())
                       ->method('toString');

        // An non-contextual operator constraint with arity = 2 and high precedence (no braces needed)
        $constraints[2]->expects($this->once())
                       ->method('arity')
                       ->with()
                       ->willReturn(2);
        $constraints[2]->expects($this->once())
                       ->method('precedence')
                       ->with()
                       ->willReturn(-1);
        $constraints[2]->expects($this->once())
                       ->method('toStringInContext')
                       ->with($this->identicalTo($constraint), 2)
                       ->willReturn('');
        $constraints[2]->expects($this->once())
                       ->method('toString')
                       ->with()
                       ->willReturn('is third');

        // A contextual operator constraint with arity = 1 (no braces needed)
        $constraints[3]->expects($this->once())
                       ->method('arity')
                       ->with()
                       ->willReturn(1);
        $constraints[3]->expects($this->never())
                       ->method('precedence');
        $constraints[3]->expects($this->once())
                       ->method('toStringInContext')
                       ->with($this->identicalTo($constraint), 3)
                       ->willReturn('is fourth');
        $constraints[3]->expects($this->never())
                       ->method('toString');

        // An operator constraint with arity = 2 and low precedence (braces needed)
        $constraints[4]->expects($this->once())
                       ->method('arity')
                       ->with()
                       ->willReturn(2);
        $constraints[4]->expects($this->once())
                       ->method('precedence')
                       ->with()
                       ->willReturn(10000);
        $constraints[4]->expects($this->never())
                       ->method('toStringInContext');
        $constraints[4]->expects($this->once())
                       ->method('toString')
                       ->with()
                       ->willReturn('is fifth or later');

        $method = new ReflectionMethod($className, 'failureDescription');
        $method->setAccessible(true);

        $expectedToString = self::operatorJoinStrings([
            'is first',
            'is second',
            'is third',
            'is fourth',
            '( is fifth or later )',
        ]);
        $expected = "'whatever' " . $expectedToString;
        $this->assertSame($expected, $method->invokeArgs($constraint, ['whatever']));
    }

    /**
     * Generates an array of "binary tuples" of size $minSize up to (and
     * including) $maxSize.
     *
     * A "binary tuple" is an array of 0s an 1s. The method generates all
     * possible combinations of 0s and 1s of size $minSize up to $maxSize.
     */
    final protected static function getBinaryTuples(int $minSize, int $maxSize): array
    {
        $tuples = [];

        for ($size = $minSize; $size <= $maxSize; $size++) {
            for ($num = 0; $num < 2 ** $size; $num++) {
                $str      = decbin($num | 2 ** $size);                      // "1xyz" (extra "1" on the left)
                $bits     = array_map('intval', str_split($str));          // ["1", "x", "y", "z"]
                $tuple    = array_slice($bits, 1);                          // ["x", "y", "z"]
                $tuples[] = $tuple;
            }
        }

        return $tuples;
    }

    /**
     * Same as getBinaryTuples(), but returns tuples of boolean values
     * instead of integers.
     */
    final protected static function getBooleanTuples(int $minSize, int $maxSize): array
    {
        $tuples = self::getBinaryTuples($minSize, $maxSize);

        return array_map(static fn ($tuple) => array_map('boolval', $tuple), $tuples);
    }

    protected static function operatorJoinStrings(array $strings): string
    {
        return implode(' ' . static::getOperatorName() . ' ', $strings);
    }
}
