<?php

namespace NoahVet\Reef\Domain\Validation;

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

class OpenApiValidator implements OpenApiValidatorInterface
{
    /**
     * @var list<array{key: string, value: mixed, message: string, template?: string}>
     */
    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>>
     */
    #[\Override]
    public function validate(array $data, array $config): array
    {
        $rootRequired = [];
        if (isset($config['required']) && \is_array($config['required'])) {
            $rootRequired = $config['required'];
            unset($config['required']);
        }

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

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

        return $this->errors;
    }

    /**
     * @param array<string, mixed> $config
     *
     * @psalm-param array-key $key
     */
    private function recursiveValidate(int|string $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 (isset($config[$key]['oneOf'])) {
            return $this->manageOneOf($key, $value, $nodeName, $config);
        }

        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(),
                        \JSON_THROW_ON_ERROR,
                    ),
                ),
            );
        }

        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
     * @param array<string, mixed> $value
     * @param array<int, string>   $requiredKeys
     */
    private function recursiveValidateRequiredKey(
        string $key,
        array $value,
        array $data,
        string $nodeName = '',
        array $requiredKeys = [],
    ): void {
        $isRequired = \in_array($key, $requiredKeys, true);
        $hasKey = \array_key_exists($key, $data);

        if (!$hasKey) {
            if ($isRequired) {
                $this->addError($nodeName.$key, null, 'required');
            }

            return;
        }

        $currentValue = $data[$key];

        if (isset($value['anyOf'])) {
            return;
        }

        if (isset($value['oneOf'])) {
            return;
        }

        $this->throwNewLogicExceptionWhenTypeDoesntExist($key, $value);

        if (null === $currentValue) {
            return;
        }

        if (SupportedTypeEnum::OBJECT->value === $value['type']) {
            if (!isset($value['properties'])) {
                throw new \LogicException('The key properties should be define for the type object.');
            }

            if (!\is_array($currentValue)) {
                return;
            }

            $childrenRequired = $value['required'] ?? [];
            foreach ($value['properties'] as $subValueKey => $subValueValue) {
                $this->recursiveValidateRequiredKey(
                    $subValueKey,
                    $subValueValue,
                    $currentValue,
                    $nodeName.$key.'.',
                    $childrenRequired,
                );
            }
        }

        if (
            SupportedTypeEnum::ARRAY->value === $value['type']
            && isset($value['items'])
            && isset($value['items']['type'])
            && SupportedTypeEnum::OBJECT->value === $value['items']['type']
            && isset($value['items']['properties'])
        ) {
            if (!\is_array($currentValue)) {
                return;
            }

            foreach ($currentValue as $index => $item) {
                if (!\is_array($item)) {
                    continue;
                }
                $childrenRequired = $value['items']['required'] ?? [];
                foreach ($value['items']['properties'] as $subValueKey => $subValueValue) {
                    $this->recursiveValidateRequiredKey(
                        $subValueKey,
                        $subValueValue,
                        $item,
                        $nodeName.$key.'['.$index.'].',
                        $childrenRequired,
                    );
                }
            }
        }
    }

    /**
     * @param array<array-key, mixed>|bool|float|int|string|null $value
     */
    private function addError(string $key, array|bool|float|int|string|null $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(),
                        \JSON_THROW_ON_ERROR,
                    ),
                ),
            );
        }

        return $return;
    }

    /**
     * @param array<string, mixed>     $config
     * @param array<int|string, mixed> $value
     */
    private function manageArrayConstraints(string $key, array $value, string $nodeName, array $config): self
    {
        if (isset($config[$key]['minItems'])) {
            if (!\is_int($config[$key]['minItems']) || 0 > $config[$key]['minItems']) {
                throw new \LogicException('The `minItems` value must be a non-negative integer.');
            }

            if (\count($value) < $config[$key]['minItems']) {
                $this->addError($nodeName.$key, $value, 'type.array.minItems');
            }
        }

        if (isset($config[$key]['maxItems'])) {
            if (!\is_int($config[$key]['maxItems']) || 0 > $config[$key]['maxItems']) {
                throw new \LogicException('The `maxItems` value must be a non-negative integer.');
            }

            if (\count($value) > $config[$key]['maxItems']) {
                $this->addError($nodeName.$key, $value, 'type.array.maxItems');
            }
        }

        if (isset($config[$key]['uniqueItems'])) {
            if (!\is_bool($config[$key]['uniqueItems'])) {
                throw new \LogicException('The `uniqueItems` value must be a boolean.');
            }

            if (true === $config[$key]['uniqueItems']) {
                $uniqueItems = \array_unique(
                    \array_map(
                        static fn ($item) => \serialize($item),
                        $value,
                    ),
                );

                if (\count($uniqueItems) !== \count($value)) {
                    $this->addError($nodeName.$key, $value, 'type.array.uniqueItems');
                }
            }
        }

        return $this;
    }

    /**
     * @param array<string, mixed> $config
     */
    private function manageArrayKey(string $key, mixed $value, string $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.');
            }
            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)) {
                        $this->manageArrayConstraints($key, $value, $nodeName, $config);

                        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');
                                } else {
                                    if (
                                        isset($config[$key]['items']['maxLength'])
                                        && \strlen($stringItem) > $config[$key]['items']['maxLength']
                                    ) {
                                        $this->addError($nodeName.$key, $stringItem, 'string.maxLength');
                                    }
                                    if (
                                        isset($config[$key]['items']['minLength'])
                                        && \strlen($stringItem) < $config[$key]['items']['minLength']
                                    ) {
                                        $this->addError($nodeName.$key, $stringItem, 'string.minLength');
                                    }
                                    if (
                                        isset($config[$key]['items']['format'])
                                        && SupportedFormatEnum::URL->value === $config[$key]['items']['format']
                                        && !\filter_var($stringItem, \FILTER_VALIDATE_URL)
                                    ) {
                                        $this->addError($nodeName.$key, $stringItem, 'format.url');
                                    }
                                }
                            }
                        }
                        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)) {
                    $this->manageArrayConstraints($key, $value, $nodeName, $config);

                    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)) {
                    $this->manageArrayConstraints($key, $value, $nodeName, $config);

                    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) {
                    if (!\is_array($value)) {
                        $this->addError($nodeName.$key, $value, 'type.array');

                        return $this;
                    }

                    $this->manageArrayConstraints($key, $value, $nodeName, $config);

                    $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
            if (null === $value) {
                return $this;
            }

            if (!\is_string($value)) {
                $this->addError($nodeName.$key, $value, 'type.string');

                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
    {
        $isNullable = isset($config[$key]['nullable']) && true === $config[$key]['nullable'];

        if ('string' === $config[$key]['type']) {
            if (null === $value && !$isNullable) {
                $this->addError($nodeName.$key, $value, 'type.string.not_nullable');
            } elseif (null !== $value && !\is_string($value)) {
                $this->addError($nodeName.$key, $value, 'type.string');
            }
        }

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

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

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

        return $this;
    }

    /**
     * @param array<string, mixed> $config
     */
    private function manageOneOf(string $key, mixed $value, string $nodeName, array $config): self
    {
        if (!\is_array($config[$key]['oneOf'])) {
            throw new \LogicException('The `oneOf` key must be an array.');
        }

        if (null === $value && (isset($config[$key]['nullable']) && true === $config[$key]['nullable'])) {
            return $this;
        }

        foreach ($config[$key]['oneOf'] as $schema) {
            if (!\is_array($schema)) {
                throw new \LogicException('Each `oneOf` item must be an array.');
            }

            $validator = new self($this->dateValidator);
            $validator->recursiveValidateRequiredKey($key, $schema, [$key => $value], $nodeName);
            $validator->recursiveValidate($key, $value, [$key => $schema], $nodeName);

            if ([] === $validator->errors) {
                return $this;
            }
        }

        $this->addError($nodeName.$key, $value, 'one_of');

        return $this;
    }

    /**
     * @param array<string, mixed> $config
     */
    private function manageObjectKey(string $key, mixed $value, string $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;
    }

    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;
    }
}
