<?php

namespace Formatrica\Forms;

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

use Formatrica\Support\Field_Key;
use Formatrica\Support\Secrets;

final class Form_Sanitizer
{
    /**
     * Every field type any shipped edition understands. Unknown types are
     * preserved-or-dropped in merge_with_defaults, never coerced to 'text'.
     *
     * @var list<string>
     */
    private const SUPPORTED_FIELD_TYPES = ['text', 'email', 'tel', 'textarea', 'checkbox', 'checkbox_group', 'select', 'radio', 'button', 'custom_text', 'file', 'multi_file', 'date', 'password', 'country', 'state', 'hidden', 'url', 'number', 'range', 'datetime', 'color', 'step_break', 'divider', 'heading', 'rating', 'static_image', 'geolocation', 'richtext', 'dynamic_select', 'calculation', 'price'];

    /**
     * Merge payload with defaults and sanitise.
     *
     * @param array<string, mixed> $payload
     * @return array{
     *   title: string,
     *   config: array<string, mixed>
     * }
     */
    public static function sanitise_payload(array $payload, array $existing = []): array
    {
        $title = isset($payload['title']) ? sanitize_text_field($payload['title']) : __('Untitled form', 'formatrica');
        $config = isset($payload['config']) && is_array($payload['config'])
            ? $payload['config']
            : self::default_config();

        $config = self::sanitise_config($config, $existing['config'] ?? []);

        return [
            'title' => $title ?: __('Untitled form', 'formatrica'),
            'config' => $config,
        ];
    }

    /**
     * Provide the base default configuration.
     *
     * @return array<string, mixed>
     */
    public static function default_config(): array
    {
        return [
            'fields' => [],
            'options' => [
                'form_type' => 'standard',
                'store_submissions' => true,
                'send_email' => true,
                'use_global_email_delivery' => true,
                // New granular global flags (default to true)
                'use_global_recipient' => true,
                'use_global_from_name' => true,
                'use_global_from_email' => true,
                // Legacy combined flag retained for backward compatibility
                'use_global_sender' => false,
                // Leave empty to fall back to global default recipient when using global
                'email_recipient' => '',
                'email_subject' => sprintf(
                    /* translators: %s: site title */
                    __('New form submission on %s', 'formatrica'),
                    get_bloginfo('name')
                ),
                'email_from_name' => get_bloginfo('name'),
                'email_from_address' => self::default_from_email(),
                'honeypot' => true,
                'throttle' => [
                    'max_requests' => 3,
                    'per_seconds' => 10,
                ],
                'success_message' => __('Thanks! Your message has been sent.', 'formatrica'),
                'error_message' => __('Something went wrong. Please try again.', 'formatrica'),
                // Appearance theme. Free: Classic preset + brand colour + mode.
                // The four preset themes are gated to Pro (P17); the block shape
                // is stable so a Pro-authored preset survives a downgrade.
                'theme' => [
                    'preset' => 'classic',
                    'brand_color' => '',
                    'mode' => 'inherit',
                ],
                // Column layout. single / two_column / three_column are Free.
                'layout' => [
                    'mode' => 'single',
                ],
                // Post-submit action. Free: same_page (inline message). Redirect
                // to an on-site URL is Pro (submissions.redirect), gated at save.
                'submit_action' => [
                    'type' => 'same_page',
                    'url' => '',
                ],
                // Save & Resume drafts (Pro submissions.drafts): per-form opt-in,
                // an optional dedicated email field for the resume link, and the
                // resume-link expiry window. Gated at save by Drafts_Rule.
                'drafts' => [
                    'enabled' => false,
                    'resume_email_field' => '',
                    'expiry_days' => 14,
                    'resume_button_label' => '',
                    'resume_email_subject' => '',
                    'resume_email_intro' => '',
                    'delete_after_final_submit' => true,
                ],
                // Submitter confirmation email (Pro emails.submitter_confirmation):
                // an autoresponder to the address the visitor typed into a chosen
                // email field. Gated at save by Submitter_Email_Rule + at send time.
                'submitter_email' => [
                    'enabled' => false,
                    'recipient_email_field' => '',
                    'subject' => __('Thank you for your submission', 'formatrica'),
                    'body' => __("Thank you for submitting {{form_title}}.\n\nWe received your message and will get back to you soon.", 'formatrica'),
                    'use_global_from_email' => true,
                    'from_email' => '',
                    'use_global_from_name' => true,
                    'from_name' => '',
                    'reply_to_email' => '',
                ],
                // Email template controls (Pro emails.template_controls): layout of
                // the notification (+ font/accent of the confirmation). Save-gated
                // by Email_Template_Rule; the renderer is capability-blind.
                'email_template' => [
                    'show_title' => true,
                    'show_metadata' => true,
                    'font_stack' => 'system',
                    'use_global_accent' => true,
                    'accent_color' => '',
                    'intro_text' => '',
                    'footer_text' => '',
                ],
                // Payments (Pro payments.basic): turns the form into a paid form.
                // The `enabled` flag is a HARD gate at save (Payments_Hard_Gate_Rule);
                // credentials are site-level (Payment_Connection), not here.
                'payments' => [
                    'enabled' => false,
                    'provider' => 'stripe',
                    'mode' => 'test',
                    'currency' => 'USD',
                    'amount_mode' => 'fixed',
                    'fixed_amount' => '',
                    'price_field_key' => '',
                    'calculation_field_name' => '',
                    'description' => '',
                    'cancel_redirect' => '',
                ],
                // Custom CSS class on the form's outer wrapper. Pro
                // (forms.wrapper_class), gated at save by Wrapper_Class_Rule.
                'wrapper_class' => '',
                // Headless API submissions (Pro api.headless): per-form opt-in,
                // write-only encrypted key, optional IP/CIDR allowlist.
                'headless_submission_enabled' => false,
                'headless_api_key' => '',
                'headless_api_key_set' => false,
                'headless_allowed_ips' => [],
                'ip_storage' => 'anonymous',
                'delete_submissions_after_days_enabled' => false,
                'delete_submissions_after_days' => 90,
                'captcha' => [
                    'provider' => 'none',
                    'site_key' => '',
                    'secret_key' => '',
                ],
                'email_delivery' => [
                    'provider' => 'wordpress',
                    'sendgrid' => [
                        'api_key' => '',
                    ],
                    'mailgun' => [
                        'api_key' => '',
                        'domain' => '',
                        'region' => 'us',
                    ],
                    'postmark' => [
                        'api_token' => '',
                    ],
                    'brevo' => [
                        'api_key' => '',
                    ],
                    'amazon_ses' => [
                        'access_key' => '',
                        'secret_key' => '',
                        'region' => 'us-east-1',
                    ],
                    'mailpit' => [
                        'host' => '127.0.0.1',
                        'port' => 1025,
                    ],
                    'smtp2go' => [
                        'api_key' => '',
                    ],
                    'smtp' => [
                        'host' => '',
                        'port' => 587,
                        'encryption' => 'tls',
                        'username' => '',
                        'password' => '',
                    ],
                ],
                'webhooks' => [
                    'enabled' => false,
                    'endpoint' => '',
                    'secret' => '',
                    'remove_secret' => false,
                ],
                'integrations' => [
                    'zapier' => [
                        'enabled' => false,
                        'webhook_url' => '',
                    ],
                    'make' => [
                        'enabled' => false,
                        'webhook_url' => '',
                        'api_key' => '',
                        'api_key_set' => false,
                    ],
                    'twilio' => [
                        'enabled' => false,
                        'account_sid' => '',
                        'api_key_sid' => '',
                        'api_key_secret' => '',
                        'api_key_secret_set' => false,
                        'sender_mode' => 'number',
                        'from_number' => '',
                        'messaging_service_sid' => '',
                        'recipient_number' => '',
                        'message_template' => '',
                        'region' => 'us1',
                    ],
                    'slack' => [
                        'enabled' => false,
                        'webhook_url' => '',
                        'message_template' => '',
                    ],
                    'wordpress_post' => [
                        'enabled' => false,
                        'post_type' => 'post',
                        'post_status' => 'pending',
                        'author_mode' => 'current_user',
                        'fixed_author_id' => 0,
                        'map' => [
                            'title' => '',
                            'content' => '',
                            'excerpt' => '',
                            'featured_image' => '',
                        ],
                        'taxonomies' => [],
                        'meta' => [],
                    ],
                    'mailchimp' => [
                        'enabled' => false,
                        'api_key' => '',
                        'list_id' => '',
                        'double_opt_in' => false,
                        'email_field' => '',
                        'first_name_field' => '',
                        'last_name_field' => '',
                        'tags' => [],
                    ],
                    'brevo' => [
                        'enabled' => false,
                        'api_key' => '',
                        'list_id' => '',
                        'email_field' => '',
                        'first_name_field' => '',
                        'last_name_field' => '',
                        'tags' => [],
                    ],
                    'salesforce' => [
                        'enabled' => false,
                        'org_id' => '',
                        'lead_source' => '',
                        'assignment_rule_id' => '',
                        'debug_email' => '',
                        'mappings' => [],
                    ],
                    'hubspot' => [
                        'enabled' => false,
                        'access_token' => '',
                        'portal_id' => '',
                        'form_guid' => '',
                        'email_field' => '',
                        'field_mappings' => [],
                        'legal_consent' => false,
                        'consent_text' => '',
                    ],
                    'woocommerce' => [
                        'enabled' => false,
                        'product_mode' => 'static',
                        'static_products' => [],
                        'product_field' => '',
                        'quantity_field' => '',
                        'variation_field' => '',
                        'price_field' => '',
                        'order_status' => 'wc-pending',
                        'create_customer' => true,
                        'customer' => [
                            'email_field' => '',
                            'first_name_field' => '',
                            'last_name_field' => '',
                            'phone_field' => '',
                            'company_field' => '',
                            'billing' => [
                                'line1_field' => '',
                                'line2_field' => '',
                                'city_field' => '',
                                'state_field' => '',
                                'postcode_field' => '',
                                'country_field' => '',
                            ],
                            'shipping' => [
                                'use_billing' => true,
                                'line1_field' => '',
                                'line2_field' => '',
                                'city_field' => '',
                                'state_field' => '',
                                'postcode_field' => '',
                                'country_field' => '',
                            ],
                        ],
                        'metadata' => [],
                    ],
                ],
                'custom_css' => '',
            ],
        ];
    }

