<?php

namespace Velis\Test\Dto;

use DateTime;
use Exception;
use ReflectionException;
use stdClass;
use Velis\App;
use Velis\Dto\Exceptions\ValidationException;
use Velis\Dto\Utility\Optional;
use Velis\Filter;
use Velis\Model\DataObject\NoColumnsException;
use Velis\Model\DataObject;
use Velis\Test\Dto\Concrete\Dto\CasterTestDto;
use Velis\Test\Dto\Concrete\Dto\EmptyTestDto;
use Velis\Test\Dto\Concrete\Dto\NestedEmptyTestDto;
use Velis\Test\Dto\Concrete\Dto\NestedStructureSerializationDto;
use Velis\Test\Dto\Concrete\Dto\RelationTest\RelatedDto;
use Velis\Test\Dto\Concrete\Dto\RelationTest\RelationHasManyTestDto;
use Velis\Test\Dto\Concrete\Dto\RelationTest\RelationHasOneTestDto;
use Velis\Test\Dto\Concrete\Dto\SerializationTestDto;
use Velis\Test\Dto\Concrete\Dto\TestDto;
use Velis\Test\Dto\Concrete\Dto\TestWithUnionTypeDto;
use Velis\Test\Dto\Concrete\Dto\UserSettingDto;
use Velis\Test\Dto\Concrete\Dto\ValidatorNullableTestDto;
use Velis\Test\Dto\Concrete\Dto\ValidatorTestDto;
use Velis\TestCase;

class BaseDtoTest extends TestCase
{
    // region Creating
    /**
     * @throws ReflectionException
     * @throws ValidationException
     */
    public function testFromArray(): void
    {
        $data = [
            'name' => 'John',
            'last_name' => 'Doe',
            'created_at' => '2021-01-01 00:00:00'
        ];

        $dto = TestDto::fromArray($data);

        $this->assertInstanceOf(TestDto::class, $dto);
    }

    public function testFromArrayWithUnionType(): void
    {
        $data = [
            'union_of_primitives' => 'Test',
            'union_of_dtos' => [
                'name' => 'John',
                'last_name' => 'Doe',
                'created_at' => '2021-01-01 00:00:00'
            ],
            'mixed' => 1
        ];

        $dto = TestWithUnionTypeDto::fromArray($data);

        $this->assertInstanceOf(TestWithUnionTypeDto::class, $dto);
    }

    public function testFromArrays(): void
    {
        $data = [
            [
                'name' => 'John',
                'last_name' => 'Doe',
                'created_at' => '2021-01-01 00:00:00'
            ],
            [
                'name' => 'John',
                'last_name' => 'Doe',
                'created_at' => '2021-01-01 00:00:00'
            ]
        ];

        $dtos = TestDto::fromArrays($data);

        for ($i = 0; $i < count($data); $i++) {
            $this->assertInstanceOf(TestDto::class, $dtos[$i]);
        }
    }

    /**
     * @throws Exception
     */
    public function testFromModel(): void
    {
        $data = [
            'name' => 'John',
            'last_name' => 'Doe',
            'created_at' => '2021-01-01 00:00:00'
        ];

        $model = $this->createMock(DataObject::class);
        $model->method('getArrayCopy')->willReturn($data);

        $dto = TestDto::fromModel($model);

        $this->assertInstanceOf(TestDto::class, $dto);
        $this->assertEquals($data['name'], $dto->name);
        $this->assertEquals($data['last_name'], $dto->last_name);
        $this->assertEquals($data['created_at'], $dto->created_at);
    }

