<?php
if (!defined('ABSPATH')) exit;

class FORMFORGE_Field_Types {
    private static $instance = null;
    public static function instance() { if (!self::$instance) self::$instance = new self(); return self::$instance; }
    private function __construct() {}

    public static function get_types() {
        return [
            'text' => ['label' => __('Text', 'formforge'), 'icon' => 'text', 'category' => 'basic'],
            'email' => ['label' => __('Email', 'formforge'), 'icon' => 'email', 'category' => 'basic'],
            'phone' => ['label' => __('Phone', 'formforge'), 'icon' => 'phone', 'category' => 'basic'],
            'number' => ['label' => __('Number', 'formforge'), 'icon' => 'editor-ol', 'category' => 'basic'],
            'textarea' => ['label' => __('Textarea', 'formforge'), 'icon' => 'editor-paragraph', 'category' => 'basic'],
            'select' => ['label' => __('Dropdown', 'formforge'), 'icon' => 'arrow-down', 'category' => 'choice'],
            'radio' => ['label' => __('Radio', 'formforge'), 'icon' => 'marker', 'category' => 'choice'],
            'checkbox' => ['label' => __('Checkbox', 'formforge'), 'icon' => 'yes', 'category' => 'choice'],
            'date' => ['label' => __('Date', 'formforge'), 'icon' => 'calendar', 'category' => 'advanced'],
            'time' => ['label' => __('Time', 'formforge'), 'icon' => 'clock', 'category' => 'advanced'],
            'file_upload' => ['label' => __('File Upload', 'formforge'), 'icon' => 'upload', 'category' => 'advanced'],
            'hidden' => ['label' => __('Hidden', 'formforge'), 'icon' => 'hidden', 'category' => 'advanced'],
            'url' => ['label' => __('URL', 'formforge'), 'icon' => 'admin-links', 'category' => 'basic'],
            'password' => ['label' => __('Password', 'formforge'), 'icon' => 'lock', 'category' => 'basic'],
            'name' => ['label' => __('Name (First + Last)', 'formforge'), 'icon' => 'admin-users', 'category' => 'advanced'],
            'address' => ['label' => __('Address', 'formforge'), 'icon' => 'location', 'category' => 'advanced'],
            'rating' => ['label' => __('Rating (Stars)', 'formforge'), 'icon' => 'star-filled', 'category' => 'advanced'],
            'range' => ['label' => __('Range (Slider)', 'formforge'), 'icon' => 'leftright', 'category' => 'advanced'],
            'color' => ['label' => __('Color Picker', 'formforge'), 'icon' => 'art', 'category' => 'advanced'],
            'heading' => ['label' => __('Heading', 'formforge'), 'icon' => 'heading', 'category' => 'layout'],
            'html_block' => ['label' => __('HTML Block', 'formforge'), 'icon' => 'editor-code', 'category' => 'layout'],
            'divider' => ['label' => __('Divider', 'formforge'), 'icon' => 'minus', 'category' => 'layout'],
            'page_break' => ['label' => __('Page Break', 'formforge'), 'icon' => 'editor-insertmore', 'category' => 'layout', 'pro' => true],
            'repeater' => ['label' => __('Repeater', 'formforge'), 'icon' => 'plus-alt', 'category' => 'advanced', 'pro' => true],
            'calculation' => ['label' => __('Calculation', 'formforge'), 'icon' => 'calculator', 'category' => 'advanced', 'pro' => true],
            'map_address' => ['label' => __('Address (Map)', 'formforge'), 'icon' => 'location-alt', 'category' => 'advanced', 'pro' => true],
            'payment' => ['label' => __('Payment (Stripe)', 'formforge'), 'icon' => 'money-alt', 'category' => 'advanced', 'pro' => true],
            'calendar_picker' => ['label' => __('Date (Calendar)', 'formforge'), 'icon' => 'calendar-alt', 'category' => 'advanced', 'pro' => true],
        ];
    }

    public static function pro_feature_for_type($type) {
        $map = [
            'page_break'      => 'multi_step',
            'calculation'     => 'calculation',
            'repeater'        => 'repeater',
            'map_address'     => 'yandex_geocoder',
            'payment'         => 'payment',
            'calendar_picker' => 'google_calendar',
        ];
        $type = sanitize_key((string) $type);
        return $map[$type] ?? '';
    }

    public static function field_type_available($type) {
        $feature = self::pro_feature_for_type($type);
        if ($feature === '') {
            return true;
        }
        return class_exists('FORMFORGE_License') && FORMFORGE_License::instance()->can($feature);
    }

    /**
 * Strip PHP-style regex delimiters and trailing modifiers so the
 * pattern is safe to drop into the HTML5 `pattern=""` attribute, which
 * expects raw JavaScript regex source (no `/.../i`).
 *
 * The PHP-side validator (preg_match) keeps using the full delimited
 * version stored in `$field['regex']` — only the rendered HTML attr
 * gets stripped.
 */
    private static function strip_regex_delimiters($regex) {
        $regex = (string) $regex;
        if ($regex === '') return '';
        // Delimiters: / # ~ % | etc. If the first char is the same as the
        // last (with optional modifier letters after), strip them.
        if (strlen($regex) >= 2) {
            $first = $regex[0];
            // Find last occurrence of the same delimiter
            $last_pos = strrpos($regex, $first);
            // Only strip if first is a non-alphanumeric delimiter and there
            // are at least 2 chars between the two delimiters.
            if ($last_pos > 0 && !ctype_alnum($first)) {
                $body = substr($regex, 1, $last_pos - 1);
                // Drop modifier letters after the closing delimiter (i, m, s, u, U, x, A, D, S)
                return $body;
            }
        }
        return $regex;
    }

    private static function sanitize_hex_color($value) {
        $value = strtolower(trim((string) $value));
        if (preg_match('/^#[0-9a-f]{3}([0-9a-f]{3})?$/', $value)) {
            return $value;
        }
        return '';
    }

    private static function sanitize_px($value, $min = 0, $max = 120) {
        if ($value === '' || $value === null) return null;
        $num = (int) $value;
        if ($num < $min) $num = $min;
        if ($num > $max) $num = $max;
        return $num;
    }

