<?php

namespace Formatrica\Submission\Validation;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Advanced per-field validation rule dispatcher (Free tier).
 *
 * Layered on top of the per-type checks in Field_Validator: the type-level
 * check (email shape, tel pattern, …) runs first and unchanged. When a field
 * opts in via `validation.advanced_enabled === true`, this dispatcher runs the
 * chosen named rule against the already-type-sanitised value.
 *
 * Rules are pure functions of (value, params, submission). The submission map
 * is passed so `sameas` can compare against a peer field's value — the caller
 * (Field_Validator) resolves the peer's stable key to its name and injects
 * `params._resolved_target_name`.
 *
 * Empty value passes every rule; `required` is a separate concern handled by
 * Field_Validator (otherwise "Letters only" would fire on an empty optional
 * field). Unknown rule ids pass silently, so a stale/Pro-only rule saved on a
 * downgraded install can never block submission. Server enforcement is
 * canonical; the JS mirror (src/frontend/validation) is UX-only.
 *
 */
final class Rule_Validator
{
    /**
     * Free-tier rule ids.
     *
     * @var list<string>
     */
    public const FREE_RULES = [
        'alpha',
        'alphaspaces',
        'alphanumeric',
        'numeric',
        'numericspaces',
        'minlength',
        'maxlength',
        'alphaaccented',
        'alphaspacesaccented',
        'alphanumericaccented',
        'email',
        'validurl',
        'ipaddress',
        'uszipcode',
        'phonenumber',
        'sameas',
    ];

    /**
     * Pro-tier rule ids (P12), gated by fields.validation_pro_rules via
     * Advanced_Validations_Rule at save. The EVALUATORS live in the Pro
     * package (Pro_Rule_Evaluators, registered via register_rule) - this Free
     * core keeps only the ids for the save-guard vocabulary. An unavailable
     * rule passes silently, so a stored Pro rule never blocks submission on a
     * Free install.
     *
     * @var list<string>
     */
    public const PRO_RULES = [
        'regex',
        'custom',
        'creditcard',
        'iban',
        'restrictedwords',
        'multiplerules',
        'emaildns',
        'password',
    ];

    /**
     * Substituted for a `password`-rule field's value in every output context
     * (storage, email, Slack, integrations) so the answer is never persisted in
     * plaintext. Applied by Field_Validator after a successful check.
     */
    public const PASSWORD_REDACTED_SENTINEL = '********';

    /** Save-time + runtime cap on regex pattern length (ReDoS surface). */
    private const REGEX_MAX_LENGTH = 200;

    /**
     * Edition-registered rule evaluators (Pro_Rule_Evaluators registers the
     * paid rules here at boot). Signature: fn($value, array $params,
     * array $submission, Rule_Validator $v): ?string.
     *
     * @var array<string, callable>
     */
    private static array $extra_rules = [];

    /**
     * emaildns MX resolver test seam, forwarded to the registered evaluator
     * set (tests only; production uses dns_get_record).
     *
     * @var (callable(string): (array|false|null))|null
     */
    private $dns_resolver = null;

    public static function register_rule(string $rule, callable $evaluator): void
    {
        self::$extra_rules[$rule] = $evaluator;
    }

    /**
     * Inject a custom DNS resolver (tests only; production uses dns_get_record).
     *
     * @param callable(string): (array|false|null)|null $resolver
     */
    public function set_dns_resolver(?callable $resolver): void
    {
        $this->dns_resolver = $resolver;
    }

    /** @internal evaluator access to the test seam. */
    public function get_dns_resolver(): ?callable
    {
        return $this->dns_resolver;
    }

    /**
     * Run a single named rule - the nested-rule entry point for compound
     * evaluators (multiplerules). @internal
     *
     * @param array<string, mixed> $params
     * @param array<string, mixed> $submission
     */
    public function run_rule(string $rule, $value, array $params, array $submission): ?string
    {
        return $this->dispatch($rule, $value, $params, $submission);
    }

    /**
     * Run the field's configured advanced rule against $value.
     *
     * @param mixed                $value      Already type-sanitised field value.
     * @param array<string, mixed> $field      Full field config (reads $field['validation']).
     * @param array<string, mixed> $submission Full sanitised value map (name => value), for sameas.
     * @return string|null  Null on pass; the error message on failure.
     */
    public function run($value, array $field, array $submission): ?string
    {
        $config = isset($field['validation']) && is_array($field['validation'])
            ? $field['validation']
            : [];

        if (empty($config['advanced_enabled'])) {
            return null;
        }

        $rule = isset($config['rule']) && is_string($config['rule']) ? $config['rule'] : '';
        if ($rule === '') {
            return null;
        }

        // Empty value passes every rule (required is separate).
        if ($this->is_empty($value)) {
            return null;
        }

        $params = isset($config['params']) && is_array($config['params']) ? $config['params'] : [];

        $error = $this->dispatch($rule, $value, $params, $submission);
        if ($error === null) {
            return null;
        }

        // Admin override wins over the rule's default message.
        $override = isset($config['message']) && is_string($config['message']) ? trim($config['message']) : '';

        return $override !== '' ? $override : $error;
    }

