# Sprint 1.4 · Validador con reglas chilenas

> Especificación del `app/Core/Validator.php` con reglas custom para Chile.

## Filosofía

- **Server-side siempre.** El cliente puede mentir.
- **Mensajes de error claros en español chileno.**
- **No reinventar la rueda:** usar regex y funciones nativas de PHP cuando se pueda.
- **Reglas componibles:** múltiples reglas por campo separadas por `|`.

---

## API básica

```php
$validator = Validator::make($data, [
    'email' => 'required|email|unique:users,email',
    'rut' => 'required|rut',
    'precio' => 'required|precio_clp|min:0',
    'slug' => 'required|slug|unique:categorias,slug',
    'imagen' => 'required|image|mimes:jpg,png,webp|max:5120|dimensions:min_w=400,min_h=400',
]);

if ($validator->fails()) {
    return Response::json(['errors' => $validator->errors()], 422);
}

$validatedData = $validator->validated();
```

---

## Reglas built-in

### Strings y básicas

| Regla | Significado | Ejemplo |
|---|---|---|
| `required` | No puede estar vacío | `'required'` |
| `nullable` | Puede ser null | `'nullable\|email'` |
| `string` | Debe ser string | |
| `min:N` | Largo mínimo (string) o valor mínimo (numeric) | `'min:5'` |
| `max:N` | Largo máximo / valor máximo | `'max:255'` |
| `between:A,B` | Entre A y B | `'between:1,100'` |
| `in:a,b,c` | Debe ser uno de los valores | `'in:NI,NF,AMBOS'` |
| `not_in:a,b` | NO puede ser ninguno | |
| `regex:pattern` | Match contra regex | `'regex:/^[a-z]+$/'` |
| `confirmed` | Coincide con `<campo>_confirmation` | `'password\|confirmed'` |
| `same:campo` | Igual que otro campo | |
| `different:campo` | Distinto a otro campo | |

### Numéricos

| Regla | Significado |
|---|---|
| `numeric` | Es numérico (int o float) |
| `integer` | Es entero |
| `decimal:precision` | Decimal con precision (ej: `decimal:2`) |
| `boolean` | true/false/0/1 |
| `gt:N` | Mayor a N |
| `lt:N` | Menor a N |
| `gte:N` | Mayor o igual |
| `lte:N` | Menor o igual |

### Email y URL

| Regla | Significado |
|---|---|
| `email` | Email válido (filter_var) |
| `email_dns` | Email + check de DNS MX (más lento) |
| `url` | URL válida |
| `active_url` | URL + check resolvable |

### Fechas

| Regla | Significado |
|---|---|
| `date` | Fecha válida (parseable) |
| `date_format:Y-m-d` | Formato específico |
| `before:fecha` | Antes de |
| `after:fecha` | Después de |

### Archivos

| Regla | Significado |
|---|---|
| `file` | Es un upload válido |
| `image` | Es imagen (jpg/png/gif/webp/svg) |
| `mimes:jpg,png` | Mime types específicos |
| `max:KB` | Tamaño máximo en KB |
| `dimensions:min_w=400,min_h=400` | Dimensiones imagen |

### Database

| Regla | Significado |
|---|---|
| `unique:tabla,columna` | No existe en BD del tenant actual |
| `unique:tabla,columna,exceptId` | Excluye un id (para updates) |
| `exists:tabla,columna` | Sí existe |

---

## Reglas custom chilenas

### `rut`

Valida RUT chileno con dígito verificador correcto.

**Implementación:**

```php
// Acepta formatos:
// 12.345.678-5
// 12345678-5
// 123456785

private static function ruleRut(string $value): bool
{
    // Limpiar: quitar puntos y guión
    $rut = preg_replace('/[.-]/', '', strtoupper($value));
    
    // Debe tener entre 7 y 9 caracteres (8 cuerpo + 1 dv)
    if (!preg_match('/^[0-9]{7,8}[0-9K]$/', $rut)) {
        return false;
    }
    
    $cuerpo = substr($rut, 0, -1);
    $dvIngresado = substr($rut, -1);
    
    // Calcular dígito verificador
    $multiplicador = 2;
    $suma = 0;
    for ($i = strlen($cuerpo) - 1; $i >= 0; $i--) {
        $suma += (int)$cuerpo[$i] * $multiplicador;
        $multiplicador = $multiplicador === 7 ? 2 : $multiplicador + 1;
    }
    $resto = $suma % 11;
    $dvCalculado = match(11 - $resto) {
        11 => '0',
        10 => 'K',
        default => (string)(11 - $resto),
    };
    
    return $dvCalculado === $dvIngresado;
}
```