    /**
     * @throws Exception
     */
    public function testFromModels(): void
    {
        $data = [
            'name' => 'John',
            'last_name' => 'Doe',
            'created_at' => '2021-01-01 00:00:00'
        ];

        $models = [
            $this->createMock(DataObject::class),
            $this->createMock(DataObject::class)
        ];
        $models[0]->method('getArrayCopy')->willReturn($data);
        $models[1]->method('getArrayCopy')->willReturn($data);

        $dtos = TestDto::fromModels($models);

        for ($i = 0; $i < 2; $i++) {
            $this->assertInstanceOf(TestDto::class, $dtos[$i]);
            $this->assertEquals($data['name'], $dtos[$i]->name);
            $this->assertEquals($data['last_name'], $dtos[$i]->last_name);
            $this->assertEquals($data['created_at'], $dtos[$i]->created_at);
        }
    }

    /**
     * @throws ValidationException
     */
    public function testFromFilter(): void
    {
        $data = [
            'name' => 'Mc',
            'last_name' => "Donald's", // the filter should replace the single apostrophe
            'created_at' => '2021-01-01 00:00:00'
        ];

        $filter = new Filter($data, App::$config->settings->autoFiltering);

        $dto = TestDto::fromFilter($filter);

        $this->assertInstanceOf(TestDto::class, $dto);
        $this->assertEquals($data['name'], $dto->name);
        $this->assertEquals($data['created_at'], $dto->created_at);

        // a single apostrophe was replaced by ’ so - filter was applied.
        $this->assertNotEquals($data['last_name'], $dto->last_name);
        $this->assertEquals('Donald’s', $dto->last_name);
    }
    // endregion

    /**
     * Checks whether casters' logic works properly.
     * It does not check whether casters are properly defined or whether they cast data to the specific expected format.
     * Only testing whether casters are called and whether they return proper values.
     *
     * @throws ReflectionException
     * @throws ValidationException
     * @throws Exception
     */
    public function testCasters(): void
    {
        $data = [
            'created_at' => new DateTime('2021-01-01 00:00:00')
        ];

        $dto = CasterTestDto::fromArray($data);

        $this->assertInstanceOf(CasterTestDto::class, $dto);
        $this->assertEquals($data['created_at']->format('m-d-Y i:m:H'), $dto->created_at);
    }

    // region Validators
    /**
     * Checks whether validators' logic works properly.
     * It does not check whether casters are properly defined or whether they validate data to the specific,
     * expected format.
     * Only testing whether validators are called and whether they return proper values.
     */

    /**
     * @throws ReflectionException
     */
    public function testValidatorsRefuse(): void
    {
        $data = [
            'name' => 'Too Long User Name'
        ];

        $expectedError = [
            'property' => 'name',
            'message' => 'Value is too long'
        ];

        try {
            ValidatorTestDto::fromArray($data);
            $this->fail(ValidationException::class . ' should be thrown.');
        } catch (ValidationException $e) {
            $this->assertEquals([$expectedError], $e->getErrors());
        }
    }

    /**
     * @throws ReflectionException
     * @throws ValidationException
     */
    public function testValidatorsPass(): void
    {
        $data = [
            'name' => 'Valid Name'
        ];

        $dto = ValidatorTestDto::fromArray($data);

        $this->assertEquals($data['name'], $dto->name);
    }

    /**
     * In this case, we pass a null value to the nullable field - with additional validation.
     * We want BaseDto not to validate a field if it is nullable, and we pass null as value.
     * It's a fix for #70903
     * @throws ValidationException
     */
    public function testValidatorNullableFieldPass(): void
    {
        $data = [
            'name' => null
        ];

        $dto = ValidatorNullableTestDto::fromArray($data);

        $this->assertEquals($data['name'], $dto->name);
    }
    // endregion

    // region Relations
    /**
     * Checks whether relations loading logic works properly.
     */

    /**
     * @throws NoColumnsException
     * @throws Exception
     */
    public function testRelationHasMany(): void
    {
        $relatedModelData = [
            'name' => 'related model\' name'
        ];
        $relatedModel = $this->createMock(DataObject::class);
        $relatedModel->method('getArrayCopy')->willReturn($relatedModelData);

        $model = $this->createMock(DataObject::class);
        $model->method('getRelation')->willReturn([$relatedModel]);
        $model->method('hasRelation')->willReturn(true);

        $dto = RelationHasManyTestDto::fromModel($model);

        $this->assertInstanceOf(RelationHasManyTestDto::class, $dto);
        $this->assertInstanceOf(RelatedDto::class, $dto->related[0]);
        $this->assertEquals($relatedModelData['name'], $dto->related[0]->name);
    }