    private static function sanitize_custom_css($css) {
        $css = trim((string) $css);
        if ($css === '') return '';
        if (preg_match('/[{}<>@]|url\s*\(|expression\s*\(|javascript:|data:|behavior\s*:/i', $css)) {
            return '';
        }

        $allowed = [
            'letter-spacing' => '/^-?\d+(\.\d+)?(px|em|rem)$|^normal$/i',
            'line-height' => '/^\d+(\.\d+)?(px|em|rem)?$/i',
            'text-transform' => '/^(none|uppercase|lowercase|capitalize)$/i',
            'text-align' => '/^(left|right|center|justify|start|end)$/i',
            'border-style' => '/^(none|solid|dashed|dotted|double)$/i',
            'padding-top' => '/^\d+(\.\d+)?(px|em|rem|%)$/i',
            'padding-right' => '/^\d+(\.\d+)?(px|em|rem|%)$/i',
            'padding-bottom' => '/^\d+(\.\d+)?(px|em|rem|%)$/i',
            'padding-left' => '/^\d+(\.\d+)?(px|em|rem|%)$/i',
            'margin-top' => '/^-?\d+(\.\d+)?(px|em|rem|%)$/i',
            'margin-right' => '/^-?\d+(\.\d+)?(px|em|rem|%)$/i',
            'margin-bottom' => '/^-?\d+(\.\d+)?(px|em|rem|%)$/i',
            'margin-left' => '/^-?\d+(\.\d+)?(px|em|rem|%)$/i',
        ];

        $clean = [];
        foreach (explode(';', $css) as $decl) {
            if (strpos($decl, ':') === false) continue;
            [$prop, $value] = array_map('trim', explode(':', $decl, 2));
            $prop = strtolower($prop);
            if (!isset($allowed[$prop])) continue;
            if (!preg_match($allowed[$prop], $value)) continue;
            $clean[] = $prop . ': ' . $value;
        }
        return $clean ? implode('; ', $clean) . ';' : '';
    }

    private static function parse_calendar_timestamp($value) {
        $dt = self::parse_calendar_datetime($value);
        return $dt ? $dt->getTimestamp() : 0;
    }

    private static function parse_calendar_datetime($value) {
        $value = trim((string) $value);
        if ($value === '') return null;

        try {
            $timezone = function_exists('wp_timezone') ? wp_timezone() : new DateTimeZone(wp_timezone_string());
            return new DateTimeImmutable($value, $timezone);
        } catch (Exception $e) {
            $ts = strtotime($value);
            return $ts ? new DateTimeImmutable('@' . $ts) : null;
        }
    }

    public static function sanitize_styles_config($styles) {
        if (!is_array($styles)) return [];
        $targets = ['wrapper', 'label', 'control', 'description', 'form', 'submit', 'message'];
        $clean = [];

        foreach ($targets as $target) {
            $src = isset($styles[$target]) && is_array($styles[$target]) ? $styles[$target] : [];
            if (!$src) continue;
            $dst = [];

            foreach (['text_color', 'background_color', 'border_color'] as $key) {
                if (array_key_exists($key, $src)) {
                    $hex = self::sanitize_hex_color($src[$key]);
                    if ($hex !== '') $dst[$key] = $hex;
                }
            }

            $px_keys = [
                'font_size' => [8, 72],
                'border_width' => [0, 20],
                'border_radius' => [0, 80],
                'padding' => [0, 80],
                'margin_bottom' => [0, 120],
            ];
            foreach ($px_keys as $key => $range) {
                if (array_key_exists($key, $src)) {
                    $num = self::sanitize_px($src[$key], $range[0], $range[1]);
                    if ($num !== null) $dst[$key] = $num;
                }
            }

            if (array_key_exists('font_weight', $src)) {
                $weight = (string) $src['font_weight'];
                if (in_array($weight, ['400', '500', '600', '700', 'normal', 'bold'], true)) {
                    $dst['font_weight'] = $weight;
                }
            }

            if (array_key_exists('custom_css', $src)) {
                $custom = self::sanitize_custom_css($src['custom_css']);
                if ($custom !== '') $dst['custom_css'] = $custom;
            }

            if ($dst) $clean[$target] = $dst;
        }

        return $clean;
    }

    public static function style_declarations($styles, $target) {
        $styles = self::sanitize_styles_config($styles);
        $src = isset($styles[$target]) && is_array($styles[$target]) ? $styles[$target] : [];
        if (!$src) return '';

        $decl = [];
        if (!empty($src['font_size'])) $decl[] = 'font-size:' . (int) $src['font_size'] . 'px !important';
        if (!empty($src['text_color'])) $decl[] = 'color:' . $src['text_color'] . ' !important';
        if (!empty($src['background_color'])) $decl[] = 'background-color:' . $src['background_color'] . ' !important';
        if (!empty($src['border_color'])) $decl[] = 'border-color:' . $src['border_color'] . ' !important';
        if (isset($src['border_width'])) $decl[] = 'border-width:' . (int) $src['border_width'] . 'px !important';
        if (isset($src['border_width']) || !empty($src['border_color'])) $decl[] = 'border-style:solid !important';
        if (isset($src['border_radius'])) $decl[] = 'border-radius:' . (int) $src['border_radius'] . 'px !important';
        if (isset($src['padding'])) $decl[] = 'padding:' . (int) $src['padding'] . 'px !important';
        if (isset($src['margin_bottom'])) $decl[] = 'margin-bottom:' . (int) $src['margin_bottom'] . 'px !important';
        if (!empty($src['font_weight'])) $decl[] = 'font-weight:' . $src['font_weight'] . ' !important';
        if (!empty($src['custom_css'])) {
            $custom_decls = [];
            foreach (explode(';', $src['custom_css']) as $custom_decl) {
                $custom_decl = trim($custom_decl);
                if ($custom_decl === '') continue;
                $custom_decls[] = $custom_decl . ' !important';
            }
            if ($custom_decls) $decl[] = implode(';', $custom_decls);
        }

        return $decl ? implode(';', $decl) . ';' : '';
    }