**Mensaje de error:** "RUT inválido. Verificá el dígito verificador."

### `telefono_chileno`

Acepta:
- `+56912345678` (formato internacional)
- `912345678` (móvil sin código país)
- `223456789` (fijo Santiago)
- `+56 9 1234 5678` (con espacios)

```php
private static function ruleTelefonoChileno(string $value): bool
{
    $clean = preg_replace('/[\s().-]/', '', $value);
    return (bool)preg_match('/^(\+56)?[2-9]\d{8}$/', $clean);
}
```

### `slug`

Para URLs amigables: solo minúsculas, dígitos, guión.

```php
private static function ruleSlug(string $value): bool
{
    return (bool)preg_match('/^[a-z][a-z0-9-]{2,60}$/', $value);
}
```

### `precio_clp`

Entero positivo, max razonable (ej: 1000 millones).

```php
private static function rulePrecioCLP(mixed $value): bool
{
    $clean = is_string($value) ? str_replace(['.', ','], '', $value) : $value;
    if (!is_numeric($clean)) return false;
    $n = (int)$clean;
    return $n >= 0 && $n <= 1_000_000_000;
}
```

### `precio_uf`

Decimal positivo con máximo 2 decimales, max 10000 UF.

```php
private static function rulePrecioUF(mixed $value): bool
{
    if (!is_numeric($value)) return false;
    $f = (float)$value;
    if ($f < 0 || $f > 10000) return false;
    // Verificar máx 2 decimales
    $parts = explode('.', (string)$f);
    return !isset($parts[1]) || strlen($parts[1]) <= 2;
}
```

### `comuna_chile` (futuro, Sprint 2.x)

Lista de comunas válidas. Por ahora dejarlo como `string|max:100`.

---

## Estructura del Validator

```php
<?php
declare(strict_types=1);

namespace App\Core;

use InvalidArgumentException;

final class Validator
{
    private array $errors = [];
    private array $validatedData = [];

    public function __construct(
        private readonly array $data,
        private readonly array $rules,
        private readonly array $customMessages = [],
    ) {}

    public static function make(array $data, array $rules, array $messages = []): self
    {
        $v = new self($data, $rules, $messages);
        $v->validate();
        return $v;
    }

    private function validate(): void
    {
        foreach ($this->rules as $field => $rulesString) {
            $value = $this->data[$field] ?? null;
            $rules = is_array($rulesString) ? $rulesString : explode('|', $rulesString);

            foreach ($rules as $rule) {
                if (!$this->checkRule($field, $value, $rule)) {
                    break; // No seguir con más reglas si una falla
                }
            }
        }
    }

    private function checkRule(string $field, mixed $value, string $rule): bool
    {
        // Parsear regla:parametro
        [$ruleName, $params] = $this->parseRule($rule);
        
        $methodName = 'rule' . str_replace(' ', '', ucwords(str_replace('_', ' ', $ruleName)));
        
        if (!method_exists($this, $methodName)) {
            throw new InvalidArgumentException("Regla no reconocida: {$ruleName}");
        }
        
        $passed = $this->$methodName($value, $params, $field);
        
        if (!$passed) {
            $this->errors[$field][] = $this->getMessage($field, $ruleName, $params);
            return false;
        }
        
        $this->validatedData[$field] = $value;
        return true;
    }

    public function passes(): bool { return empty($this->errors); }
    public function fails(): bool  { return !$this->passes(); }
    public function errors(): array { return $this->errors; }
    public function validated(): array { return $this->validatedData; }
    
    // ... implementación de cada ruleX
}
```

