<?php

declare(strict_types=1);

namespace NoahVet\Reef\Domain\Validation;

use NoahVet\Reef\Domain\Validation\Enum\SupportedFormatEnum;
use NoahVet\Reef\Domain\Validation\Enum\SupportedTypeEnum;

class OpenApiValidator implements OpenApiValidatorInterface
{
    /**
     * @var array<int|string, string|array<string, mixed>>
     */
    private array $errors = [];

    public function __construct(private readonly DateValidatorInterface $dateValidator)
    {
    }

    /**
     * @param array<string, mixed> $data
     * @param array<string, mixed> $config
     *
     * @return array<int|string, string|array<string, mixed>>
     */
    public function validate(array $data, array $config): array
    {
        foreach ($config as $key => $value) {
            $this->recursiveValidateRequiredKey($key, $value, $data);
        }

        foreach ($data as $key => $value) {
            $this->recursiveValidate($key, $value, $config);
        }

        return $this->errors;
    }

    /**
     * @param array<string, mixed> $config
     */
    private function recursiveValidate(mixed $key, mixed $value, array $config, string $nodeName = ''): self
    {
        // Ignore the provided keys that are not present in the config.
        if (!isset($config[$key])) {
            return $this;
        }

        if (null !== ($config[$key]['anyOf'] ?? null)) {
            return $this;
        }

        if (!isset($config[$key]['type'])) {
            throw new \LogicException(
                \sprintf(
                    'The type key is missing for the key %s.',
                    $key,
                ),
            );
        }

        if (null === SupportedTypeEnum::tryFrom($config[$key]['type'])) {
            throw new \LogicException(
                \sprintf(
                    'The %s type doesn\'t exist. The supported types are %s.',
                    $config[$key]['type'],
                    \json_encode(
                        SupportedTypeEnum::cases(),
                    ),
                ),
            );
        }

        return $this
            ->manageNativeKey($key, $value, $nodeName, $config)
            ->manageFormatKey($key, $value, $nodeName, $config)
            ->manageEnumKey($key, $value, $nodeName, $config)
            ->manageMinimumKey($key, $value, $nodeName, $config)
            ->manageMaximumKey($key, $value, $nodeName, $config)
            ->manageMaxLengthKey($key, $value, $nodeName, $config)
            ->manageMinLengthKey($key, $value, $nodeName, $config)
            ->managePatternKey($key, $value, $nodeName, $config)
            ->manageMultipleOfKey($key, $value, $nodeName, $config)
            ->manageObjectKey($key, $value, $nodeName, $config)
            ->manageArrayKey($key, $value, $nodeName, $config)
        ;
    }

    /**
     * @param array<string, mixed> $data
     */
    private function recursiveValidateRequiredKey(mixed $key, mixed $value, array $data, string $nodeName = ''): void
    {
        if (isset($value['anyOf'])) {
            return;
        }

        $this
            ->throwNewLogicExceptionWhenTypeDoesntExist($key, $value)
            ->manageRequiredKeys($key, $value, $data, $nodeName)
            ->manageObject($key, $value, $nodeName)
        ;
    }

    private function addError(string $key, mixed $value, string $message, ?string $template = null): self
    {
        $error = [
            'key' => $key,
            'value' => $value,
            'message' => $message,
        ];

        if (null !== $template) {
            $error['template'] = $template;
        }

        $this->errors[] = $error;

        return $this;
    }

    private function getFormatOrThrowNewLogicExceptionWhenFormatDoesntExist(string $format): SupportedFormatEnum
    {
        $return = SupportedFormatEnum::tryFrom($format);

        if (null === $return) {
            throw new \LogicException(
                \sprintf(
                    'The %s format doesn\'t exist. The supported formats are %s.',
                    $format,
                    \json_encode(
                        SupportedFormatEnum::cases(),
                    ),
                ),
            );
        }

        return $return;
    }