    /**
     * Merge stored config with defaults to ensure backwards compatibility.
     *
     * @param array<string, mixed> $config
     * @return array<string, mixed>
     */
    public static function merge_with_defaults(array $config, array $existing = [], bool $strict_fields = false): array
    {
        $defaults = self::default_config();

        // Unknown field types are NOT coerced (they used to become 'text',
        // which would corrupt a form authored by a newer edition when saved
        // by an older one). On the read/render path ($strict_fields=false)
        // a stored unknown-type field passes through verbatim — it was
        // sanitised by the version that wrote it. On the save path
        // ($strict_fields=true) an unknown type is restored from the stored
        // config by its immutable key; a NEW unknown type (which no shipped
        // builder can author) is dropped, never trusted from the request.
        $existing_by_key = [];
        foreach (($existing['fields'] ?? []) as $existing_field) {
            if (is_array($existing_field) && !empty($existing_field['key'])) {
                $existing_by_key[(string) $existing_field['key']] = $existing_field;
            }
        }

        $mapped = [];
        foreach ((isset($config['fields']) && is_array($config['fields'])) ? $config['fields'] : [] as $field) {
            if (!is_array($field)) {
                continue;
            }
            $type = isset($field['type']) ? sanitize_key((string) $field['type']) : 'text';
            if (!in_array($type, self::SUPPORTED_FIELD_TYPES, true)) {
                if (!$strict_fields) {
                    $mapped[] = $field;
                } elseif (isset($field['key'], $existing_by_key[(string) $field['key']])) {
                    $mapped[] = $existing_by_key[(string) $field['key']];
                }
                continue;
            }
            $mapped[] = self::sanitise_field($field);
        }

        $config['fields'] = self::ensure_unique_keys(array_values($mapped));

        $config['options'] = isset($config['options']) && is_array($config['options'])
            ? self::merge_option_defaults($config['options'], $defaults['options'], $existing['options'] ?? [])
            : $defaults['options'];

        return [
            'fields' => $config['fields'],
            'options' => $config['options'],
        ];
    }

    /**
     * @param array<string, mixed> $options
     * @param array<string, mixed> $defaults
     * @return array<string, mixed>
     */
    private static function merge_option_defaults(array $options, array $defaults, array $existing): array
    {
        $merged = wp_parse_args($options, $defaults);

        $merged['throttle'] = wp_parse_args(
            is_array($options['throttle'] ?? null) ? $options['throttle'] : [],
            $defaults['throttle']
        );

        $merged['captcha'] = wp_parse_args(
            is_array($options['captcha'] ?? null) ? $options['captcha'] : [],
            $defaults['captcha']
        );

        if (isset($existing['captcha']) && is_array($existing['captcha'])) {
            $merged['captcha'] = wp_parse_args($merged['captcha'], $existing['captcha']);
        }

        $merged['webhooks'] = wp_parse_args(
            is_array($options['webhooks'] ?? null) ? $options['webhooks'] : [],
            $defaults['webhooks']
        );

        if (isset($existing['webhooks']) && is_array($existing['webhooks'])) {
            $merged['webhooks'] = wp_parse_args($merged['webhooks'], $existing['webhooks']);
        }

        $email_delivery_defaults = $defaults['email_delivery'];
        $email_delivery_options = is_array($options['email_delivery'] ?? null) ? $options['email_delivery'] : [];
        $existing_delivery = is_array($existing['email_delivery'] ?? null) ? $existing['email_delivery'] : [];

        $merged['email_delivery'] = wp_parse_args($email_delivery_options, $email_delivery_defaults);

        foreach (['sendgrid', 'mailgun', 'postmark', 'brevo', 'amazon_ses', 'mailpit', 'smtp2go', 'smtp'] as $key) {
            $merged['email_delivery'][$key] = wp_parse_args(
                is_array($email_delivery_options[$key] ?? null) ? $email_delivery_options[$key] : [],
                $email_delivery_defaults[$key]
            );

            if (isset($existing_delivery[$key]) && is_array($existing_delivery[$key])) {
                $merged['email_delivery'][$key] = wp_parse_args(
                    $merged['email_delivery'][$key],
                    $existing_delivery[$key]
                );
            }
        }

        $merged['integrations'] = self::merge_integrations(
            is_array($options['integrations'] ?? null) ? $options['integrations'] : [],
            $defaults['integrations'],
            is_array($existing['integrations'] ?? null) ? $existing['integrations'] : []
        );

        return $merged;
    }

    /**
     * @param array<string, mixed> $options
     * @param array<string, mixed> $defaults
     * @param array<string, mixed> $existing
     * @return array<string, mixed>
     */
    private static function merge_integrations(array $options, array $defaults, array $existing): array
    {
        $merged = $defaults;

        foreach ($defaults as $integration_id => $default_settings) {
            $incoming = isset($options[$integration_id]) && is_array($options[$integration_id])
                ? $options[$integration_id]
                : [];
            $existing_settings = isset($existing[$integration_id]) && is_array($existing[$integration_id])
                ? $existing[$integration_id]
                : [];

            $settings = wp_parse_args($incoming, $default_settings);
            if (!empty($existing_settings)) {
                $settings = wp_parse_args($settings, $existing_settings);
            }

            if (array_key_exists('tags', $default_settings)) {
                if (isset($incoming['tags']) && is_array($incoming['tags'])) {
                    $settings['tags'] = array_values($incoming['tags']);
                } elseif (isset($existing_settings['tags']) && is_array($existing_settings['tags'])) {
                    $settings['tags'] = array_values($existing_settings['tags']);
                } else {
                    $settings['tags'] = [];
                }
            }

            if (array_key_exists('mappings', $default_settings)) {
                if (isset($incoming['mappings']) && is_array($incoming['mappings'])) {
                    $settings['mappings'] = array_values($incoming['mappings']);
                } elseif (isset($existing_settings['mappings']) && is_array($existing_settings['mappings'])) {
                    $settings['mappings'] = array_values($existing_settings['mappings']);
                } else {
                    $settings['mappings'] = [];
                }
            }

            if (array_key_exists('field_mappings', $default_settings)) {
                if (isset($incoming['field_mappings']) && is_array($incoming['field_mappings'])) {
                    $settings['field_mappings'] = array_values($incoming['field_mappings']);
                } elseif (isset($existing_settings['field_mappings']) && is_array($existing_settings['field_mappings'])) {
                    $settings['field_mappings'] = array_values($existing_settings['field_mappings']);
                } else {
                    $settings['field_mappings'] = [];
                }
            }

            $merged[$integration_id] = $settings;
        }

        return $merged;
    }

