/* FormForge Frontend */
(function($) {
    'use strict';

	    function runSubmitActions(actions) {
	        if (!Array.isArray(actions)) return;
	        actions.forEach(function(action) {
            if (!action || typeof action.selector !== 'string' || typeof action.value !== 'string') return;
            if (!/^([#.][A-Za-z0-9_\-:.]+)$/.test(action.selector)) return;
            if (!/^[A-Za-z0-9_-]+$/.test(action.value)) return;
            var $target = $(action.selector).first();
            if (!$target.length) return;
            if (action.type === 'add_class') {
                $target.addClass(action.value);
            } else if (action.type === 'remove_class') {
                $target.removeClass(action.value);
            } else if (action.type === 'toggle_class') {
                $target.toggleClass(action.value);
            }
	        });
	    }

	    function renderSubmissionSuccess($form, $msg, responseData, lockInputs) {
	        var message = responseData.message || 'Thank you. Your submission has been received.';
	        $form.addClass('formforge-form--completed');
	        $msg.addClass('formforge-message--success formforge-success-state').text(message).show();
	        if (lockInputs) {
	            $form.find('input, textarea, select, button').prop('disabled', true);
	        }
	        $form.find('.formforge-field, .formforge-step, .ff-conv-step, .formforge-submit-wrap, .formforge-progress, .formforge-step-nav, .ff-conv-progress-bar').hide();
	    }

	    var abandonmentState = {};

 // r2 (2026-05-13):
    //   Debounce raised from 1200 ms to 4000 ms. The previous setting
    //   caused a partial_save to fire on every keystroke pause, which
    //   stacked up against the inactivity timer and created near-empty
    //   abandoned rows the moment the user touched ANY field. 4 s gives
    //   normal typists enough silence before we ping the server.
    var ABANDONMENT_DEBOUNCE_MS = 4000;
    // Client-side mirror of the server-side Gate A — don't bother
    // pinging the endpoint until the visitor has actually entered
    // something. Reduces empty-payload chatter (and 400s from the
    // server) when somebody briefly tabs through a form.
    var ABANDONMENT_MIN_NON_EMPTY_FIELDS = 2;

    function makeAbandonmentSessionId() {
        var bytes = new Uint8Array(16);
        if (window.crypto && window.crypto.getRandomValues) {
            window.crypto.getRandomValues(bytes);
            return Array.prototype.map.call(bytes, function(b) {
                return ('0' + b.toString(16)).slice(-2);
            }).join('');
        }
        return 'ff_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 14);
    }

    function abandonmentFormId($form) {
        return String($form.closest('.formforge-wrap').data('form-id') || $form.find('[name="formforge_form_id"]').val() || '');
    }

    function ensureAbandonmentState($form) {
        var formId = abandonmentFormId($form);
        if (!formId) return null;
        if (!abandonmentState[formId]) {
            abandonmentState[formId] = {
                submitted: false,        // set after a successful submit AJAX response
                submitting: false,       // set while a submit AJAX is in-flight
                timer: null,
                lastPayload: '',
                sessionId: makeAbandonmentSessionId(),
                startedAt: Date.now()    // dwell baseline for server Gate A
            };
        }
        if (!abandonmentState[formId].sessionId) {
            abandonmentState[formId].sessionId = makeAbandonmentSessionId();
        }
        if (!abandonmentState[formId].startedAt) {
            abandonmentState[formId].startedAt = Date.now();
        }
        return abandonmentState[formId];
    }

    function collectAbandonmentPartial($form) {
        var partial = {};
        $form.find('input, textarea, select').each(function() {
            var name = $(this).attr('name') || '';
            if (!name || name === 'formforge_website_url') return;
            if (['formforge_form_id', 'formforge_nonce', 'formforge_token', 'formforge_timestamp', 'formforge_lang'].indexOf(name) !== -1) return;
            if ($(this).prop('disabled')) return;
            if (this.type === 'hidden') return;
            if ($(this).closest('[data-hidden="true"]').length) return;

            var value;
            if (this.type === 'checkbox') {
                if (!this.checked) return;
                value = this.value || '1';
            } else if (this.type === 'radio') {
                if (!this.checked) return;
                value = this.value || '';
            } else {
                value = $(this).val();
            }

            if (Array.isArray(value)) {
                value = value.filter(function(item) { return item !== ''; });
                if (!value.length) return;
            } else if (value === null || value === undefined || String(value).trim() === '') {
                return;
            }

            partial[name] = value;
        });
        return partial;
    }

    function sendAbandonmentPartial($form, useBeacon) {
        if (!window.formforgeFront || !formforgeFront.ajaxUrl) return;
        var formId = abandonmentFormId($form);
        if (!formId) return;
        var state = ensureAbandonmentState($form);
        // submitted = handler already returned success; submitting = a submit
        // AJAX is in-flight. Either way we MUST NOT touch the abandonment
 // endpoint — that's the race that #3 / returnee describes.
        if (!state || state.submitted || state.submitting) return;

        var partial = collectAbandonmentPartial($form);
        var keys = Object.keys(partial);
        if (!keys.length) return;

        // Client-side mirror of server Gate A. Avoid bombarding the
        // endpoint with empty drafts while the visitor is still in the
        // first 30 s and has filled fewer than 2 fields — the server
        // would 200 OK with `skipped: below_threshold` anyway, but we
        // save the round trip.
        var dwellMs = state.startedAt ? (Date.now() - state.startedAt) : 0;
        if (keys.length < ABANDONMENT_MIN_NON_EMPTY_FIELDS && dwellMs < 30000) {
            return;
        }

        var payload = JSON.stringify(partial);
        if (payload === state.lastPayload) return;
        state.lastPayload = payload;

        var nonce = $form.find('[name="formforge_nonce"]').val() || formforgeFront.nonce || '';
        if (useBeacon && navigator.sendBeacon) {
            var data = new FormData();
            data.append('action', 'formforge_save_partial');
            data.append('nonce', nonce);
            data.append('form_id', formId);
            data.append('session_id', state.sessionId);
            data.append('partial_data', payload);
            data.append('dwell_ms', String(dwellMs));
            navigator.sendBeacon(formforgeFront.ajaxUrl, data);
            return;
        }

        $.ajax({
            url: formforgeFront.ajaxUrl,
            type: 'POST',
            data: {
                action: 'formforge_save_partial',
                nonce: nonce,
                form_id: formId,
                session_id: state.sessionId,
                partial_data: payload,
                dwell_ms: dwellMs
            }
        });
    }

    function scheduleAbandonmentPartial($form) {
        var formId = abandonmentFormId($form);
        if (!formId) return;
        var state = ensureAbandonmentState($form);
        if (!state) return;
        if (state.submitted || state.submitting) return;
        clearTimeout(state.timer);
        state.timer = setTimeout(function() {
            sendAbandonmentPartial($form, false);
        }, ABANDONMENT_DEBOUNCE_MS);
    }

    $(document).on('input change', '.formforge-form input, .formforge-form textarea, .formforge-form select', function() {
        var $form = $(this).closest('.formforge-form');
        scheduleAbandonmentPartial($form);
    });

    $(window).on('pagehide beforeunload', function() {
        $('.formforge-form').each(function() {
            // Don't ping the abandonment endpoint mid-submit — the submit
            // handler will clean up any existing draft and lock the session,
            // and the pagehide beacon racing past that lock is exactly how
 // the / duplicate-row regression sneaks back in.
            var $form = $(this);
            var formId = abandonmentFormId($form);
            var state = formId ? abandonmentState[formId] : null;
            if (state && (state.submitted || state.submitting)) return;
            sendAbandonmentPartial($form, true);
        });
    });

    document.addEventListener('visibilitychange', function() {
        if (document.visibilityState !== 'hidden') return;
        $('.formforge-form').each(function() {
            var $form = $(this);
            var formId = abandonmentFormId($form);
            var state = formId ? abandonmentState[formId] : null;
            if (state && (state.submitted || state.submitting)) return;
            sendAbandonmentPartial($form, true);
        });
    });

    // AJAX form submission
    $(document).on('submit', '.formforge-form', function(e) {
        e.preventDefault();
        var $form = $(this);
        var $btn = $form.find('.formforge-submit-btn');
        var $msg = $form.find('.formforge-message');

        // HTML5 validation gate. The form is rendered with `novalidate` so the
        // browser doesn't block AJAX out from under us (and so we can ignore
        // hidden multi-step fields that aren't on screen yet). Run validity
        // ourselves on visible fields only, focus the first offender, and let
        // the browser show its native tooltip via reportValidity().
        var invalidEl = null;
        $form.find('input, select, textarea').each(function() {
            if (invalidEl) return;
            // Skip fields hidden via display:none (hidden multi-step pages,
            // collapsed conditional rules, the honeypot, hidden form-id fields
            // are type="hidden" so :hidden catches them too — that's fine,
            // they're not user-visible and shouldn't gate submit).
            if (this.type === 'hidden') return;
            if (!$(this).is(':visible')) return;
            if (typeof this.checkValidity === 'function' && !this.checkValidity()) {
                invalidEl = this;
            }
        });
        if (invalidEl) {
            if (typeof invalidEl.reportValidity === 'function') {
                invalidEl.reportValidity();
            }
            try { invalidEl.focus(); } catch(e) {}
            $form.trigger('formforge:submission-error', [{
                message: invalidEl.validationMessage || 'Something went wrong.',
                field_id: $(invalidEl).closest('.formforge-field').data('field-id') || ''
            }]);
            return;
        }

        $btn.prop('disabled', true).text('Sending...');
        $msg.hide().removeClass('formforge-message--success formforge-message--error');

        var formData = new FormData(this);
        formData.append('action', 'formforge_submit');
        var abandonState = ensureAbandonmentState($form);
        if (abandonState && abandonState.sessionId) {
            formData.append('formforge_abandonment_session', abandonState.sessionId);
        }
 // r2: arm the submit-in-flight lock BEFORE the AJAX leaves
        // so a debounced partial_save that's already queued can't fire.
        // Also cancel any pending timer outright — once we believe we're
        // submitting, no draft pings should escape.
        if (abandonState) {
            abandonState.submitting = true;
            if (abandonState.timer) {
                clearTimeout(abandonState.timer);
                abandonState.timer = null;
            }
        }

        $.ajax({
            url: formforgeFront.ajaxUrl,
            type: 'POST',
            data: formData,
            processData: false,
            contentType: false,
            success: function(r) {
                if (r.success) {
                    var formId = abandonmentFormId($form);
                    if (formId) {
                        var state = ensureAbandonmentState($form) || {};
                        state.submitted = true;
                        state.submitting = false;
                        if (state.timer) clearTimeout(state.timer);
                        state.timer = null;
                    }
                    var responseData = r.data || {};
                    var redirectUrl = responseData.redirect || '';
                    var hasPaymentField = $form.find('.formforge-payment-field').length > 0;

                    if (redirectUrl) {
                        if (hasPaymentField) {
                            $form.attr('data-ff-paid', '1');
                            $form.find('input, textarea, select, button').prop('disabled', true);
                            $form.find('.formforge-field, .formforge-step, .ff-conv-step, .formforge-submit-wrap, .formforge-progress, .formforge-step-nav, .ff-conv-progress-bar').hide();
                        }
                        $form.trigger('formforge:submission-success', [responseData]);
                        runSubmitActions(responseData.actions);
                        window.location.href = redirectUrl;
                        return;
                    }

	                    if (hasPaymentField) {
	                        $form.attr('data-ff-paid', '1');
	                    } else {
	                        $form[0].reset();
	                    }
	                    renderSubmissionSuccess($form, $msg, responseData, hasPaymentField);
	                    $form.trigger('formforge:submission-success', [responseData]);
	                    runSubmitActions(responseData.actions);
                } else {
                    // Validation / spam / server-side failure — re-open the
                    // abandonment channel so the visitor can keep filling in
                    // and we can record a fresh draft.
                    if (abandonState) abandonState.submitting = false;
                    $msg.addClass('formforge-message--error').html(r.data.message).show();
 // surface server-side errors to other handlers
                    // (conversational mode listens for `payment_field_id` and
                    // jumps the active step back to the Stripe element so
                    // the visitor actually sees the error). Plain mode keeps
                    // showing the inline message; this trigger is additive.
                    $form.trigger('formforge:submission-error', [r.data || {}]);
                }
                if ($form.attr('data-ff-paid') !== '1') {
                    $btn.prop('disabled', false).text($btn.data('original-text') || 'Submit');
                }
            },
            error: function() {
                if (abandonState) abandonState.submitting = false;
                var errorPayload = { message: 'Something went wrong.' };
                $msg.addClass('formforge-message--error').text(errorPayload.message).show();
                $form.trigger('formforge:submission-error', [errorPayload]);
                $btn.prop('disabled', false).text($btn.data('original-text') || 'Submit');
            }
        });
    });

    // Save original button text
    $('.formforge-submit-btn').each(function() { $(this).data('original-text', $(this).text()); });

    // Star rating
    $(document).on('click', '.formforge-star', function() {
        var val = $(this).data('value');
        var $wrap = $(this).closest('.formforge-rating');
        $wrap.find('.formforge-rating-input').val(val);
        $wrap.find('.formforge-star').each(function() {
            $(this).toggleClass('active', $(this).data('value') <= val);
        });
    });

    // Range slider value display
    $(document).on('input', '.formforge-range', function() {
        $(this).siblings('.formforge-range-value').text($(this).val());
    });

    // Conditional logic — show/hide fields based on other fields' values.
    //
    // Schema accepted (forwards-compatible — both shapes coexist in the wild):
    //
    //   Grouped (in-builder UI, audit r3):
    //     [
    //       {target, action: 'show'|'hide', match: 'all'|'any',
    //        rules: [{source, operator, value}, ...]},
    //       ...
    //     ]
    //
    //   Flat legacy (Round 1, AI insights, REST imports):
    //     [{target, source, operator, value, action}, ...]
    //
    // Operators: equals | not_equals | contains | not_contains
    //          | greater_than | less_than | is_empty | is_not_empty
    //
    // The `not_empty` legacy operator is aliased to `is_not_empty`.
    $('.formforge-logic-data').each(function() {
        try {
            var rules = JSON.parse($(this).text());
            if (!rules || !rules.length) return;
            var $wrap = $(this).closest('.formforge-wrap');

            // Read the current value of a source field. For multi-value inputs
            // (checkboxes, multi-select) returns a newline-joined string so the
            // `contains` operator behaves intuitively.
            function sourceValue(sourceId) {
                var sel = '[name="formforge_field_' + sourceId + '"]';
                var $els = $wrap.find(sel);
                if (!$els.length) {
                    // Some field types (e.g. radio) emit `name` per option but
                    // share a common id prefix; jQuery returns the first match's
                    // val() which is correct for single-value, but for radios
                    // we need the :checked one.
                    return '';
                }
                var first = $els.get(0);
                if (first && first.type === 'radio') {
                    var checked = $wrap.find(sel + ':checked');
                    return checked.length ? String(checked.val() || '') : '';
                }
                if (first && first.type === 'checkbox') {
                    var vals = [];
                    $wrap.find(sel + ':checked').each(function() { vals.push(String($(this).val() || '')); });
                    return vals.join('\n');
                }
                var v = $els.first().val();
                if (Array.isArray(v)) return v.map(String).join('\n');
                return v == null ? '' : String(v);
            }

            function evalRule(rule) {
                var cur = sourceValue(rule.source);
                var val = rule.value == null ? '' : String(rule.value);
                switch (rule.operator) {
                    case 'equals':       return cur === val;
                    case 'not_equals':   return cur !== val;
                    case 'contains':     return val !== '' && cur.indexOf(val) >= 0;
                    case 'not_contains': return val === '' || cur.indexOf(val) < 0;
                    case 'greater_than': return !isNaN(parseFloat(cur)) && !isNaN(parseFloat(val)) && parseFloat(cur) > parseFloat(val);
                    case 'less_than':    return !isNaN(parseFloat(cur)) && !isNaN(parseFloat(val)) && parseFloat(cur) < parseFloat(val);
                    case 'is_empty':     return cur === '';
                    case 'is_not_empty':
                    case 'not_empty':    return cur !== '';
                }
                return false;
            }

            function applyLogic() {
                rules.forEach(function(entry) {
                    if (!entry || !entry.target) return;
                    var passed;
                    if (Array.isArray(entry.rules) && entry.rules.length) {
                        // Grouped shape — AND / OR across rules.
                        var match = entry.match === 'any' ? 'any' : 'all';
                        if (match === 'all') {
                            passed = entry.rules.every(evalRule);
                        } else {
                            passed = entry.rules.some(evalRule);
                        }
                    } else {
                        // Legacy flat shape.
                        passed = evalRule(entry);
                    }

                    var show = entry.action === 'hide' ? !passed : passed;
                    var $target = $wrap.find('[data-field-id="' + entry.target + '"]').filter('.formforge-field');
                    if (!$target.length) {
                        // Backward-compat: older renderer didn't put data-field-id
                        // on the wrapper, only on a child (e.g. repeater inner).
                        $target = $wrap.find('[data-field-id="' + entry.target + '"]').closest('.formforge-field');
                    }
                    $target.attr('data-hidden', show ? 'false' : 'true');
                });
                $wrap.trigger('formforge:conditional-updated');
            }

            $wrap.find('input, select, textarea').on('change input', function() {
                applyLogic();
            });
            applyLogic();
        } catch(e) {}
    });

    // ── Multi-step forms ──
    $('.formforge-wrap').each(function() {
        var $wrap = $(this);
        var $steps = $wrap.find('.formforge-step[data-step]');
        if ($steps.length <= 1) return;

        var current = 0;
        var total = $steps.length;

        var $progress = $wrap.find('.formforge-progress').show();
        var $nav = $wrap.find('.formforge-step-nav').show();
        var $prev = $wrap.find('.formforge-btn-prev');
        var $next = $wrap.find('.formforge-btn-next');
        var $submit = $wrap.find('.formforge-submit-wrap');

        function showStep(idx) {
            $steps.hide();
            $steps.eq(idx).show();
            $prev.toggle(idx > 0);
            $next.toggle(idx < total - 1);
            $submit.toggle(idx === total - 1);

            var pct = Math.round(((idx + 1) / total) * 100);
            $wrap.find('.formforge-progress-fill').css('width', pct + '%');
            $wrap.find('.formforge-progress-text').text((idx + 1) + ' / ' + total);
        }

        $next.on('click', function() {
            if (current >= total - 1) return;
            // Validate the current step's visible fields before advancing.
            // Mirrors the submit-side gate so a multi-step form still enforces
            // HTML5 attrs even though the form carries `novalidate`.
            var invalidEl = null;
            $steps.eq(current).find('input, select, textarea').each(function() {
                if (invalidEl) return;
                if (this.type === 'hidden') return;
                if (!$(this).is(':visible')) return;
                if (typeof this.checkValidity === 'function' && !this.checkValidity()) {
                    invalidEl = this;
                }
            });
            if (invalidEl) {
                if (typeof invalidEl.reportValidity === 'function') invalidEl.reportValidity();
                try { invalidEl.focus(); } catch(e) {}
                return;
            }
            current++;
            showStep(current);
        });
        $prev.on('click', function() { if (current > 0) { current--; showStep(current); } });

        showStep(0);
    });

    // ── Repeater fields ──
    $(document).on('click', '.formforge-repeater-add', function() {
        var $repeater = $(this).closest('.formforge-repeater');
        var $rows = $repeater.find('.formforge-repeater-rows');
        var $lastRow = $rows.find('.formforge-repeater-row').last();
        var newIdx = $rows.find('.formforge-repeater-row').length;

        var $newRow = $lastRow.clone();
        $newRow.attr('data-row', newIdx);
        $newRow.find('input, textarea, select').each(function() {
            var name = $(this).attr('name') || '';
            $(this).attr('name', name.replace(/_\d+_/, '_' + newIdx + '_'));
            $(this).val('');
        });
        $rows.append($newRow);
    });

    $(document).on('click', '.formforge-repeater-remove', function() {
        var $row = $(this).closest('.formforge-repeater-row');
        if ($row.siblings('.formforge-repeater-row').length > 0) {
            $row.remove();
        }
    });

    // ── Conversational mode is now handled by conversational.js ──

    // ── Calculation fields ──
    function parseCalcMap($el) {
        var raw = $el.attr('data-field-map') || '{}';
        if (typeof raw === 'object') return raw;
        try {
            var parsed = JSON.parse(raw);
            return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
        } catch(e) {
            return {};
        }
    }

    function calcSourceValue($form, fieldId) {
        var baseName = 'formforge_field_' + fieldId;
        var $els = $form.find('[name="' + baseName + '"], [name="' + baseName + '[]"]');
        if (!$els.length) return 0;
        var first = $els.get(0);
        var raw;
        if (first && first.type === 'radio') {
            raw = $form.find('[name="' + baseName + '"]:checked').val();
        } else if (first && first.type === 'checkbox') {
            var total = 0;
            $form.find('[name="' + baseName + '"], [name="' + baseName + '[]"]').filter(':checked').each(function() {
                total += parseFloat($(this).val()) || 0;
            });
            return total;
        } else {
            raw = $els.first().val();
            if (Array.isArray(raw)) raw = raw[0];
        }
        return parseFloat(raw) || 0;
    }

    function updateCalculations() {
        $('.formforge-calculation').each(function() {
            var $calc = $(this);
            var formula = $calc.attr('data-formula') || $calc.data('formula');
            if (!formula) return;
            var $form = $calc.closest('.formforge-form');
            var fieldMap = parseCalcMap($calc);
            var decimalsRaw = $calc.attr('data-decimals');
            var decimals = decimalsRaw === '' || decimalsRaw == null ? null : parseInt(decimalsRaw, 10);

            var expression = formula.replace(/\{([^}]+)\}/g, function(match, token) {
                token = String(token || '').trim();
                var fieldId = fieldMap[token] || token;
                return String(calcSourceValue($form, fieldId));
            });

            try {
                expression = expression.replace(/[^0-9+\-*/().\s]/g, '');
                if (!expression.trim()) return;
                var val = new Function('"use strict"; return (' + expression + ');')();
                if (!isFinite(val)) return;
                if (decimals !== null && !isNaN(decimals)) {
                    decimals = Math.max(0, Math.min(6, decimals));
                    val = Number(val).toFixed(decimals);
                } else {
                    val = Math.round(val * 100) / 100;
                }
	                $calc.find('.formforge-calc-result').text(val);
	                $calc.find('.formforge-calc-input').val(val);
	                $calc.trigger('formforge:calculation-updated', [val]);
	            } catch(e) {}
	        });
	    }
    $(document).on('input change', '.formforge-form input, .formforge-form select', updateCalculations);
    updateCalculations();

})(jQuery);
