<?php

namespace App\Tests\Unit\Filter\TimeConstraint;

use App\Attribute\DateTimeConstraintAware;
use App\Filter\Doctrine\TimeConstraint\AbstractTimeFilter;
use App\Filter\Doctrine\TimeConstraint\DatetimeFilter;
use App\Service\Constraint\DateTimeConstraint;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

class TestableAbstractTimeFilter extends AbstractTimeFilter
{
    protected static ?string $annotationStartField = 'startFieldName';
    protected static ?string $annotationEndField = 'endFieldName';

    public function getAccessId()
    {
        return $this->accessId;
    }

    public function getTimeConstraint()
    {
        return $this->timeConstraint;
    }

    public function getStartAndEndFields(array $arguments): array
    {
        return parent::getStartAndEndFields($arguments);
    }

    public function getConstraints(int $accessId): array
    {
        return parent::getConstraints($accessId);
    }

    public function constructQuery(array $constraints, string $targetTableAlias, array $fields): string
    {
        return parent::constructQuery($constraints, $targetTableAlias, $fields);
    }

    public function getArithmeticValue(int $condition): ?string
    {
        return parent::getArithmeticValue($condition);
    }

    public static function setConstraintAnnotation(?string $constraintAnnotation)
    {
        static::$constraintAnnotation = $constraintAnnotation;
    }

    public static function setAnnotationStartField(?string $startField)
    {
        static::$annotationStartField = $startField;
    }

    public static function setAnnotationEndField(?string $endField)
    {
        static::$annotationEndField = $endField;
    }
}

#[\Attribute(\Attribute::TARGET_CLASS)]
class TestableConstraintAware
{
    public string $startDateFieldName;
    public string $endDateFieldName;

    public function __construct(
        string $startDateFieldName,
        string $endDateFieldName,
    ) {
        $this->startDateFieldName = $startDateFieldName;
        $this->endDateFieldName = $endDateFieldName;
    }

    public function getArguments()
    {
        return null;
    }
}

class AbstractTimeFilterTest extends TestCase
{
    private MockObject|EntityManagerInterface $em;

    public function setUp(): void
    {
        $this->em = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();

        // reset static values
        TestableAbstractTimeFilter::setConstraintAnnotation(null);
        TestableAbstractTimeFilter::setAnnotationStartField('startFieldName');
        TestableAbstractTimeFilter::setAnnotationEndField('endFieldName');
    }

    private function getAbstractTimeFilterMockFor(array $methodNames): TestableAbstractTimeFilter|MockObject
    {
        return $this
            ->getMockBuilder(TestableAbstractTimeFilter::class)
            ->setConstructorArgs([$this->em])
            ->setMethodsExcept(array_merge(['setEntityManager'], $methodNames))
            ->getMock();
    }

    public function testSetAccessId(): void
    {
        $filter = $this->getAbstractTimeFilterMockFor(['setAccessId', 'getAccessId']);
        $filter->setAccessId(123);

        $this->assertEquals(123, $filter->getAccessId());
    }

    public function testSetTimeConstraint(): void
    {
        $filter = $this->getAbstractTimeFilterMockFor(['setTimeConstraint', 'getTimeConstraint']);

        $timeConstraint = $this->getMockBuilder(DateTimeConstraint::class)->disableOriginalConstructor()->getMock();
        $filter->setTimeConstraint($timeConstraint);

        $this->assertEquals($timeConstraint, $filter->getTimeConstraint());
    }

    public function testGetStartAndEndFields(): void
    {
        $filter = $this->getAbstractTimeFilterMockFor(['getStartAndEndFields']);

        $this->assertEquals(
            ['startDate', 'endDate'],
            $filter->getStartAndEndFields(['startFieldName' => 'startDate', 'endFieldName' => 'endDate'])
        );
    }