    /**
     * @param array<string, mixed> $config
     */
    private function manageArrayKey(string $key, mixed $value, mixed $nodeName, array $config): self
    {
        if ('array' === $config[$key]['type']) {
            if (!isset($config[$key]['items'])) {
                throw new \LogicException('The key items should be define for the type array.');
            } else {
                if (!isset($config[$key]['items']['type'])) {
                    throw new \LogicException('The key type should be define for the items.');
                }
            }

            $isNullable = isset($config[$key]['nullable']) && true === $config[$key]['nullable'];
            $isItemsNullable = isset($config[$key]['items']['nullable']) && true === $config[$key]['items']['nullable'];

            if ($config[$key]['items']['type'] == SupportedTypeEnum::STRING->value) {
                if (null === $value) {
                    if (!$isNullable) {
                        $this->addError($nodeName.$key, $value, 'type.string.not_nullable');
                    }
                } else {
                    if (\is_array($value)) {
                        if ([] == $value) {
                            // If minItems = 1 or more => [] value is not allowed
                            if (isset($config[$key]['minItems']) && 0 < $config[$key]['minItems']) {
                                $this->addError($nodeName.$key, $value, 'type.array.minItems');
                            }
                        } else {
                            foreach ($value as $stringItem) {
                                if (null == $stringItem && !$isItemsNullable) {
                                    $this->addError($nodeName.$key, $stringItem, 'type.string.not_nullable');
                                } else {
                                    if (!\is_string($stringItem)) {
                                        $this->addError($nodeName.$key, $stringItem, 'type.string');
                                    }
                                }
                            }
                            if (isset($config[$key]['items']['enum'])
                                && [] !== \array_diff($value, $config[$key]['items']['enum'])
                            ) {
                                $this->addError($nodeName.$key, $value, 'enum.value');
                            }
                        }
                    } else {
                        $this->addError($nodeName.$key, $value, 'type.array');
                    }
                }
            } elseif (
                $config[$key]['items']['type'] == SupportedTypeEnum::INTEGER->value
            ) {
                if (\is_array($value)) {
                    if ([] == $value) {
                        // If minItems = 1 or more => [] value is not allowed
                        if (isset($config[$key]['minItems']) && 0 < $config[$key]['minItems']) {
                            $this->addError($nodeName.$key, $value, 'type.array.minItems');
                        }
                    } else {
                        foreach ($value as $intItem) {
                            if (null == $intItem && !$isItemsNullable) {
                                $this->addError($nodeName.$key, $intItem, 'type.int.not_nullable');
                            } else {
                                if (!\is_int($intItem)) {
                                    $this->addError($nodeName.$key, $intItem, 'type.int');
                                }
                            }
                        }
                        if (isset($config[$key]['items']['enum'])
                            && [] !== \array_diff($value, $config[$key]['items']['enum'])
                        ) {
                            $this->addError($nodeName.$key, $value, 'enum.value');
                        }
                    }
                } else {
                    $this->addError($nodeName.$key, $value, 'type.array');
                }
            } elseif (
                $config[$key]['items']['type'] == SupportedTypeEnum::NUMBER->value
            ) {
                if (\is_array($value)) {
                    if ([] == $value) {
                        // If minItems = 1 or more => [] value is not allowed
                        if (isset($config[$key]['minItems']) && 0 < $config[$key]['minItems']) {
                            $this->addError($nodeName.$key, $value, 'type.array.minItems');
                        }
                    } else {
                        foreach ($value as $numberItem) {
                            if (null == $numberItem && !$isItemsNullable) {
                                $this->addError($nodeName.$key, $numberItem, 'type.number.not_nullable');
                            } else {
                                if (!\is_int($numberItem) && !\is_float($numberItem)) {
                                    $this->addError($nodeName.$key, $numberItem, 'type.number');
                                }
                            }
                        }
                        if (isset($config[$key]['items']['enum'])
                            && [] !== \array_diff($value, $config[$key]['items']['enum'])
                        ) {
                            $this->addError($nodeName.$key, $value, 'enum.value');
                        }
                    }
                } else {
                    $this->addError($nodeName.$key, $value, 'type.array');
                }
            } else {
                // Then, it's not a string, int or number
                if ('' === $value) {
                    $this->addError($nodeName.$key, $value, 'type.array');
                } elseif (null === $value && !$isNullable) {
                    $this->addError($nodeName.$key, $value, 'type.not_nullable');
                } elseif (null !== $value) {
                    $i = 0;
                    foreach ($value as $item) {
                        if (!empty($item)) {
                            foreach ($item as $subValueKey => $subValueValue) {
                                $this->recursiveValidate(
                                    $subValueKey,
                                    $subValueValue,
                                    $config[$key]['items']['properties'],
                                    $nodeName.$key.'['.$i.'].',
                                );
                            }
                            ++$i;
                        } elseif (!$isItemsNullable) {
                            $this->addError($nodeName.$key, $item, 'type.array');
                        }
                    }
                }
            }
        }

        return $this;
    }

