<?php
declare(strict_types=1);
namespace Aen\ValidatorWorkshop;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

final class Rules
{
    public const SCENARIOS = ['contact', 'registration', 'estimate', 'period', 'order', 'import', 'lab'];

    public static function text(int $min, int $max, bool $trim = true): Assert\Sequentially
    {
        return new Assert\Sequentially([
            new Assert\Type(type: 'string', message: 'Ожидается строка.'),
            new Assert\NotBlank(message: 'Заполните поле.', normalizer: 'trim'),
            new Assert\Length(min: $min, max: $max, minMessage: 'Нужно не менее {{ limit }} символов.', maxMessage: 'Допустимо не более {{ limit }} символов.', normalizer: $trim ? 'trim' : null),
        ]);
    }

    public static function email(): Assert\Sequentially
    {
        return new Assert\Sequentially([
            self::text(1, 180),
            new Assert\Email(message: 'Укажите email правильного формата.', mode: Assert\Email::VALIDATION_MODE_HTML5, normalizer: 'trim'),
        ]);
    }

    public static function finite(): Assert\Callback
    {
        return new Assert\Callback(static function (mixed $value, ExecutionContextInterface $context): void {
            if ((is_float($value) || is_int($value)) && !is_finite((float) $value)) {
                $context->buildViolation('Число должно быть конечным.')->setCode('FINITE_NUMBER_REQUIRED')->addViolation();
            }
        });
    }

    public static function number(float $min, float $max, bool $integer = false, bool $positive = false): Assert\Sequentially
    {
        $constraints = [
            new Assert\Type(type: $integer ? 'integer' : ['integer', 'float'], message: $integer ? 'Ожидается целое JSON-число, а не строка.' : 'Ожидается JSON-число, а не строка.'),
            new Assert\NotNull(message: 'Укажите число.'),
            self::finite(),
        ];
        if ($positive) {
            $constraints[] = new Assert\Positive(message: 'Значение должно быть больше нуля.');
        }
        $constraints[] = new Assert\Range(min: $min, max: $max, notInRangeMessage: 'Допустимо значение от {{ min }} до {{ max }}.', invalidMessage: 'Укажите число.');
        if (!$integer) {
            $constraints[] = new Assert\Callback(static function (mixed $value, ExecutionContextInterface $context): void {
                if (abs($value * 100 - round($value * 100)) > 0.00001) {
                    $context->buildViolation('Допустимо не более двух знаков после запятой.')->setCode('DECIMAL_PRECISION')->addViolation();
                }
            });
        }
        return new Assert\Sequentially($constraints);
    }

    public static function flag(): Assert\Sequentially
    {
        return new Assert\Sequentially([
            new Assert\Type(type: 'bool', message: 'Ожидается true или false, а не строка.'),
            new Assert\NotNull(message: 'Укажите согласие.'),
            new Assert\IsTrue(message: 'Нужно подтвердить согласие.'),
        ]);
    }

    public static function date(): Assert\Sequentially
    {
        return new Assert\Sequentially([
            new Assert\Type(type: 'string', message: 'Дата должна быть строкой YYYY-MM-DD.'),
            new Assert\NotBlank(message: 'Укажите дату.', normalizer: 'trim'),
            new Assert\Date(message: 'Укажите существующую дату в формате YYYY-MM-DD.'),
        ]);
    }

    public static function collection(array $fields): Assert\Collection
    {
        return new Assert\Collection(fields: $fields, allowExtraFields: false, allowMissingFields: false,
            extraFieldsMessage: 'Это поле не поддерживается.', missingFieldsMessage: 'Обязательное поле отсутствует.');
    }