    public function testGetStartAndEndFieldsNoValue(): void
    {
        $filter = $this->getAbstractTimeFilterMockFor(['getStartAndEndFields']);

        $this->assertEquals(
            ['startDate', null],
            $filter->getStartAndEndFields(['startFieldName' => 'startDate'])
        );

        $this->assertEquals(
            [null, 'endDate'],
            $filter->getStartAndEndFields(['endFieldName' => 'endDate'])
        );

        $this->assertEquals(
            [null, null],
            $filter->getStartAndEndFields([])
        );
    }

    public function testGetStartAndEndFieldsMissingAnnotationStartField(): void
    {
        $filter = $this->getAbstractTimeFilterMockFor(['getStartAndEndFields']);

        TestableAbstractTimeFilter::setAnnotationStartField(null);

        $this->expectException(\RuntimeException::class);

        $filter->getStartAndEndFields([]);
    }

    public function testGetStartAndEndFieldsMissingAnnotationEndField(): void
    {
        $filter = $this->getAbstractTimeFilterMockFor(['getStartAndEndFields']);

        TestableAbstractTimeFilter::setAnnotationEndField(null);

        $this->expectException(\RuntimeException::class);

        $filter->getStartAndEndFields([]);
    }

    public function testGetConstraints(): void
    {
        $filter = $this->getAbstractTimeFilterMockFor(['getConstraints', 'setTimeConstraint']);

        $timeConstraint = $this->getMockBuilder(DateTimeConstraint::class)->disableOriginalConstructor()->getMock();
        $filter->setTimeConstraint($timeConstraint);

        $accessId = 123;
        $constraints = ['a' => ['b' => [4, 5, 6]]];

        $timeConstraint->expects(self::once())->method('invoke')->with($accessId)->willReturn($constraints);

        $this->assertEquals(
            $constraints,
            $filter->getConstraints($accessId)
        );
    }

    public function testAddFilterConstraint(): void
    {
        $filter = $this->getAbstractTimeFilterMockFor(['addFilterConstraint', 'setAccessId']);

        $targetEntity = $this->getMockBuilder(ClassMetadata::class)->disableOriginalConstructor()->getMock();
        $targetTableAlias = 'abc';

        $constraintAnnotationClass = DateTimeConstraintAware::class;
        TestableAbstractTimeFilter::setConstraintAnnotation($constraintAnnotationClass);

        $startFieldName = 'startDate';
        $endFieldName = 'endDate';

        $constraintAnnotation = $this
            ->getMockBuilder(TestableConstraintAware::class)
            ->setConstructorArgs([$startFieldName, $endFieldName])
            ->getMock();

        $constraintAnnotation->method('getArguments')->willReturn([$startFieldName, $endFieldName]);

        $reflectionClass = $this->getMockBuilder(\ReflectionClass::class)->disableOriginalConstructor()->getMock();
        $reflectionClass
            ->expects(self::once())
            ->method('getAttributes')
            ->with(DateTimeConstraintAware::class)
            ->willReturn([$constraintAnnotation]);

        $targetEntity->expects(self::once())->method('getReflectionClass')->willReturn($reflectionClass);

        $fields = ['startDate', 'endDate'];

        $filter->expects(self::once())->method('getStartAndEndFields')->with([$startFieldName, $endFieldName])->willReturn($fields);

        $accessId = 123;
        $filter->setAccessId(123);

        $constraints = [
            'start' => ['2021-12-20' => [4]],
            'end' => ['2021-12-20' => [5], 'NULL' => [0]],
        ];

        $filter->expects(self::once())->method('getConstraints')->with($accessId)->willReturn($constraints);

        $formattedFields = ['start' => 'startDate', 'end' => 'endDate'];

        $generatedSql = 'azerty=1';

        $filter
            ->expects(self::once())
            ->method('constructQuery')
            ->with($constraints, $targetTableAlias, $formattedFields)
            ->willReturn($generatedSql);

        $this->assertEquals(
            $generatedSql,
            $filter->addFilterConstraint($targetEntity, $targetTableAlias)
        );
    }

