<?php

namespace Formatrica\Submission\Validation;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}
/**
 * Validates and sanitizes form field values
 */
final class Field_Validator
{
    private File_Validator $file_validator;
    private Rule_Validator $rule_validator;
    // Pro conditional-visibility evaluator (logic.visibility). Null in a Free
    // package (the class ships only in Pro), where every field is treated as
    // visible and conditional logic is ignored.
    private ?\Formatrica\Pro\Submission\Logic\Conditional_Visibility_Resolver $visibility;

    public function __construct(
        ?File_Validator $file_validator = null,
        ?Rule_Validator $rule_validator = null,
        ?\Formatrica\Pro\Submission\Logic\Conditional_Visibility_Resolver $visibility = null
    ) {
        $this->file_validator = $file_validator ?? new File_Validator();
        $this->rule_validator = $rule_validator ?? new Rule_Validator();
        $this->visibility = $visibility ?? (
            class_exists(\Formatrica\Pro\Submission\Logic\Conditional_Visibility_Resolver::class)
                ? new \Formatrica\Pro\Submission\Logic\Conditional_Visibility_Resolver()
                : null
        );
    }

    /**
     * Edition type handlers: a registered handler OWNS the whole validate_all
     * iteration for its type (Pro registers multi_file + calculation). The
     * handler mutates the passed collectors and the loop moves on.
     * Signature: fn(array $field, array $data, array $files, array &$clean,
     *              array &$errors, array &$field_meta, Field_Validator $v): void
     *
     * @var array<string, callable>
     */
    private static array $type_handlers = [];

    /**
     * Edition value validators for sanitise_and_validate (Pro registers
     * checkbox_group, dynamic_select, rating, geolocation, richtext).
     * Signature: fn($raw, array $field): array{0:mixed,1:?string}
     *
     * @var array<string, callable>
     */
    private static array $value_validators = [];

    public static function register_type_handler(string $type, callable $handler): void
    {
        self::$type_handlers[$type] = $handler;
    }

    public static function register_value_validator(string $type, callable $validator): void
    {
        self::$value_validators[$type] = $validator;
    }