    /**
     * Sanitise configuration payload.
     *
     * @param array<string, mixed> $config
     * @return array<string, mixed>
     */
    private static function sanitise_config(array $config, array $existing = []): array
    {
        $sanitised = self::merge_with_defaults($config, $existing, true);

        $sanitised['options']['store_submissions'] = (bool) $sanitised['options']['store_submissions'];
        $sanitised['options']['send_email'] = (bool) $sanitised['options']['send_email'];
        $sanitised['options']['honeypot'] = (bool) $sanitised['options']['honeypot'];
        $sanitised['options']['use_global_email_delivery'] = (bool) ($sanitised['options']['use_global_email_delivery'] ?? true);
        // Map legacy combined flag to granular flags if granular flags are missing
        $legacy_sender = isset($sanitised['options']['use_global_sender']) ? (bool) $sanitised['options']['use_global_sender'] : null;
        $sanitised['options']['use_global_recipient'] = isset($sanitised['options']['use_global_recipient'])
            ? (bool) $sanitised['options']['use_global_recipient']
            : true;
        $sanitised['options']['use_global_from_name'] = isset($sanitised['options']['use_global_from_name'])
            ? (bool) $sanitised['options']['use_global_from_name']
            : ($legacy_sender !== null ? $legacy_sender : true);
        $sanitised['options']['use_global_from_email'] = isset($sanitised['options']['use_global_from_email'])
            ? (bool) $sanitised['options']['use_global_from_email']
            : ($legacy_sender !== null ? $legacy_sender : true);
        $sanitised['options']['email_recipient'] = sanitize_email($sanitised['options']['email_recipient']);
        $sanitised['options']['email_subject'] = sanitize_text_field($sanitised['options']['email_subject']);
        $sanitised['options']['email_from_name'] = sanitize_text_field($sanitised['options']['email_from_name']);
        $sanitised['options']['email_from_address'] = sanitize_email($sanitised['options']['email_from_address']);
        $sanitised['options']['success_message'] = wp_kses_post($sanitised['options']['success_message']);
        $sanitised['options']['error_message'] = wp_kses_post($sanitised['options']['error_message']);
        $sanitised['options']['custom_css'] = isset($sanitised['options']['custom_css'])
            ? sanitize_textarea_field($sanitised['options']['custom_css'])
            : '';

        $sanitised['options']['theme'] = self::sanitise_theme($sanitised['options']['theme'] ?? null);

        $layout_mode = $sanitised['options']['layout']['mode'] ?? 'single';
        $sanitised['options']['layout'] = [
            'mode' => in_array($layout_mode, ['single', 'two_column', 'three_column'], true) ? $layout_mode : 'single',
        ];

        $sanitised['options']['submit_action'] = self::sanitise_submit_action($sanitised['options']['submit_action'] ?? null);

        $sanitised['options']['wrapper_class'] = self::sanitise_css_classes($sanitised['options']['wrapper_class'] ?? '');

        // Save & Resume drafts (Pro submissions.drafts). enabled is gated at save
        // by Drafts_Rule; the field name + expiry window are always sanitised.
        $drafts_in = isset($sanitised['options']['drafts']) && is_array($sanitised['options']['drafts'])
            ? $sanitised['options']['drafts']
            : [];
        $sanitised['options']['drafts'] = [
            'enabled'                   => !empty($drafts_in['enabled']),
            'resume_email_field'        => sanitize_text_field((string) ($drafts_in['resume_email_field'] ?? '')),
            'expiry_days'               => max(1, min(90, (int) ($drafts_in['expiry_days'] ?? 14))),
            'resume_button_label'       => mb_substr(sanitize_text_field((string) ($drafts_in['resume_button_label'] ?? '')), 0, 80),
            'resume_email_subject'      => mb_substr(sanitize_text_field((string) ($drafts_in['resume_email_subject'] ?? '')), 0, 200),
            'resume_email_intro'        => mb_substr(sanitize_textarea_field((string) ($drafts_in['resume_email_intro'] ?? '')), 0, 500),
            'delete_after_final_submit' => !array_key_exists('delete_after_final_submit', $drafts_in) || !empty($drafts_in['delete_after_final_submit']),
        ];

        // Submitter confirmation email (Pro emails.submitter_confirmation). The
        // enabled flag is gated at save by Submitter_Email_Rule; the rest is always
        // sanitised. CR/LF stripped from subject/from-name (header-injection guard).
        $se_in = isset($sanitised['options']['submitter_email']) && is_array($sanitised['options']['submitter_email'])
            ? $sanitised['options']['submitter_email']
            : [];
        $se_from_email = sanitize_email((string) ($se_in['from_email'] ?? ''));
        $se_reply_to   = sanitize_email((string) ($se_in['reply_to_email'] ?? ''));
        $sanitised['options']['submitter_email'] = [
            // sanitize_text_field already collapses CR/LF (header-injection guard).
            'enabled'               => !empty($se_in['enabled']),
            'recipient_email_field' => sanitize_text_field((string) ($se_in['recipient_email_field'] ?? '')),
            'subject'               => mb_substr(sanitize_text_field((string) ($se_in['subject'] ?? '')), 0, 200),
            'body'                  => mb_substr(sanitize_textarea_field((string) ($se_in['body'] ?? '')), 0, 4000),
            'use_global_from_email' => !array_key_exists('use_global_from_email', $se_in) || !empty($se_in['use_global_from_email']),
            'from_email'            => is_email($se_from_email) ? $se_from_email : '',
            'use_global_from_name'  => !array_key_exists('use_global_from_name', $se_in) || !empty($se_in['use_global_from_name']),
            'from_name'             => mb_substr(sanitize_text_field((string) ($se_in['from_name'] ?? '')), 0, 150),
            'reply_to_email'        => is_email($se_reply_to) ? $se_reply_to : '',
        ];

        // Email template controls (Pro emails.template_controls). The whole block
        // is gated at save by Email_Template_Rule; always sanitised here.
        $et_in = isset($sanitised['options']['email_template']) && is_array($sanitised['options']['email_template'])
            ? $sanitised['options']['email_template']
            : [];
        $et_font   = (string) ($et_in['font_stack'] ?? 'system');
        $et_accent = (string) ($et_in['accent_color'] ?? '');
        $sanitised['options']['email_template'] = [
            'show_title'        => !array_key_exists('show_title', $et_in) || !empty($et_in['show_title']),
            'show_metadata'     => !array_key_exists('show_metadata', $et_in) || !empty($et_in['show_metadata']),
            'font_stack'        => in_array($et_font, ['system', 'sans', 'serif', 'humanist', 'geometric'], true) ? $et_font : 'system',
            'use_global_accent' => !array_key_exists('use_global_accent', $et_in) || !empty($et_in['use_global_accent']),
            'accent_color'      => preg_match('/^#[0-9A-Fa-f]{6}$/', $et_accent) === 1 ? strtoupper($et_accent) : '',
            'intro_text'        => mb_substr(sanitize_textarea_field((string) ($et_in['intro_text'] ?? '')), 0, 1000),
            'footer_text'       => mb_substr(sanitize_textarea_field((string) ($et_in['footer_text'] ?? '')), 0, 1000),
        ];

        // Payments (Pro payments.basic). enabled is HARD-gated by Payments_Hard_Gate_Rule;
        // everything else always sanitises. The amount is validated to the chosen
        // currency's shape; an invalid fixed amount clears (fail-closed).
        $pay_in    = isset($sanitised['options']['payments']) && is_array($sanitised['options']['payments'])
            ? $sanitised['options']['payments']
            : [];
        $pay_curr  = strtoupper((string) ($pay_in['currency'] ?? 'USD'));
        if (!self::payment_currency_supported($pay_curr)) {
            $pay_curr = 'USD';
        }
        $sanitised['options']['payments'] = [
            'enabled'                => !empty($pay_in['enabled']),
            'provider'               => in_array(($pay_in['provider'] ?? ''), ['stripe', 'paypal'], true) ? (string) $pay_in['provider'] : 'stripe',
            'mode'                   => ($pay_in['mode'] ?? 'test') === 'live' ? 'live' : 'test',
            'currency'               => $pay_curr,
            'amount_mode'            => in_array(($pay_in['amount_mode'] ?? ''), ['fixed', 'price', 'calculation'], true) ? (string) $pay_in['amount_mode'] : 'fixed',
            'fixed_amount'           => self::sanitise_payment_amount((string) ($pay_in['fixed_amount'] ?? ''), $pay_curr),
            'price_field_key'        => sanitize_text_field((string) ($pay_in['price_field_key'] ?? '')),
            'calculation_field_name' => sanitize_key((string) ($pay_in['calculation_field_name'] ?? '')),
            'description'            => mb_substr(sanitize_text_field((string) ($pay_in['description'] ?? '')), 0, 300),
            'cancel_redirect'        => self::sanitise_same_origin_url((string) ($pay_in['cancel_redirect'] ?? '')),
        ];

        // Headless API submissions (Pro api.headless). The API key is
        // write-only: a POSTed plaintext key is encrypted at rest; when none is
        // posted the existing encrypted blob is preserved; remove flag clears.
        $existing_headless_key = isset($existing['options']['headless_api_key'])
            ? (string) $existing['options']['headless_api_key']
            : '';
        $sanitised['options']['headless_submission_enabled'] = !empty($sanitised['options']['headless_submission_enabled']);
        $incoming_headless_key = isset($config['options']['headless_api_key']) && is_string($config['options']['headless_api_key'])
            ? trim($config['options']['headless_api_key'])
            : '';
        if ($incoming_headless_key !== '') {
            $sanitised['options']['headless_api_key'] = Secrets::encrypt($incoming_headless_key);
        } elseif (!empty($config['options']['remove_headless_api_key'])) {
            $sanitised['options']['headless_api_key'] = '';
        } else {
            $sanitised['options']['headless_api_key'] = $existing_headless_key;
        }
        unset($sanitised['options']['remove_headless_api_key']);
        $sanitised['options']['headless_api_key_set'] = $sanitised['options']['headless_api_key'] !== '';

        $raw_ips = $sanitised['options']['headless_allowed_ips'] ?? [];
        if (is_string($raw_ips)) {
            $raw_ips = preg_split('/\r\n|\r|\n|,/', $raw_ips) ?: [];
        }
        $allowed_ips = [];
        foreach ((array) $raw_ips as $entry) {
            if (is_string($entry) && \Formatrica\Support\Client_Ip_Matcher::is_valid_entry(trim($entry))) {
                $allowed_ips[] = trim($entry);
            }
        }
        $sanitised['options']['headless_allowed_ips'] = array_values(array_unique($allowed_ips));

        $allowed_ip_modes = ['full', 'anonymous', 'none'];
        $ip_storage = $sanitised['options']['ip_storage'] ?? 'full';
        $sanitised['options']['ip_storage'] = in_array($ip_storage, $allowed_ip_modes, true) ? $ip_storage : 'anonymous';

        $allowed_form_types = ['standard', 'user_registration'];
        $form_type = $sanitised['options']['form_type'] ?? 'standard';
        $sanitised['options']['form_type'] = in_array($form_type, $allowed_form_types, true) ? $form_type : 'standard';

        $sanitised['options']['delete_submissions_after_days_enabled'] = (bool) ($sanitised['options']['delete_submissions_after_days_enabled'] ?? false);
        $delete_days = (int) ($sanitised['options']['delete_submissions_after_days'] ?? 90);
        $sanitised['options']['delete_submissions_after_days'] = max(1, min(3650, $delete_days));

        $throttle = $sanitised['options']['throttle'];
        $sanitised['options']['throttle'] = [
            'max_requests' => max(1, (int) ($throttle['max_requests'] ?? 3)),
            // Capped at 1 hour so the rate-bucket cleanup horizon can never
            // prune a still-active window (F-02 follow-up).
            'per_seconds' => min(3600, max(1, (int) ($throttle['per_seconds'] ?? 10))),
        ];

        $allowed_captcha = ['none', 'recaptcha_v3', 'turnstile', 'friendlycaptcha'];
        $captcha = $sanitised['options']['captcha'];
        $captcha_provider = $captcha['provider'] ?? 'none';
        $captcha['provider'] = in_array($captcha_provider, $allowed_captcha, true) ? $captcha_provider : 'none';
        $captcha['site_key'] = sanitize_text_field($captcha['site_key'] ?? '');

        $existing_options = isset($existing['options']) && is_array($existing['options'])
            ? $existing['options']
            : [];
        $existing_captcha = isset($existing_options['captcha']) && is_array($existing_options['captcha'])
            ? $existing_options['captcha']
            : [];
        $existing_secret = isset($existing_captcha['secret_key']) ? (string) $existing_captcha['secret_key'] : '';
        $existing_secret_payload = $existing_secret !== '' ? base64_decode($existing_secret, true) : false;
        $existing_secret_is_encrypted = $existing_secret_payload !== false && strlen($existing_secret_payload) > 16;

        $incoming_captcha = isset($config['options']['captcha']) && is_array($config['options']['captcha'])
            ? $config['options']['captcha']
            : [];
        $incoming_secret = isset($incoming_captcha['secret_key']) ? trim((string) $incoming_captcha['secret_key']) : '';
        $remove_secret = !empty($incoming_captcha['remove_secret_key']);

        if ($incoming_secret !== '') {
            $captcha['secret_key'] = Secrets::encrypt($incoming_secret);
        } elseif ($remove_secret) {
            $captcha['secret_key'] = '';
        } elseif ($existing_secret !== '') {
            $captcha['secret_key'] = $existing_secret_is_encrypted
                ? $existing_secret
                : Secrets::encrypt($existing_secret);
        } else {
            $captcha['secret_key'] = '';
        }

        unset($captcha['remove_secret_key'], $captcha['secret_key_set']);
        $sanitised['options']['captcha'] = $captcha;

        $allowed_delivery = ['wordpress', 'sendgrid', 'mailgun', 'postmark', 'brevo', 'amazon_ses', 'mailpit', 'smtp2go', 'smtp'];
        $email_delivery = $sanitised['options']['email_delivery'];
        $provider = $email_delivery['provider'] ?? 'wordpress';
        $email_delivery['provider'] = in_array($provider, $allowed_delivery, true) ? $provider : 'wordpress';
        $existing_delivery = is_array($existing['options']['email_delivery'] ?? null)
            ? $existing['options']['email_delivery']
            : [];

        // Provider API keys are write-only secrets (Phase 6): an empty incoming
        // value keeps the stored one (the browser only ever gets the *_set flag
        // back — see Form_Repository::map_row_to_form), a new value is encrypted
        // at rest. Pre-Phase-6 plaintext values keep working via
        // Secrets::reveal() at send time and become encrypted on next save.
        $delivery_secret_keys = [
            ['sendgrid', 'api_key'],
            ['mailgun', 'api_key'],
            ['postmark', 'api_token'],
            ['brevo', 'api_key'],
            ['amazon_ses', 'secret_key'],
            ['smtp2go', 'api_key'],
        ];
        foreach ($delivery_secret_keys as [$prov, $key]) {
            $email_delivery[$prov][$key] = Secrets::store_write_only(
                sanitize_text_field($email_delivery[$prov][$key] ?? ''),
                (string) ($existing_delivery[$prov][$key] ?? '')
            );
            unset($email_delivery[$prov][$key . '_set']);
        }

        $email_delivery['mailgun']['domain'] = sanitize_text_field($email_delivery['mailgun']['domain'] ?? '');
        $region = $email_delivery['mailgun']['region'] ?? 'us';
        $email_delivery['mailgun']['region'] = in_array($region, ['us', 'eu'], true) ? $region : 'us';

        $email_delivery['amazon_ses']['access_key'] = sanitize_text_field($email_delivery['amazon_ses']['access_key'] ?? '');
        $email_delivery['amazon_ses']['region'] = sanitize_text_field($email_delivery['amazon_ses']['region'] ?? 'us-east-1');
        $email_delivery['mailpit']['host'] = sanitize_text_field($email_delivery['mailpit']['host'] ?? '127.0.0.1');
        $email_delivery['mailpit']['port'] = max(1, (int) ($email_delivery['mailpit']['port'] ?? 1025));
        $email_delivery['smtp']['host'] = sanitize_text_field($email_delivery['smtp']['host'] ?? '');
        $email_delivery['smtp']['port'] = max(1, (int) ($email_delivery['smtp']['port'] ?? 587));
        $valid_encryption = ['none', 'ssl', 'tls'];
        $smtp_encryption = $email_delivery['smtp']['encryption'] ?? 'tls';
        $email_delivery['smtp']['encryption'] = in_array($smtp_encryption, $valid_encryption, true) ? $smtp_encryption : 'tls';
        $email_delivery['smtp']['username'] = sanitize_text_field($email_delivery['smtp']['username'] ?? '');

        $existing_password = $existing_delivery['smtp']['password'] ?? '';
        $incoming_password = isset($email_delivery['smtp']['password'])
            ? trim((string) $email_delivery['smtp']['password'])
            : '';

        if ($incoming_password === '' && $existing_password !== '') {
            $email_delivery['smtp']['password'] = $existing_password;
        } elseif ($incoming_password !== '') {
            $email_delivery['smtp']['password'] = Secrets::encrypt($incoming_password);
        } else {
            $email_delivery['smtp']['password'] = '';
        }
        unset($email_delivery['smtp']['password_set']);

        $webhooks = $sanitised['options']['webhooks'];
        $webhooks['enabled'] = !empty($webhooks['enabled']);
        $endpoint = isset($webhooks['endpoint']) ? esc_url_raw($webhooks['endpoint']) : '';
        $webhooks['endpoint'] = $endpoint ?: '';

        $existing_webhooks = is_array($existing['options']['webhooks'] ?? null) ? $existing['options']['webhooks'] : [];
        $existing_secret = $existing_webhooks['secret'] ?? '';
        $incoming_secret = isset($webhooks['secret']) ? trim((string) $webhooks['secret']) : '';
        $remove_secret = !empty($webhooks['remove_secret']);

        if ($remove_secret) {
            $webhooks['secret'] = '';
        } elseif ($incoming_secret === '' && $existing_secret !== '') {
            $webhooks['secret'] = $existing_secret;
        } elseif ($incoming_secret !== '') {
            $webhooks['secret'] = Secrets::encrypt($incoming_secret);
        } else {
            $webhooks['secret'] = '';
        }
        unset($webhooks['remove_secret'], $webhooks['secret_set']);

        $sanitised['options']['webhooks'] = $webhooks;
        $sanitised['options']['email_delivery'] = $email_delivery;
        $existing_integrations = is_array($existing['options']['integrations'] ?? null)
            ? $existing['options']['integrations']
            : [];
        $sanitised_integrations = isset($sanitised['options']['integrations']) && is_array($sanitised['options']['integrations'])
            ? $sanitised['options']['integrations']
            : [];
        $sanitised['options']['integrations'] = self::sanitise_integrations(
            $sanitised_integrations,
            $existing_integrations
        );

        return $sanitised;
    }