    public function testAddFilterConstraintMissingConstraintAnnotation(): void
    {
        $filter = $this->getAbstractTimeFilterMockFor(['addFilterConstraint']);

        $targetEntity = $this->getMockBuilder(ClassMetadata::class)->disableOriginalConstructor()->getMock();
        $targetTableAlias = 'abc';

        TestableAbstractTimeFilter::setConstraintAnnotation(null);

        $this->expectException(\RuntimeException::class);

        $filter->addFilterConstraint($targetEntity, $targetTableAlias);
    }

    public function testAddFilterConstraintMissingAccessId(): void
    {
        $filter = $this->getAbstractTimeFilterMockFor(['addFilterConstraint']);

        $targetEntity = $this->getMockBuilder(ClassMetadata::class)->disableOriginalConstructor()->getMock();
        $targetTableAlias = 'abc';

        $constraintAnnotationClass = DateTimeConstraintAware::class;
        TestableAbstractTimeFilter::setConstraintAnnotation($constraintAnnotationClass);

        $this->expectException(\RuntimeException::class);

        $filter->addFilterConstraint($targetEntity, $targetTableAlias);
    }

    public function testAddFilterConstraintMissingTargetHasNoAnnotation(): void
    {
        $filter = $this->getAbstractTimeFilterMockFor(['addFilterConstraint', 'setAccessId']);

        $targetEntity = $this->getMockBuilder(ClassMetadata::class)->disableOriginalConstructor()->getMock();
        $targetTableAlias = 'abc';

        $constraintAnnotationClass = DateTimeConstraintAware::class;
        TestableAbstractTimeFilter::setConstraintAnnotation($constraintAnnotationClass);

        $accessId = 123;
        $filter->setAccessId(123);

        $reflectionClass = $this->getMockBuilder(\ReflectionClass::class)->disableOriginalConstructor()->getMock();
        $reflectionClass
            ->expects(self::once())
            ->method('getAttributes')
            ->with(DateTimeConstraintAware::class)
            ->willReturn([]);

        $targetEntity->expects(self::once())->method('getReflectionClass')->willReturn($reflectionClass);

        $this->assertEquals(
            '',
            $filter->addFilterConstraint($targetEntity, $targetTableAlias)
        );
    }

    public function testAddFilterConstraintAnnotationMissingStartDate(): void
    {
        $filter = $this->getAbstractTimeFilterMockFor(['addFilterConstraint', 'setAccessId']);

        $targetEntity = $this->getMockBuilder(ClassMetadata::class)->disableOriginalConstructor()->getMock();
        $targetTableAlias = 'abc';

        $constraintAnnotationClass = DateTimeConstraintAware::class;
        TestableAbstractTimeFilter::setConstraintAnnotation($constraintAnnotationClass);

        $accessId = 123;
        $filter->setAccessId(123);

        $startFieldName = 'startDate';
        $endFieldName = 'endDate';

        $constraintAnnotation = $this
            ->getMockBuilder(TestableConstraintAware::class)
            ->setConstructorArgs(['', $endFieldName])
            ->getMock();

        $constraintAnnotation->method('getArguments')->willReturn([$startFieldName, $endFieldName]);

        $reflectionClass = $this->getMockBuilder(\ReflectionClass::class)->disableOriginalConstructor()->getMock();
        $reflectionClass
            ->expects(self::once())
            ->method('getAttributes')
            ->with(DateTimeConstraintAware::class)
            ->willReturn([$constraintAnnotation]);

        $targetEntity->expects(self::once())->method('getReflectionClass')->willReturn($reflectionClass);

        $fields = ['startDate', 'endDate'];

        $filter->expects(self::once())->method('getStartAndEndFields')->willReturn(['', $endFieldName]);

        $this->expectException(\RuntimeException::class);

        $filter->addFilterConstraint($targetEntity, $targetTableAlias);
    }