    /**
     * @param array<string, mixed> $config
     */
    private function manageEnumKey(string $key, mixed $value, string $nodeName, array $config): self
    {
        if (isset($config[$key]['enum']) && (!empty($value) && !\in_array($value, $config[$key]['enum']))) {
            $this->addError($nodeName.$key, $value, 'enum.value');
        }

        return $this;
    }

    /**
     * @param array<string, mixed> $config
     */
    private function manageFormatKey(string $key, mixed $value, string $nodeName, array $config): self
    {
        if (isset($config[$key]['format'])) {
            $format = $this->getFormatOrThrowNewLogicExceptionWhenFormatDoesntExist($config[$key]['format']);

            // If $value is null, don't check the format
            //            $isNullable = isset($config[$key]['nullable']) && true === $config[$key]['nullable'];

            if (null == $value) {
                return $this;
            }

            if (
                SupportedFormatEnum::EMAIL->value === $format->value
                && !\filter_var($value, \FILTER_VALIDATE_EMAIL)
            ) {
                $this->addError($nodeName.$key, $value, 'format.email');
            }

            if (
                SupportedFormatEnum::DATE->value === $format->value
                && !$this->dateValidator->validate($value, 'Y-m-d')
            ) {
                $this->addError($nodeName.$key, $value, 'format.date');
            }

            if (
                SupportedFormatEnum::DATETIME->value === $format->value
                && !$this->dateValidator->validate($value)
            ) {
                $this->addError($nodeName.$key, $value, 'format.date-time');
            }

            if (
                SupportedFormatEnum::UUID->value === $format->value
                && !\preg_match('{^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$}Di', $value)
            ) {
                $this->addError($nodeName.$key, $value, 'format.uuid');
            }

            if (
                SupportedFormatEnum::URL->value === $format->value
                && !\filter_var($value, \FILTER_VALIDATE_URL)
            ) {
                $this->addError($nodeName.$key, $value, 'format.url');
            }
        }

        return $this;
    }

    /**
     * @param array<string, mixed> $config
     */
    private function manageMaximumKey(string $key, mixed $value, string $nodeName, array $config): self
    {
        if (null == $value) {
            return $this;
        }

        if (isset($config[$key]['maximum'])) {
            if ('integer' !== $config[$key]['type'] && 'number' !== $config[$key]['type']) {
                throw new \LogicException(
                    'The `maximum` key can only be applied to an integer / number type.',
                );
            }
            if (!\is_int($config[$key]['maximum'])) {
                throw new \LogicException(
                    'The `maximum` value must be an integer.',
                );
            }

            if ($value > $config[$key]['maximum']) {
                $this->addError(
                    $nodeName.$key,
                    $value,
                    $config[$key]['type'].'.maximum',
                    \sprintf(
                        'The value of `%s` key must be lower than %d.',
                        $key,
                        $config[$key]['maximum'],
                    ),
                );
            }
        }

        return $this;
    }