    /**
     * Validate all fields
     *
     * @param array<int, array<string, mixed>> $fields
     * @param array<string, mixed> $data
     * @param array<string, mixed> $files
     * @return Validation_Result
     */
    public function validate_all(array $fields, array $data, array $files): Validation_Result
    {
        $clean = [];
        $errors = [];
        $field_meta = [];
        $file_fields_to_process = [];

        foreach ($fields as $field) {
            $type = $field['type'] ?? 'text';
            $name = $field['name'] ?? null;

            if (!$name) {
                continue;
            }

            // Skip non-input fields
            if (in_array($type, ['button', 'custom_text', 'step_break', 'divider', 'heading', 'static_image'], true)) {
                continue;
            }

            // Renderer/validator parity: a field type this edition cannot
            // render was never shown to the visitor, so it must not be
            // validated either — a required Pro field on a downgraded install
            // would otherwise be invisible yet block every submission. Shares
            // Form_Renderer's exact availability decision.
            if (!\Formatrica\Fields\Field_Renderer_Registry::default()->is_available($type)) {
                continue;
            }

            // Conditional visibility (Pro logic.visibility): a field hidden by
            // logic is not part of the submission — clear it and skip validation
            // so a hidden required field can never block the form. In Free the
            // resolver is null, so every field is treated as visible.
            if ($this->visibility !== null && !$this->visibility->is_visible($field, $data, $fields)) {
                $clean[$name] = '';
                $field_meta[] = [
                    'name' => $name,
                    'label' => $field['label'] ?? $name,
                    'value' => '',
                    'type' => $type,
                    'meta' => [],
                ];
                continue;
            }

            $required = isset($field['required']) ? (bool) $field['required'] : true;

            // A hidden input can't be filled by the visitor, so enforcing
            // required would brick the form when its value is empty. (The
            // sanitizer strips the flag on save; this covers stored configs
            // from before it did.)
            if ($type === 'hidden') {
                $required = false;
            }

            // Handle file fields separately
            if ($type === 'file') {
                $error = $this->file_validator->validate($field, $files);

                if ($required && $error === null) {
                    $file = $this->file_validator->extract_uploaded_file($name, $files);
                    if (!$file || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
                        $error = __('This field is required.', 'formatrica');
                    }
                }

                if ($error) {
                    $errors[$name] = $error;
                } else {
                    $file_fields_to_process[] = $field;
                }

                $clean[$name] = '';
                $field_meta[] = [
                    'name' => $name,
                    'label' => $field['label'] ?? $name,
                    'value' => '',
                    'type' => $type,
                    'meta' => [],
                ];

                continue;
            }

            // Edition-owned types (Pro registers multi_file + calculation):
            // the handler owns this iteration - validation, meta seeding, all
            // of it. Free registers none, and the availability skip above
            // already dropped types this edition cannot render.
            if (isset(self::$type_handlers[$type])) {
                (self::$type_handlers[$type])($field, $data, $files, $clean, $errors, $field_meta, $this);
                continue;
            }

            // Validate regular fields
            $raw = $data[$name] ?? null;

            [$value, $error] = $this->sanitise_and_validate($type, $raw, $field);

            if ($required && $this->is_value_missing($type, $raw, $value)) {
                $error = __('This field is required.', 'formatrica');
            }

            if ($error) {
                $errors[$name] = $error;
            }

            $clean[$name] = $value;

            $field_meta[] = [
                'name' => $name,
                'label' => $field['label'] ?? $name,
                'value' => $value,
                'type' => $type,
                'meta' => [],
            ];
        }

        // Advanced-rule pass. Runs after the whole form is type-validated so a
        // rule (e.g. sameas) can reference any peer field's clean value. Rules
        // never override a required/type error already on the field, and empty
        // values pass every rule (the Rule_Validator enforces empty-passes).
        $this->apply_advanced_rules($fields, $clean, $errors);

        // Redact password-rule field values (Pro P12) everywhere — the answer is
        // never stored or emitted in plaintext, only the ******** sentinel.
        foreach ($fields as $field) {
            $name = $field['name'] ?? null;
            if (!$name || !$this->field_has_password_rule($field)) {
                continue;
            }
            if (array_key_exists($name, $clean) && $clean[$name] !== '') {
                $clean[$name] = Rule_Validator::PASSWORD_REDACTED_SENTINEL;
            }
            foreach ($field_meta as &$fm) {
                if (($fm['name'] ?? null) === $name && ($fm['value'] ?? '') !== '') {
                    $fm['value'] = Rule_Validator::PASSWORD_REDACTED_SENTINEL;
                }
            }
            unset($fm);
        }

        return new Validation_Result($clean, $errors, $field_meta);
    }

