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

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

    private function __construct() {
        // Enqueue reCAPTCHA script on frontend if keys configured (Pro feature)
        if (FORMFORGE_License::instance()->can('advanced_antispam')) {
            add_action('wp_enqueue_scripts', [$this, 'enqueue_recaptcha']);
        }
    }

    /**
 * Check submission for spam. Returns true if clean, error message if spam.
 *
 * @param array $data $_POST (or similar) from the submit request
 * @param int $form_id The form being submitted — needed to HMAC-verify
 * the formforge_token field. Pass 0 to skip token
 * check (legacy callers; new callers must provide).
 */
    public function check($data, $form_id = 0) {
        // Non-reCAPTCHA gates (honeypot, timing, HMAC token).
        $structural = self::check_structural($data, $form_id);
        if ($structural !== true) {
            return $structural;
        }

        // Google reCAPTCHA v3 — Pro feature, and only when BOTH keys are
        // actually configured . verify_recaptcha re-checks this too,
        // so a half-removed config can never block a submit. The v3 token is
        // single-use, so this runs ONLY on the final submit — never on the
        // PaymentIntent pre-step (see check_structural / class-stripe.php).
        if (FORMFORGE_License::instance()->can('advanced_antispam')
            && self::is_recaptcha_configured()) {
            $recaptcha_result = $this->verify_recaptcha($data);
            if ($recaptcha_result !== true) {
                return $recaptcha_result;
            }
        }

        return true;
    }

    /**
 * Structural spam gates that DON'T consume the single-use reCAPTCHA token:
 * honeypot, submit-timing, and the HMAC-signed form token. Shared by the
 * final submit (via check) and the PaymentIntent endpoint (#21), so the
 * payment pre-step gets the same anti-bot coverage without spending the
 * reCAPTCHA token the real submit needs.
 *
 * @return true|string true when clean, else a user-facing error message.
 */
    public static function check_structural($data, $form_id = 0) {
        // 1. Honeypot
        if (!empty($data['formforge_website_url'])) {
            return __('Spam detected.', 'formforge');
        }

        // 2. Time check (too fast = bot)
        $timestamp = intval($data['formforge_timestamp'] ?? 0);
        if ($timestamp > 0 && (time() - $timestamp) < 2) {
            return __('Please wait a moment before submitting.', 'formforge');
        }

        // 3. HMAC-signed token. Token is `{ts}.{hmac(form_id.ts, wp_salt)}`;
        // verify recomputes the HMAC with our server-side salt, rejects
        // tokens older than 6h and ones with a mismatched form_id.
        if ($form_id > 0 && !self::verify_token($data['formforge_token'] ?? '', (int) $form_id)) {
            return __('Invalid or expired submission. Please reload the page and try again.', 'formforge');
        } elseif ($form_id === 0 && empty($data['formforge_token'])) {
            // Legacy path (caller didn't pass form_id) — keep old behaviour.
            return __('Invalid submission.', 'formforge');
        }

        return true;
    }

    /**
 * Generate an HMAC-signed submission token. Called by class-form-renderer
 * when outputting the hidden `formforge_token` input.
 *
 * Format: "{unix_ts}.{hex_sha256_hmac(form_id.ts, wp_salt('auth'))}"
 * - Stateless: no transient / DB row. Verifier recomputes HMAC.
 * - Scoped to a specific form_id so a token for form A can't submit form B.
 * - Salted with wp_salt('auth') — a per-install secret in wp-config. An
 * attacker who hasn't read wp-config can't forge valid tokens.
 */
    public static function generate_token($form_id) {
        $ts  = time();
        $sig = hash_hmac('sha256', ((int) $form_id) . '.' . $ts, wp_salt('auth'));
        return $ts . '.' . $sig;
    }

    /**
 * Verify an HMAC-signed submission token.
 *
 * @param string $token The value from $_POST['formforge_token'].
 * @param int $form_id The submitted form_id (trust anchor).
 * @param int $max_age Seconds before a token is considered expired.
 * Default 21600 (6h) — generous enough for slow
 * fill-ins but short enough that a stolen token
 * doesn't stay usable.
 */
    public static function verify_token($token, $form_id, $max_age = 21600) {
        if (!is_string($token) || strpos($token, '.') === false) return false;
        $parts = explode('.', $token, 2);
        if (count($parts) !== 2) return false;
        $ts  = (int) $parts[0];
        $sig = (string) $parts[1];
        if ($ts <= 0) return false;
        $now = time();
        // Reject both ancient tokens (replay) and future-dated ones (clock
        // skew attack / malformed client).
        if ($now - $ts > $max_age) return false;
        if ($ts > $now + 60) return false;
        $expected = hash_hmac('sha256', ((int) $form_id) . '.' . $ts, wp_salt('auth'));
        return hash_equals($expected, $sig);
    }

    /**
 * Verify that the request came from our own site (or an explicitly
 * allowlisted origin). Blocks classic cross-site POSTs where the attacker
 * submits our form from their own page.
 *
 * Checks HTTP_ORIGIN first (modern browsers always send it on
 * cross-origin POSTs), falls back to HTTP_REFERER. Host-only match —
 * scheme/path/port ignored so a site on :443 and :80 both work during
 * migration.
 *
 * Filter `formforge_allowed_origins` lets developers whitelist extra
 * hosts (iframe embedding, alternate domains). Returns array of hostnames.
 */
    public static function verify_origin() {
        $origin  = isset($_SERVER['HTTP_ORIGIN'])  ? sanitize_text_field(wp_unslash($_SERVER['HTTP_ORIGIN']))  : '';
        $referer = isset($_SERVER['HTTP_REFERER']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_REFERER'])) : '';
        $probe   = $origin !== '' ? $origin : $referer;
        if ($probe === '') {
            // No Origin and no Referer — modern browsers always send at least
            // one on POST. Treat as suspicious unless explicitly opted out.
            return (bool) apply_filters('formforge_allow_empty_origin', false);
        }
        $home_host  = (string) wp_parse_url(home_url(), PHP_URL_HOST);
        $probe_host = (string) wp_parse_url($probe, PHP_URL_HOST);
        if ($probe_host === '') return false;
        if (strcasecmp($probe_host, $home_host) === 0) return true;

        // Whitelist for embeds / multi-domain setups. Filter receives
        // the default empty array; implementations add hostnames.
        $allowed = (array) apply_filters('formforge_allowed_origins', []);
        foreach ($allowed as $a) {
            if (strcasecmp(trim((string) $a), $probe_host) === 0) return true;
        }
        return false;
    }

    /**
 * IP-scoped leaky bucket using WP transients. Returns true if under the
 * limit (request allowed), false if the IP has exhausted its budget for
 * the window.
 *
 * @param string $ip Client IP (pre-validated upstream).
 * @param string $action Namespace so 'submit' and 'save_partial' don't
 * share a budget.
 * @param int $max Requests allowed in the window.
 * @param int $window Window size in seconds.
 */
    public static function check_rate_limit($ip, $action = 'submit', $max = 10, $window = 60) {
        if ($ip === '' || $ip === 'unknown') return true;   // can't rate-limit what we can't identify
        $key  = 'ff_rl_' . $action . '_' . md5($ip);
        $hits = (int) get_transient($key);
        if ($hits >= $max) return false;
        set_transient($key, $hits + 1, $window);
        return true;
    }

    /**
 * Resolve the usable reCAPTCHA site + secret keys from settings.
 *
 * the secret is stored encrypted (FORMFORGE_Crypto). decrypt
 * is identity on legacy plaintext and on empty strings, but returns ''
 * for a corrupt / un-decryptable ciphertext — so the only reliable
 * "is this key usable" test is `decrypt result is a non-empty
 * string`. The site key is plaintext.
 *
 * @return array{site:string,secret:string}
 */
    private static function recaptcha_keys() {
        $settings   = get_option('formforge_settings', []);
        $site_key   = trim((string) ($settings['recaptcha_site_key'] ?? ''));
        $secret_raw = $settings['recaptcha_secret_key'] ?? '';
        $secret_key = class_exists('FORMFORGE_Crypto')
            ? FORMFORGE_Crypto::decrypt($secret_raw)
            : $secret_raw;
        $secret_key = trim((string) $secret_key);
        return ['site' => $site_key, 'secret' => $secret_key];
    }

    /**
 * Single source of truth for "is reCAPTCHA effectively configured?".
 *
 * reCAPTCHA must enforce ONLY when BOTH a usable site key AND a
 * usable secret key are present. Previously the verify path checked the
 * secret alone while enqueue_recaptcha checked the site key alone — so
 * if the form owner cleared only one of the two (or a corrupt ciphertext
 * still decrypted to a non-empty sentinel), the badge could vanish from
 * the page while the server kept rejecting submissions for a missing
 * token. Both the verify path and the frontend badge-loader now go
 * through this helper, so they can never disagree again.
 */
    public static function is_recaptcha_configured() {
        $keys = self::recaptcha_keys();
        return $keys['site'] !== '' && $keys['secret'] !== '';
    }

    /**
 * Verify Google reCAPTCHA v3 token
 */
    private function verify_recaptcha($data) {
        // Skip entirely unless BOTH keys are usable — see
        // is_recaptcha_configured. If either is missing/blank (including a
        // secret whose ciphertext no longer decrypts), reCAPTCHA does not
        // enforce: a partially-removed config can never block a submit.
        if (!self::is_recaptcha_configured()) {
            return true;
        }

        $settings   = get_option('formforge_settings', []);
        $secret_key = self::recaptcha_keys()['secret'];

        $token = $data['formforge_recaptcha_token'] ?? '';
        if (empty($token)) {
            return __('reCAPTCHA verification failed. Please try again.', 'formforge');
        }

        // Verify with Google
        $response = wp_remote_post('https://www.google.com/recaptcha/api/siteverify', [
            'body' => [
                'secret' => $secret_key,
                'response' => $token,
                'remoteip' => sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')),
            ],
            'timeout' => 10,
        ]);

        if (is_wp_error($response)) {
            // If Google API is unreachable, let submission through (don't block legit users)
            return true;
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);

        // Check score (v3) — 0.0 = bot, 1.0 = human. Threshold: 0.5.
        // A threshold of 0 means "do not score-block". In local/dev QA,
        // Google can still reject otherwise normal tokens because the test
        // hostname is not registered. Keep production strict, but do not let
        // a threshold-0 local form become impossible to submit.
        $threshold = max(0, min(1, floatval($settings['recaptcha_threshold'] ?? 0.5)));

        if (empty($body['success'])) {
            if ($threshold <= 0 && self::is_local_request()) {
                return true;
            }
            return __('reCAPTCHA verification failed. Please try again.', 'formforge');
        }

        $score = $body['score'] ?? 1.0;

        if ($score < $threshold) {
            return __('Submission blocked by reCAPTCHA. Please try again.', 'formforge');
        }

        return true;
    }

    private static function is_local_request() {
        $host = isset($_SERVER['HTTP_HOST'])
            ? sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST']))
            : sanitize_text_field(wp_unslash($_SERVER['SERVER_NAME'] ?? ''));
        $host = strtolower((string) $host);
        if (strpos($host, ':') !== false) {
            $host = preg_replace('/:\d+$/', '', $host);
        }
        $remote = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? ''));

        return $host === 'localhost'
            || $host === '127.0.0.1'
            || $host === '::1'
            || substr($host, -6) === '.local'
            || substr($host, -5) === '.test'
            || strpos($remote, '127.') === 0
            || $remote === '::1';
    }

    /**
 * Enqueue reCAPTCHA v3 script on frontend
 */
    public function enqueue_recaptcha($force = false) {
        if (!FORMFORGE_License::instance()->can('advanced_antispam')) return;
        // load the badge ONLY when reCAPTCHA is effectively configured
        // (both keys usable). Same check the verify path uses, so the badge
        // and the server-side enforcement are always in lockstep — no more
        // "badge gone but submit still blocked".
        if (!self::is_recaptcha_configured()) return;
        if (wp_script_is('google-recaptcha-v3', 'enqueued')) return;

        $settings = get_option('formforge_settings', []);
        $site_key = $settings['recaptcha_site_key'] ?? '';

        // Only on pages with our shortcode
        if (!$force) {
            global $post;
            if (!$post || !has_shortcode($post->post_content, 'formforge')) return;
        }

        wp_enqueue_script(
            'google-recaptcha-v3',
            'https://www.google.com/recaptcha/api.js?render=' . esc_attr($site_key),
            [],
            FORMFORGE_VERSION,
            true
        );

        // Inline script to get token before form submit.
        
        // (2026-05-13): the previous implementation used
        // addEventListener(..., {once: true}). That left the second user
        // submit attempt without a fresh reCAPTCHA token — the listener was
        // gone, the jQuery delegated handler in frontend.js ran with a
        // stale token in the hidden input, and the resulting submission
        // raced against any in-flight recaptcha promise from the first
        // attempt. The visible symptom was a successful submission showing
        // up inside .formforge-message--error styling, because the same
        // event reached BOTH the recaptcha listener AND the jQuery
        // delegated handler synchronously (events bubble even after
        // preventDefault — only stopImmediatePropagation actually keeps
        // delegated jQuery handlers from running on the same event).
        
        // Defensive rewrite:
        // - Listener stays attached for every submit (no once:true).
        // - stopImmediatePropagation prevents the jQuery delegated
        // handler from firing on the user-initiated event.
        // - data-ff-recaptcha-resubmit guards against the programmatic
        // re-trigger looping back into this listener.
        // - The token input is cleared at the start of every attempt so
        // a stale token can never reach the server.
        wp_add_inline_script('google-recaptcha-v3', '
            document.addEventListener("DOMContentLoaded", function() {
                var errorText = "' . esc_js(__('reCAPTCHA verification failed. Please try again.', 'formforge')) . '";

                window.formforgeEnsureRecaptchaToken = function(formEl) {
                    return new Promise(function(resolve, reject) {
                        if (!formEl || typeof grecaptcha === "undefined" || typeof grecaptcha.ready !== "function") {
                            reject(new Error(errorText));
                            return;
                        }
                        var input = formEl.querySelector("input[name=formforge_recaptcha_token]");
                        if (input) input.value = "";
                        grecaptcha.ready(function() {
                            grecaptcha.execute("' . esc_js($site_key) . '", {action: "formforge_submit"}).then(function(token) {
                                if (!input) {
                                    input = document.createElement("input");
                                    input.type = "hidden";
                                    input.name = "formforge_recaptcha_token";
                                    formEl.appendChild(input);
                                }
                                input.value = token;
                                resolve(token);
                            }).catch(function() {
                                reject(new Error(errorText));
                            });
                        });
                    });
                };

                var forms = document.querySelectorAll(".formforge-form");
                forms.forEach(function(form) {
                    form.addEventListener("submit", function(e) {
                        var formEl = this;
                        if (formEl.dataset.ffRecaptchaResubmit === "1") {
                            formEl.dataset.ffRecaptchaResubmit = "";
                            return;
                        }
                        e.preventDefault();
                        if (typeof e.stopImmediatePropagation === "function") {
                            e.stopImmediatePropagation();
                        }
                        window.formforgeEnsureRecaptchaToken(formEl).then(function() {
                            formEl.dataset.ffRecaptchaResubmit = "1";
                            if (typeof jQuery !== "undefined") {
                                jQuery(formEl).trigger("submit");
                            } else {
                                formEl.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
                            }
                        }).catch(function(err) {
                            var box = formEl.querySelector(".formforge-message");
                            if (box) {
                                box.classList.remove("formforge-message--success");
                                box.classList.add("formforge-message--error");
                                box.textContent = err && err.message ? err.message : errorText;
                                box.style.display = "block";
                            }
                        });
                    });
                });
            });
        ');
    }

    /**
 * Get reCAPTCHA site key for frontend
 */
    public static function get_site_key() {
        $settings = get_option('formforge_settings', []);
        return $settings['recaptcha_site_key'] ?? '';
    }
}