    public static function rows(array $fields): Assert\Sequentially
    {
        return new Assert\Sequentially([
            new Assert\Type(type: 'array', message: 'Ожидается JSON-массив строк.'),
            new Assert\NotNull(message: 'Передайте массив строк.'),
            new Assert\Callback(static function (mixed $value, ExecutionContextInterface $context): void {
                if (!array_is_list($value)) {
                    $context->buildViolation('Строки должны быть списком с последовательными индексами.')->setCode('LIST_REQUIRED')->addViolation();
                }
            }),
            new Assert\Count(min: 1, max: 10, minMessage: 'Добавьте хотя бы одну строку.', maxMessage: 'Допускается не более 10 строк.'),
            new Assert\All(constraints: [new Assert\Sequentially([
                new Assert\Type(type: 'array', message: 'Строка должна быть JSON-объектом.'),
                new Assert\NotNull(message: 'Строка не может быть null.'),
                self::collection($fields),
            ])]),
        ]);
    }

    /** @return list<Constraint> */
    public static function forScenario(string $scenario): array
    {
        return match ($scenario) {
            'contact' => [self::collection([
                'name' => self::text(2, 80), 'email' => self::email(), 'message' => self::text(10, 1000), 'consent' => self::flag(),
            ])],
            'registration' => [self::collection([
                'username' => new Assert\Sequentially([self::text(3, 24), new Assert\Regex(pattern: '/^[A-Za-z0-9_-]+$/D', message: 'Используйте латинские буквы, цифры, дефис или подчёркивание.')]),
                'email' => self::email(), 'password' => self::text(8, 64, false), 'confirmPassword' => self::text(1, 64, false), 'terms' => self::flag(),
            ]), new Assert\Callback(static function (mixed $data, ExecutionContextInterface $context): void {
                if (is_string($data['password'] ?? null) && is_string($data['confirmPassword'] ?? null) && $data['password'] !== $data['confirmPassword']) {
                    $context->buildViolation('Пароли не совпадают.')->atPath('[confirmPassword]')->setCode('PASSWORDS_DIFFER')->addViolation();
                }
            })],
            'estimate' => [self::collection([
                'title' => self::text(2, 80), 'quantity' => self::number(0, 10000, positive: true),
                'unitPrice' => self::number(0, 1000000), 'discount' => self::number(0, 50),
            ])],
            'period' => [self::collection(['start' => self::date(), 'end' => self::date()]), new Assert\Callback(static function (mixed $data, ExecutionContextInterface $context): void {
                $start = self::parsedDate($data['start'] ?? null); $end = self::parsedDate($data['end'] ?? null);
                if (!$start || !$end) { return; }
                if ($end < $start) {
                    $context->buildViolation('Дата окончания не должна быть раньше начала.')->atPath('[end]')->setCode('PERIOD_ORDER')->addViolation();
                } elseif ($start->diff($end)->days > 366) {
                    $context->buildViolation('Разница между датами не должна превышать 366 дней.')->atPath('[end]')->setCode('PERIOD_TOO_LONG')->addViolation();
                }
            })],
            'order' => [self::collection([
                'customerEmail' => self::email(),
                'delivery' => new Assert\Sequentially([self::text(1, 20), new Assert\Choice(choices: ['pickup', 'courier'], message: 'Выберите самовывоз или курьерскую доставку.')]),
                'items' => self::rows(['sku' => self::text(2, 24), 'quantity' => self::number(1, 99, integer: true, positive: true), 'price' => self::number(0, 1000000)]),
            ])],
            'import' => [self::collection([
                'rows' => self::rows(['name' => self::text(2, 80), 'email' => self::email(), 'age' => self::number(18, 120, integer: true)]),
            ])],
            default => throw new BadRequest('unsupported_scenario', 'Неизвестный сценарий.'),
        };
    }

    public static function parsedDate(mixed $value): ?\DateTimeImmutable
    {
        if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}$/D', $value)) { return null; }
        $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value, new \DateTimeZone('UTC'));
        return $date && $date->format('Y-m-d') === $value && checkdate((int) substr($value, 5, 2), (int) substr($value, 8, 2), (int) substr($value, 0, 4)) ? $date : null;
    }
}