    public static function style_attr($styles, $target) {
        $decl = self::style_declarations($styles, $target);
        return $decl !== '' ? ' style="' . esc_attr($decl) . '"' : '';
    }

    private static function calculation_token_slug($value) {
        $value = remove_accents(wp_strip_all_tags((string) $value));
        $value = strtolower($value);
        $value = preg_replace('/[^a-z0-9]+/', '_', $value);
        return trim((string) $value, '_');
    }

    private static function calculation_field_map($current_field, $form) {
        $fields = (is_object($form) && isset($form->fields) && is_array($form->fields)) ? $form->fields : [];
        if (!$fields) return [];

        $current_id = (string) ($current_field['id'] ?? '');
        $non_sources = ['heading', 'html_block', 'divider', 'page_break', 'hidden', 'file_upload', 'repeater', 'map_address', 'payment', 'calculation'];
        $map = [];

        foreach ($fields as $field) {
            if (!is_array($field)) continue;
            $id = (string) ($field['id'] ?? '');
            if ($id === '' || $id === $current_id) continue;
            if (in_array($field['type'] ?? '', $non_sources, true)) continue;

            foreach ([$id, $field['label'] ?? ''] as $candidate) {
                $token = self::calculation_token_slug($candidate);
                if ($token !== '' && !isset($map[$token])) {
                    $map[$token] = $id;
                }
            }
        }

        return $map;
    }