    /**
     * @param array<string, mixed> $integrations
     * @param array<string, mixed> $existing
     * @return array<string, mixed>
     */
    private static function sanitise_integrations(array $integrations, array $existing): array
    {
        $defaults = self::default_config()['options']['integrations'];
        $sanitised = [];

        foreach ($defaults as $integration_id => $default_settings) {
            $incoming = isset($integrations[$integration_id]) && is_array($integrations[$integration_id])
                ? $integrations[$integration_id]
                : [];
            $existing_settings = isset($existing[$integration_id]) && is_array($existing[$integration_id])
                ? $existing[$integration_id]
                : [];

            $sanitised[$integration_id] = self::sanitise_single_integration(
                $integration_id,
                $incoming,
                $existing_settings,
                $default_settings
            );
        }

        return $sanitised;
    }

    /**
     * @param array<string, mixed> $incoming
     * @param array<string, mixed> $existing
     * @param array<string, mixed> $default
     * @return array<string, mixed>
     */
    private static function sanitise_single_integration(string $integration_id, array $incoming, array $existing, array $default): array
    {
        $enabled = !empty($incoming['enabled']);

        switch ($integration_id) {
            case 'zapier':
                $url = isset($incoming['webhook_url']) ? esc_url_raw($incoming['webhook_url']) : '';
                return [
                    'enabled' => $enabled,
                    'webhook_url' => $url,
                ];

            case 'make':
                // Make.com custom API-key auth (Pro P-WH2, x-make-apikey
                // header). Write-only secret: POSTed plaintext encrypts; empty
                // preserves the stored blob; remove flag clears. Zapier has no
                // analogous receive-side header, hence no api_key there.
                $url = isset($incoming['webhook_url']) ? esc_url_raw($incoming['webhook_url']) : '';
                $existing_key = isset($existing['api_key']) ? (string) $existing['api_key'] : '';
                $remove_key = !empty($incoming['remove_api_key']);
                $api_key_raw = isset($incoming['api_key']) ? trim((string) $incoming['api_key']) : '';

                if ($remove_key) {
                    $api_key = '';
                } elseif ($api_key_raw !== '') {
                    $api_key = Secrets::encrypt($api_key_raw);
                } else {
                    $api_key = $existing_key;
                }

                return [
                    'enabled' => $enabled,
                    'webhook_url' => $url,
                    'api_key' => $api_key,
                    'api_key_set' => $api_key !== '',
                ];

            case 'twilio':
                // Twilio SMS (Pro integrations.sms_twilio). One fixed admin
                // recipient; secrets write-only encrypted; SIDs/numbers strictly
                // validated so the dispatcher never has to guess.
                // In a Free package the Pro Twilio_Options (validation constants)
                // is absent — Free cannot author Twilio, so keep the stored config
                // untouched and ignore the incoming values.
                if (!class_exists(\Formatrica\Pro\Integrations\Twilio_Options::class)) {
                    return is_array($existing) ? $existing : ['enabled' => false];
                }

                $existing_secret = isset($existing['api_key_secret']) ? (string) $existing['api_key_secret'] : '';
                $remove_secret = !empty($incoming['remove_api_key_secret']);
                $secret_raw = isset($incoming['api_key_secret']) ? trim((string) $incoming['api_key_secret']) : '';

                if ($remove_secret) {
                    $api_key_secret = '';
                } elseif ($secret_raw !== '') {
                    $api_key_secret = Secrets::encrypt($secret_raw);
                } else {
                    $api_key_secret = $existing_secret;
                }

                $account_sid = isset($incoming['account_sid']) ? trim((string) $incoming['account_sid']) : '';
                if (!preg_match(\Formatrica\Pro\Integrations\Twilio_Options::ACCOUNT_SID_REGEX, $account_sid)) {
                    $account_sid = '';
                }
                $api_key_sid = isset($incoming['api_key_sid']) ? trim((string) $incoming['api_key_sid']) : '';
                if (!preg_match(\Formatrica\Pro\Integrations\Twilio_Options::API_KEY_SID_REGEX, $api_key_sid)) {
                    $api_key_sid = '';
                }

                $sender_mode = ($incoming['sender_mode'] ?? 'number') === 'messaging_service' ? 'messaging_service' : 'number';
                $from_number = isset($incoming['from_number']) ? trim((string) $incoming['from_number']) : '';
                if (!preg_match(\Formatrica\Pro\Integrations\Twilio_Options::E164_REGEX, $from_number)) {
                    $from_number = '';
                }
                $messaging_service_sid = isset($incoming['messaging_service_sid']) ? trim((string) $incoming['messaging_service_sid']) : '';
                if (!preg_match(\Formatrica\Pro\Integrations\Twilio_Options::MESSAGING_SERVICE_SID_REGEX, $messaging_service_sid)) {
                    $messaging_service_sid = '';
                }
                $recipient_number = isset($incoming['recipient_number']) ? trim((string) $incoming['recipient_number']) : '';
                if (!preg_match(\Formatrica\Pro\Integrations\Twilio_Options::E164_REGEX, $recipient_number)) {
                    $recipient_number = '';
                }

                $template = isset($incoming['message_template'])
                    ? sanitize_textarea_field((string) $incoming['message_template'])
                    : '';
                // {{submission_json}} would expand the entire payload into an
                // SMS — reject at storage (the dispatcher also strips it).
                $template = str_replace('{{submission_json}}', '', $template);
                $template = self::safe_substr($template, 0, \Formatrica\Pro\Integrations\Twilio_Options::MAX_TEMPLATE_LENGTH);

                $region = isset($incoming['region']) ? sanitize_key((string) $incoming['region']) : '';
                if (!in_array($region, \Formatrica\Pro\Integrations\Twilio_Options::ALLOWED_REGIONS, true)) {
                    $region = \Formatrica\Pro\Integrations\Twilio_Options::DEFAULT_REGION;
                }

                return [
                    'enabled' => $enabled,
                    'account_sid' => $account_sid,
                    'api_key_sid' => $api_key_sid,
                    'api_key_secret' => $api_key_secret,
                    'api_key_secret_set' => $api_key_secret !== '',
                    'sender_mode' => $sender_mode,
                    'from_number' => $from_number,
                    'messaging_service_sid' => $messaging_service_sid,
                    'recipient_number' => $recipient_number,
                    'message_template' => $template,
                    'region' => $region,
                ];

            case 'slack':
                $url = isset($incoming['webhook_url']) ? esc_url_raw($incoming['webhook_url']) : '';
                $template = isset($incoming['message_template'])
                    ? sanitize_textarea_field((string) $incoming['message_template'])
                    : '';
                $template = self::safe_substr($template, 0, 4000);

                return [
                    'enabled' => $enabled,
                    'webhook_url' => $url,
                    'message_template' => $template,
                ];

            case 'wordpress_post':
                $post_type = isset($incoming['post_type']) ? sanitize_key((string) $incoming['post_type']) : 'post';
                if ($post_type === '') {
                    $post_type = 'post';
                }

                $allowed_statuses = ['draft', 'pending', 'publish', 'future', 'private'];
                $post_status = isset($incoming['post_status']) ? sanitize_key((string) $incoming['post_status']) : 'pending';
                if (!in_array($post_status, $allowed_statuses, true)) {
                    $post_status = 'pending';
                }

                $author_mode = isset($incoming['author_mode']) ? sanitize_key((string) $incoming['author_mode']) : 'current_user';
                if (!in_array($author_mode, ['current_user', 'fixed', 'anonymous'], true)) {
                    $author_mode = 'current_user';
                }

                $fixed_author_id = isset($incoming['fixed_author_id']) ? absint((int) $incoming['fixed_author_id']) : 0;

                $map_in = isset($incoming['map']) && is_array($incoming['map']) ? $incoming['map'] : [];
                $map = [
                    'title' => isset($map_in['title']) ? sanitize_key((string) $map_in['title']) : '',
                    'content' => isset($map_in['content']) ? sanitize_key((string) $map_in['content']) : '',
                    'excerpt' => isset($map_in['excerpt']) ? sanitize_key((string) $map_in['excerpt']) : '',
                    'featured_image' => isset($map_in['featured_image']) ? sanitize_key((string) $map_in['featured_image']) : '',
                ];

                $taxonomies_in = isset($incoming['taxonomies']) && is_array($incoming['taxonomies']) ? $incoming['taxonomies'] : [];
                $taxonomies = [];
                foreach ($taxonomies_in as $row) {
                    if (!is_array($row)) {
                        continue;
                    }
                    $taxonomy = isset($row['taxonomy']) ? sanitize_key((string) $row['taxonomy']) : '';
                    $field = isset($row['field']) ? sanitize_key((string) $row['field']) : '';
                    $mode = isset($row['mode']) && $row['mode'] === 'ids' ? 'ids' : 'names';
                    if ($taxonomy === '' || $field === '') {
                        continue;
                    }
                    $taxonomies[] = [
                        'taxonomy' => $taxonomy,
                        'field' => $field,
                        'mode' => $mode,
                    ];
                }

                $meta_in = isset($incoming['meta']) && is_array($incoming['meta']) ? $incoming['meta'] : [];
                $meta = [];
                foreach ($meta_in as $row) {
                    if (!is_array($row)) {
                        continue;
                    }
                    $key = isset($row['key']) ? sanitize_key((string) $row['key']) : '';
                    $field = isset($row['field']) ? sanitize_key((string) $row['field']) : '';
                    if ($key === '' || $field === '') {
                        continue;
                    }
                    $meta[] = [
                        'key' => $key,
                        'field' => $field,
                    ];
                }

                return [
                    'enabled' => $enabled,
                    'post_type' => $post_type,
                    'post_status' => $post_status,
                    'author_mode' => $author_mode,
                    'fixed_author_id' => $fixed_author_id,
                    'map' => $map,
                    'taxonomies' => $taxonomies,
                    'meta' => $meta,
                ];

            case 'woocommerce':
                $product_mode = isset($incoming['product_mode']) && $incoming['product_mode'] === 'field' ? 'field' : 'static';
                $static_products_in = isset($incoming['static_products']) && is_array($incoming['static_products'])
                    ? $incoming['static_products']
                    : [];
                $static_products = [];
                foreach ($static_products_in as $row) {
                    if (!is_array($row)) {
                        continue;
                    }
                    $product_id = isset($row['product_id']) ? absint((int) $row['product_id']) : 0;
                    $quantity = isset($row['quantity']) ? max(1, (int) $row['quantity']) : 1;
                    $variation_id = isset($row['variation_id']) ? absint((int) $row['variation_id']) : 0;
                    if ($product_id <= 0) {
                        continue;
                    }
                    $static_products[] = [
                        'product_id' => $product_id,
                        'variation_id' => $variation_id,
                        'quantity' => $quantity,
                    ];
                }

                $product_field = isset($incoming['product_field']) ? sanitize_key((string) $incoming['product_field']) : '';
                $quantity_field = isset($incoming['quantity_field']) ? sanitize_key((string) $incoming['quantity_field']) : '';
                $variation_field = isset($incoming['variation_field']) ? sanitize_key((string) $incoming['variation_field']) : '';
                $price_field = isset($incoming['price_field']) ? sanitize_key((string) $incoming['price_field']) : '';

                $order_status = isset($incoming['order_status']) ? sanitize_text_field((string) $incoming['order_status']) : 'wc-pending';
                if ($order_status !== '' && strncmp($order_status, 'wc-', 3) !== 0) {
                    $order_status = 'wc-' . ltrim($order_status, 'wc-');
                }
                $available_statuses = function_exists('wc_get_order_statuses')
                    ? array_keys((array) wc_get_order_statuses())
                    : [];
                if (!in_array($order_status, $available_statuses, true)) {
                    $order_status = 'wc-pending';
                }

                $create_customer = !empty($incoming['create_customer']);

                $customer_in = isset($incoming['customer']) && is_array($incoming['customer']) ? $incoming['customer'] : [];
                $billing_in = isset($customer_in['billing']) && is_array($customer_in['billing']) ? $customer_in['billing'] : [];
                $shipping_in = isset($customer_in['shipping']) && is_array($customer_in['shipping']) ? $customer_in['shipping'] : [];

                $customer = [
                    'email_field' => isset($customer_in['email_field']) ? sanitize_key((string) $customer_in['email_field']) : '',
                    'first_name_field' => isset($customer_in['first_name_field']) ? sanitize_key((string) $customer_in['first_name_field']) : '',
                    'last_name_field' => isset($customer_in['last_name_field']) ? sanitize_key((string) $customer_in['last_name_field']) : '',
                    'phone_field' => isset($customer_in['phone_field']) ? sanitize_key((string) $customer_in['phone_field']) : '',
                    'company_field' => isset($customer_in['company_field']) ? sanitize_key((string) $customer_in['company_field']) : '',
                    'billing' => [
                        'line1_field' => isset($billing_in['line1_field']) ? sanitize_key((string) $billing_in['line1_field']) : '',
                        'line2_field' => isset($billing_in['line2_field']) ? sanitize_key((string) $billing_in['line2_field']) : '',
                        'city_field' => isset($billing_in['city_field']) ? sanitize_key((string) $billing_in['city_field']) : '',
                        'state_field' => isset($billing_in['state_field']) ? sanitize_key((string) $billing_in['state_field']) : '',
                        'postcode_field' => isset($billing_in['postcode_field']) ? sanitize_key((string) $billing_in['postcode_field']) : '',
                        'country_field' => isset($billing_in['country_field']) ? sanitize_key((string) $billing_in['country_field']) : '',
                    ],
                    'shipping' => [
                        'use_billing' => !empty($shipping_in['use_billing']),
                        'line1_field' => isset($shipping_in['line1_field']) ? sanitize_key((string) $shipping_in['line1_field']) : '',
                        'line2_field' => isset($shipping_in['line2_field']) ? sanitize_key((string) $shipping_in['line2_field']) : '',
                        'city_field' => isset($shipping_in['city_field']) ? sanitize_key((string) $shipping_in['city_field']) : '',
                        'state_field' => isset($shipping_in['state_field']) ? sanitize_key((string) $shipping_in['state_field']) : '',
                        'postcode_field' => isset($shipping_in['postcode_field']) ? sanitize_key((string) $shipping_in['postcode_field']) : '',
                        'country_field' => isset($shipping_in['country_field']) ? sanitize_key((string) $shipping_in['country_field']) : '',
                    ],
                ];

                $metadata_in = isset($incoming['metadata']) && is_array($incoming['metadata']) ? $incoming['metadata'] : [];
                $metadata = [];
                foreach ($metadata_in as $row) {
                    if (!is_array($row)) {
                        continue;
                    }
                    $meta_key = isset($row['key']) ? sanitize_key((string) $row['key']) : '';
                    $field = isset($row['field']) ? sanitize_key((string) $row['field']) : '';
                    if ($meta_key === '' || $field === '') {
                        continue;
                    }
                    $metadata[] = [
                        'key' => $meta_key,
                        'field' => $field,
                    ];
                }

                return [
                    'enabled' => $enabled,
                    'product_mode' => $product_mode,
                    'static_products' => $static_products,
                    'product_field' => $product_field,
                    'quantity_field' => $quantity_field,
                    'variation_field' => $variation_field,
                    'price_field' => $price_field,
                    'order_status' => $order_status,
                    'create_customer' => $create_customer,
                    'customer' => $customer,
                    'metadata' => $metadata,
                ];

            case 'mailchimp':
                $existing_key = isset($existing['api_key']) ? (string) $existing['api_key'] : '';
                $remove_key = !empty($incoming['remove_api_key']);
                $api_key_raw = isset($incoming['api_key']) ? trim((string) $incoming['api_key']) : '';

                if ($remove_key) {
                    $api_key = '';
                } elseif ($api_key_raw !== '') {
                    $api_key = Secrets::encrypt($api_key_raw);
                } else {
                    $api_key = $existing_key;
                }

                $list_id = isset($incoming['list_id']) ? sanitize_text_field($incoming['list_id']) : '';
                $list_id = self::safe_substr($list_id, 0, 64);
                $double_opt_in = !empty($incoming['double_opt_in']);
                $email_field = isset($incoming['email_field']) ? sanitize_key($incoming['email_field']) : '';
                $first_name_field = isset($incoming['first_name_field']) ? sanitize_key($incoming['first_name_field']) : '';
                $last_name_field = isset($incoming['last_name_field']) ? sanitize_key($incoming['last_name_field']) : '';

                $tags = [];
                if (isset($incoming['tags']) && is_array($incoming['tags'])) {
                    foreach ($incoming['tags'] as $tag) {
                        $tag_value = sanitize_text_field((string) $tag);
                        if ($tag_value !== '') {
                            $tags[] = self::safe_substr($tag_value, 0, 50);
                        }
                    }
                } elseif (isset($existing['tags']) && is_array($existing['tags'])) {
                    foreach ($existing['tags'] as $tag) {
                        $tag_value = sanitize_text_field((string) $tag);
                        if ($tag_value !== '') {
                            $tags[] = self::safe_substr($tag_value, 0, 50);
                        }
                    }
                }

                $tags = array_slice(array_values(array_unique($tags)), 0, 20);

                return [
                    'enabled' => $enabled,
                    'api_key' => $api_key,
                    'list_id' => $list_id,
                    'double_opt_in' => $double_opt_in,
                    'email_field' => $email_field,
                    'first_name_field' => $first_name_field,
                    'last_name_field' => $last_name_field,
                    'tags' => $tags,
                ];

            case 'brevo':
                $existing_key = isset($existing['api_key']) ? (string) $existing['api_key'] : '';
                $remove_key = !empty($incoming['remove_api_key']);
                $api_key_raw = isset($incoming['api_key']) ? trim((string) $incoming['api_key']) : '';

                if ($remove_key) {
                    $api_key = '';
                } elseif ($api_key_raw !== '') {
                    $api_key = Secrets::encrypt($api_key_raw);
                } else {
                    $api_key = $existing_key;
                }

                // Brevo list ids are numeric; keep digits only.
                $list_id = isset($incoming['list_id']) ? preg_replace('/[^0-9]/', '', (string) $incoming['list_id']) : '';
                $list_id = self::safe_substr((string) $list_id, 0, 20);
                $email_field = isset($incoming['email_field']) ? sanitize_key($incoming['email_field']) : '';
                $first_name_field = isset($incoming['first_name_field']) ? sanitize_key($incoming['first_name_field']) : '';
                $last_name_field = isset($incoming['last_name_field']) ? sanitize_key($incoming['last_name_field']) : '';

                $tags = [];
                $tags_source = (isset($incoming['tags']) && is_array($incoming['tags']))
                    ? $incoming['tags']
                    : ((isset($existing['tags']) && is_array($existing['tags'])) ? $existing['tags'] : []);
                foreach ($tags_source as $tag) {
                    $tag_value = sanitize_text_field((string) $tag);
                    if ($tag_value !== '') {
                        $tags[] = self::safe_substr($tag_value, 0, 50);
                    }
                }
                $tags = array_slice(array_values(array_unique($tags)), 0, 20);

                return [
                    'enabled' => $enabled,
                    'api_key' => $api_key,
                    'list_id' => $list_id,
                    'email_field' => $email_field,
                    'first_name_field' => $first_name_field,
                    'last_name_field' => $last_name_field,
                    'tags' => $tags,
                ];

            case 'salesforce':
                $org_id = isset($incoming['org_id']) ? strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string) $incoming['org_id'])) : '';
                $org_id = self::safe_substr($org_id, 0, 18);
                $lead_source = isset($incoming['lead_source']) ? sanitize_text_field($incoming['lead_source']) : '';
                $lead_source = self::safe_substr($lead_source, 0, 80);
                $assignment_rule_id = isset($incoming['assignment_rule_id'])
                    ? strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string) $incoming['assignment_rule_id']))
                    : '';
                $assignment_rule_id = self::safe_substr($assignment_rule_id, 0, 18);
                $debug_email = isset($incoming['debug_email']) ? sanitize_email($incoming['debug_email']) : '';

                $mappings = [];
                if (isset($incoming['mappings']) && is_array($incoming['mappings'])) {
                    foreach ($incoming['mappings'] as $mapping) {
                        if (!is_array($mapping)) {
                            continue;
                        }

                        $sf_field = isset($mapping['salesforce_field'])
                            ? preg_replace('/[^A-Za-z0-9_]/', '', (string) $mapping['salesforce_field'])
                            : '';
                        $sf_field = $sf_field !== null ? $sf_field : '';
                        $sf_field = self::safe_substr($sf_field, 0, 80);
                        $form_field = isset($mapping['form_field']) ? sanitize_key($mapping['form_field']) : '';

                        if ($sf_field !== '' && $form_field !== '') {
                            $mappings[] = [
                                'salesforce_field' => $sf_field,
                                'form_field' => $form_field,
                            ];
                        }
                    }
                } elseif (isset($existing['mappings']) && is_array($existing['mappings'])) {
                    foreach ($existing['mappings'] as $mapping) {
                        if (!is_array($mapping)) {
                            continue;
                        }

                        $sf_field = isset($mapping['salesforce_field'])
                            ? preg_replace('/[^A-Za-z0-9_]/', '', (string) $mapping['salesforce_field'])
                            : '';
                        $sf_field = $sf_field !== null ? $sf_field : '';
                        $sf_field = self::safe_substr($sf_field, 0, 80);
                        $form_field = isset($mapping['form_field']) ? sanitize_key($mapping['form_field']) : '';

                        if ($sf_field !== '' && $form_field !== '') {
                            $mappings[] = [
                                'salesforce_field' => $sf_field,
                                'form_field' => $form_field,
                            ];
                        }
                    }
                }

                return [
                    'enabled' => $enabled,
                    'org_id' => $org_id,
                    'lead_source' => $lead_source,
                    'assignment_rule_id' => $assignment_rule_id,
                    'debug_email' => $debug_email,
                    'mappings' => array_slice($mappings, 0, 50),
                ];

            case 'hubspot':
                $existing_token = isset($existing['access_token']) ? (string) $existing['access_token'] : '';
                $remove_token = !empty($incoming['remove_access_token']);
                $token_input = isset($incoming['access_token']) ? trim((string) $incoming['access_token']) : '';

                if ($remove_token) {
                    $access_token = '';
                } elseif ($token_input !== '') {
                    $access_token = Secrets::encrypt($token_input);
                } else {
                    $access_token = $existing_token;
                }

                $portal_id = isset($incoming['portal_id']) ? preg_replace('/[^A-Za-z0-9]/', '', (string) $incoming['portal_id']) : '';
                $portal_id = self::safe_substr($portal_id, 0, 20);
                $form_guid = isset($incoming['form_guid']) ? preg_replace('/[^A-Fa-f0-9\\-]/', '', (string) $incoming['form_guid']) : '';
                $form_guid = self::safe_substr($form_guid, 0, 50);
                $email_field = isset($incoming['email_field']) ? sanitize_key($incoming['email_field']) : '';
                $legal_consent = !empty($incoming['legal_consent']);
                $consent_text = isset($incoming['consent_text'])
                    ? sanitize_textarea_field((string) $incoming['consent_text'])
                    : '';
                $consent_text = self::safe_substr($consent_text, 0, 4000);

                $field_mappings = [];
                if (isset($incoming['field_mappings']) && is_array($incoming['field_mappings'])) {
                    foreach ($incoming['field_mappings'] as $mapping) {
                        if (!is_array($mapping)) {
                            continue;
                        }

                        $hub_field = isset($mapping['hubspot_field'])
                            ? sanitize_key($mapping['hubspot_field'])
                            : '';
                        $form_field = isset($mapping['form_field']) ? sanitize_key($mapping['form_field']) : '';

                        if ($hub_field !== '' && $form_field !== '') {
                            $field_mappings[] = [
                                'hubspot_field' => $hub_field,
                                'form_field' => $form_field,
                            ];
                        }
                    }
                } elseif (isset($existing['field_mappings']) && is_array($existing['field_mappings'])) {
                    foreach ($existing['field_mappings'] as $mapping) {
                        if (!is_array($mapping)) {
                            continue;
                        }

                        $hub_field = isset($mapping['hubspot_field'])
                            ? sanitize_key($mapping['hubspot_field'])
                            : '';
                        $form_field = isset($mapping['form_field']) ? sanitize_key($mapping['form_field']) : '';

                        if ($hub_field !== '' && $form_field !== '') {
                            $field_mappings[] = [
                                'hubspot_field' => $hub_field,
                                'form_field' => $form_field,
                            ];
                        }
                    }
                }

                return [
                    'enabled' => $enabled,
                    'access_token' => $access_token,
                    'portal_id' => $portal_id,
                    'form_guid' => $form_guid,
                    'email_field' => $email_field,
                    'field_mappings' => array_slice($field_mappings, 0, 50),
                    'legal_consent' => $legal_consent,
                    'consent_text' => $consent_text,
                ];

            default:
                return [
                    'enabled' => $enabled,
                ] + $default;
        }
    }

    /**
     * Sanitise a single field definition.
     *
     * @param array<string, mixed> $field
     * @return array<string, mixed>
     */
    private static function sanitise_field(array $field): array
    {
        $type = isset($field['type']) ? sanitize_key($field['type']) : 'text';
        $supported = self::SUPPORTED_FIELD_TYPES;

        if (!in_array($type, $supported, true)) {
            $type = 'text';
        }

        // Debug logging - only when WP_DEBUG is enabled
        if (defined('WP_DEBUG') && WP_DEBUG && ($type === 'country' || $type === 'state')) {
            do_action('formatrica_debug_log', sprintf('[Sanitizer] Incoming field: type=%s name=%s', $type, (string) ($field['name'] ?? '')));
        }

        $name = isset($field['name']) ? sanitize_key($field['name']) : self::generate_field_name($type);

        $label_raw = isset($field['label']) ? (string) $field['label'] : '';
        $sanitised_label = self::sanitize_label($label_raw, $type);
        if ($sanitised_label === '' && !in_array($type, ['step_break', 'divider', 'heading', 'static_image'], true)) {
            $sanitised_label = ucfirst($type);
        }

        $sanitised = [
            'id' => isset($field['id']) ? sanitize_key($field['id']) : self::generate_field_id($type),
            // Stable, immutable identity. Preserve a valid incoming key; backfill
            // legacy/missing keys. Within-form uniqueness is enforced afterwards
            // by ensure_unique_keys() (handles duplicated fields sharing a key).
            'key' => Field_Key::is_valid($field['key'] ?? null) ? $field['key'] : Field_Key::generate(),
            'type' => $type,
            'name' => $name,
            'label' => $sanitised_label,
            'placeholder' => isset($field['placeholder']) ? sanitize_text_field($field['placeholder']) : '',
            'required' => isset($field['required']) ? (bool) $field['required'] : true,
            'options' => isset($field['options']) && is_array($field['options'])
                ? array_map('sanitize_text_field', $field['options'])
                : [],
            'multiple' => $type === 'select' ? !empty($field['multiple']) : false,
            'content' => $type === 'custom_text' ? wp_kses_post($field['content'] ?? '') : '',
            'accept' => in_array($type, ['file', 'multi_file'], true) ? sanitize_text_field($field['accept'] ?? '') : '',
            'validation' => self::sanitise_field_validation($field['validation'] ?? null),
            'layout' => self::sanitise_field_layout($field['layout'] ?? null),
            'logic' => self::sanitise_field_logic($field['logic'] ?? null),
        ];

        if (in_array($type, ['file', 'multi_file'], true) && isset($field['maxFileSize'])) {
            $maxFileSize = (int) $field['maxFileSize'];
            $sanitised['maxFileSize'] = max(1, min(50, $maxFileSize));
        }

        // Multi-file (Pro fields.multi_file): cap on the number of files.
        if ($type === 'multi_file') {
            $max_files = isset($field['maxFiles']) ? (int) $field['maxFiles'] : 5;
            $sanitised['maxFiles'] = max(1, min(20, $max_files));
        }

        // Rich text (Pro fields.richtext): editor height in rows.
        if ($type === 'richtext') {
            $rows = isset($field['rows']) ? (int) $field['rows'] : 8;
            $sanitised['rows'] = max(3, min(30, $rows));
        }

        // Calculation (Pro fields.calculation): parse the formula ONCE here and
        // store the canonical AST; the runtime walks the AST only (never evals
        // the source). scale/prefix/suffix drive display. An invalid formula is
        // kept verbatim with a null AST so the admin can fix it (the field just
        // computes nothing until then).
        // Price (Pro payments.basic): presentational — no submit value, never
        // required, logic force-nulled (an admin can't hide a shown price while
        // still charging it). The amount is validated to a decimal shape; the
        // authoritative currency re-check happens at pay time (Payment_Amount_Resolver).
        if ($type === 'price') {
            $raw_amount = isset($field['amount']) ? (string) $field['amount'] : '';
            $sanitised['amount']     = self::sanitise_payment_amount($raw_amount, 'USD');
            $sanitised['show_label'] = !isset($field['show_label']) || (bool) $field['show_label'];
            $sanitised['required']   = false;
            $sanitised['logic']      = ['enabled' => false];
        }

        if ($type === 'calculation') {
            $formula = isset($field['formula']) && is_string($field['formula']) ? trim($field['formula']) : '';
            $sanitised['formula'] = $formula;
            $sanitised['formula_ast'] = null;
            // The Pro formula parser ships only in Pro; Free stores the raw
            // formula with a null AST (Free cannot author calculation fields, and
            // the Pro engine re-parses when the licence is present).
            if ($formula !== '' && class_exists(\Formatrica\Pro\Calculation\Formula_Parser::class)) {
                try {
                    $sanitised['formula_ast'] = (new \Formatrica\Pro\Calculation\Formula_Parser())->parse($formula);
                } catch (\Formatrica\Pro\Calculation\Formula_Exception $e) {
                    $sanitised['formula_ast'] = null;
                }
            }
            $scale = isset($field['scale']) ? (int) $field['scale'] : 2;
            $sanitised['scale'] = max(0, min(4, $scale));
            $sanitised['prefix'] = isset($field['prefix']) ? sanitize_text_field((string) $field['prefix']) : '';
            $sanitised['suffix'] = isset($field['suffix']) ? sanitize_text_field((string) $field['suffix']) : '';
            $sanitised['required'] = false;
        }

        // Dynamic select (Pro fields.dynamic_select): a registered data-source id
        // + a small string param map. Options are resolved at render, not stored.
        if ($type === 'dynamic_select') {
            $sanitised['source'] = isset($field['source']) && is_string($field['source'])
                ? sanitize_key($field['source'])
                : '';
            $params = [];
            if (isset($field['source_params']) && is_array($field['source_params'])) {
                foreach ($field['source_params'] as $k => $v) {
                    if (is_string($k) && is_scalar($v)) {
                        $params[sanitize_key($k)] = sanitize_text_field((string) $v);
                    }
                }
            }
            $sanitised['source_params'] = $params;
        }

        // Country field properties - always include
        if ($type === 'country') {
            $mode = isset($field['woocommerce_mode']) && is_string($field['woocommerce_mode'])
                ? sanitize_text_field($field['woocommerce_mode'])
                : 'all';
            $allowed_modes = ['all', 'shipping', 'selling', 'both'];
            $sanitised['woocommerce_mode'] = in_array($mode, $allowed_modes, true) ? $mode : 'all';
        }

        // State field properties - always include
        if ($type === 'state') {
            $sanitised['country_field'] = isset($field['country_field']) && is_string($field['country_field'])
                ? sanitize_text_field($field['country_field'])
                : '';
            $sanitised['allow_text_input'] = isset($field['allow_text_input']) ? (bool) $field['allow_text_input'] : true;
        }

        if ($type === 'hidden') {
            if (isset($field['value'])) {
                $sanitised['value'] = sanitize_text_field($field['value']);
            }
            // A visitor can never fill a hidden input, so a required flag
            // would make the form unsubmittable when the value is empty.
            $sanitised['required'] = false;
        }

        // Step break (multi-step page divider): custom next/prev button labels
        // plus the first step's tab title (carried by the first break, since
        // no break precedes step 1 to name it).
        if ($type === 'step_break') {
            $sanitised['next_label'] = isset($field['next_label']) ? sanitize_text_field($field['next_label']) : '';
            $sanitised['prev_label'] = isset($field['prev_label']) ? sanitize_text_field($field['prev_label']) : '';
            $sanitised['first_step_label'] = isset($field['first_step_label']) ? sanitize_text_field($field['first_step_label']) : '';
        }

        // Divider (Pro fields.divider): line style + optional label above it.
        if ($type === 'divider') {
            $style = isset($field['style']) && is_string($field['style']) ? $field['style'] : 'solid';
            $sanitised['style'] = in_array($style, ['solid', 'dashed', 'dotted'], true) ? $style : 'solid';
            $sanitised['show_label'] = !empty($field['show_label']);
        }

        // Heading (Pro fields.heading): level h2–h4 + alignment.
        if ($type === 'heading') {
            $level = isset($field['level']) ? (int) $field['level'] : 2;
            $sanitised['level'] = in_array($level, [2, 3, 4], true) ? $level : 2;
            $align = isset($field['align']) && is_string($field['align']) ? $field['align'] : 'left';
            $sanitised['align'] = in_array($align, ['left', 'center', 'right'], true) ? $align : 'left';
        }

        // Rating (Pro fields.rating): star count + optional clear.
        if ($type === 'rating') {
            $max = isset($field['max']) ? (int) $field['max'] : 5;
            $sanitised['max'] = max(2, min(10, $max));
            $sanitised['allow_clear'] = !empty($field['allow_clear']);
        }

        // Static image (Pro fields.static_image): presentational, no submitted
        // value. src is guarded to a same-site path or same-origin https URL;
        // an image with a src but empty alt drops the src (accessibility).
        if ($type === 'static_image') {
            $src = self::sanitise_static_image_src(is_string($field['src'] ?? null) ? $field['src'] : '');
            $alt = isset($field['alt']) ? sanitize_text_field((string) $field['alt']) : '';
            if ($src !== '' && $alt === '') {
                $src = '';
            }
            $sanitised['src'] = $src;
            $sanitised['alt'] = $alt;
            $sanitised['caption'] = isset($field['caption']) ? sanitize_text_field((string) $field['caption']) : '';
            $sanitised['required'] = false;
        }

        // Geolocation (Pro fields.geolocation): consent notice/gate + capture
        // precision. The submitted value is validated JSON, handled at runtime.
        if ($type === 'geolocation') {
            $notice = isset($field['consent_notice']) ? sanitize_textarea_field((string) $field['consent_notice']) : '';
            $sanitised['consent_notice'] = mb_substr($notice, 0, 500);
            $sanitised['require_consent'] = !empty($field['require_consent']);
            $decimals = isset($field['precision_decimals']) ? (int) $field['precision_decimals'] : 6;
            $sanitised['precision_decimals'] = in_array($decimals, [3, 6], true) ? $decimals : 6;
        }

        // Custom HTML attributes (Pro: fields.custom_attrs) — only carried when
        // at least one allow-listed attribute survives.
        $attrs = self::sanitise_field_attrs($field['attrs'] ?? null);
        if ($attrs !== []) {
            $sanitised['attrs'] = $attrs;
        }

        // Number / range bounds. Kept as numeric (int or float) so the builder
        // and the renderer agree on min/max/step.
        if ($type === 'number' || $type === 'range') {
            foreach (['min', 'max', 'step'] as $prop) {
                if (isset($field[$prop]) && $field[$prop] !== '' && is_numeric($field[$prop])) {
                    $sanitised[$prop] = $field[$prop] + 0;
                }
            }
        }

        // Debug logging - only when WP_DEBUG is enabled
        if (defined('WP_DEBUG') && WP_DEBUG && ($type === 'country' || $type === 'state')) {
            do_action('formatrica_debug_log', sprintf('[Sanitizer] Sanitized field: type=%s name=%s id=%s', $type, (string) ($sanitised['name'] ?? ''), (string) ($sanitised['id'] ?? '')));
        }

        return $sanitised;
    }

    private static function sanitize_label(string $label, string $type): string
    {
        $label = trim($label);

        if ($label === '') {
            return '';
        }

        if ($type === 'checkbox') {
            return wp_kses($label, self::allowed_label_tags());
        }

        return sanitize_text_field($label);
    }

    /**
     * @return array<string, array<string, bool>>
     */
    private static function allowed_label_tags(): array
    {
        return [
            'a' => [
                'href' => true,
                'title' => true,
                'target' => true,
                'rel' => true,
            ],
        ];
    }

    /**
     * Guarantee every field carries a unique stable key within the form.
     *
     * A duplicated field arrives carrying the source field's key; the first
     * occurrence keeps it and any later collision gets a fresh key. Already
     * unique keys are left untouched so identities stay stable across saves.
     *
     * @param array<int, array<string, mixed>> $fields
     * @return array<int, array<string, mixed>>
     */
    private static function ensure_unique_keys(array $fields): array
    {
        $seen = [];

        foreach ($fields as $index => $field) {
            $key = $field['key'] ?? '';

            if (!Field_Key::is_valid($key) || isset($seen[$key])) {
                $key = Field_Key::generate();
                $fields[$index]['key'] = $key;
            }

            $seen[$key] = true;
        }

        return $fields;
    }

    /**
     * Sanitise a field's advanced-validation block to a stable shape:
     * { advanced_enabled: bool, rule: string, params: {n?, field_key?}, message: string }.
     * Only recognised param keys survive; the rule id is validated at runtime by
     * Rule_Validator (unknown ids pass), so no allow-list is enforced here (Pro
     * rule ids are preserved on downgrade, gated at save-time by P12).
     *
     * @param mixed $validation
     * @return array<string, mixed>
     */
    private static function sanitise_field_validation($validation): array
    {
        if (!is_array($validation)) {
            return ['advanced_enabled' => false, 'rule' => '', 'params' => [], 'message' => ''];
        }

        $in_params = isset($validation['params']) && is_array($validation['params']) ? $validation['params'] : [];
        $rule = isset($validation['rule']) && is_string($validation['rule']) ? sanitize_key($validation['rule']) : '';
        $params = self::sanitise_validation_params($in_params);

        return [
            'advanced_enabled' => !empty($validation['advanced_enabled']),
            'rule' => $rule,
            'params' => $params,
            'message' => isset($validation['message']) ? sanitize_text_field((string) $validation['message']) : '',
        ];
    }

    /**
     * Sanitise the params for any advanced validation rule (Free + Pro P12).
     * Each key is kept only in its safe shape. Password: a freshly-POSTed
     * plaintext passphrase is hashed here and never stored; otherwise the
     * builder round-trips the existing bcrypt hash (admin-only screen, validated
     * to a hash shape) so it survives a save without re-entering the passphrase.
     *
     * @param array<string, mixed> $in
     * @return array<string, mixed>
     */
    private static function sanitise_validation_params(array $in): array
    {
        $params = [];

        // Free params.
        if (isset($in['n'])) {
            $params['n'] = max(0, (int) $in['n']);
        }
        if (isset($in['field_key'])) {
            $params['field_key'] = sanitize_text_field((string) $in['field_key']);
        }

        // Pro params (P12).
        if (isset($in['charset']) && is_string($in['charset'])) {
            $params['charset'] = sanitize_text_field($in['charset']);
        }
        if (isset($in['pattern']) && is_string($in['pattern'])) {
            // Raw regex — the runtime hardens it. Strip control chars, cap length.
            $params['pattern'] = mb_substr(preg_replace('/[\x00-\x1F]/', '', $in['pattern']) ?? '', 0, 200);
        }
        if (isset($in['list']) && is_string($in['list'])) {
            $params['list'] = sanitize_textarea_field($in['list']);
        }
        if (isset($in['strict'])) {
            $params['strict'] = (bool) $in['strict'];
        }

        // password: hash a new passphrase; else keep a round-tripped bcrypt hash
        // (validated to a hash shape so only a real hash — never arbitrary text —
        // is stored). The plaintext passphrase is never persisted.
        if (isset($in['passphrase']) && is_string($in['passphrase']) && $in['passphrase'] !== '') {
            $params['hash'] = password_hash($in['passphrase'], PASSWORD_DEFAULT);
        } elseif (isset($in['hash']) && is_string($in['hash']) && preg_match('/^\$(2y|argon2)/', $in['hash']) === 1) {
            $params['hash'] = $in['hash'];
        }

        // multiplerules: recurse into nested rules (one level; no compound-in-compound).
        if (isset($in['rules']) && is_array($in['rules'])) {
            $nested = [];
            foreach ($in['rules'] as $r) {
                if (!is_array($r) || !isset($r['rule']) || !is_string($r['rule'])) {
                    continue;
                }
                $nr = sanitize_key($r['rule']);
                if ($nr === '' || $nr === 'multiplerules') {
                    continue;
                }
                $np = is_array($r['params'] ?? null) ? self::sanitise_validation_params($r['params']) : [];
                $nested[] = ['rule' => $nr, 'params' => $np];
            }
            $params['rules'] = $nested;
            $params['match'] = ($in['match'] ?? 'all') === 'any' ? 'any' : 'all';
        }

        return $params;
    }

    /**
     * Allowlist-sanitise the appearance theme block to a stable shape:
     * { preset, brand_color, mode }. Preset accepts the known set (Classic is
     * Free; the four others are Pro, gated at save by P17); brand_color must be
     * a 6-digit hex or empty; mode is light|dark|auto.
     *
     * @param mixed $theme
     * @return array{preset:string,brand_color:string,mode:string}
     */
    private static function sanitise_theme($theme): array
    {
        $theme = is_array($theme) ? $theme : [];

        $known_presets = ['classic', 'material', 'clean', 'card', 'underline'];
        $preset = isset($theme['preset']) && is_string($theme['preset']) ? sanitize_key($theme['preset']) : 'classic';
        if (!in_array($preset, $known_presets, true)) {
            $preset = 'classic';
        }

        $brand = isset($theme['brand_color']) && is_string($theme['brand_color']) ? trim($theme['brand_color']) : '';
        if ($brand !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $brand)) {
            $brand = '';
        }
        $brand = strtolower($brand);

        // no forcing: the preset's base
        // palette follows the page; light/dark force a scheme; auto follows
        // the visitor's OS preference.
        $modes = ['inherit', 'light', 'dark', 'auto'];
        $mode = isset($theme['mode']) && is_string($theme['mode']) ? sanitize_key($theme['mode']) : 'inherit';
        if (!in_array($mode, $modes, true)) {
            $mode = 'inherit';
        }

        return ['preset' => $preset, 'brand_color' => $brand, 'mode' => $mode];
    }

    /**
     * Sanitise a field's column-layout block to { width: full|half|third }.
     * full / half / third are all Free. Kept capability-blind so any saved
     * width is preserved and rendered.
     *
     * @param mixed $layout
     * @return array{width:string}
     */
    private static function sanitise_field_layout($layout): array
    {
        $width = is_array($layout) && isset($layout['width']) && is_string($layout['width']) ? $layout['width'] : 'full';
        return ['width' => in_array($width, ['full', 'half', 'third'], true) ? $width : 'full'];
    }

    /**
     * Sanitise the post-submit action to { type: same_page|url, url }. The URL
     * must be ON-SITE — an off-site or protocol-relative target is dropped and
     * the action falls back to same_page (open-redirect guard). Gated at save by
     * Submit_Redirect_Rule.
     *
     * @param mixed $value
     * @return array{type:string,url:string}
     */
    private static function sanitise_submit_action($value): array
    {
        if (!is_array($value)) {
            return ['type' => 'same_page', 'url' => ''];
        }

        $type = isset($value['type']) && $value['type'] === 'url' ? 'url' : 'same_page';
        $url = '';

        if ($type === 'url' && isset($value['url']) && is_scalar($value['url'])) {
            $url = self::sanitise_onsite_url(trim((string) $value['url']));
        }

        // A url action with no valid on-site target degrades to same_page.
        if ($type === 'url' && $url === '') {
            $type = 'same_page';
        }

        return ['type' => $type, 'url' => $url];
    }

    /**
     * Return the URL only if it targets this site (absolute same-host, or a
     * site-relative path). Everything else — off-site hosts, protocol-relative
     * "//host", junk — becomes ''.
     */
    private static function sanitise_onsite_url(string $raw): string
    {
        if ($raw === '') {
            return '';
        }

        // Site-relative path ("/thank-you"), but not protocol-relative ("//evil").
        if (strncmp($raw, '/', 1) === 0 && strncmp($raw, '//', 2) !== 0) {
            return esc_url_raw($raw);
        }

        $clean = esc_url_raw($raw);
        if ($clean === '') {
            return '';
        }

        $host = wp_parse_url($clean, PHP_URL_HOST);
        $home_host = wp_parse_url(home_url(), PHP_URL_HOST);

        if ($host && $home_host && strcasecmp($host, $home_host) === 0) {
            return $clean;
        }

        return '';
    }

    /**
     * Validate a Static Image `src` against the same-site allowlist. Accepts a
     * relative path under the document root (e.g. `wp-content/uploads/x.jpg`) or
     * an absolute http(s) URL on this site's own origin. Rejects external
     * schemes, javascript:/data:, traversal, protocol-relative "//host", null
     * bytes and HTML/JS sigils. Empty passes (image not yet picked).
     */
    private static function sanitise_static_image_src(string $raw): string
    {
        $value = trim($raw);
        if ($value === '') {
            return '';
        }

        // Reject null bytes, control chars, and HTML/JS sigils outright.
        if (preg_match('/[\x00-\x1F<>"\'`]/', $value) === 1) {
            return '';
        }

        // Reject traversal regardless of leading slash.
        if (preg_match('#(^|/)\.\.(/|$)#', $value) === 1) {
            return '';
        }

        // Explicit scheme present: allow only http(s) on this site's origin.
        if (preg_match('#^[a-z][a-z0-9+.\-]*:#i', $value) === 1) {
            if (!preg_match('#^https?://#i', $value)) {
                return '';
            }
            $clean = esc_url_raw($value);
            if ($clean === '') {
                return '';
            }
            $host = wp_parse_url($clean, PHP_URL_HOST);
            $home_host = wp_parse_url(home_url(), PHP_URL_HOST);
            if ($host && $home_host && strcasecmp($host, $home_host) === 0) {
                return $clean;
            }
            return '';
        }

        // Disallow protocol-relative "//host/...".
        if (strncmp($value, '//', 2) === 0) {
            return '';
        }

        // Relative path: strip a leading slash for a consistent storage shape.
        return ltrim($value, '/');
    }

    /**
     * Sanitise a space-separated CSS class list — each token through
     * sanitize_html_class, empties dropped. Used for the Pro wrapper class.
     *
     * @param mixed $value
     */
    /**
     * Keep a URL only when it is on this site (open-redirect guard). Empty or
     * off-site → ''. Used for the payment cancel redirect.
     */
    private static function sanitise_same_origin_url(string $url): string
    {
        $url = trim($url);
        if ($url === '') {
            return '';
        }

        $host = wp_parse_url($url, PHP_URL_HOST);
        $home = wp_parse_url(home_url('/'), PHP_URL_HOST);

        return is_string($host) && strcasecmp($host, (string) $home) === 0 ? esc_url_raw($url) : '';
    }

    /**
     * Currency support check via the payments engine when it ships; a plain
     * ISO-shape check when it doesn't (the wporg package strips app/Payments —
     * the stored value is inert there but must survive sanitisation).
     */
    private static function payment_currency_supported(string $currency): bool
    {
        if (class_exists(\Formatrica\Payments\Payment_Amount_Resolver::class)) {
            return \Formatrica\Payments\Payment_Amount_Resolver::is_supported($currency);
        }

        return preg_match('/^[A-Z]{3}$/', $currency) === 1;
    }

    /**
     * Currency-aware amount validation when the payments engine ships; a
     * decimal-shape check when it doesn't. Fail-closed: invalid clears.
     */
    private static function sanitise_payment_amount(string $raw, string $currency): string
    {
        if (class_exists(\Formatrica\Payments\Payment_Amount_Resolver::class)) {
            $validated = \Formatrica\Payments\Payment_Amount_Resolver::validate_configured_amount($raw, $currency);

            return $validated['error'] === null ? $validated['amount'] : '';
        }

        $raw = trim($raw);

        return preg_match('/^\d+(\.\d{1,4})?$/', $raw) === 1 ? $raw : '';
    }

    private static function sanitise_css_classes($value): string
    {
        if (!is_scalar($value)) {
            return '';
        }
        $tokens = preg_split('/\s+/', trim((string) $value)) ?: [];
        $clean = [];
        foreach ($tokens as $token) {
            $token = sanitize_html_class($token);
            if ($token !== '') {
                $clean[] = $token;
            }
        }
        return implode(' ', array_unique($clean));
    }

    /**
     * Sanitise a field's custom HTML attributes to an allow-listed name => value
     * map. Allowed: data-* / aria-* prefixes plus a fixed safe set. Rejected:
     * on* handlers, style, form-submit-hijack attrs, and anything else — enforced
     * here at SAVE (defence in depth with the renderer). Pro (fields.custom_attrs).
     *
     * @param mixed $value
     * @return array<string, string>
     */
    private static function sanitise_field_attrs($value): array
    {
        if (!is_array($value)) {
            return [];
        }

        $allow = ['autocomplete', 'disabled', 'dir', 'inputmode', 'lang', 'readonly', 'spellcheck', 'tabindex', 'title'];
        $out = [];

        foreach ($value as $name => $val) {
            if (!is_string($name) || $name === '') {
                continue;
            }
            $lname = strtolower(trim($name));
            $allowed = strncmp($lname, 'data-', 5) === 0
                || strncmp($lname, 'aria-', 5) === 0
                || in_array($lname, $allow, true);
            if (!$allowed) {
                continue;
            }
            if (!preg_match('/^[a-z0-9_:\-]+$/', $lname)) {
                continue;
            }
            $out[$lname] = sanitize_text_field(is_scalar($val) ? (string) $val : '');
        }

        ksort($out);
        return $out;
    }

    /**
     * Sanitise a field's conditional-logic block to a stable shape:
     * { enabled, action(show|hide), match(all|any), rules:[{field_key,operator,value}] }.
     * The 8 operators are allow-listed. Gated at save by Conditional_Logic_Rule;
     * shape kept stable so grandfathered logic survives a downgrade.
     *
     * @param mixed $logic
     * @return array<string, mixed>
     */
    private static function sanitise_field_logic($logic): array
    {
        if (!is_array($logic)) {
            return ['enabled' => false, 'action' => 'show', 'match' => 'all', 'rules' => []];
        }

        $operators = ['equals', 'not_equals', 'contains', 'not_contains', 'greater_than', 'less_than', 'is_empty', 'is_not_empty'];

        $rules = [];
        if (isset($logic['rules']) && is_array($logic['rules'])) {
            foreach ($logic['rules'] as $rule) {
                if (!is_array($rule)) {
                    continue;
                }
                $op = isset($rule['operator']) && is_string($rule['operator']) ? $rule['operator'] : 'equals';
                $rules[] = [
                    'field_key' => isset($rule['field_key']) ? sanitize_text_field((string) $rule['field_key']) : '',
                    'operator' => in_array($op, $operators, true) ? $op : 'equals',
                    'value' => isset($rule['value']) ? sanitize_text_field((string) $rule['value']) : '',
                ];
            }
        }

        return [
            'enabled' => !empty($logic['enabled']),
            'action' => (($logic['action'] ?? 'show') === 'hide') ? 'hide' : 'show',
            'match' => (($logic['match'] ?? 'all') === 'any') ? 'any' : 'all',
            'rules' => $rules,
        ];
    }

    private static function generate_field_name(string $type): string
    {
        return "{$type}_" . wp_generate_password(6, false, false);
    }

    private static function generate_field_id(string $type): string
    {
        return "{$type}-" . wp_generate_password(8, false, false);
    }

    private static function default_from_email(): string
    {
        $candidate_hosts = [
            wp_parse_url(home_url(), PHP_URL_HOST),
            wp_parse_url(site_url(), PHP_URL_HOST),
            isset($_SERVER['SERVER_NAME']) ? sanitize_text_field(wp_unslash($_SERVER['SERVER_NAME'])) : null,
            php_uname('n') ?: null,
        ];

        $domain = '';

        foreach ($candidate_hosts as $host) {
            if (is_string($host) && $host !== '') {
                $sanitised = sanitize_text_field($host);
                if ($sanitised !== '') {
                    $domain = $sanitised;
                    break;
                }
            }
        }

        $domain = preg_replace('/^www\\./i', '', $domain);
        $domain = preg_replace('/[^a-z0-9\\-\\.]/i', '', $domain);

        if ($domain === '') {
            $domain = 'example.com';
        }

        $email = sanitize_email('noreply@' . $domain);

        if (!is_email($email)) {
            $email = 'noreply@example.com';
        }

        return $email;
    }

    /**
     * Safe substring that works with or without mbstring extension.
     *
     * Falls back to substr() if mbstring is not available.
     * This prevents fatal errors on hosts without the mbstring PHP extension.
     *
     * @param string $string The input string
     * @param int $start Start position
     * @param int|null $length Maximum length
     * @return string The substring
     */
    private static function safe_substr(string $string, int $start, ?int $length = null): string
    {
        if (function_exists('mb_substr')) {
            return mb_substr($string, $start, $length);
        }

        if ($length === null) {
            return substr($string, $start);
        }

        return substr($string, $start, $length);
    }
}