---

## Mensajes de error (español chileno)

```php
const DEFAULT_MESSAGES = [
    'required' => 'El campo :field es obligatorio.',
    'email' => 'El email no es válido.',
    'rut' => 'El RUT no es válido. Verificá el dígito verificador.',
    'telefono_chileno' => 'El teléfono no es válido. Usá formato +56912345678.',
    'slug' => 'El slug solo puede contener letras minúsculas, números y guiones.',
    'precio_clp' => 'El precio en CLP debe ser un número positivo.',
    'precio_uf' => 'El precio en UF debe ser un número positivo (max 10.000).',
    'unique' => 'Ya existe un registro con este :field.',
    'exists' => 'El :field seleccionado no existe.',
    'min' => 'El campo :field debe tener al menos :param caracteres/valor.',
    'max' => 'El campo :field excede el máximo permitido (:param).',
    'image' => 'El archivo debe ser una imagen.',
    'mimes' => 'El archivo debe ser de tipo: :param.',
    'dimensions' => 'Las dimensiones de la imagen no son válidas.',
    'in' => 'El valor de :field no es válido.',
    'numeric' => 'El campo :field debe ser numérico.',
    'integer' => 'El campo :field debe ser un entero.',
    'boolean' => 'El campo :field debe ser verdadero o falso.',
];
```

---

## Tests obligatorios

`tests/unit/ValidatorTest.php` debe cubrir:

### RUT
- ✅ RUTs válidos: `12.345.678-5`, `12345678-5`, `1-9`, `11.111.111-1`, `7.654.321-K`
- ❌ RUTs inválidos: `12.345.678-9` (DV mal), `abc-d`, `123-x`, vacío

### Teléfono
- ✅ `+56912345678`, `912345678`, `223456789`, `+56 9 1234 5678`
- ❌ `123`, `+1234567890`, `abc`

### Slug
- ✅ `urnas`, `producto-premium`, `nivel-1`
- ❌ `Urnas` (uppercase), `pro_ducto` (underscore), `pr` (corto)

### Precio CLP
- ✅ `0`, `100`, `450000`, `1000000000`
- ❌ `-100`, `2000000000`, `abc`

### Precio UF
- ✅ `0`, `50`, `120.5`, `9999.99`
- ❌ `-1`, `10001`, `120.555` (más de 2 decimales)

### Email
- ✅ `vendedor@demo.cl`
- ❌ `noemail`, `@.com`

### Required
- ✅ "Hola", `0`, `false`
- ❌ `null`, `''`, `[]`

### Unique (con BD)
- ✅ Registros nuevos
- ❌ Duplicados

### Image dimensions
- ✅ Imagen 800x600
- ❌ Imagen 200x200 si min es 400x400

---

## Frontend hint

El validador es server-side. Para mejor UX, replicar **algunas** validaciones del lado cliente (HTML5 + JS sutil):

```html
<input type="email" required minlength="5" maxlength="180" pattern="...">
```

Pero **siempre** validar también en server. Cliente solo es feedback rápido.

JS para RUT (replicar el algoritmo):

```js
function validarRUT(rut) {
    const limpio = rut.replace(/[.-]/g, '').toUpperCase();
    if (!/^[0-9]{7,8}[0-9K]$/.test(limpio)) return false;
    
    const cuerpo = limpio.slice(0, -1);
    const dvIngresado = limpio.slice(-1);
    
    let multiplicador = 2;
    let suma = 0;
    for (let i = cuerpo.length - 1; i >= 0; i--) {
        suma += parseInt(cuerpo[i]) * multiplicador;
        multiplicador = multiplicador === 7 ? 2 : multiplicador + 1;
    }
    const resto = suma % 11;
    let dvCalculado = 11 - resto;
    if (dvCalculado === 11) dvCalculado = '0';
    else if (dvCalculado === 10) dvCalculado = 'K';
    else dvCalculado = String(dvCalculado);
    
    return dvCalculado === dvIngresado;
}
```

Mostrar inline mientras escribe (debounced 300ms).