    /**
 * Render a field on the frontend
 */
    public static function render($field, $value = '', $form = null) {
        $type = $field['type'] ?? 'text';
        if (!self::field_type_available($type)) {
            return '<div class="formforge-pro-locked" role="note">' . esc_html__('This PRO field is disabled on the current plan.', 'formforge') . '</div>';
        }

        $name = 'formforge_field_' . ($field['id'] ?? '');
        $field_id = (string) ($field['id'] ?? '');
        // Resolve form id for the translate filter — used by LangForge to scope
        // translations per form. Empty form_id is fine: filter falls back to
        // the original string with no translation lookup.
        $form_id = (is_object($form) && isset($form->id)) ? (int) $form->id : 0;

        $label_raw = $field['label'] ?? '';
        $placeholder_raw = $field['placeholder'] ?? '';
        $label = FORMFORGE_Form_Renderer::translate_string($label_raw, 'label', $field_id, $form_id);
        $placeholder = FORMFORGE_Form_Renderer::translate_string($placeholder_raw, 'placeholder', $field_id, $form_id);
        $required = !empty($field['required']);
        $css_class = 'formforge-field formforge-field--' . $type;
        // Append user-defined CSS classes (Advanced field setting) onto the
        // wrapper div so themes can target individual fields without poking
        // at IDs. Multiple classes accepted, sanitized to whitespace-safe
        // tokens (no quotes, no angle brackets).
        if (!empty($field['css_class'])) {
            $extra = preg_replace('/[^A-Za-z0-9\-_ ]/', '', (string) $field['css_class']);
            $extra = trim(preg_replace('/\s+/', ' ', $extra));
            if ($extra !== '') {
                $css_class .= ' ' . $extra;
            }
        }
        $req_attr = $required ? 'required' : '';
        $req_mark = $required ? '<span class="formforge-required">*</span>' : '';
        $style_config = $field['styles'] ?? [];
        $wrapper_style = self::style_attr($style_config, 'wrapper');
        $label_attrs = ' data-ff-style-target="label"' . self::style_attr($style_config, 'label');
        $control_attrs = ' data-ff-style-target="control"' . self::style_attr($style_config, 'control');
        $description_attrs = ' data-ff-style-target="description"' . self::style_attr($style_config, 'description');

        // Apply the value precedence: explicit submitted value first, then
        // the field's configured default_value (only on initial render —
        // never overwrite a real submitted value with the default).
        if (($value === '' || $value === null) && isset($field['default_value']) && $field['default_value'] !== '') {
            $value = $field['default_value'];
        }

        // Validation attributes shared by text-class inputs (HTML5 native
        // gating before the form is submitted). PHP re-validates on submit
        // so a tampered client can't bypass these.
        $minlen_attr = (isset($field['min_length']) && $field['min_length'] !== '' && (int)$field['min_length'] > 0)
            ? ' minlength="' . (int)$field['min_length'] . '"' : '';
        $maxlen_attr = (isset($field['max_length']) && $field['max_length'] !== '' && (int)$field['max_length'] > 0)
            ? ' maxlength="' . (int)$field['max_length'] . '"' : '';
        $pattern_attr = (!empty($field['regex']) && is_string($field['regex']))
            ? ' pattern="' . esc_attr(self::strip_regex_delimiters($field['regex'])) . '"' : '';
        $regex_msg_attr = (!empty($field['regex_message']))
            ? ' title="' . esc_attr($field['regex_message']) . '"' : '';

        // `data-field-id` lets the conditional-logic runtime in frontend.js
        // locate the wrapper to hide/show without scanning labels or names.
        // Repeater also emits its own data-field-id deeper in the tree, but
        // the wrapper-level one is what drives the show/hide logic.
        $field_id_attr = !empty($field['id']) ? ' data-field-id="' . esc_attr($field['id']) . '"' : '';
        $html = '<div class="' . esc_attr($css_class) . '"' . $field_id_attr . $wrapper_style . '>';

        $is_single_checkbox = ($type === 'checkbox' && empty($field['options'] ?? []));

        // Label (skip for layout types and single checkboxes; their inline
        // checkbox label is the visible field label).
        if (!in_array($type, ['hidden', 'heading', 'divider', 'html_block', 'page_break', 'paragraph'], true) && !$is_single_checkbox) {
            $html .= '<label class="formforge-label" for="' . esc_attr($name) . '"' . $label_attrs . '>' . esc_html($label) . $req_mark . '</label>';
        }

        switch ($type) {
            case 'text':
            case 'email':
            case 'phone':
            case 'tel':       // HTML5 'tel' synonym — older AI-generated
                              // forms / templates / migrations sometimes saved
                              // the field with the HTML5 input-type name
                              // ('tel') instead of FormForge's canonical
                              // 'phone'. Without this synonym they fell
                              // through to the unhandled-type branch and
                              // rendered only the label. Treated identical
                              // to 'phone'.
            case 'url':
            case 'password':
                $input_type = ($type === 'phone' || $type === 'tel') ? 'tel' : $type;
                // Pattern + minlength + maxlength only meaningful for the
                // text-shaped types; we emit them unconditionally (browsers
                // ignore the unsupported ones, e.g. <input type="email">
                // ignores `pattern` if `type=email` validation succeeds).
                $html .= '<input type="' . $input_type . '" name="' . esc_attr($name) . '" id="' . esc_attr($name) . '" value="' . esc_attr($value) . '" placeholder="' . esc_attr($placeholder) . '" class="formforge-input"' . $control_attrs . $minlen_attr . $maxlen_attr . $pattern_attr . $regex_msg_attr . ' ' . $req_attr . '>';
                break;

            case 'color':
                $color_value = is_string($value) && preg_match('/^#[0-9a-f]{6}$/i', $value) ? $value : '#3B82F6';
                $html .= '<input type="color" name="' . esc_attr($name) . '" id="' . esc_attr($name) . '" value="' . esc_attr($color_value) . '" class="formforge-input formforge-color"' . $control_attrs . ' ' . $req_attr . '>';
                break;

            case 'number':
                $min = $field['min'] ?? '';
                $max = $field['max'] ?? '';
                $step = $field['step'] ?? '';
                $step_attr = ($step !== '' && $step !== null) ? ' step="' . esc_attr($step) . '"' : '';
                $min_attr  = ($min  !== '' && $min  !== null) ? ' min="' . esc_attr($min) . '"' : '';
                $max_attr  = ($max  !== '' && $max  !== null) ? ' max="' . esc_attr($max) . '"' : '';
                $html .= '<input type="number" name="' . esc_attr($name) . '" id="' . esc_attr($name) . '" value="' . esc_attr($value) . '" placeholder="' . esc_attr($placeholder) . '"' . $min_attr . $max_attr . $step_attr . ' class="formforge-input"' . $control_attrs . ' ' . $req_attr . '>';
                break;

            case 'textarea':
                $rows = $field['rows'] ?? 4;
                $html .= '<textarea name="' . esc_attr($name) . '" id="' . esc_attr($name) . '" rows="' . (int)$rows . '" placeholder="' . esc_attr($placeholder) . '" class="formforge-textarea"' . $control_attrs . $minlen_attr . $maxlen_attr . ' ' . $req_attr . '>' . esc_textarea($value) . '</textarea>';
                break;

            case 'select':
                $options = $field['options'] ?? [];
                $html .= '<select name="' . esc_attr($name) . '" id="' . esc_attr($name) . '" class="formforge-select"' . $control_attrs . ' ' . $req_attr . '>';
                $html .= '<option value="">' . esc_html($placeholder ?: __('Select...', 'formforge')) . '</option>';
                foreach ($options as $opt) {
                    $opt_val = $opt['value'] ?? $opt;
                    $opt_label_raw = $opt['label'] ?? $opt;
                    // Per-option translate key: "option_label:<value>" — keeps
                    // option strings stable across re-saves as long as the
                    // value column stays the same. If the editor renames the
                    // value, the translation key resets (intentional —
                    // value renames are rare and likely change the meaning).
                    $opt_label = FORMFORGE_Form_Renderer::translate_string(
                        $opt_label_raw, 'option_label:' . $opt_val, $field_id, $form_id
                    );
                    $html .= '<option value="' . esc_attr($opt_val) . '"' . selected($value, $opt_val, false) . '>' . esc_html($opt_label) . '</option>';
                }
                $html .= '</select>';
                break;

            case 'radio':
                $options = $field['options'] ?? [];
                foreach ($options as $i => $opt) {
                    $opt_val = $opt['value'] ?? $opt;
                    $opt_label_raw = $opt['label'] ?? $opt;
                    $opt_label = FORMFORGE_Form_Renderer::translate_string(
                        $opt_label_raw, 'option_label:' . $opt_val, $field_id, $form_id
                    );
                    $html .= '<label class="formforge-radio-label"' . $control_attrs . '><input type="radio" name="' . esc_attr($name) . '" value="' . esc_attr($opt_val) . '"' . checked($value, $opt_val, false) . ' ' . ($i === 0 && $required ? 'required' : '') . '> ' . esc_html($opt_label) . '</label>';
                }
                break;

            case 'checkbox':
                $options = $field['options'] ?? [];
                if (empty($options)) {
                    // Single checkbox
                    $checkbox_label = $placeholder ?: $label;
                    $html .= '<label class="formforge-checkbox-label"' . $control_attrs . '><input type="checkbox" name="' . esc_attr($name) . '" value="1"' . checked($value, '1', false) . ' ' . $req_attr . '> <span class="formforge-checkbox-text"' . $label_attrs . '>' . esc_html($checkbox_label) . $req_mark . '</span></label>';
                } else {
                    foreach ($options as $opt) {
                        $opt_val = $opt['value'] ?? $opt;
                        $opt_label_raw = $opt['label'] ?? $opt;
                        $opt_label = FORMFORGE_Form_Renderer::translate_string(
                            $opt_label_raw, 'option_label:' . $opt_val, $field_id, $form_id
                        );
                        $html .= '<label class="formforge-checkbox-label"' . $control_attrs . '><input type="checkbox" name="' . esc_attr($name) . '[]" value="' . esc_attr($opt_val) . '"> ' . esc_html($opt_label) . '</label>';
                    }
                }
                break;

            case 'date':
                $html .= '<input type="date" name="' . esc_attr($name) . '" id="' . esc_attr($name) . '" value="' . esc_attr($value) . '" class="formforge-input"' . $control_attrs . ' ' . $req_attr . '>';
                break;

            case 'time':
                $html .= '<input type="time" name="' . esc_attr($name) . '" id="' . esc_attr($name) . '" value="' . esc_attr($value) . '" class="formforge-input"' . $control_attrs . ' ' . $req_attr . '>';
                break;

            case 'file_upload':
                if (!FORMFORGE_License::instance()->can('file_uploads')) {
                    $html .= '<div class="formforge-pro-locked" role="note">' . esc_html__('File uploads require Form Forge Pro. This field is disabled on the current plan.', 'formforge') . '</div>';
                    break;
                }
                $accept = $field['accept'] ?? '';
                $html .= '<input type="file" name="' . esc_attr($name) . '" id="' . esc_attr($name) . '" accept="' . esc_attr($accept) . '" class="formforge-file"' . $control_attrs . ' ' . $req_attr . '>';
                break;

            case 'hidden':
                $html .= '<input type="hidden" name="' . esc_attr($name) . '" value="' . esc_attr($field['default_value'] ?? '') . '">';
                break;

            case 'name':
                $html .= '<div class="formforge-name-group">';
                $html .= '<input type="text" name="' . esc_attr($name) . '_first" placeholder="' . esc_attr__('First name', 'formforge') . '" class="formforge-input formforge-input--half"' . $control_attrs . ' ' . $req_attr . '>';
                $html .= '<input type="text" name="' . esc_attr($name) . '_last" placeholder="' . esc_attr__('Last name', 'formforge') . '" class="formforge-input formforge-input--half"' . $control_attrs . ' ' . $req_attr . '>';
                $html .= '</div>';
                break;

            case 'address':
                $html .= '<input type="text" name="' . esc_attr($name) . '_street" placeholder="' . esc_attr__('Street', 'formforge') . '" class="formforge-input"' . $control_attrs . ' ' . $req_attr . '>';
                $html .= '<div class="formforge-address-row">';
                $html .= '<input type="text" name="' . esc_attr($name) . '_city" placeholder="' . esc_attr__('City', 'formforge') . '" class="formforge-input formforge-input--half"' . $control_attrs . '>';
                $html .= '<input type="text" name="' . esc_attr($name) . '_zip" placeholder="' . esc_attr__('ZIP', 'formforge') . '" class="formforge-input formforge-input--quarter"' . $control_attrs . '>';
                $html .= '</div>';
                break;

            case 'rating':
                $max_stars = $field['max_stars'] ?? 5;
                $html .= '<div class="formforge-rating" data-max="' . (int)$max_stars . '">';
                for ($s = 1; $s <= $max_stars; $s++) {
                    $html .= '<span class="formforge-star" data-value="' . $s . '">&#9733;</span>';
                }
                $html .= '<input type="hidden" name="' . esc_attr($name) . '" value="' . esc_attr($value) . '" class="formforge-rating-input">';
                $html .= '</div>';
                break;

            case 'range':
                $min = (isset($field['min']) && $field['min'] !== '' && is_numeric($field['min'])) ? (string) $field['min'] : '0';
                $max = (isset($field['max']) && $field['max'] !== '' && is_numeric($field['max'])) ? (string) $field['max'] : '100';
                $step = (isset($field['step']) && $field['step'] !== '' && is_numeric($field['step']) && (float) $field['step'] > 0) ? (string) $field['step'] : '1';
                $range_value = ($value !== '' && $value !== null && is_numeric($value)) ? (string) $value : $min;
                $html .= '<div class="formforge-range-wrap">';
                $html .= '<input type="range" name="' . esc_attr($name) . '" min="' . esc_attr($min) . '" max="' . esc_attr($max) . '" step="' . esc_attr($step) . '" value="' . esc_attr($range_value) . '" class="formforge-range"' . $control_attrs . '>';
                $html .= '<span class="formforge-range-value">' . esc_html($range_value) . '</span>';
                $html .= '</div>';
                break;

            case 'heading':
                $allowed_heading = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
                $tag = in_array($field['heading_level'] ?? 'h3', $allowed_heading, true) ? $field['heading_level'] : 'h3';
                $html .= '<' . $tag . ' class="formforge-heading"' . $control_attrs . '>' . esc_html($label) . '</' . $tag . '>';
                break;

            case 'html_block':
                $html .= '<div class="formforge-html"' . $control_attrs . '>' . wp_kses_post($field['content'] ?? '') . '</div>';
                break;

            case 'paragraph':
                $content = $field['content'] ?? ($field['description'] ?? $label_raw);
                $html .= '<div class="formforge-html formforge-paragraph"' . $control_attrs . '>' . wp_kses_post($content) . '</div>';
                break;

            case 'divider':
                $html .= '<hr class="formforge-divider"' . $control_attrs . '>';
                break;

            case 'page_break':
                // Page breaks are now handled at the renderer level, which
                // groups subsequent fields into a fresh <div class="formforge-step">.
                // The renderer skips page_break entirely, so this case should
                // not be reached when the form is rendered through the standard
                // multi-step pipeline. We keep an empty branch here for legacy
                // safety: returning early keeps the wrapper <div> from line 55
                // closed correctly by line 278.
                break;

            case 'repeater':
                $sub_fields = $field['sub_fields'] ?? [['type' => 'text', 'label' => 'Item', 'id' => $field['id'] . '_sub']];
                $html .= '<div class="formforge-repeater" data-field-id="' . esc_attr($field['id'] ?? '') . '">';
                $html .= '<div class="formforge-repeater-rows">';
                $html .= '<div class="formforge-repeater-row" data-row="0">';
                foreach ($sub_fields as $sf) {
                    $sf_name = esc_attr($name . '_0_' . ($sf['id'] ?? 'sub'));
                    $html .= '<input type="text" name="' . $sf_name . '" placeholder="' . esc_attr($sf['label'] ?? '') . '" class="formforge-input formforge-repeater-input"' . $control_attrs . '>';
                }
                $html .= '<button type="button" class="formforge-repeater-remove" title="Remove">&times;</button>';
                $html .= '</div></div>';
                $html .= '<button type="button" class="formforge-repeater-add">+ ' . esc_html__('Add Row', 'formforge') . '</button>';
                $html .= '</div>';
                break;

            case 'calculation':
                $formula = esc_attr($field['formula'] ?? '');
                $prefix = trim((string) ($field['prefix'] ?? ''));
                $suffix = trim((string) ($field['suffix'] ?? ''));
                $decimals = isset($field['decimals']) && $field['decimals'] !== '' ? max(0, min(6, (int) $field['decimals'])) : '';
                $initial = $decimals !== '' ? number_format(0, (int) $decimals, '.', '') : '0';
                $field_map = esc_attr(wp_json_encode(self::calculation_field_map($field, $form)));
                $html .= '<div class="formforge-calculation" data-formula="' . $formula . '" data-field-map="' . $field_map . '" data-decimals="' . esc_attr($decimals) . '">';
                $html .= '<span class="formforge-calc-prefix">' . esc_html($prefix) . '</span>';
                $html .= '<span class="formforge-calc-result" id="' . esc_attr($name) . '_display">' . esc_html($initial) . '</span>';
                $html .= '<span class="formforge-calc-suffix">' . esc_html($suffix) . '</span>';
                $html .= '<input type="hidden" name="' . esc_attr($name) . '" id="' . esc_attr($name) . '" value="' . esc_attr($initial) . '" class="formforge-calc-input">';
                $html .= '</div>';
                break;

            case 'map_address':
                if (!self::field_type_available('map_address')) {
                    $html .= '<div class="formforge-pro-locked" role="note">' . esc_html__('This PRO field is disabled on the current plan.', 'formforge') . '</div>';
                    break;
                }
                $default_lat = esc_attr($field['default_lat'] ?? '41.311158');
                $default_lng = esc_attr($field['default_lng'] ?? '69.279737');
                $default_zoom = (int)($field['default_zoom'] ?? 13);
                $html .= '<div class="formforge-map-address" data-lat="' . $default_lat . '" data-lng="' . $default_lng . '" data-zoom="' . $default_zoom . '" data-field-name="' . esc_attr($name) . '">';
                $html .= '<div class="formforge-map-suggest-shell">';
                $html .= '<input type="text" name="' . esc_attr($name) . '" id="' . esc_attr($name) . '" value="' . esc_attr($value) . '" placeholder="' . esc_attr($placeholder ?: __('Start typing an address...', 'formforge')) . '" class="formforge-input formforge-map-input" autocomplete="off"' . $control_attrs . ' ' . $req_attr . '>';
                $html .= '</div>';
                $html .= '<div class="formforge-map-container" id="' . esc_attr($name) . '_map" style="width:100%;height:250px;border-radius:8px;margin-top:8px;border:1px solid #e2e8f0;"></div>';
                $html .= '<div class="formforge-map-error" style="display:none;margin-top:6px;color:#dc2626;font-size:14px;"></div>';
                $html .= '<input type="hidden" name="' . esc_attr($name) . '_lat" id="' . esc_attr($name) . '_lat" value="">';
                $html .= '<input type="hidden" name="' . esc_attr($name) . '_lng" id="' . esc_attr($name) . '_lng" value="">';
                $html .= '<input type="hidden" name="' . esc_attr($name) . '_formatted" id="' . esc_attr($name) . '_formatted" value="">';
                $html .= '</div>';
                break;

            case 'calendar_picker':
                if (!self::field_type_available('calendar_picker')) {
                    $html .= '<div class="formforge-pro-locked" role="note">' . esc_html__('This PRO field is disabled on the current plan.', 'formforge') . '</div>';
                    break;
                }
                // availability settings (slot duration, work hours,
                // advance window, days-of-week) live on the FIELD itself so
                // the picker is usable without Google Calendar. The form-
                // level Google Calendar card only controls upstream
                // integration (calendar selection, event title, busy
                // filtering on top of these slots).
                
                // Backwards-compat: if a field has no availability keys (an
                // older form created before ), fall back to the form-
                // level calendar_* keys we used to read.
                $fs = (is_object($form) && isset($form->settings)) ? $form->settings : [];
                $slot_duration = (int)($field['slot_duration'] ?? $fs['calendar_slot_duration'] ?? 30);
                $work_start    = (int)($field['work_start']    ?? $fs['calendar_work_start']    ?? 9);
                $work_end      = (int)($field['work_end']      ?? $fs['calendar_work_end']      ?? 17);
                $advance_days  = (int)($field['advance_days']  ?? 30);
                $available_days = isset($field['available_days']) && is_array($field['available_days'])
                    ? array_values(array_filter(array_map('intval', $field['available_days'])))
                    : [];
                $days_attr = $available_days ? ' data-available-days="' . esc_attr(implode(',', $available_days)) . '"' : '';
                $html .= '<div class="formforge-calendar-picker" data-field-name="' . esc_attr($name) . '" data-slot-duration="' . $slot_duration . '" data-work-start="' . $work_start . '" data-work-end="' . $work_end . '" data-advance-days="' . $advance_days . '"' . $days_attr . '>';
                $html .= '<input type="date" id="' . esc_attr($name) . '_date" class="formforge-input formforge-calendar-date"' . $control_attrs . ' min="' . gmdate('Y-m-d') . '" max="' . gmdate('Y-m-d', strtotime('+' . $advance_days . ' days')) . '" ' . $req_attr . '>';
                $html .= '<div class="formforge-calendar-slots" id="' . esc_attr($name) . '_slots" style="display:none;margin-top:8px;">';
                $html .= '<p class="formforge-calendar-loading" style="color:#64748b;font-size:14px;">' . esc_html__('Loading available times...', 'formforge') . '</p>';
                $html .= '</div>';
                $html .= '<input type="hidden" name="' . esc_attr($name) . '" id="' . esc_attr($name) . '" value="' . esc_attr($value) . '">';
                $html .= '</div>';
                break;

            case 'payment':
                if (!self::field_type_available('payment')) {
                    $html .= '<div class="formforge-pro-locked" role="note">' . esc_html__('Stripe payments require Form Forge Pro. This field is disabled on the current plan.', 'formforge') . '</div>';
                    break;
                }
                // audit-r3: Amount/Currency/Mode/Description editor in Field Settings.
                
                // Backward-compat: legacy fields stored `amount` directly in cents
                // (and only `currency` + `payment_description`). New fields store
                // `amount_type`/`amount_value`/`amount_field_id`/`payment_mode`.
                // Both shapes render correctly so existing forms keep working.
                $currency = strtolower(sanitize_text_field($field['currency'] ?? 'usd'));
                $payment_desc = esc_attr($field['payment_description'] ?? '');
                $payment_mode = sanitize_text_field($field['payment_mode'] ?? 'global');
                $amount_type = sanitize_text_field($field['amount_type'] ?? '');

                // Resolve the cents amount + which mode to render. Dynamic mode
                // emits a 0 placeholder; stripe-field.js reads the live calc-field
                // value at submit time. Fixed mode multiplies the dollar value
                // (49.99) by 100 to get 4999 cents — Stripe's required unit.
                $amount_cents = 0;
                $amount_source = '';  // formforge_field_<id> for dynamic
                if ($amount_type === 'dynamic') {
                    $src_id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($field['amount_field_id'] ?? ''));
                    if ($src_id !== '') {
                        $amount_source = 'formforge_field_' . $src_id;
                    }
                } elseif ($amount_type === 'fixed') {
                    $val = isset($field['amount_value']) ? (float) $field['amount_value'] : 0;
                    if ($val > 0) {
                        $amount_cents = (int) round($val * 100);
                    }
                } else {
                    // Legacy `amount` (cents) — fall through if no new keys set.
                    $legacy = isset($field['amount']) ? (int) $field['amount'] : 0;
                    if ($legacy > 0) {
                        $amount_cents = $legacy;
                    }
                }

                $data_attrs = ' data-currency="' . esc_attr($currency) . '"';
                $data_attrs .= ' data-amount="' . esc_attr($amount_cents) . '"';
                $data_attrs .= ' data-description="' . $payment_desc . '"';
                $data_attrs .= ' data-field-name="' . esc_attr($name) . '"';
                $data_attrs .= ' data-payment-mode="' . esc_attr($payment_mode) . '"';
                if ($amount_source !== '') {
                    $data_attrs .= ' data-amount-source="' . esc_attr($amount_source) . '"';
                }

                $html .= '<div class="formforge-payment-field"' . $data_attrs . $control_attrs . '>';
                if ($amount_cents > 0 && $amount_type !== 'dynamic') {
                    $html .= '<div class="formforge-payment-amount" style="font-size:20px;font-weight:700;color:#1e293b;margin-bottom:12px;">' . esc_html(strtoupper($currency)) . ' ' . esc_html(number_format($amount_cents / 100, 2)) . '</div>';
                } elseif ($amount_type === 'dynamic') {
                    // Live preview that updates from the calculation field.
                    $html .= '<div class="formforge-payment-amount" id="' . esc_attr($name) . '_amount_display" style="font-size:20px;font-weight:700;color:#1e293b;margin-bottom:12px;">' . esc_html(strtoupper($currency)) . ' 0.00</div>';
                }
                $html .= '<div id="' . esc_attr($name) . '_card_element" class="formforge-stripe-element" style="padding:12px;border:1.5px solid #e2e8f0;border-radius:8px;background:#fff;"></div>';
                $html .= '<div id="' . esc_attr($name) . '_card_errors" class="formforge-stripe-errors" style="color:#EF4444;font-size:14px;margin-top:6px;"></div>';
                $html .= '<input type="hidden" name="' . esc_attr($name) . '_payment_intent" id="' . esc_attr($name) . '_payment_intent" value="">';
                $html .= '<input type="hidden" name="' . esc_attr($name) . '" id="' . esc_attr($name) . '" value="">';
                $html .= '</div>';
                break;

            default:
                // belt-and-braces fallback. Any unrecognised field
                // type (legacy import, AI-generated form with a synonym,
                // future field type pre-rollout) used to render only the
                // label and an empty wrapper — leaving the user staring at
                // "Phone *" with no input. Now we emit a plain text input
                // so the field is always usable; the field's required /
                // pattern attributes still apply at the HTML5 layer.
                $html .= '<input type="text" name="' . esc_attr($name) . '" id="' . esc_attr($name) . '" value="' . esc_attr($value) . '" placeholder="' . esc_attr($placeholder) . '" class="formforge-input"' . $control_attrs . $minlen_attr . $maxlen_attr . $pattern_attr . $regex_msg_attr . ' ' . $req_attr . '>';
                break;
        }

