<?php
declare(strict_types=1);
namespace PpPhp\Core\Validation;

use PpPhp\Core\PluginRegistry;

/**
 * Validator — the single server-side consumer of the Validation plugins.
 *
 * Runs a field's ordered list of validation rules against a value and returns
 * the FIRST failing message (or null when everything passes). Used by the
 * generated Add/Edit save flow, before the custom `.server.validate` event
 * hooks, so built-in rules and hand-written business rules coexist.
 *
 * A rule is [ 'type' => 'email', 'params' => [...], 'message' => 'override' ].
 */
final class Validator
{
    /**
     * @param array<int,array<string,mixed>> $rules    ordered validation rules for one field
     * @param array<string,mixed>            $record    the full row (for cross-field rules)
     * @param array<string,mixed>            $context   { table, field, id } — for DB-aware rules (unique)
     * @return string|null  error message, or null when valid
     */
    public static function validate(array $rules, mixed $value, string $label = '', array $record = [], array $context = []): ?string
    {
        if ($rules === []) {
            return null;
        }

        $items = PluginRegistry::validation();
        $isEmpty = self::isEmpty($value);

        foreach ($rules as $rule) {
            if (!is_array($rule)) {
                continue;
            }
            $type = (string) ($rule['type'] ?? '');
            $item = $items[$type] ?? null;
            if ($item === null || !isset($item['class']) || !class_exists($item['class'])) {
                continue; // unknown rule → skip rather than block a save
            }
            $class = $item['class'];

            // Most rules treat an empty value as valid (skip) so optional fields
            // don't fire format errors when left blank. A rule opts INTO running
            // on empty by declaring validatesEmpty()===true (required, mustAccept).
            $runsOnEmpty = method_exists($class, 'validatesEmpty') && $class::validatesEmpty();
            if ($isEmpty && !$runsOnEmpty) {
                continue;
            }

            // Conditional rule: only applies when its `when` clause matches the
            // record (e.g. require "reason" only when status = 'other').
            $when = $rule['when'] ?? null;
            if (is_array($when) && !self::whenMet($when, $record)) {
                continue;
            }

            $params = self::resolveParams($rule['params'] ?? [], (array) ($item['params'] ?? []));

            try {
                // 4th arg (context) is ignored by rules that don't declare it.
                $ok = (bool) $class::serverCheck($value, $params, $record, $context);
            } catch (\Throwable $e) {
                $ok = true; // a broken rule must never block a save
            }

            if (!$ok) {
                $msg = trim((string) ($rule['message'] ?? ''));
                if ($msg === '') {
                    $msg = (string) $class::defaultMessage();
                }
                return self::interpolate($msg, $label, $params);
            }
        }

        return null;
    }

    /**
     * Evaluate a rule's `when` clause against the record. A clause is
     * { field, op, value }; op ∈ ==, !=, empty, notEmpty, in, contains.
     * An unknown op or a missing field name means "always apply".
     *
     * @param array<string,mixed> $when
     * @param array<string,mixed> $record
     */
    private static function whenMet(array $when, array $record): bool
    {
        $field = (string) ($when['field'] ?? '');
        if ($field === '') return true;
        $other = array_key_exists($field, $record) ? (string) $record[$field] : '';
        $val = (string) ($when['value'] ?? '');
        switch ((string) ($when['op'] ?? '==')) {
            case '==':       return $other === $val;
            case '!=':       return $other !== $val;
            case 'empty':    return trim($other) === '';
            case 'notEmpty': return trim($other) !== '';
            case 'in':       return in_array($other, array_map('trim', explode(',', $val)), true);
            case 'contains': return $val !== '' && mb_stripos($other, $val) !== false;
        }
        return true;
    }

    /** True when the value carries nothing to validate. */
    public static function isEmpty(mixed $value): bool
    {
        if ($value === null) {
            return true;
        }
        if (is_array($value)) {
            return $value === [];
        }
        return trim((string) $value) === '';
    }

    /** Merge user params over schema defaults. */
    private static function resolveParams(mixed $given, array $schema): array
    {
        $given = is_array($given) ? $given : [];
        $out = [];
        foreach ($schema as $def) {
            $key = (string) ($def['key'] ?? '');
            if ($key === '') {
                continue;
            }
            $out[$key] = array_key_exists($key, $given) ? $given[$key] : ($def['default'] ?? null);
        }
        // keep any extra user-supplied params too
        foreach ($given as $k => $v) {
            if (!array_key_exists($k, $out)) {
                $out[$k] = $v;
            }
        }
        return $out;
    }

    /** Replace {label} and {paramKey} tokens in a message. */
    private static function interpolate(string $msg, string $label, array $params): string
    {
        $repl = ['{label}' => $label !== '' ? $label : 'This field'];
        foreach ($params as $k => $v) {
            if (is_scalar($v)) {
                $repl['{' . $k . '}'] = (string) $v;
            }
        }
        return strtr($msg, $repl);
    }
}