    /**
     * @param array<string, mixed> $config
     */
    private function manageMinimumKey(string $key, mixed $value, string $nodeName, array $config): self
    {
        if (null == $value) {
            return $this;
        }

        if (isset($config[$key]['minimum'])) {
            if ('integer' !== $config[$key]['type'] && 'number' !== $config[$key]['type']) {
                throw new \LogicException(
                    'The `minimum` key can only be applied to an integer / number type.',
                );
            }
            if (!\is_int($config[$key]['minimum'])) {
                throw new \LogicException(
                    'The `minimum` value must be an integer.',
                );
            }

            if ($value < $config[$key]['minimum']) {
                $this->addError(
                    $nodeName.$key,
                    $value,
                    $config[$key]['type'].'.minimum',
                    \sprintf(
                        'The value of `%s` key must be greater than %d.',
                        $key,
                        $config[$key]['minimum'],
                    ),
                );
            }
        }

        return $this;
    }

    /**
     * @param array<string, mixed> $config
     */
    private function manageMaxLengthKey(string $key, mixed $value, string $nodeName, array $config): self
    {
        if (null == $value) {
            return $this;
        }

        if (isset($config[$key]['maxLength'])) {
            if ('string' !== $config[$key]['type']) {
                throw new \LogicException(
                    'The `maxLength` key can only be applied to a string type.',
                );
            }
            if (!\is_int($config[$key]['maxLength'])) {
                throw new \LogicException(
                    'The `maxLength` value must be an int.',
                );
            }

            if (\strlen($value) > $config[$key]['maxLength']) {
                $this->addError(
                    $nodeName.$key,
                    $value,
                    $config[$key]['type'].'.maxLength',
                    \sprintf(
                        'The value of `%s` key must be lower or equals to %d.',
                        $key,
                        $config[$key]['maxLength'],
                    ),
                );
            }
        }

        return $this;
    }

    /**
     * @param array<string, mixed> $config
     */
    private function manageMinLengthKey(string $key, mixed $value, string $nodeName, array $config): self
    {
        if (null == $value) {
            return $this;
        }

        if (isset($config[$key]['minLength'])) {
            if ('string' !== $config[$key]['type']) {
                throw new \LogicException(
                    'The `minLength` key can only be applied to a string type.',
                );
            }
            if (!\is_int($config[$key]['minLength'])) {
                throw new \LogicException(
                    'The `minLength` value must be an int.',
                );
            }

            if (\strlen($value) < $config[$key]['minLength']) {
                $this->addError(
                    $nodeName.$key,
                    $value,
                    $config[$key]['type'].'.minLength',
                    \sprintf(
                        'The value of `%s` key must be greater or equals to %d.',
                        $key,
                        $config[$key]['minLength'],
                    ),
                );
            }
        }

        return $this;
    }

    /**
     * @param array<string, mixed> $config
     */
    private function managePatternKey(string $key, mixed $value, string $nodeName, array $config): self
    {
        if (null === $value) {
            return $this;
        }
        if (isset($config[$key]['pattern'])) {
            if ('string' !== $config[$key]['type']) {
                throw new \LogicException(
                    'The `pattern` key can only be applied to an string type.',
                );
            }

            if (
                !\is_string($config[$key]['pattern'])
                || '' == $config[$key]['pattern']
                || '0' == $config[$key]['pattern']
            ) {
                throw new \LogicException(
                    'The `pattern` value must be an string.',
                );
            }

            if (!\preg_match($config[$key]['pattern'], $value)) {
                $this->addError(
                    $nodeName.$key,
                    $value,
                    'string.pattern',
                    \sprintf(
                        'The value of `%s` should be respect the pattern %s.',
                        $key,
                        $config[$key]['pattern'],
                    ),
                );
            }
        }

        return $this;
    }

