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

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

    private function __construct() {
        add_action('admin_menu', [$this, 'admin_menu']);
        add_action('admin_menu', [$this, 'ensure_submenu_registered'], PHP_INT_MAX - 1);
        add_action('admin_menu', [$this, 'reorder_submenu'], PHP_INT_MAX);
        add_action('admin_head', [$this, 'reorder_submenu'], 1);
        // hide "New form" from sidebar via CSS (slug stays
        // registered so deep links still resolve).
        add_action('admin_head', [$this, 'hide_grouped_sidebar_items']);
        add_action('admin_enqueue_scripts', [$this, 'enqueue_admin']);
        add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend']);
        add_action('init', [$this, 'register_blocks']);
        add_filter('admin_body_class', [$this, 'add_body_class']);
        // branded header on every Form Forge admin screen.
        add_action('in_admin_header', [$this, 'render_branded_header']);
        add_action('admin_init', [$this, 'handle_csv_export']);
        add_action('wp_ajax_formforge_download_pdf', [$this, 'handle_pdf_download']);
        // top-level toolbar bulk PDF export of filtered submissions.
        add_action('wp_ajax_formforge_download_bulk_pdf', [$this, 'handle_bulk_pdf_download']);
        add_action('wp_ajax_formforge_download_file', [$this, 'handle_file_download']);
        add_action('wp_ajax_formforge_delete_submission', [$this, 'handle_delete_submission']);
        add_action('wp_ajax_formforge_calendar_slots', [$this, 'handle_calendar_slots']);
        add_action('wp_ajax_formforge_gpicker_token', [$this, 'handle_gpicker_token']);
        add_action('wp_ajax_formforge_calendar_list', [$this, 'handle_calendar_list']);
        add_action('wp_ajax_nopriv_formforge_calendar_slots', [$this, 'handle_calendar_slots']);
        add_action('wp_ajax_formforge_yandex_geocode', [$this, 'ajax_yandex_geocode']);
        add_action('wp_ajax_nopriv_formforge_yandex_geocode', [$this, 'ajax_yandex_geocode']);
        add_action('wp_ajax_formforge_google_status', [$this, 'ajax_google_status']);
        // dedicated HubSpot connect flow. The Settings page button used
        // to be a bare type="submit" that posted the whole settings <form>;
        // now it POSTs here and gets a clear JSON result rendered inline in
        // the HubSpot card.
        add_action('wp_ajax_formforge_hubspot_connect', [$this, 'ajax_hubspot_connect']);
        // FC-44: WhatsApp connect = mint a /start <token> nonce; status = poll registration.
        add_action('wp_ajax_formforge_whatsapp_connect', [$this, 'ajax_whatsapp_connect']);
        add_action('wp_ajax_formforge_whatsapp_status',  [$this, 'ajax_whatsapp_status']);
    }

    /**
 * FC-44: issue a WhatsApp registration token. Admin sees the resulting
 * `/start <token>` line in the Settings UI and sends it from their
 * personal WhatsApp to our Avakode business number; the Worker webhook
 * then writes the phone↔site mapping.
 */
    public function ajax_whatsapp_connect() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(['message' => 'forbidden'], 403);
        }
        check_ajax_referer('formforge_whatsapp_connect', '_wpnonce');
        if (!$this->can_feature('whatsapp')) {
            wp_send_json_error(['message' => __('WhatsApp notifications require Form Forge Pro.', 'formforge')], 403);
        }

        $forge_api = FORMFORGE_Forge_API::instance();
        if (!$forge_api->has_license_key()) {
            wp_send_json_error([
                'message' => __('Connect Form Forge to your Avakode license before enabling WhatsApp notifications.', 'formforge'),
            ], 400);
        }
        if (!$forge_api->prime_site_token()) {
            wp_send_json_error([
                'message' => __('Could not verify your site capability token. Open the Form Forge license page, reconnect to Avakode, then try again.', 'formforge'),
            ], 400);
        }

        $result = $forge_api->whatsapp_register_token();
        if (is_wp_error($result)) {
            wp_send_json_error([
                'message' => trim((string) $result->get_error_message()) ?: __('Could not start WhatsApp connection. Try again.', 'formforge'),
            ], 400);
        }

        // Pass the bilingual instructions straight through — Worker
        // already localised them based on the WP locale's first two
        // chars (see /notifications/whatsapp/register-token).
        wp_send_json_success([
            'token'               => $result['token'] ?? '',
            'expires_in_seconds'  => $result['expires_in_seconds'] ?? 600,
            'instructions_en'     => $result['instructions_en'] ?? '',
            'instructions_ru'     => $result['instructions_ru'] ?? '',
        ]);
    }

    /**
 * FC-44: poll registration status. Returns `connected: true` and the
 * phone once the admin's /start has been processed by the Worker.
 */
    public function ajax_whatsapp_status() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(['message' => 'forbidden'], 403);
        }
        check_ajax_referer('formforge_whatsapp_status', '_wpnonce');
        if (!$this->can_feature('whatsapp')) {
            wp_send_json_success(['connected' => false]);
        }

        $forge_api = FORMFORGE_Forge_API::instance();
        if (!$forge_api->has_license_key()) {
            wp_send_json_success(['connected' => false]);
        }
        $result = $forge_api->whatsapp_status();
        if (is_wp_error($result)) {
            wp_send_json_success(['connected' => false]);
        }
        wp_send_json_success([
            'connected'          => !empty($result['registered']),
            'phone_e164'         => $result['phone_e164'] ?? '',
            'registered_at'      => $result['registered_at'] ?? '',
            'last_seen_at'       => $result['last_seen_at'] ?? '',
            'pending'            => !empty($result['pending']),
            'pending_token'      => $result['pending_token'] ?? '',
            'pending_expires_at' => $result['pending_expires_at'] ?? '',
        ]);
    }

    /**
 * admin polls this after clicking "Connect Google Account" so the
 * UI can flip to connected-state without a manual page refresh. Proxies
 * to Forge API google_status (live call, not cached, worker-side).
 */
    public function ajax_google_status() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(['message' => 'forbidden'], 403);
        }
        check_ajax_referer('formforge_google_status', '_wpnonce');
        if (!$this->can_feature('google_sheets') && !$this->can_feature('google_calendar')) {
            wp_send_json_success(['connected' => false]);
        }
        $status = FORMFORGE_Forge_API::instance()->google_status();
        if (is_wp_error($status)) {
            wp_send_json_success(['connected' => false]);
        }
        wp_send_json_success(['connected' => !empty($status['connected'])]);
    }

    public function ajax_yandex_geocode() {
        if (!check_ajax_referer('formforge_yandex_geocode', 'nonce', false)) {
            wp_send_json_error(['message' => __('Invalid map request. Refresh the page and try again.', 'formforge')], 403);
        }
        // Origin/Referer check — parity with submit/save_partial. Without it
        // an attacker who pulled the JS-localized nonce off the public page
        // can loop this endpoint from any origin and burn the form-owner's
        // Yandex Geocoder quota. Independent review (Codex 2026-05-26).
        if (class_exists('FORMFORGE_Anti_Spam') && !FORMFORGE_Anti_Spam::verify_origin()) {
            wp_send_json_error(['message' => __('Request origin not allowed.', 'formforge')], 403);
        }
        if (!$this->can_feature('yandex_geocoder')) {
            wp_send_json_error(['message' => __('Yandex Geocoder requires Form Forge Pro.', 'formforge')], 403);
        }

        $settings = get_option('formforge_settings', []);
        if (!is_array($settings)) {
            $settings = [];
        }
        if (($settings['map_provider'] ?? 'google') !== 'yandex') {
            wp_send_json_error(['message' => __('Yandex Maps is not enabled for this site.', 'formforge')], 400);
        }

        $api_key = trim((string) ($settings['map_yandex_geocoder_key'] ?? ''));
        if ($api_key === '') {
            $api_key = trim((string) ($settings['map_yandex_js_api_key'] ?? ''));
        }
        if ($api_key === '') {
            $api_key = trim((string) ($settings['map_api_key'] ?? ''));
        }
        if ($api_key === '') {
            wp_send_json_error(['message' => __('Enter a Yandex Geocoder API key in Form Forge settings.', 'formforge')], 400);
        }
        if (!$this->consume_yandex_geocode_rate_limit()) {
            wp_send_json_error(['message' => __('Too many address lookup requests. Try again in a minute.', 'formforge')], 429);
        }

        $query = isset($_POST['query']) ? sanitize_text_field(wp_unslash($_POST['query'])) : '';
        $lat_raw = isset($_POST['lat']) ? sanitize_text_field(wp_unslash($_POST['lat'])) : '';
        $lng_raw = isset($_POST['lng']) ? sanitize_text_field(wp_unslash($_POST['lng'])) : '';
        $geocode = '';

        if ($lat_raw !== '' && $lng_raw !== '') {
            if (!is_numeric($lat_raw) || !is_numeric($lng_raw)) {
                wp_send_json_error(['message' => __('Invalid map coordinates.', 'formforge')], 400);
            }
            $lat = (float) $lat_raw;
            $lng = (float) $lng_raw;
            if ($lat < -90 || $lat > 90 || $lng < -180 || $lng > 180) {
                wp_send_json_error(['message' => __('Invalid map coordinates.', 'formforge')], 400);
            }
            $geocode = $lng . ',' . $lat;
        } else {
            $query = trim($query);
            if ($query === '') {
                wp_send_json_error(['message' => __('Enter an address to search.', 'formforge')], 400);
            }
            if (function_exists('mb_strlen') ? mb_strlen($query, 'UTF-8') > 300 : strlen($query) > 300) {
                wp_send_json_error(['message' => __('Address search query is too long.', 'formforge')], 400);
            }
            $geocode = $query;
        }

        $url = add_query_arg([
            'apikey' => $api_key,
            'geocode' => $geocode,
            'lang' => 'ru_RU',
            'format' => 'json',
            'results' => 1,
        ], 'https://geocode-maps.yandex.ru/v1');

        $response = wp_remote_get($url, [
            'timeout' => 8,
            'redirection' => 2,
            'headers' => [
                'Accept' => 'application/json',
            ],
        ]);
        if (is_wp_error($response)) {
            wp_send_json_error(['message' => __('Yandex Geocoder request failed. Check the API key and try again.', 'formforge')], 502);
        }

        $status = (int) wp_remote_retrieve_response_code($response);
        if ($status < 200 || $status >= 300) {
            wp_send_json_error(['message' => __('Yandex Geocoder rejected the request. Check the Geocoder API key and restrictions.', 'formforge')], 502);
        }

        $body = (string) wp_remote_retrieve_body($response);
        $data = json_decode($body, true);
        if (!is_array($data)) {
            wp_send_json_error(['message' => __('Yandex Geocoder returned an invalid response.', 'formforge')], 502);
        }

        $result = $this->parse_yandex_geocode_result($data);
        if (!$result) {
            wp_send_json_error(['message' => __('This address could not be resolved. Choose a point on the map.', 'formforge')], 404);
        }

        wp_send_json_success($result);
    }

    private function consume_yandex_geocode_rate_limit() {
        $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown';
        $key = 'formforge_yandex_geocode_' . md5($ip);
        $count = (int) get_transient($key);
        if ($count >= 60) {
            return false;
        }
        set_transient($key, $count + 1, MINUTE_IN_SECONDS);
        return true;
    }

    private function parse_yandex_geocode_result($data) {
        $members = $data['response']['GeoObjectCollection']['featureMember'] ?? [];
        if (empty($members[0]['GeoObject']) || !is_array($members[0]['GeoObject'])) {
            return null;
        }

        $geo = $members[0]['GeoObject'];
        $pos = trim((string) ($geo['Point']['pos'] ?? ''));
        $parts = preg_split('/\s+/', $pos);
        if (!$parts || count($parts) < 2 || !is_numeric($parts[0]) || !is_numeric($parts[1])) {
            return null;
        }

        $metadata = $geo['metaDataProperty']['GeocoderMetaData'] ?? [];
        $address = '';
        if (is_array($metadata)) {
            $address = trim((string) ($metadata['text'] ?? ''));
            if ($address === '' && !empty($metadata['Address']['formatted'])) {
                $address = trim((string) $metadata['Address']['formatted']);
            }
        }
        if ($address === '' && !empty($geo['name'])) {
            $address = trim((string) $geo['name']);
        }

        $lng = (float) $parts[0];
        $lat = (float) $parts[1];
        return [
            'address' => $address !== '' ? $address : $lat . ', ' . $lng,
            'lat' => (string) $lat,
            'lng' => (string) $lng,
            'source' => 'yandex-geocoder',
        ];
    }

    /**
 * dedicated HubSpot connect endpoint.
 *
 * Replaces the old bare type="submit" button that posted the entire
 * Form Forge Settings <form> with no dedicated handler. This:
 * 1. Validates the private app token shape (pat-…).
 * 2. Primes the site capability token via the SHARED
 * Forge_Connect::get_or_refresh_site_token helper — fixing the
 * "Missing site capability token" error when the cached token was
 * missing/stale (FF-GOOGLE-001 root cause, also bit HubSpot).
 * 3. Calls the Worker /integrations/hubspot/connect and returns a
 * clear JSON result the Settings page renders INLINE in the
 * HubSpot card — success ("Connected to HubSpot") or the specific
 * error, never a generic page-top notice.
 */
    public function ajax_hubspot_connect() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(['message' => __('You do not have permission to do this.', 'formforge')], 403);
        }
        check_ajax_referer('formforge_hubspot_connect', '_wpnonce');
        if (!$this->can_feature('hubspot')) {
            wp_send_json_error(['message' => __('HubSpot integration requires Form Forge Pro.', 'formforge')], 403);
        }

        $api_key = isset($_POST['hubspot_api_key'])
            ? sanitize_text_field(wp_unslash($_POST['hubspot_api_key']))
            : '';
        if ($api_key === '') {
            wp_send_json_error(['message' => __('Enter your HubSpot private app token.', 'formforge')], 400);
        }
        if (preg_match('/^pat-[A-Za-z0-9-]{8,}$/', $api_key) !== 1) {
            wp_send_json_error(['message' => __('Enter a HubSpot private app token that starts with pat-.', 'formforge')], 400);
        }

        $forge_api = FORMFORGE_Forge_API::instance();
        if (!$forge_api->has_license_key()) {
            wp_send_json_error([
                'message' => __('Connect Form Forge to your Avakode license before connecting HubSpot.', 'formforge'),
            ], 400);
        }

        // Refresh the site capability token via the shared helper BEFORE the
        // Worker call. Without a valid site_token the Worker rejects the
        // request with "Missing site capability token".
        if (!$forge_api->prime_site_token()) {
            wp_send_json_error([
                'message' => __('Could not verify your site capability token. Open the Form Forge license page, reconnect to Avakode, then try again.', 'formforge'),
            ], 400);
        }

        $result = $forge_api->hubspot_connect($api_key);
        if (is_wp_error($result)) {
            $message = trim((string) $result->get_error_message());
            $normalized = strtolower($message);
            if ($normalized === 'invalid request' || $normalized === 'api error' || $message === '') {
                $message = __('Could not connect to HubSpot. Check the private app token and try again.', 'formforge');
            }
            wp_send_json_error(['message' => $message], 400);
        }

        wp_send_json_success(['message' => __('Connected to HubSpot', 'formforge')]);
    }

    public function add_body_class($classes) {
        $screen = get_current_screen();
        if ($screen && strpos($screen->id, 'formforge') !== false) {
            $classes .= ' forge-admin ff-admin';
            // Add a Free/Pro hint so CSS can disable interaction on
            // Pro-only settings cards in the form editor when license
            // is Free. Stops users from filling in API keys / toggles
            // that the save handler would silently strip.
            if (class_exists('FORMFORGE_License') && FORMFORGE_License::instance()->is_free()) {
                $classes .= ' ff-admin--free';
            }
        }
        return $classes;
    }

    public function register_blocks() {
        if (!function_exists('register_block_type')) {
            return;
        }

        register_block_type('formforge/form', [
            'api_version'     => 2,
            'title'           => __('Form Forge Form', 'formforge'),
            'category'        => 'widgets',
            'attributes'      => [
                'formId' => [
                    'type'    => 'integer',
                    'default' => 0,
                ],
                'id' => [
                    'type'    => 'integer',
                    'default' => 0,
                ],
            ],
            'render_callback' => [$this, 'render_form_block'],
        ]);
    }

    public function render_form_block($attributes) {
        $form_id = absint($attributes['formId'] ?? $attributes['id'] ?? 0);
        if (!$form_id) {
            return '<p class="formforge-error">' . esc_html__('Select a Form Forge form to display.', 'formforge') . '</p>';
        }

        return FORMFORGE_Form_Renderer::instance()->render($form_id);
    }

    /**
 * Branded header (logo + brand gradient) on every Form Forge admin
 * screen. Same pattern as SEO Forge / Lang Forge; styles share the
 * .sf-brand-header class and pull color from the .ff-admin --accent vars.
 */
    public function render_branded_header() {
        $screen = get_current_screen();
        if (!$screen || strpos($screen->id, 'formforge') === false) return;
        $version = defined('FORMFORGE_VERSION') ? FORMFORGE_VERSION : '';
        $logo    = FORMFORGE_URL . 'assets/logo.svg';
        ?>
        <div class="sf-brand-header">
            <div class="sf-brand-header__inner">
                <img class="sf-brand-header__logo" src="<?php echo esc_url($logo); ?>" alt="Form Forge" width="40" height="40">
                <div class="sf-brand-header__text">
                    <div class="sf-brand-header__title">Form Forge</div>
                    <div class="sf-brand-header__tagline"><?php esc_html_e('Beautiful forms with AI generation, built for WordPress.', 'formforge'); ?></div>
                </div>
                <?php if ($version): ?>
                <div class="sf-brand-header__version">v<?php echo esc_html($version); ?></div>
                <?php endif; ?>
                <?php if (class_exists('Forge_Credits_Widget')) { Forge_Credits_Widget::render_chip_in_header(); } ?>
            </div>
        </div>
        <?php
    }

    public function admin_menu() {
        // Forge Suite unified dashboard
        if (class_exists('Forge_Suite_Dashboard')) {
            Forge_Suite_Dashboard::maybe_register_menu();
        }

        add_menu_page(__('Form Forge', 'formforge'), __('Form Forge', 'formforge'), 'manage_options', 'formforge', [FORMFORGE_Admin::instance(), 'render_forms_list'], 'dashicons-feedback', 30);

        $this->register_submenus();
    }

    private function register_submenus(): void {
        // sidebar restructure (2026-05-05). Goals from the tester:
        // 1. Drop the "All forms" → "Forms" rename so it matches the
        // WP CPT-list convention (the page IS the list of forms).
        // 2. Remove "New form" from the sidebar — there's already a
        // primary "+ New Form" button on the list page.
        // 3. Show a [PRO] badge on the Analytics sidebar entry only
        // while the current install does not have Analytics access.
        
        // Final visible order: Forms → Submissions → Analytics
        // → Migration (registered in class-migration.php) → Settings
        // → Freemius (Contact Us, License).
        $pro_badge = FORMFORGE_License::instance()->can('analytics')
            ? ''
            : ' <span class="ff-pro-badge" style="background:#fff4e5;color:#b45309;border:1px solid #fcd9a8;border-radius:3px;font-size:10px;font-weight:700;padding:1px 5px;letter-spacing:.4px;text-transform:uppercase;line-height:1;">PRO</span>';

        add_submenu_page('formforge', __('Forms', 'formforge'),       __('Forms', 'formforge'),       'manage_options', 'formforge',             [FORMFORGE_Admin::instance(), 'render_forms_list'],   1);
        add_submenu_page('formforge', __('Submissions', 'formforge'), __('Submissions', 'formforge'), 'manage_options', 'formforge-submissions', [FORMFORGE_Admin::instance(), 'render_submissions'],  2);
        add_submenu_page('formforge', __('Analytics', 'formforge'),   __('Analytics', 'formforge') . $pro_badge, 'manage_options', 'formforge-analytics',   [FORMFORGE_Admin::instance(), 'render_analytics'],    3);
        // pos=99 keeps Settings the last visible item; reorder_submenu
        // below refines this once Freemius has added Contact Us / License.
        add_submenu_page('formforge', __('Settings', 'formforge'),    __('Settings', 'formforge'),    'manage_options', 'formforge-settings',    [FORMFORGE_Admin::instance(), 'render_settings'],     99);

        // "New form" stays REGISTERED but hidden from sidebar so
        // existing deep links (`?page=formforge-new`) keep working —
        // the form-editor screen, the templates redirect, and any
        // bookmarks the tester might have. The list page's "+ New Form"
        // button remains the primary entry point.
        add_submenu_page('formforge', __('New form', 'formforge'), __('New form', 'formforge'), 'manage_options', 'formforge-new', [FORMFORGE_Admin::instance(), 'render_form_editor']);
    }

    public function ensure_submenu_registered() {
        global $submenu;

        $items = $submenu['formforge'] ?? [];
        if (!is_array($items) || empty($items)) {
            $this->register_submenus();
        }

        if (!$this->submenu_has_slug('formforge-migration') && class_exists('FORMFORGE_Migration')) {
            add_submenu_page(
                'formforge',
                __('Migration', 'formforge'),
                __('Migration', 'formforge'),
                'manage_options',
                'formforge-migration',
                [FORMFORGE_Migration::instance(), 'render_page']
            );
        }

        if (!$this->submenu_has_slug('formforge-license') && class_exists('FORMFORGE_License_Admin')) {
            add_submenu_page(
                'formforge',
                __('License', 'formforge'),
                __('License & Upgrade', 'formforge'),
                'manage_options',
                'formforge-license',
                [FORMFORGE_License_Admin::instance(), 'render_page']
            );
        }
    }

    private function submenu_has_slug(string $slug): bool {
        global $submenu;
        foreach (($submenu['formforge'] ?? []) as $item) {
            if (($item[2] ?? '') === $slug) {
                return true;
            }
        }
        return false;
    }

    /**
 * hide the "New form" submenu entry from the WP sidebar
 * via CSS while keeping the slug registered (so deep links still
 * land on the editor). add_action('admin_head') fires after WP
 * has rendered the sidebar, so the :has selector can target
 * the wrapping <li>.
 */
    public function hide_grouped_sidebar_items() {
        $hidden = ['formforge-new'];
        $selectors = [];
        foreach ($hidden as $slug) {
            $selectors[] = '#toplevel_page_formforge .wp-submenu li:has(> a[href$="page=' . $slug . '"])';
        }
        echo '<style id="formforge-hide-grouped-submenus">' .
             implode(',', $selectors) . // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- trusted CSS selectors built from internal admin page slugs
             '{display:none !important;}</style>';
    }

    /**
 * Canonical sidebar order: Forms, Submissions, Analytics [PRO],
 * Migration, Settings, License, Contact Us.
 */
    public function reorder_submenu() {
        global $submenu;
        if (empty($submenu['formforge'])) return;

        $sorted = [];
        foreach ($submenu['formforge'] as $idx => $item) {
            $slug  = isset($item[2]) ? (string) $item[2] : '';
            $title = isset($item[0]) ? strtolower(wp_strip_all_tags((string) $item[0])) : '';
            $pos   = 30 + $idx;

            if ($slug === 'formforge')                                                             $pos = 10;
            elseif ($slug === 'formforge-submissions')                                             $pos = 20;
            elseif ($slug === 'formforge-analytics')                                               $pos = 30;
            elseif (strpos($slug, 'migration') !== false || strpos($title, 'migrat') !== false)    $pos = 40;
            elseif ($slug === 'formforge-settings')                                                $pos = 50;
            elseif (strpos($title, 'license') !== false  || strpos($title, 'account') !== false
                 || strpos($title, 'affiliation') !== false || strpos($title, 'pricing') !== false
                 || strpos($title, 'upgrade') !== false  || strpos($title, 'add-ons') !== false)  $pos = 60;
            elseif (strpos($title, 'contact') !== false || strpos($title, 'support') !== false)   $pos = 70;

            $sorted[] = ['pos' => $pos, 'idx' => $idx, 'item' => $item];
        }
        usort($sorted, function ($a, $b) {
            if ($a['pos'] !== $b['pos']) return $a['pos'] - $b['pos'];
            return $a['idx'] - $b['idx'];
        });
        $submenu['formforge'] = array_map(function ($s) { return $s['item']; }, $sorted);
    }

    public function enqueue_admin($hook) {
        if (strpos($hook, 'formforge') === false && strpos($hook, 'ai-forms') === false) return;
        // v3: previous version used only filemtime of admin.js, so a
        // CSS-only change (today's Availability layout fix) shipped with the
        // unchanged ?ver= string and Hostinger / browser cache served the
        // stale stylesheet — exactly the same class of bug as (which
        // bit us on calendar-field.js earlier this week). Use a per-asset
        // version so each file gets its own cache-bust on its own mtime.
        $vjs  = FORMFORGE_VERSION . '.' . filemtime(FORMFORGE_DIR . 'assets/js/admin.js');
        $vcss = FORMFORGE_VERSION . '.' . filemtime(FORMFORGE_DIR . 'assets/css/admin.css');
        $vfront_css = FORMFORGE_VERSION . '.' . filemtime(FORMFORGE_DIR . 'assets/css/frontend.css');
        wp_enqueue_style('formforge-admin', FORMFORGE_URL . 'assets/css/admin.css', [], $vcss);
        wp_enqueue_style('formforge-frontend', FORMFORGE_URL . 'assets/css/frontend.css', [], $vfront_css);
        wp_enqueue_script('jquery-ui-sortable');
        wp_enqueue_script('jquery-ui-draggable');
        wp_enqueue_script('formforge-admin', FORMFORGE_URL . 'assets/js/admin.js', ['jquery', 'jquery-ui-sortable', 'jquery-ui-draggable'], $vjs, true);
        // Backward-compat: keep $v defined so any later code in this method
        // referring to it still works.
        $v = $vjs;
        wp_localize_script('formforge-admin', 'formforgeAdmin', [
            'ajaxUrl' => admin_url('admin-ajax.php'),
            'restUrl' => rest_url('formforge/v1'),
            'frontendCssUrl' => FORMFORGE_URL . 'assets/css/frontend.css?ver=' . rawurlencode($vfront_css),
            'conversationalCssUrl' => FORMFORGE_URL . 'assets/css/conversational.css?ver=' . rawurlencode(FORMFORGE_VERSION . '.' . filemtime(FORMFORGE_DIR . 'assets/css/conversational.css')),
            'previewBaseUrl' => home_url('/'),
            'shortcodeDocsUrl' => defined('FORGE_DOCS_URL_FORMFORGE') ? rtrim(FORGE_DOCS_URL_FORMFORGE, '/') . '/docs/user-guide/adding-a-form-to-your-website/' : '',
            'nonce' => wp_create_nonce('formforge_nonce'),
            'queueNonce' => wp_create_nonce('forge_queue_nonce'),
            'restNonce' => wp_create_nonce('wp_rest'),
            // PRO gating for the in-builder Conditional Logic editor:
            // free users see an upgrade nudge instead of the rule editor.
            'isPro' => FORMFORGE_License::instance()->can('conditional_logic'),
            'upgradeUrl' => FORMFORGE_License::instance()->upgrade_url(),
            'fieldTypeLabels' => array_map(function ($type) {
                return $type['label'] ?? '';
            }, FORMFORGE_Field_Types::get_types()),
            'i18n' => [
                'form' => __('Form', 'formforge'),
                'fieldWrapper' => __('Field wrapper', 'formforge'),
                'label' => __('Label', 'formforge'),
                'input' => __('Input', 'formforge'),
                'description' => __('Description', 'formforge'),
                'submitButton' => __('Submit button', 'formforge'),
                'message' => __('Success message', 'formforge'),
                'background' => __('Background', 'formforge'),
                'textColor' => __('Text color', 'formforge'),
                'borderColor' => __('Border color', 'formforge'),
                'borderWidth' => __('Border width', 'formforge'),
                'borderRadius' => __('Border radius', 'formforge'),
                'padding' => __('Padding', 'formforge'),
                'marginBottom' => __('Margin bottom', 'formforge'),
                'fontSize' => __('Font size', 'formforge'),
                'fontWeight' => __('Font weight', 'formforge'),
                'customCssDeclarations' => __('Custom CSS declarations', 'formforge'),
                'copyShortcode' => __('Copy shortcode', 'formforge'),
                'shortcodeInstruction' => __('Copy this shortcode and paste it into a page, post, widget, or page builder module.', 'formforge'),
                'embeddingForms' => __('Embedding forms', 'formforge'),
                'shortcodeDocsSummary' => __('Learn where to paste the shortcode and how to test the published form.', 'formforge'),
                'readDocs' => __('Read the docs', 'formforge'),
                'convNext' => __('Next →', 'formforge'),
                'convBack' => __('← Back', 'formforge'),
                'convSubmit' => __('Submit', 'formforge'),
                'leaveUnsaved' => __('You have unsaved form changes. Leave this page?', 'formforge'),
                'autoDraft' => __('Changes update the draft automatically.', 'formforge'),
                'close' => __('Close', 'formforge'),
                'aiRecommendation' => __('AI recommendation', 'formforge'),
                'aiOpenedField' => __('Review this field based on the selected AI recommendation.', 'formforge'),
                'fromAiAnalysis' => __('From AI Analysis', 'formforge'),
                'targetField' => __('Target field', 'formforge'),
                'aiTargetField' => __('AI target field', 'formforge'),
            ],
        ]);

        // Google Picker — only on the form editor screen, and only when the
        // Pro "Google Sheets" feature is available. drive.file scope means the
        // Picker / Drive API only see the spreadsheet the user picks. Gating the
        // enqueue keeps the external Google SDK off Free-only admin screens where
        // the Picker is inert (the token AJAX handler also requires this feature).
        if (strpos($hook, 'formforge') !== false && $this->can_feature('google_sheets')) {
            wp_enqueue_script('google-api', 'https://apis.google.com/js/api.js', [], FORMFORGE_VERSION, true);
            wp_enqueue_script(
                'formforge-google-picker',
                FORMFORGE_URL . 'assets/js/google-picker.js',
                ['google-api'],
                $v,
                true
            );
            wp_localize_script('formforge-google-picker', 'formforgePicker', [
                'ajaxUrl'  => admin_url('admin-ajax.php'),
                'nonce'    => wp_create_nonce('formforge_gpicker'),
                'clientId' => defined('FORMFORGE_GOOGLE_CLIENT_ID')
                    ? FORMFORGE_GOOGLE_CLIENT_ID
                    : '96252856401-3mm7gf4s9ekl3fnuajnutb4qjfd6imhd.apps.googleusercontent.com',
                'i18n'     => [
                    'pick_failed'   => __('Failed to open picker.', 'formforge'),
                    'not_connected' => __('Connect Google in Settings first.', 'formforge'),
                    'create_failed' => __('Could not create sheet.', 'formforge'),
                    'naming_prompt' => __('Name for the new spreadsheet:', 'formforge'),
                    'default_name'  => __('Form Forge Submissions', 'formforge'),
                ],
            ]);
        }
    }

    public function enqueue_frontend() {
        // Only load on pages with shortcode
        global $post;
        if ($post && (has_shortcode($post->post_content, 'formforge') || has_block('formforge/form', $post))) {
            // Per-asset cache-bust via filemtime so editor changes to the
            // conditional-logic runtime ship without manual version bumps.
            $vfront_js  = FORMFORGE_VERSION . '.' . filemtime(FORMFORGE_DIR . 'assets/js/frontend.js');
            $vfront_css = FORMFORGE_VERSION . '.' . filemtime(FORMFORGE_DIR . 'assets/css/frontend.css');
            wp_enqueue_style('formforge-frontend', FORMFORGE_URL . 'assets/css/frontend.css', [], $vfront_css);
            wp_enqueue_script('formforge-frontend', FORMFORGE_URL . 'assets/js/frontend.js', ['jquery'], $vfront_js, true);
            wp_localize_script('formforge-frontend', 'formforgeFront', [
                'ajaxUrl' => admin_url('admin-ajax.php'),
                'nonce' => wp_create_nonce('formforge_submit'),
            ]);
        }
    }

    /**
 * Handle CSV export of form submissions
 */
    public function handle_csv_export() {
        if (!isset($_GET['ff_export_csv']) || !current_user_can('manage_options')) return;
        if (!wp_verify_nonce(isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : '', 'ff_export_csv')) return;

        $form_id = intval($_GET['form_id'] ?? 0);
        if (!$form_id) return;

        global $wpdb;
        $form = FORMFORGE_Form_Builder::instance()->get($form_id);
        $subs = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query is intended.
            "SELECT * FROM {$wpdb->prefix}formforge_submissions WHERE form_id = %d ORDER BY created_at DESC", $form_id
        ));

        $filename = sanitize_file_name(($form ? $form->title : 'form') . '-submissions-' . gmdate('Y-m-d')) . '.csv';

        header('Content-Type: text/csv; charset=UTF-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Pragma: no-cache');

        $output = fopen('php://output', 'w');
        fwrite($output, "\xEF\xBB\xBF"); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Writing to php://output stream, not the filesystem. UTF-8 BOM

        // Header row
        $headers = ['ID', 'Date', 'Status', 'IP'];
        $field_keys = [];
        $field_labels = [];
        $display_rows = [];
        foreach ($subs as $sub) {
            $data = json_decode($sub->data, true);
            $display_fields = FORMFORGE_Value_Formatter::submission_display_fields(
                is_array($data) ? $data : [],
                [
                    'include_empty' => true,
                    'payment_status' => $sub->payment_status ?? '',
                ]
            );
            $display_rows[(int) $sub->id] = $display_fields;
            foreach ($display_fields as $key => $field) {
                if (in_array($key, $field_keys, true)) continue;
                $field_keys[] = $key;
                $field_labels[$key] = $field['label'] ?? $key;
            }
        }
        foreach ($field_keys as $key) {
            $headers[] = $field_labels[$key] ?? $key;
        }
        fputcsv($output, array_map([self::class, 'csv_safe'], $headers));

        // Data rows
        foreach ($subs as $sub) {
            $row = [$sub->id, $sub->created_at, $sub->status, $sub->ip_address];
            $data = $display_rows[(int) $sub->id] ?? [];
            foreach ($field_keys as $key) {
                $val = $data[$key] ?? '';
                if (is_array($val)) $val = $val['value'] ?? implode(', ', $val);
                $row[] = FORMFORGE_Value_Formatter::text($val, '');
            }
            $row = array_map([self::class, 'csv_safe'], $row);
            fputcsv($output, $row);
        }

        fclose($output); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the php://output stream, not a filesystem handle.
        exit;
    }

    private static function csv_safe($value) {
        if ($value === null) return '';
        $s = (string) $value;
        if (strlen($s) > 0 && in_array($s[0], ['=', '+', '-', '@', "\t", "\r"], true)) {
            return "'" . $s;
        }
        return $s;
    }

    /**
 * R4: shared field-label resolution for the PDF export.
 *
 * Mirrors FORMFORGE_Admin::resolve_field_label exactly so the
 * Submissions table, the View modal, and the PDF can never drift apart
 * on what a field is called. A submission's stored `label` can be empty,
 * missing, or just a copy of the field id (legacy-abandoned rows, some
 * field types like repeater / calculation / payment) — in those cases
 * fall back to the LIVE form definition's label, and only then to the
 * raw key.
 *
 * @param string $key Field id / submission data key.
 * @param array $field Stored submission field entry.
 * @param array $field_config_map id => live field config (from $form->fields).
 * @return string Human label.
 */
    private function resolve_pdf_field_label($key, $field, $field_config_map = []) {
        $key = (string) $key;
        $stored = is_array($field) ? trim((string) ($field['label'] ?? '')) : '';
        if ($stored !== '' && $stored !== $key) {
            return $stored;
        }
        if (isset($field_config_map[$key]) && is_array($field_config_map[$key])) {
            $live = trim((string) ($field_config_map[$key]['label'] ?? ''));
            if ($live !== '') {
                return $live;
            }
        }
        return $stored !== '' ? $stored : $key;
    }

    /**
 * R4: build the id => live field config map used by
 * resolve_pdf_field_label from a loaded form object.
 */
    private function build_field_config_map($form) {
        $map = [];
        if ($form && !empty($form->fields) && is_array($form->fields)) {
            foreach ($form->fields as $fc) {
                if (!empty($fc['id'])) {
                    $map[(string) $fc['id']] = $fc;
                }
            }
        }
        return $map;
    }

    /**
 * Build the synthetic Payment-details rows that get appended to
 * the PDF field list.
 *
 * Sources, in priority order:
 * - $data['_payment'] (set by webhook handler + on submit)
 * - the payment field row in $data (amount/currency saved on submit)
 * - the submission row's `payment_status` column (authoritative
 * post-webhook even if `_payment` wasn't rewritten)
 *
 * Returns an empty array if the submission has no payment metadata so
 * the section is skipped entirely — never render empty rows.
 */
    private function build_pdf_payment_rows($data, $submission_row) {
        if (!is_array($data)) return [];

        $payment_bag = isset($data['_payment']) && is_array($data['_payment']) ? $data['_payment'] : [];
        $field_row = null;
        foreach ($data as $field) {
            if (!is_array($field)) continue;
            if (($field['type'] ?? '') === 'payment') { $field_row = $field; break; }
        }

        $row_status = is_object($submission_row) ? (string) ($submission_row->payment_status ?? '') : '';

        $status = (string) ($payment_bag['status'] ?? '');
        if ($status === '' && $row_status !== '') $status = $row_status;

        $payment_id = (string) ($payment_bag['payment_id'] ?? '');
        if ($payment_id === '' && $field_row && is_string($field_row['value'] ?? null)) {
            $payment_id = (string) $field_row['value'];
        }

        $amount = isset($payment_bag['amount']) ? (int) $payment_bag['amount'] : 0;
        if ($amount <= 0 && $field_row && isset($field_row['amount'])) {
            $amount = (int) $field_row['amount'];
        }

        $currency = strtolower((string) ($payment_bag['currency'] ?? ''));
        if ($currency === '' && $field_row && !empty($field_row['currency'])) {
            $currency = strtolower((string) $field_row['currency']);
        }

        $updated_at = (string) ($payment_bag['updated_at'] ?? '');

        // No payment metadata — bail so the section is omitted entirely.
        if ($status === '' && $payment_id === '' && $amount <= 0) {
            return [];
        }

        $rows = [];
        if ($status !== '') {
            $rows['status'] = [
                'label' => __('Payment status', 'formforge'),
                'value' => $status,
                'type'  => 'text',
            ];
        }
        if ($amount > 0 && $currency !== '') {
            $rows['amount'] = [
                'label' => __('Payment amount', 'formforge'),
                'value' => strtoupper($currency) . ' ' . number_format($amount / 100, 2),
                'type'  => 'text',
            ];
        }
        if ($payment_id !== '') {
            $rows['payment_id'] = [
                'label' => __('Payment ID', 'formforge'),
                'value' => $payment_id,
                'type'  => 'text',
            ];
        }
        if ($updated_at !== '') {
            $rows['date'] = [
                'label' => __('Payment date', 'formforge'),
                'value' => $updated_at,
                'type'  => 'text',
            ];
        }

        return $rows;
    }

    /**
 * Handle PDF download for a submission.
 * Generates PDF bytes via Forge API, with a local PDF fallback.
 */
    public function handle_pdf_download() {
        if (!current_user_can('manage_options')) wp_die('Unauthorized');
        if (!wp_verify_nonce(isset($_GET['nonce']) ? sanitize_text_field(wp_unslash($_GET['nonce'])) : '', 'formforge_pdf')) wp_die('Invalid nonce');

        $form_id = intval($_GET['form_id'] ?? 0);
        $submission_id = intval($_GET['submission_id'] ?? 0);
        if (!$form_id || !$submission_id) wp_die('Missing parameters');

        global $wpdb;
        $sub = $wpdb->get_row($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query is intended.
            "SELECT * FROM {$wpdb->prefix}formforge_submissions WHERE id = %d AND form_id = %d",
            $submission_id, $form_id
        ));
        if (!$sub) wp_die('Submission not found');

        $form = FORMFORGE_Form_Builder::instance()->get($form_id);
        if (!$form) wp_die('Form not found');

        $data = json_decode($sub->data, true) ?: [];

        // Build fields array for PDF API.
        // skip internal keys (anything starting with `_` — e.g.
        // `_payment`, `_touched_fields`) so they don't bleed into the field
        // list as label-less rows. A clean Payment-details group is appended
        // below as synthetic rows that the Forge API + local fallback both
        // already render as ordinary label/value pairs.
        // R4: resolve labels through resolve_pdf_field_label — the
        // same resolver the bulk PDF and (mirrored) the View modal use — so
        // a field whose stored label is empty/missing still prints its real
        // name instead of the raw `f4` key.
        $field_config_map = $this->build_field_config_map($form);
        $fields = [];
        foreach ($data as $key => $field) {
            $key_str = (string) $key;
            if ($key_str !== '' && $key_str[0] === '_') continue;
            $fields[$key] = [
                'label' => $this->resolve_pdf_field_label($key, is_array($field) ? $field : [], $field_config_map),
                'value' => is_array($field) ? FORMFORGE_Value_Formatter::text($field['value'] ?? '', '') : '',
                'type' => is_array($field) ? ($field['type'] ?? 'text') : 'text',
            ];
        }

        // assemble a Payment-details section. If the submission
        // has no payment metadata, no rows are appended (skip the section).
        $payment_rows = $this->build_pdf_payment_rows($data, $sub);
        foreach ($payment_rows as $row_key => $row) {
            $fields['_payment_' . $row_key] = $row;
        }

        // Try Forge API first
        $result = FORMFORGE_Forge_API::instance()->generate_pdf(
            $form->title,
            $submission_id,
            $form_id,
            $fields,
            'pdf'
        );

        if (is_wp_error($result) || empty($result['body'])) {
            $pdf = $this->generate_local_pdf_bytes($form->title, $submission_id, $fields, $sub->created_at);
        } else {
            $pdf = (string) $result['body'];
        }

        nocache_headers();
        header('Content-Type: application/pdf');
        header('Content-Disposition: attachment; filename="submission-' . $submission_id . '.pdf"');
        header('Content-Length: ' . strlen($pdf));
        header('X-Content-Type-Options: nosniff');
        header('Referrer-Policy: no-referrer');
        echo $pdf; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw binary PDF bytes; escaping would corrupt the stream
        exit;
    }

    /**
 * bulk PDF export for the Submissions toolbar.
 *
 * Round 3 returnee. History: R1 the toolbar button ran window.print
 * and printed the whole WP admin page; R2 it auto-downloaded only the
 * "latest visible" submission (confusing); we then removed it entirely
 * — which the tester rejected. The toolbar action is back, and now does
 * what it should have all along: a proper BULK PDF export.
 *
 * Scope: form_id, like handle_csv_export — BUT with one deliberate
 * difference. Sergei's R4 retest: the bulk PDF was including
 * `abandoned` submissions, which render as essentially blank pages
 * (form title + Submission ID/date/site + footer, no field payload), so
 * the packet read as a broken export. Decision: a PDF packet is a set
 * of *records*; an abandoned draft is partial tracking data, not a
 * completed record — so the bulk PDF filters them out
 * (`status != 'abandoned'`). Export CSV intentionally keeps them: it is
 * for raw data analysis. If the CSV scope ever grows real filters, the
 * shared part of the WHERE clause must be kept in sync.
 *
 * Output: ONE application/pdf attachment, every filtered submission on
 * its own page, branded header per page, NO WP admin chrome — the same
 * clean surface as the row-level `formforge_download_pdf` endpoint
 * (reuses the very same local PDF builder).
 *
 * Guard: capped at BULK_PDF_MAX submissions, applied AFTER the
 * abandoned filter; over the cap the user gets a plain admin notice
 * telling them to narrow the filter or use CSV instead (CSV streams
 * row-by-row and has no such ceiling).
 */
    public function handle_bulk_pdf_download() {
        if (!current_user_can('manage_options')) wp_die('Unauthorized');
        if (!wp_verify_nonce(isset($_GET['nonce']) ? sanitize_text_field(wp_unslash($_GET['nonce'])) : '', 'formforge_bulk_pdf')) wp_die('Invalid nonce');

        $form_id = intval($_GET['form_id'] ?? 0);
        if (!$form_id) wp_die('Missing parameters');

        $form = FORMFORGE_Form_Builder::instance()->get($form_id);
        if (!$form) wp_die('Form not found');

        // Guard against runaway exports. The single-pass PDF assembler holds
        // every page stream in memory, so an unbounded export on a busy form
        // could exhaust PHP's memory_limit. CSV has no such ceiling because
        // it streams to php://output row by row — so that's what we point
        // the user at.
        $max = 500;

        global $wpdb;
        // R4: exclude `abandoned` submissions — they render blank in
        // the PDF. The cap below is checked against this filtered count, so
        // a form with thousands of abandoned drafts but few real records
        // still exports fine.
        $total = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query is intended.
            "SELECT COUNT(*) FROM {$wpdb->prefix}formforge_submissions WHERE form_id = %d AND status != 'abandoned'",
            $form_id
        ));

        if ($total > $max) {
            wp_die(
                sprintf(
                    /* translators: 1: number of matching submissions, 2: export cap */
                    esc_html__('This export matches %1$s submissions, which is over the %2$s-submission limit for a single PDF. Narrow the form/filter selection, or use Export CSV for the full set.', 'formforge'),
                    esc_html(number_format_i18n($total)),
                    esc_html(number_format_i18n($max))
                ),
                esc_html__('Export too large', 'formforge'),
                ['response' => 200, 'back_link' => true]
            );
        }

        $subs = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query is intended.
            "SELECT * FROM {$wpdb->prefix}formforge_submissions WHERE form_id = %d AND status != 'abandoned' ORDER BY created_at DESC",
            $form_id
        ));

        if (empty($subs)) {
            // R4: distinguish "form genuinely has no submissions" from
            // "every submission is an abandoned draft" — in the latter case
            // the admin clicked a button that looked like it should work, so
            // tell them WHY the PDF is empty and point them at CSV.
            $any = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query is intended.
                "SELECT COUNT(*) FROM {$wpdb->prefix}formforge_submissions WHERE form_id = %d",
                $form_id
            ));
            $message = $any > 0
                ? esc_html__('No completed submissions to export for the current filter — abandoned drafts are not included in PDF export; use Export CSV if you need them.', 'formforge')
                : esc_html__('There are no submissions to export for this form.', 'formforge');
            wp_die(
                $message, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $message already esc_html__'d at assignment
                esc_html__('Nothing to export', 'formforge'),
                ['response' => 200, 'back_link' => true]
            );
        }

        // R4: pass the live form's field map so each submission's
        // labels resolve the same way the table and the View modal do.
        $field_config_map = $this->build_field_config_map($form);
        $pdf = $this->generate_local_bulk_pdf_bytes($form->title, $subs, $field_config_map);

        $filename = sanitize_file_name(($form->title ?: 'form') . '-submissions-' . gmdate('Y-m-d')) . '.pdf';

        nocache_headers();
        header('Content-Type: application/pdf');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Content-Length: ' . strlen($pdf));
        header('X-Content-Type-Options: nosniff');
        header('Referrer-Policy: no-referrer');
        echo $pdf; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw binary PDF bytes; escaping would corrupt the stream
        exit;
    }

    /**
 * Download a private file attached to a submission.
 */
    public function handle_file_download() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized', 'formforge'), '', ['response' => 403]);

        $submission_id = intval($_GET['submission_id'] ?? 0);
        $field_key = sanitize_text_field(wp_unslash($_GET['field'] ?? ''));
        $token = sanitize_text_field(wp_unslash($_GET['token'] ?? ''));
        if (!$submission_id || $field_key === '' || $token === '') wp_die(esc_html__('Missing parameters', 'formforge'), '', ['response' => 400]);
        if (!wp_verify_nonce(isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : '', 'formforge_file_' . $submission_id . '_' . $field_key)) {
            wp_die(esc_html__('Invalid nonce', 'formforge'), '', ['response' => 403]);
        }

        global $wpdb;
        $sub = $wpdb->get_row($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query is intended.
            "SELECT * FROM {$wpdb->prefix}formforge_submissions WHERE id = %d",
            $submission_id
        ));
        if (!$sub) wp_die(esc_html__('Submission not found', 'formforge'), '', ['response' => 404]);

        $data = json_decode($sub->data, true) ?: [];
        $entry = $data[$field_key] ?? null;
        if (empty($entry['file']) || !is_array($entry['file'])) wp_die(esc_html__('File not found', 'formforge'), '', ['response' => 404]);
        $file = $entry['file'];
        if (!hash_equals((string) ($file['token'] ?? ''), $token)) wp_die(esc_html__('Invalid file token', 'formforge'), '', ['response' => 403]);

        $uploads = wp_upload_dir();
        $private_base = realpath(trailingslashit($uploads['basedir']) . 'formforge-private');
        $relative = ltrim((string) ($file['relative_path'] ?? ''), '/');
        $path = realpath(trailingslashit($uploads['basedir']) . $relative);
        if (!$private_base || !$path || strpos($path, $private_base) !== 0 || !is_file($path)) {
            wp_die(esc_html__('File not found', 'formforge'), '', ['response' => 404]);
        }

        $name = sanitize_file_name($file['name'] ?? basename($path));
        $mime = (string) ($file['mime'] ?? '');
        if ($mime === '') {
            $detected = wp_check_filetype($name);
            $mime = $detected['type'] ?: 'application/octet-stream';
        }

        nocache_headers();
        header('Content-Type: ' . $mime);
        header('Content-Length: ' . filesize($path));
        header('Content-Disposition: attachment; filename="' . rawurlencode($name) . '"');
        header('X-Content-Type-Options: nosniff');
        readfile($path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Streaming a validated private file to an authenticated user; no WP_Filesystem streaming equivalent.
        exit;
    }

    public function handle_delete_submission() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(['message' => __('Unauthorized', 'formforge')], 403);
        }

        $submission_id = intval($_POST['submission_id'] ?? 0);
        if (!$submission_id) {
            wp_send_json_error(['message' => __('Missing submission.', 'formforge')], 400);
        }

        if (!wp_verify_nonce(isset($_POST['_wpnonce']) ? sanitize_text_field(wp_unslash($_POST['_wpnonce'])) : '', 'formforge_delete_submission_' . $submission_id)) {
            wp_send_json_error(['message' => __('Invalid nonce.', 'formforge')], 403);
        }

        global $wpdb;
        $table = $wpdb->prefix . 'formforge_submissions';
        $sub = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $submission_id)); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, value is %d-prepared; custom plugin table.
        if (!$sub) {
            wp_send_json_error(['message' => __('Submission not found.', 'formforge')], 404);
        }

        $this->delete_submission_files($sub);
        $deleted = $wpdb->delete($table, ['id' => $submission_id], ['%d']); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query is intended.
        if (!$deleted) {
            wp_send_json_error(['message' => __('Could not delete submission.', 'formforge')], 500);
        }

        wp_send_json_success(['deleted' => $submission_id]);
    }

    private function delete_submission_files($submission) {
        $data = json_decode($submission->data ?? '', true);
        if (!is_array($data)) {
            return;
        }

        $uploads = wp_upload_dir();
        $private_base = realpath(trailingslashit($uploads['basedir']) . 'formforge-private');
        if (!$private_base) {
            return;
        }

        foreach ($data as $field) {
            if (empty($field['file']['relative_path'])) {
                continue;
            }
            $relative = ltrim((string) $field['file']['relative_path'], '/');
            $path = realpath(trailingslashit($uploads['basedir']) . $relative);
            if ($path && strpos($path, $private_base) === 0 && is_file($path)) {
                wp_delete_file($path);
            }
        }
    }

    /**
 * Local fallback: generate printable HTML for submission.
 */
    private function generate_local_pdf_html($title, $submission_id, $fields, $date) {
        $rows = '';
        foreach ($fields as $field) {
            $val = FORMFORGE_Value_Formatter::text($field['value'] ?? '', "\xE2\x80\x94");
            $rows .= '<tr><td style="padding:10px 12px;font-weight:600;color:#1e293b;width:35%;vertical-align:top;border-bottom:1px solid #f1f5f9;">' . esc_html($field['label']) . '</td>';
            $rows .= '<td style="padding:10px 12px;color:#475569;border-bottom:1px solid #f1f5f9;">' . esc_html($val) . '</td></tr>';
        }

        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>' . esc_html($title) . '</title>'
            . '<style>@media print{body{margin:0}.no-print{display:none}}body{font-family:Inter,-apple-system,sans-serif;color:#1e293b;max-width:800px;margin:0 auto;padding:40px}'
            . '.header{border-bottom:3px solid #3B82F6;padding-bottom:16px;margin-bottom:24px}.title{font-size:24px;font-weight:700;margin:0}'
            . '.meta{font-size:14px;color:#64748b;margin-top:6px}table{width:100%;border-collapse:collapse}'
            . '.footer{margin-top:32px;padding-top:16px;border-top:1px solid #e2e8f0;font-size:14px;color:#94a3b8}'
            . '.no-print{text-align:center;margin-bottom:20px}</style></head><body>'
            . '<div class="no-print"><button onclick="window.print()" style="padding:10px 24px;background:#3B82F6;color:#fff;border:none;border-radius:8px;font-size:14px;cursor:pointer;">Print / Save as PDF</button></div>'
            . '<div class="header"><h1 class="title">' . esc_html($title) . '</h1>'
            . '<div class="meta">Submission #' . $submission_id . ' &bull; ' . esc_html($date) . ' &bull; ' . esc_html(get_bloginfo('name')) . '</div></div>'
            . '<table>' . $rows . '</table>'
            . '<div class="footer">Generated by Form Forge</div></body></html>';
    }

    /**
 * Build the PDF content streams (one entry per physical page) for a
 * single submission: branded header, two-column field rows, footer.
 *
 * extracted from generate_local_pdf_bytes so the bulk export
 * endpoint can call it once per filtered submission and concatenate the
 * resulting page streams into one document. Each call starts its own
 * header + fresh stream, so the first row of every submission always
 * lands on a new page — exactly the "page break between submissions"
 * the bulk PDF needs. The single-submission path
 * (generate_local_pdf_bytes) feeds the same streams straight into
 * assemble_pdf_document, so its output stays byte-identical.
 *
 * @return string Page content streams.
 */
    private function build_pdf_page_streams($title, $submission_id, $fields, $date) {
        $pages = [];
        $stream = '';
        $y = 730;

        $text = function($font, $size, $x, $ypos, $value, $rgb = '0.118 0.161 0.231') {
            return $rgb . ' rg BT /' . $font . ' ' . (int) $size . ' Tf ' . (int) $x . ' ' . (int) $ypos . ' Td (' . $this->pdf_escape_text($value) . ") Tj ET\n";
        };

        $header = function() use (&$stream, &$y, $text, $title, $submission_id, $date) {
            $stream .= "0.231 0.510 0.965 rg 40 760 515 46 re f\n";
            $stream .= $text('F2', 16, 58, 788, $title, '1 1 1');
            $stream .= $text('F1', 9, 58, 770, 'Submission #' . $submission_id . ' | ' . $date . ' | ' . get_bloginfo('name'), '0.925 0.961 1');
            $y = 730;
        };

        $finish_page = function() use (&$pages, &$stream, $text) {
            if ($stream === '') return;
            $stream .= "0.89 0.92 0.96 rg 40 52 515 1 re f\n";
            $stream .= $text('F1', 8, 40, 34, 'Generated by Form Forge', '0.392 0.455 0.545');
            $pages[] = $stream;
            $stream = '';
        };

        $ensure_space = function($height) use (&$y, &$stream, $finish_page, $header) {
            if (($y - $height) < 75) {
                $finish_page();
                $header();
            }
        };

        $header();

        foreach ($fields as $field) {
            $label = (string) ($field['label'] ?? 'Field');
            $value = FORMFORGE_Value_Formatter::text($field['value'] ?? '', '-');
            $label_lines = $this->pdf_wrap_text($label, 24);
            $value_lines = $this->pdf_wrap_text($value, 58);
            $line_count = max(count($label_lines), count($value_lines));
            $row_height = max(44, 18 + ($line_count * 13));
            $ensure_space($row_height + 8);

            $row_y = $y - $row_height;
            $stream .= "0.973 0.980 0.988 rg 40 " . (int) $row_y . " 515 " . (int) $row_height . " re f\n";
            $stream .= "0.878 0.905 0.941 RG 40 " . (int) $row_y . " 515 " . (int) $row_height . " re S\n";
            $stream .= "0.878 0.905 0.941 RG 195 " . (int) $row_y . " 0 " . (int) $row_height . " l S\n";

            $line_y = $y - 20;
            foreach ($label_lines as $idx => $line) {
                $stream .= $text('F2', 9, 58, $line_y - ($idx * 13), $line, '0.118 0.161 0.231');
            }
            foreach ($value_lines as $idx => $line) {
                $stream .= $text('F1', 10, 214, $line_y - ($idx * 13), $line, '0.278 0.333 0.412');
            }
            $y = $row_y - 8;
        }

        if ($stream !== '') {
            $finish_page();
        }

        return $pages;
    }

    /**
 * Assemble a finished PDF byte string from an array of page content
 * streams (as produced by build_pdf_page_streams).
 *
 * extracted from generate_local_pdf_bytes so the single and
 * bulk export paths share one PDF object/xref writer. Output for a
 * given $pages array is byte-identical to the pre-refactor code.
 */
    private function assemble_pdf_document(array $pages) {
        if (empty($pages)) {
            $pages[] = "BT /F1 12 Tf 50 790 Td (No submission data.) Tj ET\n";
        }

        $objects = [];
        $objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
        $objects[3] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>';
        $objects[4] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>';

        $kids = [];
        $next = 5;
        foreach ($pages as $page_stream) {
            $page_obj = $next++;
            $content_obj = $next++;
            $kids[] = $page_obj . ' 0 R';
            $objects[$page_obj] = '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ' . $content_obj . ' 0 R >>';
            $objects[$content_obj] = "<< /Length " . strlen($page_stream) . " >>\nstream\n" . $page_stream . "endstream";
        }
        $objects[2] = '<< /Type /Pages /Kids [' . implode(' ', $kids) . '] /Count ' . count($kids) . ' >>';
        ksort($objects);

        $pdf = "%PDF-1.4\n";
        $offsets = [0];
        foreach ($objects as $num => $object) {
            $offsets[$num] = strlen($pdf);
            $pdf .= $num . " 0 obj\n" . $object . "\nendobj\n";
        }

        $xref = strlen($pdf);
        $count = max(array_keys($objects)) + 1;
        $pdf .= "xref\n0 " . $count . "\n";
        $pdf .= "0000000000 65535 f \n";
        for ($i = 1; $i < $count; $i++) {
            $pdf .= sprintf("%010d 00000 n \n", $offsets[$i] ?? 0);
        }
        $pdf .= "trailer\n<< /Size " . $count . " /Root 1 0 R >>\nstartxref\n" . $xref . "\n%%EOF";

        return $pdf;
    }

    /**
 * Local fallback: generate PDF bytes for a single submission.
 *
 * now a thin wrapper over build_pdf_page_streams +
 * assemble_pdf_document. Output is byte-identical to the previous
 * monolithic implementation — the row-level `formforge_download_pdf`
 * endpoint keeps behaving exactly as before.
 */
    private function generate_local_pdf_bytes($title, $submission_id, $fields, $date) {
        return $this->assemble_pdf_document(
            $this->build_pdf_page_streams($title, $submission_id, $fields, $date)
        );
    }

    /**
 * build the field array a PDF page needs from a raw submission
 * row, mirroring exactly what handle_pdf_download does for the
 * single-submission export — skip internal `_` keys, then append the
 * synthetic Payment-details rows. Shared so the bulk export renders
 * each submission identically to its row-level PDF.
 *
 * R4: takes the live form's id => field-config map and resolves
 * labels through resolve_pdf_field_label — the same resolver the
 * single-submission PDF uses and a mirror of the View modal's
 * resolve_field_label — so a field with an empty/missing stored label
 * still prints its real name (Price, Repeater, …) instead of `f4`.
 */
    private function build_pdf_fields_for_submission($submission_row, $field_config_map = []) {
        $data = json_decode($submission_row->data ?? '', true) ?: [];

        $fields = [];
        foreach ($data as $key => $field) {
            $key_str = (string) $key;
            if ($key_str !== '' && $key_str[0] === '_') continue;
            if (!is_array($field)) continue;
            $fields[$key] = [
                'label' => $this->resolve_pdf_field_label($key, $field, $field_config_map),
                'value' => FORMFORGE_Value_Formatter::text($field['value'] ?? '', ''),
                'type'  => $field['type'] ?? 'text',
            ];
        }

        $payment_rows = $this->build_pdf_payment_rows($data, $submission_row);
        foreach ($payment_rows as $row_key => $row) {
            $fields['_payment_' . $row_key] = $row;
        }

        return $fields;
    }

    /**
 * bulk PDF export. Generates ONE clean PDF document containing
 * every supplied submission, each starting on its own page (the page
 * break falls out of build_pdf_page_streams opening a fresh header
 * + stream per submission). Reuses the exact single-submission local
 * builder per row, so a submission looks the same here as in its
 * row-level PDF. No WP admin chrome — same output surface as the
 * row-level endpoint.
 *
 * @param string $form_title Form title (used in each page header).
 * @param object $submissions Submission rows from the DB.
 * @param array $field_config_map id => live field config, for label resolution (R4).
 * @return string PDF byte string.
 */
    private function generate_local_bulk_pdf_bytes($form_title, $submissions, $field_config_map = []) {
        $pages = [];
        foreach ($submissions as $sub) {
            $fields = $this->build_pdf_fields_for_submission($sub, $field_config_map);
            $pages = array_merge($pages, $this->build_pdf_page_streams(
                $form_title,
                (int) $sub->id,
                $fields,
                $sub->created_at
            ));
        }
        return $this->assemble_pdf_document($pages);
    }

    private function pdf_wrap_text($text, $max_chars) {
        $text = $this->pdf_clean_text($text);
        if ($text === '') {
            return ['-'];
        }
        return explode("\n", wordwrap($text, $max_chars, "\n", true));
    }

    private function pdf_clean_text($text) {
        $text = html_entity_decode(wp_strip_all_tags((string) $text), ENT_QUOTES, 'UTF-8');
        if (function_exists('iconv')) {
            $converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text);
            if ($converted !== false) {
                $text = $converted;
            }
        }
        $text = preg_replace('/[^\x20-\x7E]/', ' ', $text);
        return trim(preg_replace('/\s+/', ' ', $text));
    }

    private function pdf_escape_text($text) {
        return str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $this->pdf_clean_text($text));
    }

    /**
 * AJAX: Get available calendar time slots for a date.
 */
    private function calendar_slot_minutes($slot, $time_zone) {
        $label = '';
        if (is_array($slot) && !empty($slot['local_start'])) {
            $label = (string) $slot['local_start'];
        } elseif (is_string($slot)) {
            $label = $slot;
        }

        if ($label !== '' && preg_match('/^(\d{1,2}):(\d{2})/', $label, $m)) {
            $hours = (int) $m[1];
            $minutes = (int) $m[2];
            if ($hours >= 0 && $hours <= 23 && $minutes >= 0 && $minutes <= 59) {
                return ($hours * 60) + $minutes;
            }
        }

        if (is_array($slot) && !empty($slot['start'])) {
            try {
                $dt = new DateTimeImmutable((string) $slot['start']);
                $dt = $dt->setTimezone($time_zone);
                return ((int) $dt->format('G') * 60) + (int) $dt->format('i');
            } catch (Exception $e) {
                return null;
            }
        }

        return null;
    }

    private function filter_calendar_slots_by_availability($slots, $work_start, $work_end, $time_zone) {
        if (!is_array($slots)) return [];
        $min = max(0, min(23, (int) $work_start)) * 60;
        $max = max(1, min(24, (int) $work_end)) * 60;
        if ($max <= $min) return [];

        $filtered = [];
        foreach ($slots as $slot) {
            $minutes = $this->calendar_slot_minutes($slot, $time_zone);
            if ($minutes === null) continue;
            if ($minutes < $min || $minutes >= $max) continue;
            $filtered[] = $slot;
        }
        return $filtered;
    }

    public function handle_calendar_slots() {
        if (!wp_verify_nonce(isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '', 'formforge_submit')) {
            wp_send_json_error(['message' => 'Invalid nonce']);
        }
        // Origin/Referer check — parity with submit/save_partial. The
        // per-IP rate limit below caps abuse but doesn't stop a single
        // attacker from burning quota with one nonce + spoofed Origin.
        // Independent review (Codex 2026-05-26).
        if (class_exists('FORMFORGE_Anti_Spam') && !FORMFORGE_Anti_Spam::verify_origin()) {
            wp_send_json_error(['message' => __('Request origin not allowed.', 'formforge')], 403);
        }
        if (!$this->can_feature('google_calendar')) {
            wp_send_json_error(['message' => __('Google Calendar booking requires Form Forge Pro.', 'formforge')], 403);
        }

        // HIGH-3: per-IP rate limit. The endpoint hits the Forge API → Google
        // Calendar's freebusy quota (60 calls/min/calendar) on every call.
        // Without this gate, an attacker with one valid nonce can loop and
        // burn the form-owner's Google quota + our forge-api budget.
        $ip = isset($_SERVER['HTTP_CF_CONNECTING_IP'])
            ? sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']))
            : sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
        $rl_key = 'ff_cal_rl_' . md5($ip);
        $hits = (int) get_transient($rl_key);
        if ($hits >= 30) { // 30/min/IP — generous for legitimate UI clicks
            wp_send_json_error(['message' => __('Too many requests.', 'formforge')], 429);
        }
        set_transient($rl_key, $hits + 1, MINUTE_IN_SECONDS);

        $date = sanitize_text_field(wp_unslash($_POST['date'] ?? ''));
        $form_id = intval($_POST['form_id'] ?? 0);

        if (empty($date)) {
            wp_send_json_error(['message' => 'Missing date']);
        }

        // availability settings live on the calendar_picker FIELD.
        // Find the field, read its slot_duration / work_start / work_end /
        // available_days. Form-level Google Calendar card only contributes
        // calendar_id (for busy filtering) + timezone.
        // Backwards-compat: missing field keys fall back to form-level keys.
        $form = FORMFORGE_Form_Builder::instance()->get($form_id);
        $fs = ($form && isset($form->settings)) ? $form->settings : [];
        $picker = null;
        if ($form && isset($form->fields) && is_array($form->fields)) {
            foreach ($form->fields as $f) {
                if (($f['type'] ?? '') === 'calendar_picker') { $picker = $f; break; }
            }
        }
        $calendar_id   = (string) ($fs['calendar_id'] ?? '');
        $slot_duration = intval($picker['slot_duration'] ?? $fs['calendar_slot_duration'] ?? $_POST['slot_duration'] ?? 30);
        $work_start    = intval($picker['work_start']    ?? $fs['calendar_work_start']    ?? $_POST['work_start']    ?? 9);
        $work_end      = intval($picker['work_end']      ?? $fs['calendar_work_end']      ?? $_POST['work_end']      ?? 17);
        $available_days = isset($picker['available_days']) && is_array($picker['available_days'])
            ? array_map('intval', $picker['available_days'])
            : [];
        // v4: work_start/work_end are clock-face values entered by the
        // admin in the WP Settings → General "Timezone". This is what the
        // tester explicitly demanded — slot generation must interpret
        // work_start in the SITE timezone, NOT the form-level
        // calendar_timezone (which earlier code used). For a site in
        // Europe/Moscow (UTC+3) with work_start=3, the first slot has to
        // be 03:00+03:00 = 00:00Z; with the old code (form's
        // calendar_timezone = Europe/London) it came out 03:00 BST = 02:00Z.
        
        // calendar_timezone stays in form settings — it's still used by
        // fire_google_calendar to set the Google event's display timeZone
        // — but it must NOT influence slot generation.
        $cal_tz = function_exists('wp_timezone_string') ? wp_timezone_string() : '';
        if ($cal_tz === '') {
            $offset = (float) get_option('gmt_offset', 0);
            $hours = (int) $offset;
            $minutes = abs((int) (($offset - $hours) * 60));
            $cal_tz = sprintf('%+03d:%02d', $hours, $minutes);
        }
        $slot_tz = new DateTimeZone($cal_tz ?: 'UTC');

        // Day-of-week filter: if configured, drop the entire day if the
        // picked date isn't among the allowed days. ISO 1=Mon..7=Sun.
        if (!empty($available_days)) {
            $iso_dow = (int) gmdate('N', strtotime($date));
            if (!in_array($iso_dow, $available_days, true)) {
                wp_send_json_success(['slots' => []]);
                return;
            }
        }

        // Try Forge API (Google Calendar). trust the worker's response
        // even when it's an EMPTY available_slots array — that means
        // "everything in this day is busy", and falling back to local
        // generation would silently re-show those busy slots as available
        // (the original double-booking complaint). Only fall back when no
        // calendar is configured or the worker actually errored.
        if (!empty($calendar_id)) {
            $time_min = $date . 'T00:00:00Z';
            $time_max = $date . 'T23:59:59Z';
            $result = FORMFORGE_Forge_API::instance()->calendar_freebusy(
                $calendar_id, $time_min, $time_max, $slot_duration, $work_start, $work_end, $cal_tz
            );

            if (!is_wp_error($result) && isset($result['available_slots'])) {
                $slots = $this->filter_calendar_slots_by_availability(
                    $result['available_slots'],
                    $work_start,
                    $work_end,
                    $slot_tz
                );
                wp_send_json_success(['slots' => $slots]);
                return;
            }
        }

        // Fallback: generate all slots locally (no calendar check)
        $slots = [];
        for ($h = $work_start; $h < $work_end; $h++) {
            for ($m = 0; $m < 60; $m += $slot_duration) {
                $slots[] = sprintf('%02d:%02d', $h, $m);
            }
        }
        wp_send_json_success(['slots' => $slots]);
    }

    /**
 * AJAX: return a short-lived Google OAuth access_token so the admin
 * browser can initialize Google Picker. We never expose the refresh_token.
 * Admin-only: Picker is rendered only on the form editor.
 */
    public function handle_gpicker_token() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(['error' => 'forbidden'], 403);
        }
        if (!wp_verify_nonce(isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '', 'formforge_gpicker')) {
            wp_send_json_error(['error' => 'bad_nonce'], 403);
        }
        if (!$this->can_feature('google_sheets')) {
            wp_send_json_error(['error' => __('Google Sheets picker requires Form Forge Pro.', 'formforge')], 403);
        }

        $res = FORMFORGE_Forge_API::instance()->sheets_picker_token();
        if (is_wp_error($res)) {
            wp_send_json_error(['error' => $res->get_error_message()]);
        }
        if (empty($res['access_token'])) {
            wp_send_json_error(['error' => __('Google not connected.', 'formforge')]);
        }
        wp_send_json_success(['access_token' => $res['access_token']]);
    }

    /**
 * AJAX: return the user's Google Calendars for the per-form dropdown.
 */
    public function handle_calendar_list() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(['error' => 'forbidden'], 403);
        }
        if (!wp_verify_nonce(isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '', 'formforge_nonce')) {
            wp_send_json_error(['error' => 'bad_nonce'], 403);
        }
        if (!$this->can_feature('google_calendar')) {
            wp_send_json_error(['error' => __('Google Calendar integration requires Form Forge Pro.', 'formforge')], 403);
        }

        $res = FORMFORGE_Forge_API::instance()->calendar_list();
        if (is_wp_error($res)) wp_send_json_error(['error' => $res->get_error_message()]);
        wp_send_json_success(['calendars' => $res['calendars'] ?? []]);
    }

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

    public static function activate() {
        global $wpdb;
        $charset = $wpdb->get_charset_collate();

	        $sql1 = "CREATE TABLE {$wpdb->prefix}formforge_forms (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            title varchar(255) NOT NULL DEFAULT '',
            fields longtext DEFAULT '',
            settings text DEFAULT '',
            conditional_logic text DEFAULT '',
            status varchar(20) NOT NULL DEFAULT 'publish',
            created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (id)
        ) $charset;";

	        $sql2 = "CREATE TABLE {$wpdb->prefix}formforge_submissions (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            form_id bigint(20) unsigned NOT NULL,
            data longtext NOT NULL DEFAULT '',
            ip_address varchar(45) DEFAULT '',
            user_agent varchar(255) DEFAULT '',
            user_id bigint(20) unsigned DEFAULT 0,
            status varchar(20) NOT NULL DEFAULT 'new',
            session_id varchar(64) DEFAULT '',
            payment_status varchar(20) DEFAULT '',
            created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            KEY form_id (form_id),
            KEY status (status),
            KEY form_session_status (form_id, session_id, status),
            KEY payment_status (payment_status)
        ) $charset;";

	        $sql3 = "CREATE TABLE {$wpdb->prefix}formforge_analytics (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            form_id bigint(20) unsigned NOT NULL,
            event_type varchar(50) NOT NULL DEFAULT 'view',
            created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            KEY form_event (form_id, event_type, created_at)
        ) $charset;";

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta($sql1);
        dbDelta($sql2);
        dbDelta($sql3);
    }
}
