<?php
declare(strict_types=1);

namespace Infolab;

/**
 * Validation fluente des payloads JSON. Accumule les erreurs par champ, puis
 * `check()` lance une ApiException 422 conforme au contrat documenté :
 *
 *   { "error": "validation_failed", "fields": { "title": "required", ... } }
 *
 * Toutes les règles autres que `required()` sont silencieuses si la clé
 * n'existe pas — ainsi les PATCH partiels passent sans déclarer les champs absents.
 */
final class Validator
{
    /** @var array<string,mixed> */
    private array $data;

    /** @var array<string,string> code d'erreur par champ */
    private array $errors = [];

    /** @param array<string,mixed> $data */
    public function __construct(array $data)
    {
        $this->data = $data;
    }

    public function has(string $key): bool
    {
        return array_key_exists($key, $this->data);
    }

    public function value(string $key, mixed $default = null): mixed
    {
        return $this->data[$key] ?? $default;
    }

    /** Définit manuellement une erreur sur un champ (ex: "not_found" après lookup BDD). */
    public function setError(string $key, string $code): self
    {
        $this->errors[$key] = $code;
        return $this;
    }

    public function required(string $key): self
    {
        if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
            $this->errors[$key] ??= 'required';
        }
        return $this;
    }

    public function string(string $key, ?int $max = null, ?int $min = null): self
    {
        if (!$this->has($key) || $this->data[$key] === null) {
            return $this;
        }
        $v = $this->data[$key];
        if (!is_string($v)) {
            $this->errors[$key] ??= 'must_be_string';
            return $this;
        }
        $len = mb_strlen($v);
        if ($min !== null && $len < $min) $this->errors[$key] ??= 'too_short';
        if ($max !== null && $len > $max) $this->errors[$key] ??= 'too_long';
        return $this;
    }

    public function integer(string $key, ?int $min = null, ?int $max = null): self
    {
        if (!$this->has($key) || $this->data[$key] === null) {
            return $this;
        }
        $v = $this->data[$key];
        if (is_int($v)) {
            $i = $v;
        } elseif (is_string($v) && preg_match('/^-?\d+$/', $v)) {
            $i = (int) $v;
        } else {
            $this->errors[$key] ??= 'must_be_integer';
            return $this;
        }
        if ($min !== null && $i < $min) $this->errors[$key] ??= 'too_small';
        if ($max !== null && $i > $max) $this->errors[$key] ??= 'too_large';
        return $this;
    }

    public function boolean(string $key): self
    {
        if (!$this->has($key) || $this->data[$key] === null) {
            return $this;
        }
        $v = $this->data[$key];
        if (!is_bool($v) && !in_array($v, [0, 1, '0', '1', 'true', 'false'], true)) {
            $this->errors[$key] ??= 'must_be_boolean';
        }
        return $this;
    }

    /** @param array<int,mixed> $allowed */
    public function in(string $key, array $allowed): self
    {
        if (!$this->has($key) || $this->data[$key] === null) {
            return $this;
        }
        if (!in_array($this->data[$key], $allowed, true)) {
            $this->errors[$key] ??= 'invalid_value';
        }
        return $this;
    }

    public function url(string $key, int $max = 1024): self
    {
        if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
            return $this;
        }
        $v = $this->data[$key];
        if (!is_string($v) || filter_var($v, FILTER_VALIDATE_URL) === false || mb_strlen($v) > $max) {
            $this->errors[$key] ??= 'invalid_url';
        }
        return $this;
    }

    public function email(string $key): self
    {
        if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
            return $this;
        }
        if (!is_string($this->data[$key]) || filter_var($this->data[$key], FILTER_VALIDATE_EMAIL) === false) {
            $this->errors[$key] ??= 'invalid_email';
        }
        return $this;
    }

    /** Format ISO date YYYY-MM-DD strict. */
    public function date(string $key): self
    {
        if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
            return $this;
        }
        $v = $this->data[$key];
        if (!is_string($v)) {
            $this->errors[$key] ??= 'invalid_date';
            return $this;
        }
        $d = \DateTimeImmutable::createFromFormat('Y-m-d', $v);
        if ($d === false || $d->format('Y-m-d') !== $v) {
            $this->errors[$key] ??= 'invalid_date';
        }
        return $this;
    }

    public function colorHex(string $key): self
    {
        if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
            return $this;
        }
        if (!is_string($this->data[$key]) || !preg_match('/^#[0-9A-Fa-f]{6}$/', $this->data[$key])) {
            $this->errors[$key] ??= 'invalid_color_hex';
        }
        return $this;
    }

    public function fails(): bool
    {
        return $this->errors !== [];
    }

    /** @return array<string,string> */
    public function errors(): array
    {
        return $this->errors;
    }

    /** Termine la validation : lance ApiException 422 si au moins une erreur. */
    public function check(): void
    {
        if ($this->errors !== []) {
            throw new \ApiException(422, [
                'error'  => 'validation_failed',
                'fields' => $this->errors,
            ]);
        }
    }
}