    /**
     * @param array<string, mixed> $config
     */
    private function manageMultipleOfKey(string $key, mixed $value, string $nodeName, array $config): self
    {
        if (isset($config[$key]['multipleOf'])) {
            if ('integer' !== $config[$key]['type'] && 'number' !== $config[$key]['type']) {
                throw new \LogicException(
                    'The `multipleOf` openApi property only works with integer and number types.',
                );
            }
            if (!\is_int($config[$key]['multipleOf']) && !\is_float($config[$key]['multipleOf'])) {
                throw new \LogicException(
                    'The `multipleOf` value must be an integer or number.',
                );
            }

            if ($value % $config[$key]['multipleOf']) {
                $this->addError(
                    $nodeName.$key,
                    $value,
                    'multiple_of',
                    \sprintf(
                        'The value of `%s` key must be a multiple of %d.',
                        $key,
                        $config[$key]['multipleOf'],
                    ),
                );
            }
        }

        return $this;
    }

    /**
     * @param array<string, mixed> $config
     */
    private function manageNativeKey(string $key, mixed $value, string $nodeName, array $config): self
    {
        if ('string' === $config[$key]['type'] && null !== $value && !\is_string($value)) {
            $this->addError($nodeName.$key, $value, 'type.string');
        }

        if ('integer' === $config[$key]['type'] && null !== $value && !\is_int($value)
        ) {
            $this->addError($nodeName.$key, $value, 'type.integer');
        }

        if ('number' === $config[$key]['type'] && null !== $value && !\is_int($value) && !\is_float($value)
        ) {
            $this->addError($nodeName.$key, $value, 'type.number');
        }

        if ('boolean' === $config[$key]['type'] && !\is_bool($value)
        ) {
            $this->addError($nodeName.$key, $value, 'type.boolean');
        }

        return $this;
    }

    private function manageObject(string $key, mixed $value, string $nodeName = ''): self
    {
        if ('object' === $value['type']) {
            if (!isset($value['properties'])) {
                throw new \LogicException('The key properties should be define for the type object.');
            }

            if (!\is_array($value)) {
                $this->addError($nodeName.$key, $value, 'type.object');
            } else {
                foreach ($value['properties'] as $subValueKey => $subValueValue) {
                    $this->recursiveValidateRequiredKey(
                        $subValueKey,
                        $subValueValue,
                        $value['properties'],
                        $nodeName.$key.'.',
                    );
                }
            }
        }

        return $this;
    }

    /**
     * @param array<string, mixed> $config
     */
    private function manageObjectKey(string $key, mixed $value, mixed $nodeName, array $config): self
    {
        if ('object' === $config[$key]['type']) {
            if (!isset($config[$key]['properties'])) {
                throw new \LogicException('The key properties should be define for the type object.');
            }
            $isNullable = isset($config[$key]['nullable']) && true === $config[$key]['nullable'];

            if (!empty($value) && \is_array($value)) {
                foreach ($value as $subValueKey => $subValueValue) {
                    $this->recursiveValidate(
                        $subValueKey,
                        $subValueValue,
                        $config[$key]['properties'],
                        $nodeName.$key.'.',
                    );
                }
            } elseif (!$isNullable) {
                $this->addError($nodeName.$key, $value, 'type.object');
            }
        }

        return $this;
    }

    /**
     * @param array<string, mixed> $data
     */
    private function manageRequiredKeys(string $key, mixed $value, array $data, string $nodeName): self
    {
        if (
            !isset($data[$key])
            && (
                (isset($value['nullable']) && !$value['nullable']) || !isset($value['nullable'])
            )
        ) {
            $this->addError($nodeName.$key, null, 'required');
        }

        return $this;
    }

    private function throwNewLogicExceptionWhenTypeDoesntExist(string $key, mixed $value): self
    {
        if (!isset($value['type'])) {
            throw new \LogicException(
                \sprintf(
                    'The type key is missing for the key %s.',
                    $key,
                ),
            );
        }

        return $this;
    }
}