    /**
     * @throws NoColumnsException
     * @throws Exception
     */
    public function testRelationHasOne(): void
    {
        $relatedModelData = [
            'name' => 'related model\' name'
        ];
        $relatedModel = $this->createMock(DataObject::class);
        $relatedModel->method('getArrayCopy')->willReturn($relatedModelData);

        $model = $this->createMock(DataObject::class);
        $model->method('getRelation')->willReturn($relatedModel);
        $model->method('hasRelation')->willReturn(true);

        $dto = RelationHasOneTestDto::fromModel($model);

        $this->assertInstanceOf(RelationHasOneTestDto::class, $dto);
        $this->assertInstanceOf(RelatedDto::class, $dto->related);
        $this->assertEquals($relatedModelData['name'], $dto->related->name);
    }
    // endregion

    /**
     * @throws ValidationException
     */
    public function testJsonSerializable(): void
    {
        $data = [
            'require_string' => 'string',
            'required_array' => ['array'],
            'required_int' => 1,
            'required_float' => 1.1,
            'required_bool' => true,
            'nullable_string' => null,
            'nullable_array' => null,
            'nullable_int' => null,
            'nullable_float' => null,
            'nullable_bool' => null,
            // 'optional_string', - DO NOT SEND THIS VALUE, we are checking the behavior of Optional
            // 'optional_array', - DO NOT SEND THIS VALUE, we are checking the behavior of Optional
            // 'optional_int', - DO NOT SEND THIS VALUE, we are checking the behavior of Optional
            // 'optional_float', - DO NOT SEND THIS VALUE, we are checking the behavior of Optional
            // 'optional_bool', - DO NOT SEND THIS VALUE, we are checking the behavior of Optional
            'nullable_optional_string' => null,
            'nullable_optional_array' => null,
            'nullable_optional_int' => null,
            'nullable_optional_float' => null,
            'nullable_optional_bool' => null,
        ];

        $dto = SerializationTestDto::fromArray($data);

        // Check whether the DTO was created properly
        $this->assertEquals($data['require_string'], $dto->require_string);
        $this->assertEquals($data['required_array'], $dto->required_array);
        $this->assertEquals($data['required_int'], $dto->required_int);
        $this->assertEquals($data['required_float'], $dto->required_float);
        $this->assertEquals($data['required_bool'], $dto->required_bool);
        $this->assertEquals($data['nullable_string'], $dto->nullable_string);
        $this->assertEquals($data['nullable_array'], $dto->nullable_array);
        $this->assertEquals($data['nullable_int'], $dto->nullable_int);
        $this->assertEquals($data['nullable_float'], $dto->nullable_float);
        $this->assertEquals($data['nullable_bool'], $dto->nullable_bool);
        $this->assertInstanceOf(Optional::class, $dto->optional_string);
        $this->assertInstanceOf(Optional::class, $dto->optional_int);
        $this->assertInstanceOf(Optional::class, $dto->optional_float);
        $this->assertInstanceOf(Optional::class, $dto->optional_bool);
        $this->assertInstanceOf(Optional::class, $dto->optional_array);

        // Simulate returning DTO as response from the controller's method
        $array = $dto->jsonSerialize();
        $json = json_encode($array);
        $this->assertJson($json);

        // Check whether the serialized data is correct
        $unserialized = json_decode($json, true);

        // These values should look like original data
        $this->assertEquals($data['require_string'], $unserialized['require_string']);
        $this->assertEquals($data['required_array'], $unserialized['required_array']);
        $this->assertEquals($data['required_int'], $unserialized['required_int']);
        $this->assertEquals($data['required_float'], $unserialized['required_float']);
        $this->assertEquals($data['required_bool'], $unserialized['required_bool']);
        $this->assertEquals($data['nullable_string'], $unserialized['nullable_string']);
        $this->assertEquals($data['nullable_array'], $unserialized['nullable_array']);
        $this->assertEquals($data['nullable_int'], $unserialized['nullable_int']);
        $this->assertEquals($data['nullable_float'], $unserialized['nullable_float']);
        $this->assertEquals($data['nullable_bool'], $unserialized['nullable_bool']);
        $this->assertEquals($data['optional_string'], $unserialized['optional_string']);
        $this->assertEquals($data['optional_array'], $unserialized['optional_array']);
        $this->assertEquals($data['optional_int'], $unserialized['optional_int']);
        $this->assertEquals($data['optional_float'], $unserialized['optional_float']);
        $this->assertEquals($data['optional_bool'], $unserialized['optional_bool']);
        $this->assertEquals($data['nullable_optional_string'], $unserialized['nullable_optional_string']);
        $this->assertEquals($data['nullable_optional_array'], $unserialized['nullable_optional_array']);
        $this->assertEquals($data['nullable_optional_int'], $unserialized['nullable_optional_int']);
        $this->assertEquals($data['nullable_optional_float'], $unserialized['nullable_optional_float']);
        $this->assertEquals($data['nullable_optional_bool'], $unserialized['nullable_optional_bool']);

        // The Optional fields without value (and not nullable) should not be serialized
        $this->assertNotTrue(array_key_exists('optional_string', $unserialized));
        $this->assertNotTrue(array_key_exists('optional_array', $unserialized));
        $this->assertNotTrue(array_key_exists('optional_int', $unserialized));
        $this->assertNotTrue(array_key_exists('optional_float', $unserialized));
        $this->assertNotTrue(array_key_exists('optional_bool', $unserialized));
    }

