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

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

    private function __construct() {
        add_action('wp_ajax_formforge_submit', [$this, 'handle_submit']);
        add_action('wp_ajax_nopriv_formforge_submit', [$this, 'handle_submit']);
        // validate-first endpoint for Stripe. stripe-field.js POSTs the
        // full form payload here BEFORE confirmCardPayment; the server runs
        // the exact same gate + field validation handle_submit runs, but
        // saves nothing. Only a pass lets the client proceed to charge the
        // card. Closes the paid-but-not-submitted window: a submission that
        // would fail validation never reaches Stripe at all.
        add_action('wp_ajax_formforge_validate_submission', [$this, 'handle_validate_submission']);
        add_action('wp_ajax_nopriv_formforge_validate_submission', [$this, 'handle_validate_submission']);
    }

    /**
 * Build an XSS-safe AJAX error payload from a list of validation messages.
 * Each fragment is HTML-escaped before being <br>-joined: a malicious field
 * label or custom regex message stored in the form schema (by an editor or
 * a malicious import) must not execute when the client renders
 * r.data.message via jQuery .html in frontend.js.
 */
    public static function build_error_payload(array $errors): array {
        $safe = array_map('esc_html', $errors);
        return ['message' => implode('<br>', $safe), 'errors' => $safe];
    }

    private function private_upload_base() {
        $uploads = wp_upload_dir();
        $base = trailingslashit($uploads['basedir']) . 'formforge-private';
        if (!is_dir($base)) {
            wp_mkdir_p($base);
        }
        if (is_dir($base)) {
            $htaccess = trailingslashit($base) . '.htaccess';
            if (!file_exists($htaccess)) {
                file_put_contents($htaccess, "Deny from all\n");
            }
            $index = trailingslashit($base) . 'index.html';
            if (!file_exists($index)) {
                file_put_contents($index, '');
            }
        }
        return $base;
    }

    private function store_private_upload($file, $field) {
        // Ensure the private base dir (+ .htaccess deny + index.html) exists.
        $base = $this->private_upload_base();
        if (!$base || !is_dir($base)) {
            return new WP_Error('upload_dir_unwritable', __('Private upload directory is not writable.', 'formforge'));
        }

        $subdir   = gmdate('Y/m');
        $original = sanitize_file_name($file['name']);
        $ext      = strtolower(pathinfo($original, PATHINFO_EXTENSION));
        $stored   = wp_generate_uuid4() . ($ext ? '.' . $ext : '');

        // Route WordPress's own uploader into our private, .htaccess-protected
        // area (uploads/formforge-private/Y/m) and force the random UUID name.
        // wp_handle_upload — instead of a raw move_uploaded_file — is the
        // WordPress.org-compliant way to move an HTTP upload: it runs the
        // secure is_uploaded_file+move internally and creates the target dir.
        // FormForge already validates type/extension upstream (validate_fields),
        // so test_type is disabled to preserve the existing allow-list.
        $redirect = static function ($dirs) use ($base, $subdir) {
            $dirs['path']   = trailingslashit($base) . $subdir;
            $dirs['url']    = trailingslashit($dirs['baseurl'] ?? '') . 'formforge-private/' . $subdir;
            $dirs['subdir'] = '/' . $subdir;
            return $dirs;
        };
        $rename = static function () use ($stored) {
            return $stored;
        };

        if (!function_exists('wp_handle_upload')) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }

        add_filter('upload_dir', $redirect);
        $moved = wp_handle_upload(
            $file,
            [
                'test_form'                => false,
                'test_type'                => false,
                'unique_filename_callback' => $rename,
            ]
        );
        remove_filter('upload_dir', $redirect);

        if (!is_array($moved) || empty($moved['file']) || !empty($moved['error'])) {
            return new WP_Error('upload_failed', __('Could not store uploaded file.', 'formforge'));
        }

        // Tighten perms on the stored private file (best-effort; the .htaccess
        // deny rule is the primary guard). WP_Filesystem has no single-file
        // chmod primitive that avoids an FTP-credential prompt here.
        @chmod($moved['file'], 0640); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Best-effort perm tightening on an already-stored private file; .htaccess is the primary guard

        $uploads      = wp_upload_dir();
        $stored_name  = basename($moved['file']);
        $relative     = ltrim(str_replace(trailingslashit($uploads['basedir']), '', $moved['file']), '/');

        return [
            'token'         => wp_generate_password(32, false, false),
            'name'          => $original,
            'stored_name'   => $stored_name,
            'relative_path' => $relative,
            'mime'          => $moved['type'] ?? ($file['type'] ?? ''),
            'size'          => (int) ($file['size'] ?? 0),
            'field_id'      => (string) ($field['id'] ?? ''),
        ];
    }

    /**
 * shared request gate. Runs the nonce + origin + rate-limit +
 * form-lookup + anti-spam checks that BOTH handle_submit and the
 * validate-first endpoint must pass. On failure it emits the JSON error
 * (with the right HTTP status) and ends the request via wp_send_json_*;
 * on success it returns the loaded $form object.
 *
 * Centralised so the validate-first endpoint and the real submit can
 * never drift apart on what counts as a valid request.
 *
 * @param string $rl_action Rate-limit namespace ('submit' for the real
 * submit; the validate endpoint shares it so a
 * bot can't get 2x the budget by alternating).
 * @return object The loaded form.
 */
    private function require_valid_request($rl_action = 'submit') {
        $form_id = intval($_POST['formforge_form_id'] ?? 0);

        // 1. Nonce (cheap fail, protects against casual CSRF + logged-in replay).
        if (!wp_verify_nonce(isset($_POST['formforge_nonce']) ? sanitize_text_field(wp_unslash($_POST['formforge_nonce'])) : '', 'formforge_submit')) {
            wp_send_json_error(['message' => __('Security check failed.', 'formforge')]);
        }

        // 2. Origin / Referer check — reject cross-site POSTs. For anonymous
        // form submissions the WP nonce is shared between all guests
        // (UID=-1), so this is the main barrier that actually prevents an
        // attacker's own page from POSTing to our handler. Allowlist
        // available via `formforge_allowed_origins` filter for iframe embeds.
        if (!FORMFORGE_Anti_Spam::verify_origin()) {
            wp_send_json_error(['message' => __('Security check failed (origin).', 'formforge')], 403);
        }

        // 3. Rate limit per IP — 10 submits/min is plenty for a human filling
        // a form; bots spraying the endpoint exhaust their budget fast.
        // Runs BEFORE form lookup / DB writes so abuse doesn't hammer our DB.
        $ip = $this->get_client_ip();
        if (!FORMFORGE_Anti_Spam::check_rate_limit($ip, $rl_action, 10, 60)) {
            wp_send_json_error(['message' => __('Too many attempts. Please slow down.', 'formforge')], 429);
        }

        $form = FORMFORGE_Form_Builder::instance()->get($form_id);
        if (!$form) {
            wp_send_json_error(['message' => __('Form not found.', 'formforge')]);
        }

        // 4. Anti-spam (honeypot, timing, HMAC token verify, reCAPTCHA PRO).
        // Pass form_id so the token check verifies that the token was issued
        // for THIS form — prevents cross-form token reuse.
        $spam_check = FORMFORGE_Anti_Spam::instance()->check($_POST, $form_id);
        if ($spam_check !== true) {
            wp_send_json_error(['message' => $spam_check]);
        }

        return $form;
    }

    /**
 * validate-first endpoint. stripe-field.js calls this BEFORE it
 * confirms the card payment. It runs the identical request gate
 * (require_valid_request) and the identical field-level validation
 * (validate_fields) the real submit runs — but persists NOTHING and
 * contacts NO payment API. The payment field's own "payment_intent is
 * required" check is skipped here (the visitor hasn't paid yet — that's
 * the whole point), every OTHER required field and anti-spam rule is
 * enforced exactly as on final submit.
 *
 * Guarantee: if this returns an error, the client must not call
 * confirmCardPayment, so a submission that would fail validation never
 * results in a charge. If it returns success the client charges the card
 * and re-submits to handle_submit; the brief window between the two is
 * covered because handle_submit re-validates from scratch and, on the
 * rare race where validation passes here but fails there, the captured
 * charge is recorded on the submission's payment_status (see handle_submit).
 */
    public function handle_validate_submission() {
        $form = $this->require_valid_request('submit');

        $errors = $this->validate_fields($form, true /* skip payment_intent presence check */);

        if (!empty($errors['errors'])) {
            $payload = self::build_error_payload($errors['errors']);
            if (!empty($errors['payment_field_id'])) {
                $payload['payment_field_id'] = $errors['payment_field_id'];
            } elseif (!empty($errors['field_id'])) {
                $payload['field_id'] = $errors['field_id'];
            }
            wp_send_json_error($payload);
        }

        wp_send_json_success(['valid' => true]);
    }

    /**
 * field-level validation + data collection, extracted from
 * handle_submit so the validate-first endpoint shares ONE code path
 * with the real submit (no drift). Walks every field, runs required /
 * type / file / payment validation, and builds the $data array.
 *
 * @param object $form
 * @param bool $skip_payment_intent_check When true (validate-first
 * endpoint), a payment field with no posted payment_intent
 * does NOT produce a "required" error — the visitor pays
 * only after this passes. Every other field is validated
 * identically. handle_submit passes false so the real
 * submit still requires a completed payment.
 * @return array{errors:string,data:array,payment_status:string,payment_field_id:string,field_id:string}
 */
    private function validate_fields($form, $skip_payment_intent_check = false) {
        $fields = $form->fields;
        $data = [];
        $errors = [];
        $payment_status = '';
        $first_error_field_id = '';
        $add_error = function($message, $field_id = '') use (&$errors, &$first_error_field_id) {
            $errors[] = $message;
            if ($first_error_field_id === '' && $field_id !== '') {
                $first_error_field_id = (string) $field_id;
            }
        };
        // when a payment-related error fires, remember which field's id
        // it belongs to so the response can carry it back to the conversational
        // runtime. In conv mode the Stripe element lives on an earlier step
        // than the final "All done" step, so without this hint the visitor
        // sees no error at all — the toast/inline error sits on a hidden step.
        $payment_error_field_id = '';

        // Pre-compute which fields are hidden by conditional logic given the
        // posted values. The frontend.js runtime hides them via display:none,
        // but a tampered or non-browser client can still POST values for those
        // fields — and worse, leave them empty when they were configured as
        // required. Without this gate, a hidden+required field would trip
        // `is required` even though the visitor never saw it.
        
        // Skip hidden conditionally-hidden fields entirely from validation AND
        // saving. This matches how WPForms / Gravity / Ninja Forms handle it.
        $hidden_field_ids = $this->can_feature('conditional_logic') ? $this->compute_hidden_fields($form, $_POST) : []; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_valid_request.

        foreach ($fields as $field) {
            if (!FORMFORGE_Field_Types::field_type_available($field['type'] ?? '')) {
                continue;
            }

            if (in_array($field['type'] ?? '', ['heading', 'html_block', 'divider', 'page_break', 'paragraph'], true)) continue;

            // Conditional logic — skip hidden fields without validating them.
            if (!empty($field['id']) && in_array($field['id'], $hidden_field_ids, true)) {
                continue;
            }

            $field_name = 'formforge_field_' . ($field['id'] ?? '');

            // Handle repeater rows. The frontend names row inputs as
            // formforge_field_{field_id}_{row_index}_{sub_field_id}; collect
            // them back into row objects so notifications/webhooks/PDFs can
            // show a grouped repeater instead of losing every row.
            if (($field['type'] ?? '') === 'repeater') {
                $sub_fields = isset($field['sub_fields']) && is_array($field['sub_fields'])
                    ? $field['sub_fields']
                    : [];
                if (empty($sub_fields)) {
                    $sub_fields = [[
                        'id' => ($field['id'] ?? 'repeater') . '_sub',
                        'label' => __('Item', 'formforge'),
                        'type' => 'text',
                    ]];
                }

                $sub_by_id = [];
                foreach ($sub_fields as $sf) {
                    if (!is_array($sf) || empty($sf['id'])) continue;
                    $sub_by_id[(string) $sf['id']] = $sf;
                }

                $rows = [];
                $prefix_re = '/^' . preg_quote($field_name, '/') . '_(\d+)_([A-Za-z0-9_\-]+)$/';
                foreach ($_POST as $post_key => $raw_value) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_valid_request.
                    if (!preg_match($prefix_re, (string) $post_key, $m)) continue;
                    $row_index = (int) $m[1];
                    $sub_id = (string) $m[2];
                    $sub_field = $sub_by_id[$sub_id] ?? ['id' => $sub_id, 'type' => 'text', 'label' => $sub_id];
                    $value = FORMFORGE_Field_Types::sanitize($sub_field, wp_unslash($raw_value));
                    if ($value === '' || $value === null || $value === []) continue;
                    if (!isset($rows[$row_index])) $rows[$row_index] = [];
                    $rows[$row_index][$sub_id] = $value;
                }
                ksort($rows);
                $rows = array_values(array_filter($rows, function ($row) {
                    return is_array($row) && !empty($row);
                }));

                if (!empty($field['required']) && empty($rows)) {
                    /* translators: %s: field label */
                    $add_error(sprintf(__('%s is required.', 'formforge'), $field['label'] ?? __('Repeater', 'formforge')), $field['id'] ?? '');
                    continue;
                }

                foreach ($rows as $row) {
                    foreach ($sub_fields as $sf) {
                        if (empty($sf['required']) || empty($sf['id'])) continue;
                        $sub_id = (string) $sf['id'];
                        if (!array_key_exists($sub_id, $row) || $row[$sub_id] === '') {
                            /* translators: %s: field label */
                            $add_error(sprintf(__('%s is required.', 'formforge'), $sf['label'] ?? $sub_id), $field['id'] ?? '');
                            continue 3;
                        }
                    }
                }

                if (!empty($rows)) {
                    $data[$field['id'] ?? $field['label']] = [
                        'label' => $field['label'] ?? '',
                        'value' => $rows,
                        'type' => 'repeater',
                        'sub_fields' => $sub_fields,
                    ];
                }
                continue;
            }

            // Handle map_address fields — collect lat/lng/formatted
            if (($field['type'] ?? '') === 'map_address') {
                $address = sanitize_text_field(wp_unslash($_POST[$field_name] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_valid_request.
                $lat = sanitize_text_field(wp_unslash($_POST[$field_name . '_lat'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_valid_request.
                $lng = sanitize_text_field(wp_unslash($_POST[$field_name . '_lng'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_valid_request.
                $formatted = sanitize_text_field(wp_unslash($_POST[$field_name . '_formatted'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_valid_request.

                // B7: $required is not assigned in this branch (it's only set
                // in the generic field path further below). Read straight from
                // the field def so map_address required validation actually
                // fires instead of silently passing on missing addresses.
                if (!empty($field['required']) && empty($address)) {
                    /* translators: %s: field label */
                    $add_error(sprintf(__('%s is required.', 'formforge'), $field['label'] ?? 'Address'), $field['id'] ?? '');
                    continue;
                }
                if ($address !== '' && (!is_numeric($lat) || !is_numeric($lng))) {
                    $add_error(__('Please select a resolved map address or choose a point on the map.', 'formforge'), $field['id'] ?? '');
                    continue;
                }

                $data[$field['id'] ?? $field['label']] = [
                    'label' => $field['label'] ?? '',
                    'value' => wp_json_encode([
                        'address' => $address,
                        'lat' => $lat,
                        'lng' => $lng,
                        'formatted' => $formatted,
                    ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
                    'type' => 'map_address',
                ];
                continue;
            }

            // Handle payment fields — store payment intent ID
            if (($field['type'] ?? '') === 'payment') {
                $payment_intent = sanitize_text_field(wp_unslash($_POST[$field_name . '_payment_intent'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_valid_request.
                if ($payment_intent === '') {
                    // validate-first: on the pre-payment validation pass
                    // the visitor legitimately has no payment_intent yet — the
                    // whole point is to validate BEFORE charging. Skip only the
                    // payment field's presence check here; every other field
                    // (including other required fields) is still validated, so
                    // a form that would fail validation never reaches Stripe.
                    if ($skip_payment_intent_check) {
                        continue;
                    }
                    /* translators: %s: field label */
                    $add_error(sprintf(__('%s is required.', 'formforge'), $field['label'] ?? __('Payment', 'formforge')), $field['id'] ?? '');
                    // capture the payment field's id so the JSON error
                    // can tell conversational.js which step to jump back to.
                    if ($payment_error_field_id === '' && !empty($field['id'])) {
                        $payment_error_field_id = (string) $field['id'];
                    }
                    continue;
                }
                $payment_status = 'succeeded';
                // capture amount + currency at submit so the admin
                // View modal and PDF export can render meaningful payment
                // metadata BEFORE the webhook fires (webhook back-fills
                // $data['_payment'] with the authoritative Stripe-side
                // values; until then these defaults come from the field
                // definition or POST). Fixed/legacy stores cents directly;
                // dynamic mode trusts the value already validated by the
                // create-payment-intent handler.
                $field_currency = strtolower(sanitize_text_field($field['currency'] ?? 'usd'));
                $amount_type = (string) ($field['amount_type'] ?? '');
                $field_amount_cents = 0;
                if ($amount_type === 'fixed') {
                    $field_amount_cents = (int) round(((float) ($field['amount_value'] ?? 0)) * 100);
                } elseif ($amount_type === 'dynamic') {
                    $field_amount_cents = absint(wp_unslash($_POST[$field_name . '_amount_cents'] ?? 0)); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_valid_request.
                    if ($field_amount_cents <= 0) {
                        $field_amount_cents = absint(wp_unslash($_POST['amount'] ?? 0)); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_valid_request.
                    }
                } else {
                    $field_amount_cents = (int) ($field['amount'] ?? 0);
                }
                $data[$field['id'] ?? $field['label']] = [
                    'label' => $field['label'] ?? __('Payment', 'formforge'),
                    'value' => $payment_intent,
                    'type' => 'payment',
                    'amount' => $field_amount_cents,
                    'currency' => $field_currency,
                ];
                if (!isset($data['_payment'])) {
                    $data['_payment'] = [
                        'status'     => 'succeeded',
                        'payment_id' => $payment_intent,
                        'amount'     => $field_amount_cents,
                        'currency'   => $field_currency,
                        'updated_at' => current_time('c'),
                    ];
                }
                continue;
            }

            // Handle file uploads separately
            if (($field['type'] ?? '') === 'file_upload') {
                if (!FORMFORGE_License::instance()->can('file_uploads')) {
                    /* translators: %s: field label */
                    $add_error(sprintf(__('%s requires Form Forge Pro and cannot accept uploads on the current plan.', 'formforge'), $field['label'] ?? __('File upload', 'formforge')), $field['id'] ?? '');
                    continue;
                }

                if (!empty($_FILES[$field_name]) && (int) ($_FILES[$field_name]['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified in require_valid_request; $_FILES upload error code cast to int.
                    $file = $_FILES[$field_name]; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified in require_valid_request; $_FILES path validated by upload routine.

                    if ($file['error'] !== UPLOAD_ERR_OK) {
                        /* translators: %s: field label */
                        $add_error(sprintf(__('%s: file upload error.', 'formforge'), $field['label'] ?? 'File'), $field['id'] ?? '');
                        continue;
                    }

                    // File size limit. Per-field override via `max_size_mb`,
                    // capped at 100 MB regardless (server can't deal with
                    // larger uploads on shared hosting). Default = 10 MB
                    // (legacy behaviour, matches docs).
                    $configured_mb = isset($field['max_size_mb']) && (int)$field['max_size_mb'] > 0
                        ? min(100, (int)$field['max_size_mb'])
                        : 10;
                    $max_size = $configured_mb * 1024 * 1024;
                    if ($file['size'] > $max_size) {
                        /* translators: 1: field label, 2: maximum file size in MB */
                        $add_error(sprintf(__('%1$s must be smaller than %2$dMB.', 'formforge'), $field['label'] ?? 'File', $configured_mb), $field['id'] ?? '');
                        continue;
                    }

                    // Sanitize filename
                    $file['name'] = sanitize_file_name($file['name']);

                    // Server-side MIME type validation. If the field has a
                    // custom `accept` string, we derive the allowlist from
                    // it; otherwise we fall back to the safe default set.
                    // The accept attribute can carry MIME types
                    // (`image/jpeg`) and/or file extensions (`.pdf`).
                    $default_allowed = [
                        'image/jpeg', 'image/png', 'image/gif', 'image/webp',
                        'application/pdf',
                        'application/msword',
                        'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
                        'text/plain', 'text/csv',
                    ];
                    $accept_csv = isset($field['accept']) ? trim((string) $field['accept']) : '';
                    $allowed_types = $default_allowed;
                    $allowed_exts = [];
                    if ($accept_csv !== '') {
                        $allowed_types = [];
                        $tokens = array_map('trim', explode(',', $accept_csv));
                        foreach ($tokens as $tok) {
                            if ($tok === '') continue;
                            if ($tok[0] === '.') {
                                $allowed_exts[] = strtolower(ltrim($tok, '.'));
                            } else {
                                $allowed_types[] = $tok;
                            }
                        }
                    }
                    $file_type = wp_check_filetype_and_ext($file['tmp_name'], $file['name']);
                    $detected_type = $file_type['type'] ?? '';
                    $detected_ext  = isset($file_type['ext']) ? strtolower((string)$file_type['ext']) : '';

                    // FF additional: hard-deny set ALWAYS wins over the per-form
                    // accept allowlist. SVG carries inline JS that runs when the
                    // file URL is opened directly; HTML/PHP/etc. execute or
                    // template-render in admin contexts. wp_check_filetype_and_ext
                    // does NOT sanitize SVG/HTML content, so even if an admin
                    // adds .svg to a field's accept settings we still block it.
                    $hard_deny_exts = ['svg', 'svgz', 'html', 'htm', 'xhtml', 'xml', 'phtml', 'phar', 'php', 'php3', 'php4', 'php5', 'php7', 'js', 'mjs', 'cjs', 'jsp', 'asp', 'aspx', 'cgi', 'pl', 'sh', 'exe', 'bat'];
                    $hard_deny_types = ['image/svg+xml', 'text/html', 'application/xhtml+xml', 'application/xml', 'text/xml', 'application/x-httpd-php', 'application/x-php', 'application/javascript', 'text/javascript'];
                    $name_ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
                    if (in_array($name_ext, $hard_deny_exts, true)
                        || in_array($detected_ext, $hard_deny_exts, true)
                        || in_array($detected_type, $hard_deny_types, true)) {
                        /* translators: %s: field label */
                        $add_error(sprintf(__('%s: this file type is not permitted.', 'formforge'), $field['label'] ?? 'File'), $field['id'] ?? '');
                        continue;
                    }

                    $type_ok = false;
                    if (!empty($allowed_types)) {
                        // Allow exact match or wildcard like `image/*`.
                        foreach ($allowed_types as $allowed) {
                            if ($allowed === $detected_type) { $type_ok = true; break; }
                            if (substr($allowed, -2) === '/*' && $detected_type !== '' && strpos($detected_type, substr($allowed, 0, -1)) === 0) {
                                $type_ok = true; break;
                            }
                        }
                    }
                    if (!$type_ok && !empty($allowed_exts) && in_array($detected_ext, $allowed_exts, true)) {
                        $type_ok = true;
                    }
                    if (!$type_ok) {
                        /* translators: %s: field label */
                        $add_error(sprintf(__('%s: file type not allowed.', 'formforge'), $field['label'] ?? 'File'), $field['id'] ?? '');
                        continue;
                    }

                    $stored = $this->store_private_upload($file, $field);
                    if (is_wp_error($stored)) {
                        /* translators: 1: field label, 2: error message */
                        $add_error(sprintf(__('%1$s: %2$s', 'formforge'), $field['label'] ?? 'File', $stored->get_error_message()), $field['id'] ?? '');
                        continue;
                    }

                    $data[$field['id'] ?? $field['label']] = [
                        'label' => $field['label'] ?? '',
                        'value' => $stored['name'],
                        'type'  => 'file_upload',
                        'file'  => $stored,
                    ];
                } elseif (!empty($field['required'])) {
                    /* translators: %s: field label */
                    $add_error(sprintf(__('%s is required.', 'formforge'), $field['label'] ?? 'File'), $field['id'] ?? '');
                }
                continue;
            }

            // Compose values for split-input field types so the captured
            // submission actually contains what the visitor typed. Renderer
            // outputs `name` as `{name}_first`/`{name}_last`; `address` as
            // `_street`/`_city`/`_zip`. Without this branch the handler read
            // `$_POST['formforge_field_fX']` (a key that never exists) and
            // saved the field with an empty string — silently losing data.
            if (($field['type'] ?? '') === 'name') {
                $first = sanitize_text_field(wp_unslash($_POST[$field_name . '_first'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_valid_request.
                $last  = sanitize_text_field(wp_unslash($_POST[$field_name . '_last']  ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_valid_request.
                $value = trim($first . ' ' . $last);
                if (!empty($field['required']) && $value === '') {
                    /* translators: %s: field label */
                    $add_error(sprintf(__('%s is required.', 'formforge'), $field['label'] ?? 'Name'), $field['id'] ?? '');
                    continue;
                }
                $data[$field['id'] ?? $field['label']] = [
                    'label' => $field['label'] ?? '',
                    'value' => $value,
                    'first' => $first,
                    'last'  => $last,
                    'type'  => 'name',
                ];
                continue;
            }

            if (($field['type'] ?? '') === 'address') {
                $street = sanitize_text_field(wp_unslash($_POST[$field_name . '_street'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_valid_request.
                $city   = sanitize_text_field(wp_unslash($_POST[$field_name . '_city']   ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_valid_request.
                $zip    = sanitize_text_field(wp_unslash($_POST[$field_name . '_zip']    ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_valid_request.
                $parts  = array_filter([$street, $city, $zip], 'strlen');
                $value  = implode(', ', $parts);
                if (!empty($field['required']) && $value === '') {
                    /* translators: %s: field label */
                    $add_error(sprintf(__('%s is required.', 'formforge'), $field['label'] ?? 'Address'), $field['id'] ?? '');
                    continue;
                }
                $data[$field['id'] ?? $field['label']] = [
                    'label'  => $field['label'] ?? '',
                    'value'  => $value,
                    'street' => $street,
                    'city'   => $city,
                    'zip'    => $zip,
                    'type'   => 'address',
                ];
                continue;
            }

            $value = isset($_POST[$field_name]) ? wp_unslash($_POST[$field_name]) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified in require_valid_request; value sanitized per field type below via FORMFORGE_Field_Types::sanitize.

            // Sanitize
            $value = FORMFORGE_Field_Types::sanitize($field, $value);

            // Validate
            $valid = FORMFORGE_Field_Types::validate($field, $value);
            if ($valid !== true) {
                $add_error($valid, $field['id'] ?? '');
                continue;
            }

            $data[$field['id'] ?? $field['label']] = [
                'label' => $field['label'] ?? '',
                'value' => $value,
                'type' => $field['type'] ?? 'text',
            ];
        }

        return [
            'errors'           => $errors,
            'data'             => $data,
            'payment_status'   => $payment_status,
            'payment_field_id' => $payment_error_field_id,
            'field_id'         => $first_error_field_id,
        ];
    }

    public function handle_submit() {
        $form    = $this->require_valid_request('submit');
        $form_id = (int) $form->id;

        $abandonment_session = $this->sanitize_session_id($_POST['formforge_abandonment_session'] ?? ''); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified in require_valid_request; value unslashed + sanitized in sanitize_session_id.

        // identical field validation the validate-first endpoint runs
        // (skip flag = false here → a completed payment_intent IS required on
        // the real submit). Shared code path, so the two can never drift.
        $validation     = $this->validate_fields($form, false);
        $errors         = $validation['errors'];
        $data           = $validation['data'];
        $payment_status = $validation['payment_status'];

        if (!empty($errors)) {
            $error_payload = self::build_error_payload($errors);
            // surface the offending payment field id so the
            // conversational runtime can jump back to the step that hosts the
            // Stripe element. Plain (non-conversational) mode ignores this
            // key, so it's safe to always include when set.
            if ($validation['payment_field_id'] !== '') {
                $error_payload['payment_field_id'] = $validation['payment_field_id'];
            } elseif ($validation['field_id'] !== '') {
                $error_payload['field_id'] = $validation['field_id'];
            }
            // if the validation that failed was on a submission that
            // ALREADY carries a captured Stripe charge (the rare race where
            // validate-first passed, the card was charged, then this final
            // validation still failed — e.g. a token expiring in the seconds
            // between the two calls, or a concurrent edit to the form), we
            // must NOT silently drop the payment. Persist a row flagged
            // payment_status='captured_unsubmitted' so the charge is
            // admin-visible and reconcilable instead of lost. With
            // validate-first in place this is a near-impossible window, but
            // money-safety code records it rather than trusting it can't happen.
            $orphan_intent = '';
            foreach ($data as $maybe) {
                if (is_array($maybe) && ($maybe['type'] ?? '') === 'payment' && !empty($maybe['value'])) {
                    $orphan_intent = (string) $maybe['value'];
                    break;
                }
            }
            if ($orphan_intent === '') {
                $orphan_intent = sanitize_text_field(wp_unslash($_POST['formforge_field_payment_payment_intent'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_valid_request.
            }
            if ($orphan_intent !== '') {
                global $wpdb;
                $wpdb->insert($wpdb->prefix . 'formforge_submissions', [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query is intended.
                    'form_id'        => $form_id,
                    'data'           => wp_json_encode([
                        '_payment' => [
                            'status'      => 'captured_unsubmitted',
                            'payment_id'  => $orphan_intent,
                            'updated_at'  => current_time('c'),
                            'note'        => 'Charge captured but submission failed final validation — reconcile manually.',
                        ],
                        '_validation_errors' => $errors,
                    ]),
                    'ip_address'     => $this->get_client_ip(),
                    'user_agent'     => sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'] ?? '')),
                    'user_id'        => get_current_user_id(),
                    'status'         => 'new',
                    'session_id'     => $abandonment_session,
                    'payment_status' => 'captured_unsubmitted',
                ]);
            }
            wp_send_json_error($error_payload);
        }

        // Save submission
        global $wpdb;
        $inserted = $wpdb->insert($wpdb->prefix . 'formforge_submissions', [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query is intended.
            'form_id' => $form_id,
            'data' => wp_json_encode($data),
            'ip_address' => $this->get_client_ip(),
            'user_agent' => sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'] ?? '')),
            'user_id' => get_current_user_id(),
            'status' => 'new',
            'session_id' => $abandonment_session,
            'payment_status' => $payment_status,
        ]);
        $submission_id = $wpdb->insert_id;
        if ($inserted === false || (int) $submission_id <= 0) {
            // persistence failed AFTER a payment may have been
            // captured. Don't return a bare "try again" that loses the
            // charge — record an admin-visible orphan row keyed to the
            // payment intent so the money is traceable. (Best-effort: if
            // this insert also fails there's nothing more we can do from
            // here, but the Stripe dashboard + webhook still have the intent.)
            if ($payment_status === 'succeeded' && isset($data['_payment']['payment_id'])) {
                $wpdb->insert($wpdb->prefix . 'formforge_submissions', [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query is intended.
                    'form_id'        => $form_id,
                    'data'           => wp_json_encode([
                        '_payment' => array_merge(
                            (array) $data['_payment'],
                            ['status' => 'captured_unsubmitted', 'note' => 'Payment captured but submission row failed to save — reconcile manually.']
                        ),
                    ]),
                    'ip_address'     => $this->get_client_ip(),
                    'user_agent'     => sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'] ?? '')),
                    'user_id'        => get_current_user_id(),
                    'status'         => 'new',
                    'session_id'     => $abandonment_session,
                    'payment_status' => 'captured_unsubmitted',
                ]);
            }
            wp_send_json_error(['message' => __('We could not save your submission. Please try again.', 'formforge')]);
        }

        if ($abandonment_session !== '') {
            $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query is intended.
                $wpdb->prefix . 'formforge_submissions',
                [
                    'form_id' => $form_id,
                    'session_id' => $abandonment_session,
                    'status' => 'abandoned',
                ],
                ['%d', '%s', '%s']
            );

            // r2 / race fix: after we delete any existing
            // abandoned row, plant a short-lived completion marker. Any
            // partial_save ping that arrives later for this session_id —
            // e.g. an inactivity timer that already fired in the browser
            // before the submit AJAX resolved, or a sendBeacon queued by
            // pagehide on the redirect — will see the marker and silently
            // drop instead of resurrecting an abandoned row.
            if (class_exists('FORMFORGE_Abandonment')) {
                FORMFORGE_Abandonment::mark_session_completed($form_id, $abandonment_session);
            }
        }

        // Track submission. Analytics is Pro — guard for Free-only sites so
        // the submit path never fatals when formforge-pro isn't installed.
        if (class_exists('FORMFORGE_Analytics') && $this->can_feature('analytics')) {
            FORMFORGE_Analytics::instance()->track($form_id, 'submit');
        }

        // Send notifications
        FORMFORGE_Notifications::instance()->send($form, $data, $submission_id);

        // Generic webhooks are Free and live in the Free core. Branded
        // integrations below remain in formforge-pro and self-gate by feature.
        if (class_exists('FORMFORGE_Webhooks')) {
            FORMFORGE_Webhooks::instance()->fire_webhook($form, $data, $submission_id);
        }

        if (class_exists('FORMFORGE_Integrations')) {
            $integrations = FORMFORGE_Integrations::instance();

            // Telegram notification
            $integrations->fire_telegram($form, $data, $submission_id);

            // WhatsApp notification (FC-44)
            $integrations->fire_whatsapp($form, $data, $submission_id);

            // Slack notification
            $integrations->fire_slack($form, $data, $submission_id);

            // Discord notification
            $integrations->fire_discord($form, $data, $submission_id);

            // Mailchimp subscribe
            $integrations->fire_mailchimp($form, $data, $submission_id);

            // HubSpot CRM
            $integrations->fire_hubspot($form, $data, $submission_id);

            // Google Sheets
            $integrations->fire_google_sheets($form, $data, $submission_id);

            // Google Calendar — create event for booking forms
            $integrations->fire_google_calendar($form, $data, $submission_id);
        }

        // Post Submission (if enabled in form settings)
        $settings = $form->settings;
        if (!empty($settings['post_submission_enabled']) && FORMFORGE_License::instance()->can('post_submission')) {
            $title_field = $settings['post_title_field'] ?? '';
            $content_field = $settings['post_content_field'] ?? '';

            $post_title = '';
            $post_content = '';

            if ($title_field && isset($data[$title_field])) {
                $post_title = FORMFORGE_Value_Formatter::text($data[$title_field]['value'] ?? '', '');
            }
            if ($content_field && isset($data[$content_field])) {
                $post_content = FORMFORGE_Value_Formatter::html($data[$content_field]['value'] ?? '', '');
            }

            // Fallback: if no title mapped, use form title + submission ID
            if (empty($post_title)) {
                $post_title = sprintf('%s — #%d', $form->title, $submission_id);
            }

            // Fallback: if no content mapped, compile all fields
            if (empty($post_content)) {
                $parts = [];
                foreach ($data as $field) {
                    $val = FORMFORGE_Value_Formatter::text($field['value'] ?? '', '');
                    if ($val !== '') {
                        $parts[] = sprintf('<strong>%s:</strong> %s', esc_html($field['label'] ?? ''), nl2br(esc_html($val)));
                    }
                }
                $post_content = implode("\n", $parts);
            }

            $allowed_post_status = ['draft', 'publish', 'pending', 'private'];
            $ps_status = sanitize_key($settings['post_submission_status'] ?? 'draft');
            if (!in_array($ps_status, $allowed_post_status, true)) {
                $ps_status = 'draft';
            }

            $post_data = [
                'post_title'   => sanitize_text_field($post_title),
                'post_content' => wp_kses_post($post_content),
                'post_status'  => $ps_status,
                'post_type'    => sanitize_key($settings['post_submission_type'] ?? 'post'),
                'post_author'  => get_current_user_id() ?: 1,
            ];

            $new_post_id = wp_insert_post($post_data);

            // Store reference in submission meta
            if ($new_post_id && !is_wp_error($new_post_id)) {
                update_post_meta($new_post_id, '_formforge_submission_id', $submission_id);
                update_post_meta($new_post_id, '_formforge_form_id', $form->id);
            }
        }

        // User Registration (if enabled in form settings)
        if (!empty($settings['create_user']) && FORMFORGE_License::instance()->can('user_registration')) {
            $email = '';
            $name = '';
            $email_field = sanitize_key($settings['user_email_field'] ?? '');
            $name_field = sanitize_key($settings['user_name_field'] ?? '');
            if ($email_field && isset($data[$email_field])) {
                $email = FORMFORGE_Value_Formatter::text($data[$email_field]['value'] ?? '', '');
            }
	            if ($name_field && isset($data[$name_field])) {
	                $name = FORMFORGE_Value_Formatter::text($data[$name_field]['value'] ?? '', '');
	            }
	            foreach ($data as $field) {
	                if ($field['type'] === 'email' && !$email) $email = FORMFORGE_Value_Formatter::text($field['value'] ?? '', '');
	                if (in_array($field['type'], ['name', 'text'], true) && !$name) $name = FORMFORGE_Value_Formatter::text($field['value'] ?? '', '');
	            }
	            $email = sanitize_email($email);
	            if ($email && is_email($email) && !email_exists($email)) {
	                $username = sanitize_user(strtolower(explode('@', $email)[0]));
                if (username_exists($username)) $username .= wp_rand(100, 999);
                $password = wp_generate_password(12);
                $role = sanitize_text_field($settings['user_role'] ?? 'subscriber');
                if (!in_array($role, ['subscriber', 'contributor', 'author'], true)) {
                    $role = 'subscriber';
                }
                $user_id = wp_create_user($username, $password, $email);
                if (!is_wp_error($user_id)) {
                    wp_update_user(['ID' => $user_id, 'display_name' => $name ?: $username, 'role' => $role]);
                    wp_new_user_notification($user_id, null, 'user');
                }
            }
        }

        $response = $this->build_submit_response($form, $settings);

        wp_send_json_success($response);
    }

    private function build_submit_response($form, $settings) {
        $action = sanitize_key($settings['submit_action'] ?? '');
        if (!in_array($action, ['message', 'redirect', 'custom_js'], true)) {
            $action = !empty($settings['redirect_url']) ? 'redirect' : 'message';
        }

        $success_message = $settings['success_message'] ?? __('Thank you!', 'formforge');
        $success_message = FORMFORGE_Form_Renderer::translate_string(
            $success_message, 'success_message', '', (int) $form->id
        );

        $response = [
            'message' => $success_message,
            'redirect' => '',
            'actions' => [],
            'submit_action' => $action,
        ];

        if ($action === 'redirect') {
            $response['redirect'] = $this->safe_redirect_url($settings['redirect_url'] ?? '');
        } elseif ($action === 'custom_js') {
            $response['actions'] = $this->parse_safe_submit_actions($settings['custom_js'] ?? '');
        }

        return $response;
    }

    private function safe_redirect_url($url) {
        $url = trim((string) $url);
        if ($url === '') return '';
        if (preg_match('/^(javascript|data|vbscript):/i', $url) || strpos($url, '//') === 0) {
            return '';
        }
        if ($url[0] === '/') {
            return esc_url_raw($url);
        }

        $clean = esc_url_raw($url);
        if ($clean === '') return '';
        $home = wp_parse_url(home_url('/'));
        $target = wp_parse_url($clean);
        if (empty($target['host']) || empty($home['host'])) return '';
        if (strtolower($target['host']) !== strtolower($home['host'])) return '';
        return $clean;
    }

    private function parse_safe_submit_actions($script) {
        $script = trim((string) $script);
        if ($script === '') return [];
        if (preg_match('/fetch\s*\(|XMLHttpRequest|eval\s*\(|Function\s*\(|<|>|script|innerHTML|outerHTML|document\.cookie|localStorage|sessionStorage|https?:|\/\/|\bwindow\b|=/i', $script)) {
            return [];
        }

        $actions = [];
        $patterns = [
            '/document\.getElementById\(\s*[\'"]([A-Za-z][A-Za-z0-9_\-:\.]*)[\'"]\s*\)\s*\.\s*(?:classList\.)?(add|remove|toggle|addClass|removeClass|toggleClass)\(\s*[\'"]([A-Za-z0-9_\-]+)[\'"]\s*\)/i',
            '/document\.querySelector\(\s*[\'"]([#.][A-Za-z0-9_\-:\.]+)[\'"]\s*\)\s*\.\s*classList\.(add|remove|toggle)\(\s*[\'"]([A-Za-z0-9_\-]+)[\'"]\s*\)/i',
        ];

        foreach ($patterns as $idx => $pattern) {
            if (!preg_match_all($pattern, $script, $matches, PREG_SET_ORDER)) continue;
            foreach ($matches as $match) {
                $selector = $idx === 0 ? '#' . $match[1] : $match[1];
                $method = strtolower($match[2]);
                $class_name = sanitize_html_class($match[3]);
                if ($class_name === '') continue;
                if ($method === 'addclass') $method = 'add';
                if ($method === 'removeclass') $method = 'remove';
                if ($method === 'toggleclass') $method = 'toggle';
                if (!in_array($method, ['add', 'remove', 'toggle'], true)) continue;
                $actions[] = [
                    'type' => $method . '_class',
                    'selector' => $selector,
                    'value' => $class_name,
                ];
            }
        }

        return array_values($actions);
    }

    private function get_client_ip() {
        $cf = sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''));
        if ($cf && filter_var($cf, FILTER_VALIDATE_IP)) {
            return $cf;
        }
        $remote = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? ''));
        return $remote ? $remote : 'unknown';
    }

    private function sanitize_session_id($raw) {
        $session_id = sanitize_text_field(wp_unslash((string) $raw));
        if (!preg_match('/^[A-Za-z0-9_-]{12,64}$/', $session_id)) {
            return '';
        }
        return $session_id;
    }

    /**
 * Evaluate the form's conditional_logic rules against the posted POST
 * data and return an array of field IDs that are currently hidden.
 *
 * Supports BOTH the legacy flat rule shape from Round 1 / AI insights:
 * [{target, source, operator, value, action}]
 * AND the new grouped shape produced by the in-builder UI:
 * [{target, action, match: 'all'|'any', rules: [{source, operator, value}]}]
 *
 * Operators (in sync with frontend.js): equals, not_equals, contains,
 * not_contains, greater_than, less_than, is_empty, is_not_empty.
 *
 * Note: this is the SAME evaluation the frontend runs in JS. We re-evaluate
 * server-side so a tampered client (browser DevTools, curl, headless bot)
 * can't bypass conditional skipping by simply showing the field locally
 * and POSTing a value, OR vice versa.
 */
    private function compute_hidden_fields($form, $post_data) {
        $hidden = [];
        $logic = is_array($form->conditional_logic ?? null) ? $form->conditional_logic : [];
        if (empty($logic)) return $hidden;

        // Build a lookup of source → posted value.
        $val_for = function($source_id) use ($post_data) {
            $key = 'formforge_field_' . $source_id;
            if (!isset($post_data[$key])) return '';
            $v = $post_data[$key];
            // Arrays come from multi-select / multi-checkbox — flatten to a
            // newline string so `contains` etc. behave intuitively.
            if (is_array($v)) return implode("\n", array_map('strval', $v));
            return (string) $v;
        };

        $eval_rule = function($rule) use ($val_for) {
            $src = $rule['source'] ?? '';
            $op  = $rule['operator'] ?? 'equals';
            $val = isset($rule['value']) ? (string) $rule['value'] : '';
            $cur = $val_for($src);
            switch ($op) {
                case 'equals':         return $cur === $val;
                case 'not_equals':     return $cur !== $val;
                case 'contains':       return $val !== '' && strpos($cur, $val) !== false;
                case 'not_contains':   return $val === '' || strpos($cur, $val) === false;
                case 'greater_than':   return is_numeric($cur) && is_numeric($val) && (float) $cur > (float) $val;
                case 'less_than':      return is_numeric($cur) && is_numeric($val) && (float) $cur < (float) $val;
                case 'is_empty':       return $cur === '';
                case 'is_not_empty':   return $cur !== '';
                default:               return false;
            }
        };

        foreach ($logic as $entry) {
            if (!is_array($entry)) continue;
            $target = $entry['target'] ?? '';
            if ($target === '') continue;
            $action = $entry['action'] ?? 'show';

            if (isset($entry['rules']) && is_array($entry['rules']) && !empty($entry['rules'])) {
                // Grouped shape: AND/OR across multiple rules.
                $match = ($entry['match'] ?? 'all') === 'any' ? 'any' : 'all';
                $passed = ($match === 'all');
                foreach ($entry['rules'] as $rule) {
                    if (!is_array($rule)) continue;
                    $r = $eval_rule($rule);
                    if ($match === 'all' && !$r) { $passed = false; break; }
                    if ($match === 'any' && $r)  { $passed = true; break; }
                    if ($match === 'any')        { $passed = false; }
                }
            } else {
                // Legacy flat shape.
                $passed = $eval_rule($entry);
            }

            $should_show = ($action === 'hide') ? !$passed : $passed;
            if (!$should_show) {
                $hidden[] = $target;
            }
        }

        return array_values(array_unique($hidden));
    }

    private function can_feature($feature) {
        return class_exists('FORMFORGE_License') && FORMFORGE_License::instance()->can($feature);
    }
}