    /**
     * Whether a field's advanced rule is `password` (directly or as a nested
     * rule inside a `multiplerules` compound) — the trigger for value redaction.
     *
     * @param array<string, mixed> $field
     */
    private function field_has_password_rule(array $field): bool
    {
        $v = $field['validation'] ?? null;
        if (!is_array($v) || empty($v['advanced_enabled'])) {
            return false;
        }
        $rule = $v['rule'] ?? '';
        if ($rule === 'password') {
            return true;
        }
        if ($rule === 'multiplerules' && isset($v['params']['rules']) && is_array($v['params']['rules'])) {
            foreach ($v['params']['rules'] as $nested) {
                if (is_array($nested) && ($nested['rule'] ?? '') === 'password') {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Run each field's configured advanced validation rule.
     *
     * @param array<int, array<string, mixed>> $fields
     * @param array<string, mixed>             $clean  Sanitised value map (name => value).
     * @param array<string, string>            $errors Accumulated errors, mutated by reference.
     */
    private function apply_advanced_rules(array $fields, array $clean, array &$errors): void
    {
        // Map stable field key => submission name, so sameas can resolve its
        // target (rules reference the immutable key, never the editable name).
        $key_to_name = [];
        foreach ($fields as $field) {
            if (isset($field['key'], $field['name']) && is_string($field['key'])) {
                $key_to_name[$field['key']] = (string) $field['name'];
            }
        }

        foreach ($fields as $field) {
            $type = $field['type'] ?? 'text';
            $name = $field['name'] ?? null;

            if (!$name || in_array($type, ['button', 'custom_text', 'file', 'multi_file', 'step_break', 'divider', 'heading', 'static_image', 'calculation'], true)) {
                continue;
            }

            // Don't stack a rule error on top of a required/type error.
            if (isset($errors[$name])) {
                continue;
            }

            $config = isset($field['validation']) && is_array($field['validation']) ? $field['validation'] : [];
            if (empty($config['advanced_enabled'])) {
                continue;
            }

            // Resolve the sameas peer key -> name for the pure-function rule.
            if (($config['rule'] ?? '') === 'sameas') {
                $target_key = $config['params']['field_key'] ?? '';
                $field['validation']['params']['_resolved_target_name'] = is_string($target_key)
                    ? ($key_to_name[$target_key] ?? '')
                    : '';
            }

            $error = $this->rule_validator->run($clean[$name] ?? null, $field, $clean);
            if ($error !== null) {
                $errors[$name] = $error;
            }
        }
    }

    /**
     * Sanitize and validate a field value
     *
     * @param string $type
     * @param mixed $raw
     * @param array<string, mixed> $field
     * @return array{0: mixed, 1: ?string} [value, error]
     */
    public function sanitise_and_validate(string $type, $raw, array $field): array
    {
        switch ($type) {
            case 'email':
                $value = is_string($raw) ? sanitize_email($raw) : '';
                if ($value === '') {
                    return [$value, __('Please provide a valid email.', 'formatrica')];
                }
                return [$value, null];

            case 'tel':
                $value = is_string($raw) ? sanitize_text_field($raw) : '';
                if ($value !== '' && !preg_match('/^[\d\s\(\)\-\+\.ext#]+$/i', $value)) {
                    return ['', __('Please provide a valid phone number.', 'formatrica')];
                }
                return [$value, null];

            case 'textarea':
                $value = is_string($raw) ? sanitize_textarea_field($raw) : '';
                return [$value, null];

            case 'checkbox':
                $value = (bool) $raw;
                return [$value ? __('Yes', 'formatrica') : __('No', 'formatrica'), null];

            case 'select':
                return $this->validate_select($raw, $field);

            case 'radio':
                return $this->validate_radio($raw, $field);

            case 'date':
                return $this->validate_date($raw);

            case 'password':
                // Passwords are secrets, not display text. Preserve bytes and
                // validate policy elsewhere; sanitizing here changes credentials.
                $value = is_string($raw) ? $raw : '';
                return [$value, null];

            case 'url':
                $trimmed = is_string($raw) ? trim($raw) : '';
                if ($trimmed === '') {
                    return ['', null];
                }
                // esc_url_raw sanitises and drops dangerous schemes (-> '');
                // FILTER_VALIDATE_URL then rejects malformed input that
                // esc_url_raw would otherwise "repair" (e.g. "not a url").
                $value = esc_url_raw($trimmed);
                $host  = $value !== '' ? (string) (wp_parse_url($value, PHP_URL_HOST) ?? '') : '';
                // FILTER_VALIDATE_URL accepts a dotless host, so bare garbage like
                // "sdfsdfsdf" passes as "http://sdfsdfsdf". Require a dotted host so
                // only real domains (and IPs) are accepted.
                if (
                    $value === ''
                    || filter_var($value, FILTER_VALIDATE_URL) === false
                    || strpos($host, '.') === false
                ) {
                    return ['', __('Please provide a valid URL.', 'formatrica')];
                }
                return [$value, null];

            case 'number':
            case 'range':
                $str = (is_string($raw) || is_numeric($raw)) ? trim((string) $raw) : '';
                if ($str === '') {
                    return ['', null];
                }
                if (!is_numeric($str)) {
                    return ['', __('Please enter a valid number.', 'formatrica')];
                }
                $num = $str + 0;
                if (isset($field['min']) && is_numeric($field['min']) && $num < $field['min'] + 0) {
                    /* translators: %s: minimum numeric value */
                    return ['', sprintf(__('Value must be at least %s.', 'formatrica'), $field['min'])];
                }
                if (isset($field['max']) && is_numeric($field['max']) && $num > $field['max'] + 0) {
                    /* translators: %s: maximum numeric value */
                    return ['', sprintf(__('Value must be at most %s.', 'formatrica'), $field['max'])];
                }
                return [$str, null];

            case 'datetime':
                $value = is_string($raw) ? sanitize_text_field(trim($raw)) : '';
                if ($value === '') {
                    return ['', null];
                }
                if (strtotime($value) === false) {
                    return ['', __('Please enter a valid date and time.', 'formatrica')];
                }
                return [$value, null];

            case 'color':
                $value = is_string($raw) ? trim($raw) : '';
                if ($value === '') {
                    return ['', null];
                }
                if (!preg_match('/^#[0-9a-fA-F]{6}$/', $value)) {
                    return ['', __('Please choose a valid color.', 'formatrica')];
                }
                return [strtolower($value), null];

            case 'text':
            default:
                // Edition value validators (Pro types). The availability skip
                // in validate_all means an UNREGISTERED paid type never gets
                // here; this lookup serves the registered (Pro) ones.
                if (isset(self::$value_validators[$type])) {
                    return (self::$value_validators[$type])($raw, $field);
                }
                $value = is_string($raw) ? sanitize_text_field($raw) : '';
                return [$value, null];
        }
    }

    /**
     * @param mixed $raw
     * @param array<string, mixed> $field
     * @return array{0: mixed, 1: ?string}
     */
    private function validate_select($raw, array $field): array
    {
        $options = isset($field['options']) && is_array($field['options']) ? $field['options'] : [];
        $multiple = !empty($field['multiple']);

        if ($multiple) {
            $values = [];

            if (is_array($raw)) {
                $values = array_map('sanitize_text_field', array_map('strval', $raw));
            } elseif (is_string($raw) && $raw !== '') {
                $values = [sanitize_text_field($raw)];
            }

            $invalid = array_diff($values, $options);

            if (!empty($invalid)) {
                return [[], __('Invalid selection.', 'formatrica')];
            }

            return [$values, null];
        }

        $value = is_string($raw) ? sanitize_text_field($raw) : '';
        if ($value !== '' && !in_array($value, $options, true)) {
            return ['', __('Invalid selection.', 'formatrica')];
        }
        return [$value, null];
    }

    /**
     * @param mixed $raw
     * @param array<string, mixed> $field
     * @return array{0: string, 1: ?string}
     */
    private function validate_radio($raw, array $field): array
    {
        $options = isset($field['options']) && is_array($field['options']) ? $field['options'] : [];
        $value = is_string($raw) ? sanitize_text_field($raw) : '';

        if ($value !== '' && !in_array($value, $options, true)) {
            return ['', __('Invalid selection.', 'formatrica')];
        }

        return [$value, null];
    }

    /**
     * @param mixed $raw
     * @return array{0: string, 1: ?string}
     */
    private function validate_date($raw): array
    {
        $value = is_string($raw) ? trim($raw) : '';
        if ($value === '') {
            return ['', null];
        }

        $dt = \DateTime::createFromFormat('Y-m-d', $value);
        if (!$dt || $dt->format('Y-m-d') !== $value) {
            return ['', __('Please enter a valid date.', 'formatrica')];
        }

        return [$value, null];
    }

    /**
     * Check if a value should be considered missing/empty
     *
     * @param string $type
     * @param mixed $raw
     * @param mixed $value
     * @return bool
     */
    public function is_value_missing(string $type, $raw, $value): bool
    {
        if ($type === 'checkbox') {
            return !$this->is_checkbox_checked($raw);
        }

        if ($type === 'select' || $type === 'checkbox_group') {
            if (is_array($value)) {
                return count($value) === 0;
            }
        }

        // Rich text: a visually-empty editor still submits markup (<p><br></p>).
        // Treat it as missing when the text content (tags/entities stripped) is
        // blank.
        if ($type === 'richtext' && is_string($value)) {
            $text = trim(html_entity_decode(wp_strip_all_tags($value), ENT_QUOTES, 'UTF-8'));
            $text = str_replace("\xC2\xA0", '', $text); // drop &nbsp; artifacts
            return trim($text) === '';
        }

        return $value === '' || $value === null || $value === [];
    }

    /**
     * Interpret incoming checkbox payload
     *
     * @param mixed $raw
     * @return bool
     */
    private function is_checkbox_checked($raw): bool
    {
        if (is_array($raw)) {
            foreach ($raw as $entry) {
                if ($this->is_checkbox_checked($entry)) {
                    return true;
                }
            }

            return false;
        }

        if (is_bool($raw)) {
            return $raw;
        }

        if (is_string($raw)) {
            $normalised = strtolower($raw);

            return !in_array($normalised, ['', '0', 'false', 'off'], true);
        }

        if ($raw === null) {
            return false;
        }

        return (bool) $raw;
    }
}