    /**
     * @param array<string, mixed> $params
     * @param array<string, mixed> $submission
     */
    private function dispatch(string $rule, $value, array $params, array $submission): ?string
    {
        $string = $this->value_as_string($value);

        switch ($rule) {
            case 'alpha':
                return preg_match('/^[A-Za-z]+$/', $string) === 1
                    ? null : __('Please use letters only.', 'formatrica');

            case 'alphaspaces':
                return preg_match('/^[A-Za-z ]+$/', $string) === 1
                    ? null : __('Please use letters and spaces only.', 'formatrica');

            case 'alphanumeric':
                return preg_match('/^[A-Za-z0-9]+$/', $string) === 1
                    ? null : __('Please use letters and numbers only.', 'formatrica');

            case 'numeric':
                return preg_match('/^[0-9]+$/', $string) === 1
                    ? null : __('Please use numbers only.', 'formatrica');

            case 'numericspaces':
                return preg_match('/^[0-9 ]+$/', $string) === 1
                    ? null : __('Please use numbers and spaces only.', 'formatrica');

            case 'alphaaccented':
                return preg_match('/^\p{L}+$/u', $string) === 1
                    ? null : __('Please use letters only.', 'formatrica');

            case 'alphaspacesaccented':
                return preg_match('/^[\p{L} ]+$/u', $string) === 1
                    ? null : __('Please use letters and spaces only.', 'formatrica');

            case 'alphanumericaccented':
                return preg_match('/^[\p{L}\p{N}]+$/u', $string) === 1
                    ? null : __('Please use letters and numbers only.', 'formatrica');

            case 'minlength':
                $n = isset($params['n']) ? (int) $params['n'] : 0;
                if ($n <= 0) {
                    return null;
                }
                return $this->utf8_length($string) >= $n
                    ? null
                    /* translators: %d: minimum number of characters */
                    : sprintf(__('Please enter at least %d characters.', 'formatrica'), $n);

            case 'maxlength':
                $n = isset($params['n']) ? (int) $params['n'] : 0;
                if ($n <= 0) {
                    return null;
                }
                return $this->utf8_length($string) <= $n
                    ? null
                    /* translators: %d: maximum number of characters */
                    : sprintf(__('Please enter no more than %d characters.', 'formatrica'), $n);

            case 'email':
                return filter_var($string, FILTER_VALIDATE_EMAIL) !== false
                    ? null : __('Please enter a valid email address.', 'formatrica');

            case 'validurl':
                // Accept a full http(s) scheme OR a bare www. prefix, matching
                // the url field type. Other schemes reject.
                $has_scheme = preg_match('#^https?://#i', $string) === 1;
                $has_www    = preg_match('/^www\./i', $string) === 1;
                if (!$has_scheme && !$has_www) {
                    return __('Please enter a valid URL.', 'formatrica');
                }
                $probe = $has_scheme ? $string : 'https://' . $string;
                return filter_var($probe, FILTER_VALIDATE_URL) !== false
                    ? null : __('Please enter a valid URL.', 'formatrica');

            case 'ipaddress':
                return filter_var($string, FILTER_VALIDATE_IP) !== false
                    ? null : __('Please enter a valid IP address.', 'formatrica');

            case 'uszipcode':
                return preg_match('/^\d{5}(-\d{4})?$/', $string) === 1
                    ? null : __('Please enter a valid ZIP code.', 'formatrica');

            case 'phonenumber':
                return preg_match('/^[\d\s\-+().]+$/', $string) === 1
                    ? null : __('Please enter a valid phone number.', 'formatrica');

            case 'sameas':
                return $this->validate_sameas($string, $params, $submission);

            default:
                // Edition-registered rule (the Pro package registers its paid
                // evaluators at boot). Unknown/unavailable rule ids pass, so a
                // stored Pro rule never blocks submission on a Free install.
                if (isset(self::$extra_rules[$rule])) {
                    return (self::$extra_rules[$rule])($value, $params, $submission, $this);
                }

                return null;
        }
    }

    /**
     * sameas — the value must match a peer field's value on the same form.
     * The caller resolves the peer's stable key to its submission name and
     * injects it as params._resolved_target_name.
     *
     * @param array<string, mixed> $params
     * @param array<string, mixed> $submission
     */
    private function validate_sameas(string $value, array $params, array $submission): ?string
    {
        $peer_name = isset($params['_resolved_target_name']) && is_string($params['_resolved_target_name'])
            ? $params['_resolved_target_name']
            : '';

        if ($peer_name === '') {
            // Half-configured or unresolved target — fail open, don't bounce.
            return null;
        }

        $peer_value = $submission[$peer_name] ?? '';
        if (!is_string($peer_value)) {
            $peer_value = is_scalar($peer_value) ? (string) $peer_value : '';
        }

        return $value === $peer_value ? null : __('The values do not match.', 'formatrica');
    }

    private function is_empty($value): bool
    {
        if ($value === null) {
            return true;
        }
        if (is_string($value)) {
            return trim($value) === '';
        }
        if (is_array($value)) {
            return $value === [];
        }
        return false;
    }

    /** @internal shared with edition-registered evaluators. */
    public function value_as_string($value): string
    {
        if (is_string($value)) {
            return $value;
        }
        return is_scalar($value) ? (string) $value : '';
    }

    private function utf8_length(string $value): int
    {
        return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value);
    }
}