        // Field description
        if (!empty($field['description']) && !in_array($type, ['heading', 'html_block', 'divider', 'page_break', 'paragraph'], true)) {
            $description = FORMFORGE_Form_Renderer::translate_string(
                $field['description'], 'description', $field_id, $form_id
            );
            $html .= '<p class="formforge-description"' . $description_attrs . '>' . esc_html($description) . '</p>';
        }

        $html .= '</div>';
        return $html;
    }

    /**
 * Validate a field value
 */
    public static function validate($field, $value) {
        $type = $field['type'] ?? 'text';
        $label = $field['label'] ?? 'Field';
        $required = !empty($field['required']);

        if ($required && (is_string($value) && trim($value) === '') || ($required && empty($value) && $value !== '0')) {
            /* translators: %s: field label */
            return sprintf(__('%s is required.', 'formforge'), $label);
        }

        if (empty($value)) return true;

        switch ($type) {
            case 'email':
                /* translators: %s: field label */
                if (!is_email($value)) return sprintf(__('%s must be a valid email.', 'formforge'), $label);
                break;
            case 'phone':
                /* translators: %s: field label */
                if (!preg_match('/^[\+\d\s\-\(\)]{7,20}$/', $value)) return sprintf(__('%s must be a valid phone number.', 'formforge'), $label);
                break;
            case 'url':
                /* translators: %s: field label */
                if (!filter_var($value, FILTER_VALIDATE_URL)) return sprintf(__('%s must be a valid URL.', 'formforge'), $label);
                break;
            case 'calendar_picker':
                $slot_dt = self::parse_calendar_datetime($value);
                $slot_ts = $slot_dt ? $slot_dt->getTimestamp() : 0;
                $now_ts = function_exists('current_time') ? (int) current_time('timestamp') : time();
                if ($slot_ts <= 0 || $slot_ts < ($now_ts - MINUTE_IN_SECONDS)) {
                    return __('Please select an available future date/time.', 'formforge');
                }
                $advance_days = isset($field['advance_days']) ? (int) $field['advance_days'] : 30;
                if ($advance_days > 0 && $slot_ts > strtotime('+' . $advance_days . ' days', $now_ts)) {
                    return __('Please select an available future date/time.', 'formforge');
                }
                $timezone = function_exists('wp_timezone') ? wp_timezone() : new DateTimeZone(wp_timezone_string() ?: 'UTC');
                $local_dt = $slot_dt->setTimezone($timezone);
                $available_days = isset($field['available_days']) && is_array($field['available_days'])
                    ? array_values(array_filter(array_map('intval', $field['available_days'])))
                    : [];
                if ($available_days && !in_array((int) $local_dt->format('N'), $available_days, true)) {
                    return __('Please select an available date/time within the configured availability.', 'formforge');
                }
                $work_start = isset($field['work_start']) ? max(0, min(23, (int) $field['work_start'])) : 0;
                $work_end = isset($field['work_end']) ? max(1, min(24, (int) $field['work_end'])) : 24;
                if ($work_end <= $work_start) {
                    return __('Please select an available date/time within the configured availability.', 'formforge');
                }
                $minutes = ((int) $local_dt->format('G') * 60) + (int) $local_dt->format('i');
                if ($minutes < ($work_start * 60) || $minutes >= ($work_end * 60)) {
                    return __('Please select an available date/time within the configured availability.', 'formforge');
                }
                break;
            case 'number':
            case 'range':
                /* translators: %s: field label */
                if (!is_numeric($value)) return sprintf(__('%s must be a number.', 'formforge'), $label);
                /* translators: 1: field label, 2: minimum value */
                if (isset($field['min']) && $field['min'] !== '' && $value < $field['min']) return sprintf(__('%1$s must be at least %2$s.', 'formforge'), $label, $field['min']);
                /* translators: 1: field label, 2: maximum value */
                if (isset($field['max']) && $field['max'] !== '' && $value > $field['max']) return sprintf(__('%1$s must be at most %2$s.', 'formforge'), $label, $field['max']);
                // Step constraint: value must be a multiple of step relative to
                // min (or 0 if min is unset). Skip when step is 0 or unset, or
                // when min is non-numeric (defensive). Float-safe via fmod +
                // epsilon so e.g. step=0.1 doesn't throw on rounding noise.
                if (isset($field['step']) && $field['step'] !== '' && (float) $field['step'] > 0) {
                    $step_val = (float) $field['step'];
                    $base_val = (isset($field['min']) && $field['min'] !== '' && is_numeric($field['min'])) ? (float) $field['min'] : 0.0;
                    $remainder = fmod((float) $value - $base_val, $step_val);
                    if (abs($remainder) > 0.0001 && abs(abs($remainder) - $step_val) > 0.0001) {
                        /* translators: 1: field label, 2: step increment */
                        return sprintf(__('%1$s must be in increments of %2$s.', 'formforge'), $label, $field['step']);
                    }
                }
                break;
        }

        // Min/max length
        if (!empty($field['min_length']) && mb_strlen($value) < $field['min_length']) {
            /* translators: 1: field label, 2: minimum number of characters */
            return sprintf(__('%1$s must be at least %2$d characters.', 'formforge'), $label, $field['min_length']);
        }
        if (!empty($field['max_length']) && mb_strlen($value) > $field['max_length']) {
            /* translators: 1: field label, 2: maximum number of characters */
            return sprintf(__('%1$s must be at most %2$d characters.', 'formforge'), $label, $field['max_length']);
        }

        // Custom regex. Builder UI saves the pattern without PHP delimiters
        // (so users can write `^[a-zA-Z]+$` naturally). If the stored value
        // looks delimited (e.g. `/^.../i`), use it as-is; otherwise wrap it
        // in `~...~` delimiters so preg_match doesn't fail with a warning.
        if (!empty($field['regex'])) {
            $rx = (string) $field['regex'];
            $first = strlen($rx) > 0 ? $rx[0] : '';
            $is_delimited = ($first !== '' && !ctype_alnum($first) && $first !== '\\' && strrpos($rx, $first) > 0);
            $compiled = $is_delimited ? $rx : '~' . str_replace('~', '\\~', $rx) . '~u';
            // Suppress warnings on a malformed pattern; treat as no match
            // so the user sees their custom error rather than a PHP notice.
            $matched = @preg_match($compiled, $value);
            if ($matched !== 1) {
                /* translators: %s: field label */
                return $field['regex_message'] ?? sprintf(__('%s format is invalid.', 'formforge'), $label);
            }
        }

        return true;
    }

    public static function sanitize($field, $value) {
        $type = $field['type'] ?? 'text';
        switch ($type) {
            case 'email': return sanitize_email($value);
            case 'url': return esc_url_raw($value);
            case 'textarea':
                $sanitized = sanitize_textarea_field($value);
                // fall back to a tag-stripped copy when sanitization empties the value.
                if ($sanitized === '' && trim($value) !== '') {
                    $sanitized = trim(wp_strip_all_tags($value));
                }
                return $sanitized;
            case 'number': case 'rating': case 'range': return is_numeric($value) ? $value : '';
            case 'map_address': case 'payment': case 'calendar_picker': return sanitize_text_field($value);
            case 'html_block': return wp_kses_post($value);
            default:
                if (is_array($value)) {
                    return array_map('sanitize_text_field', $value);
                }
                $sanitized = sanitize_text_field($value);
                // fall back to a tag-stripped copy when sanitization empties the value.
                if ($sanitized === '' && trim($value) !== '') {
                    $sanitized = trim(wp_strip_all_tags($value));
                }
                return $sanitized;
        }
    }
}