    public function testNestedStructureCreating(): void
    {
        $data = [
            'name' => 'Test',
            'testDtos' => [
                [
                    'name' => 'John',
                    'last_name' => 'Doe',
                    'created_at' => '2021-01-01 00:00:00'
                ],
                [
                    'name' => 'John',
                    'last_name' => 'Doe',
                    'created_at' => '2021-01-01 00:00:00'
                ]
            ]
        ];

        $dto = NestedStructureSerializationDto::fromArray($data);

        $this->assertInstanceOf(NestedStructureSerializationDto::class, $dto);
        $this->assertCount(2, $dto->testDtos);
        $this->assertInstanceOf(TestDto::class, $dto->testDtos[0]);
        $this->assertInstanceOf(TestDto::class, $dto->testDtos[1]);
    }

    public function testNestedStructureCreatingOptionalField()
    {
        $data = [
            'name' => 'Test',
        ];
        $dto = NestedStructureSerializationDto::fromArray($data);

        $this->assertInstanceOf(NestedStructureSerializationDto::class, $dto);
        $this->assertFalse(method_exists($dto, 'testDtos'));
    }

    public function testNestedStructureOptionalFieldSerialization()
    {
        $data = [
            'name' => 'Test',
        ];
        $dto = NestedStructureSerializationDto::fromArray($data);

        $serialized = $dto->toArray();

        $this->assertArrayNotHasKey('testDtos', $serialized);
        $this->assertArrayHasKey('name', $serialized);
        $this->assertEquals($data['name'], $serialized['name']);
    }

    public function testGettingOtherFieldValueInValidator()
    {
        $data = [
            'password' => 'my password',
            'password_confirmation' => 'NOT my password'
        ];

        $this->expectExceptionMessage($data['password_confirmation']);

        UserSettingDto::fromArray($data);
    }


    public function testEmptyNestedDtoSerialization()
    {
        $data = [
            'name' => 'string',
            'empty_dto' => []
        ];

        $dto = NestedEmptyTestDto::fromArray($data);
        $array = $dto->toArray();
        $this->assertInstanceOf(stdClass::class, $array['empty_dto']);
    }
}