    public function testAddFilterConstraintAnnotationMissingEndDate(): void
    {
        $filter = $this->getAbstractTimeFilterMockFor(['addFilterConstraint', 'setAccessId']);

        $targetEntity = $this->getMockBuilder(ClassMetadata::class)->disableOriginalConstructor()->getMock();
        $targetTableAlias = 'abc';

        $constraintAnnotationClass = DateTimeConstraintAware::class;
        TestableAbstractTimeFilter::setConstraintAnnotation($constraintAnnotationClass);

        $accessId = 123;
        $filter->setAccessId(123);

        $startFieldName = 'startDate';
        $endFieldName = 'endDate';

        $constraintAnnotation = $this
            ->getMockBuilder(TestableConstraintAware::class)
            ->setConstructorArgs([$startFieldName, ''])
            ->getMock();

        $constraintAnnotation->method('getArguments')->willReturn([$startFieldName, $endFieldName]);

        $reflectionClass = $this->getMockBuilder(\ReflectionClass::class)->disableOriginalConstructor()->getMock();
        $reflectionClass
            ->expects(self::once())
            ->method('getAttributes')
            ->with(DateTimeConstraintAware::class)
            ->willReturn([$constraintAnnotation]);

        $targetEntity->expects(self::once())->method('getReflectionClass')->willReturn($reflectionClass);

        $fields = ['startDate', 'endDate'];

        $filter->expects(self::once())->method('getStartAndEndFields')->willReturn([$startFieldName, '']);

        $this->expectException(\RuntimeException::class);

        $filter->addFilterConstraint($targetEntity, $targetTableAlias);
    }

    /**
     * @see DateTimeFilter::constructQuery()
     */
    public function testConstructQuery(): void
    {
        // Exceptionnellement on ne mock jamais une méthode (getArithmeticValue), il n'y a aucune plus value à le faire et ça alourdirait beaucoup les tests
        $filter = $this->getAbstractTimeFilterMockFor(['constructQuery', 'getArithmeticValue']);

        $constraints = [
            'start' => ['2021-12-20' => [4]],
            'end' => ['2021-12-20' => [5], 'NULL' => [0]],
        ];

        $tableAlias = 'o';

        $fields = [
            'start' => 'startDate',
            'end' => 'endDate',
        ];

        $queryExpected = "(o.startDate <= '2021-12-20') AND (o.endDate > '2021-12-20' OR o.endDate IS NULL)";

        $this->assertEquals(
            $queryExpected,
            $filter->constructQuery($constraints, $tableAlias, $fields)
        );
    }

    /**
     * @see DateTimeFilter::constructQuery()
     */
    public function testConstructQueryWithOr(): void
    {
        // Exceptionnellement on ne mock jamais une méthode (getArithmeticValue), il n'y a aucune plus value à le faire et ça alourdirait beaucoup les tests
        $filter = $this->getAbstractTimeFilterMockFor(['constructQuery', 'getArithmeticValue']);

        $constraints = [
            'start' => ['2021-12-20' => [1], '2021-12-30' => [5]],
            'end' => ['2022-01-20' => [1]],
        ];

        $tableAlias = 'o';

        $fields = [
            'start' => 'startDate',
            'end' => 'endDate',
        ];

        $queryExpected = "(o.startDate < '2021-12-20' OR o.startDate > '2021-12-30') AND (o.endDate < '2022-01-20')";

        $this->assertEquals(
            $queryExpected,
            $filter->constructQuery($constraints, $tableAlias, $fields)
        );
    }

    /**
     * @see DateTimeFilter::getArithmeticValue()
     */
    public function testGetArithmeticValue(): void
    {
        $filter = $this->getAbstractTimeFilterMockFor(['getArithmeticValue']);

        $this->assertEquals('<', $filter->getArithmeticValue(1));
        $this->assertEquals('<=', $filter->getArithmeticValue(4));
        $this->assertEquals('=', $filter->getArithmeticValue(3));
        $this->assertEquals('>=', $filter->getArithmeticValue(8));
        $this->assertEquals('>', $filter->getArithmeticValue(5));
        $this->assertEquals(null, $filter->getArithmeticValue(0));
    }
}
