<?php
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_query,WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- NexDirectory uses trusted WordPress/custom table names and context-specific custom queries; user-controlled values are sanitized or prepared before use.

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

trait NEXDIRECTORY_Content_Trait {
    private $nexdirectory_inline_asset_index = 0;

    /**
     * Enqueues a plugin-owned stylesheet from the packaged assets directory.
     *
     * @param string $slug          Stable handle suffix.
     * @param string $relative_path Path relative to the plugin root.
     * @return void
     */
    public function enqueue_local_style_asset($slug, $relative_path) {
        $asset = $this->get_local_asset_reference($relative_path);
        if (empty($asset)) {
            return;
        }
        $handle = 'nexdirectory-' . sanitize_key((string) $slug);
        wp_enqueue_style($handle, $asset['url'], [], $asset['version']);
    }

    /**
     * Enqueues packaged JavaScript while preserving the former before/after
     * relationship with the main frontend runtime.
     *
     * @param string $slug          Stable handle suffix.
     * @param string $relative_path Path relative to the plugin root.
     * @param string $position      before|after.
     * @return void
     */
    public function enqueue_local_script_asset($slug, $relative_path, $position = 'before') {
        $asset = $this->get_local_asset_reference($relative_path);
        if (empty($asset)) {
            return;
        }

        $handle = 'nexdirectory-' . sanitize_key((string) $slug);
        $target = wp_script_is('nexdirectory-alpine', 'registered') ? 'nexdirectory-alpine' : 'nexdirectory-frontend';
        $after  = $position === 'after';
        wp_register_script($handle, $asset['url'], $after && wp_script_is($target, 'registered') ? [$target] : [], $asset['version'], true);

        if (!$after && wp_script_is($target, 'registered')) {
            $scripts = wp_scripts();
            if (isset($scripts->registered[$target])) {
                $dependencies = (array) $scripts->registered[$target]->deps;
                if (!in_array($handle, $dependencies, true)) {
                    array_unshift($dependencies, $handle);
                    $scripts->registered[$target]->deps = $dependencies;
                }
                wp_enqueue_script($target);
                return;
            }
        }

        wp_enqueue_script($handle);
    }

    /**
     * Resolves a packaged asset without permitting traversal or remote URLs.
     *
     * @param string $relative_path Path relative to the plugin root.
     * @return array{path:string,url:string,version:string}|array{}
     */
    private function get_local_asset_reference($relative_path) {
        $relative_path = ltrim(wp_normalize_path((string) $relative_path), '/');
        if ($relative_path === '' || strpos($relative_path, '..') !== false || !preg_match('/^[A-Za-z0-9_\\.\\/-]+$/', $relative_path)) {
            return [];
        }

        $root = plugin_dir_path(NEXDIRECTORY_PLUGIN_FILE);
        $path = wp_normalize_path($root . $relative_path);
        if (strpos($path, wp_normalize_path($root)) !== 0 || !is_readable($path)) {
            return [];
        }

        return [
            'path'    => $path,
            'url'     => plugins_url($relative_path, NEXDIRECTORY_PLUGIN_FILE),
            'version' => (string) filemtime($path),
        ];
    }

    /**
     * Backward-compatible inline helper retained for short dynamic fragments.
     *
     * Static CSS is packaged and enqueued through enqueue_local_style_asset().
     */
    public function enqueue_inline_style_asset($slug, $css) {
        $css = trim((string) $css);
        if ($css === '') {
            return;
        }
        $slug = sanitize_key((string) $slug);
        if ($slug === '') {
            $slug = 'inline-style-' . (++$this->nexdirectory_inline_asset_index);
        }
        $handle = 'nexdirectory-' . $slug;
        wp_register_style($handle, false, [], self::VERSION);
        wp_enqueue_style($handle);
        wp_add_inline_style($handle, wp_strip_all_tags($css));
    }

    public function enqueue_inline_script_asset($slug, $script, $position = 'before') {
        $script = trim((string) $script);
        if ($script === '') {
            return;
        }
        $script = str_replace('</script', '<\/script', wp_check_invalid_utf8($script));
        $slug = sanitize_key((string) $slug);
        if ($slug === '') {
            $slug = 'inline-script-' . (++$this->nexdirectory_inline_asset_index);
        }
        $target_handle = wp_script_is('nexdirectory-alpine', 'registered') ? 'nexdirectory-alpine' : 'nexdirectory-frontend';
        if (!wp_script_is($target_handle, 'registered')) {
            wp_register_script('nexdirectory-inline-runtime', false, [], self::VERSION, true);
            wp_enqueue_script('nexdirectory-inline-runtime');
            $target_handle = 'nexdirectory-inline-runtime';
        }
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Inline JavaScript is plugin-authored, UTF-8 checked above, and printed by WordPress.
        wp_add_inline_script($target_handle, $script, $position === 'after' ? 'after' : 'before');
    }

    private function create_rsvp_table() {
        global $wpdb;
        $table = $wpdb->prefix . self::RSVP_TABLE;
        $charset = $wpdb->get_charset_collate();
        $sql = "CREATE TABLE {$table} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            post_id bigint(20) unsigned NOT NULL,
            user_id bigint(20) unsigned NOT NULL,
            status varchar(20) NOT NULL DEFAULT 'yes',
            created_at datetime NOT NULL,
            updated_at datetime NOT NULL,
            PRIMARY KEY  (id),
            UNIQUE KEY post_user (post_id,user_id),
            KEY event_id (post_id),
            KEY user_id (user_id),
            KEY post_status (post_id,status),
            KEY user_status (user_id,status)
        ) {$charset};";

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


    private function create_inbox_tables() {
        global $wpdb;
        $threads_table = $wpdb->prefix . self::INBOX_THREADS_TABLE;
        $messages_table = $wpdb->prefix . self::INBOX_MESSAGES_TABLE;
        $charset = $wpdb->get_charset_collate();

        $threads_sql = "CREATE TABLE {$threads_table} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            post_id bigint(20) unsigned NOT NULL,
            participant_a bigint(20) unsigned NOT NULL,
            participant_b bigint(20) unsigned NOT NULL,
            subject varchar(191) NOT NULL DEFAULT '',
            created_at datetime NOT NULL,
            updated_at datetime NOT NULL,
            last_message_id bigint(20) unsigned NOT NULL DEFAULT 0,
            last_sender_id bigint(20) unsigned NOT NULL DEFAULT 0,
            PRIMARY KEY  (id),
            UNIQUE KEY event_pair (post_id,participant_a,participant_b),
            KEY participant_a (participant_a),
            KEY participant_b (participant_b),
            KEY updated_at (updated_at)
        ) {$charset};";

        $messages_sql = "CREATE TABLE {$messages_table} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            thread_id bigint(20) unsigned NOT NULL,
            post_id bigint(20) unsigned NOT NULL,
            sender_id bigint(20) unsigned NOT NULL,
            recipient_id bigint(20) unsigned NOT NULL,
            subject varchar(191) NOT NULL DEFAULT '',
            body longtext NOT NULL,
            attachment_url text NULL,
            created_at datetime NOT NULL,
            read_at datetime NULL DEFAULT NULL,
            PRIMARY KEY  (id),
            KEY thread_id (thread_id),
            KEY post_id (post_id),
            KEY sender_id (sender_id),
            KEY recipient_unread (recipient_id,read_at),
            KEY created_at (created_at)
        ) {$charset};";

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta($threads_sql);
        dbDelta($messages_sql);
    }

    /**
     * Creates the recipient-scoped account notification feed.
     *
     * @return void
     */
    private function create_notifications_table() {
        global $wpdb;
        $table = $wpdb->prefix . self::NOTIFICATIONS_TABLE;
        $charset = $wpdb->get_charset_collate();
        $sql = "CREATE TABLE {$table} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            recipient_id bigint(20) unsigned NOT NULL,
            actor_id bigint(20) unsigned NOT NULL DEFAULT 0,
            type varchar(50) NOT NULL DEFAULT 'system',
            object_id bigint(20) unsigned NOT NULL DEFAULT 0,
            secondary_id bigint(20) unsigned NOT NULL DEFAULT 0,
            title varchar(191) NOT NULL DEFAULT '',
            body text NULL,
            url text NULL,
            dedupe_key varchar(191) NOT NULL DEFAULT '',
            dedupe_token char(64) NULL DEFAULT NULL,
            created_at datetime NOT NULL,
            read_at datetime NULL DEFAULT NULL,
            PRIMARY KEY  (id),
            KEY recipient_created (recipient_id,created_at),
            KEY recipient_unread (recipient_id,read_at),
            KEY type_object (type,object_id),
            KEY recipient_dedupe (recipient_id,dedupe_key),
            UNIQUE KEY recipient_dedupe_token (recipient_id,dedupe_token)
        ) {$charset};";

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


    public function create_analytics_table() {
        global $wpdb;
        $table = $wpdb->prefix . self::ANALYTICS_TABLE;
        $charset = $wpdb->get_charset_collate();
        $sql = "CREATE TABLE {$table} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            event_id bigint(20) unsigned NOT NULL DEFAULT 0,
            owner_id bigint(20) unsigned NOT NULL DEFAULT 0,
            user_id bigint(20) unsigned NOT NULL DEFAULT 0,
            metric varchar(64) NOT NULL,
            context varchar(64) NOT NULL DEFAULT '',
            meta_key varchar(64) NOT NULL DEFAULT '',
            meta_value varchar(191) NOT NULL DEFAULT '',
            value decimal(18,4) NOT NULL DEFAULT 1.0000,
            created_at datetime NOT NULL,
            PRIMARY KEY  (id),
            KEY event_metric_date (event_id, metric, created_at),
            KEY owner_metric_date (owner_id, metric, created_at),
            KEY user_metric_date (user_id, metric, created_at),
            KEY metric_date (metric, created_at),
            KEY context_metric_date (context, metric, created_at),
            KEY metric_meta_lookup (metric, meta_key, meta_value(64))
        ) {$charset};";

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta($sql);
    }    /**
     * Returns every custom table expected for the current database schema.
     *
     * @return array<int, string>
     */
    private function get_required_schema_table_names() {
        global $wpdb;

        $tables = [
            $wpdb->prefix . self::RSVP_TABLE,
            $wpdb->prefix . self::INBOX_THREADS_TABLE,
            $wpdb->prefix . self::INBOX_MESSAGES_TABLE,
            $wpdb->prefix . self::NOTIFICATIONS_TABLE,
            $wpdb->prefix . self::ANALYTICS_TABLE,
        ];

        if (method_exists($this, 'create_private_files_table')) {
            $tables[] = $wpdb->prefix . 'nexdirectory_private_files';
        }

        return array_values(array_unique($tables));
    }


    /**
     * Verifies that every table required by the current schema exists.
     *
     * @return bool
     */
    private function required_schema_tables_exist() {
        global $wpdb;

        foreach ($this->get_required_schema_table_names() as $table_name) {
            $found = $wpdb->get_var(
                $wpdb->prepare(
                    'SHOW TABLES LIKE %s',
                    $wpdb->esc_like($table_name)
                )
            );
            if ((string) $found !== (string) $table_name) {
                return false;
            }
        }

        return true;
    }


    public function maybe_upgrade_schema() {
        if ((string) get_option('nexdirectory_role_schema_version', '') !== NEXDIRECTORY_ROLE_SCHEMA_VERSION) {
            self::add_roles();
            update_option('nexdirectory_role_schema_version', NEXDIRECTORY_ROLE_SCHEMA_VERSION, false);
        }

        $db_version = (string) get_option('nexdirectory_db_version', '');
        $verified_version = (string) get_option('nexdirectory_schema_verified_version', '');
        if ($db_version === NEXDIRECTORY_DB_VERSION && $verified_version === NEXDIRECTORY_DB_VERSION) {
            if ((string) get_option('nexdirectory_plugin_version', '') !== self::VERSION) {
                update_option('nexdirectory_plugin_version', self::VERSION, false);
            }
            return;
        }
        if ($db_version === NEXDIRECTORY_DB_VERSION && $this->required_schema_tables_exist()) {
            update_option('nexdirectory_schema_verified_version', NEXDIRECTORY_DB_VERSION, false);
            if ((string) get_option('nexdirectory_plugin_version', '') !== self::VERSION) {
                update_option('nexdirectory_plugin_version', self::VERSION, false);
            }
            return;
        }

        $this->create_rsvp_table();
        $this->create_inbox_tables();
        $this->create_notifications_table();
        $this->create_analytics_table();
        if (method_exists($this, 'create_private_files_table')) {
            $this->create_private_files_table();
        }
        $this->cleanup_retired_demo_brand_references();
        if (!$this->required_schema_tables_exist()) {
            delete_option('nexdirectory_schema_verified_version');
            return;
        }

        update_option('nexdirectory_db_version', NEXDIRECTORY_DB_VERSION, false);
        update_option('nexdirectory_schema_verified_version', NEXDIRECTORY_DB_VERSION, false);
        update_option('nexdirectory_plugin_version', self::VERSION, false);
        $this->schedule_privacy_retention_cleanup();
        /**
         * Allows licensed add-ons to refresh their own schedules after a core
         * schema upgrade without making the free plugin own premium jobs.
         */
        do_action('nexdirectory_extension_schedules_refresh', 'schema_upgrade');
    }


    /**
     * Removes references to the retired bundled demo logo without touching
     * unrelated Media Library files.
     *
     * @return void
     */
    private function cleanup_retired_demo_brand_references() {
        foreach ([
            'nexdirectory_frontend_sticky_nav_logo_url',
            'nexdirectory_frontend_footer_logo_url',
        ] as $option_name) {
            $option_value = (string) get_option($option_name, '');
            if ($option_value !== '' && stripos($option_value, 'nexusEventsLogo') !== false) {
                update_option($option_name, '', false);
            }
        }
        delete_option('eep_frontend_sticky_nav_logo_url');

        global $wpdb;
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT post_id, meta_key, meta_value
                FROM {$wpdb->postmeta}
                WHERE meta_value LIKE %s
                AND meta_key IN (
                    'eep_webapp_icon_url',
                    'eep_webapp_header_logo_url',
                    'nexdirectory_webapp_icon_url',
                    'nexdirectory_webapp_header_logo_url'
                )",
                '%nexusEventsLogo%'
            ),
            ARRAY_A
        );

        foreach ((array) $rows as $row) {
            $post_id = absint((int) ($row['post_id'] ?? 0));
            $meta_key = sanitize_key((string) ($row['meta_key'] ?? ''));
            $old_value = (string) ($row['meta_value'] ?? '');
            if ($post_id <= 0 || $meta_key === '' || $old_value === '') {
                continue;
            }

            if ($meta_key === 'nexdirectory_webapp_icon_url') {
                $cover_url = esc_url_raw((string) get_post_meta($post_id, 'nexdirectory_cover_image_url', true));
                if ($cover_url !== '' && stripos($cover_url, 'nexusEventsLogo') === false) {
                    update_post_meta($post_id, $meta_key, $cover_url, $old_value);
                    continue;
                }
            }

            delete_post_meta($post_id, $meta_key, $old_value);
        }
    }


    public function register_content() {
        $brand_name = method_exists($this, 'get_white_label_brand_name')
            ? (string) $this->get_white_label_brand_name()
            : __('Nex Directory Free', 'nexdirectory');
        $ui_labels = method_exists($this, 'get_site_profile_ui_labels') ? (array) $this->get_site_profile_ui_labels() : [];
        $singular_label = sanitize_text_field((string) ($ui_labels['singular'] ?? __('Event', 'nexdirectory')));
        $plural_label = sanitize_text_field((string) ($ui_labels['plural'] ?? __('Events', 'nexdirectory')));
        $taxonomy_type_singular = sanitize_text_field((string) ($ui_labels['taxonomy_type_singular'] ?? __('Event Type', 'nexdirectory')));
        $taxonomy_type_plural = sanitize_text_field((string) ($ui_labels['taxonomy_type_plural'] ?? __('Event Types', 'nexdirectory')));
        $taxonomy_mode_singular = sanitize_text_field((string) ($ui_labels['taxonomy_mode_singular'] ?? __('Event Mode', 'nexdirectory')));
        $taxonomy_mode_plural = sanitize_text_field((string) ($ui_labels['taxonomy_mode_plural'] ?? __('Event Modes', 'nexdirectory')));
        $taxonomy_tag_singular = sanitize_text_field((string) ($ui_labels['taxonomy_tag_singular'] ?? __('Event Tag', 'nexdirectory')));
        $taxonomy_tag_plural = sanitize_text_field((string) ($ui_labels['taxonomy_tag_plural'] ?? __('Event Tags', 'nexdirectory')));
        register_post_type(self::POST_TYPE, [
            'labels' => [
                'name' => trim($brand_name . ' ' . $plural_label),
                'singular_name' => $singular_label,
                /* translators: %s: Placeholder value. */
                'add_new' => sprintf(__('Add New %s', 'nexdirectory'), $singular_label),
                /* translators: %s: Placeholder value. */
                'add_new_item' => sprintf(__('Add New %s', 'nexdirectory'), $singular_label),
                /* translators: %s: Placeholder value. */
                'edit_item' => sprintf(__('Edit %s', 'nexdirectory'), $singular_label),
            ],
            'public' => true,
            'has_archive' => false,
            'rewrite' => ['slug' => 'events'],
            'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'author'],
            'show_in_rest' => true,
            'menu_icon' => 'dashicons-calendar-alt',
            'capability_type' => ['nexdirectory_event', 'nexdirectory_events'],
            'capabilities' => [
                // Frontend submissions use the plugin's own guarded workflow. Core
                // wp-admin/REST mutations are reserved for platform moderators so
                // they cannot bypass verification, moderation, or platform mode.
                'create_posts' => 'nexdirectory_moderate_events',
                'edit_posts' => 'nexdirectory_moderate_events',
                'edit_others_posts' => 'nexdirectory_moderate_events',
                'edit_private_posts' => 'nexdirectory_moderate_events',
                'edit_published_posts' => 'nexdirectory_moderate_events',
                'publish_posts' => 'nexdirectory_moderate_events',
                'delete_posts' => 'nexdirectory_moderate_events',
                'delete_others_posts' => 'nexdirectory_moderate_events',
                'delete_private_posts' => 'nexdirectory_moderate_events',
                'delete_published_posts' => 'nexdirectory_moderate_events',
                'read_private_posts' => 'nexdirectory_moderate_events',
            ],
            'map_meta_cap' => true,
        ]);

        // The public directory is a shortcode page while individual listings
        // share its URL prefix. Reserve /page/{n}/ for the page query before
        // the post-type permastructure can interpret "page" as a listing slug.
        $archive_page_slug = $this->get_profile_route_slug('events');
        if ($archive_page_slug !== '') {
            add_rewrite_rule(
                '^' . preg_quote($archive_page_slug, '#') . '/page/([0-9]+)/?$',
                'index.php?pagename=' . $archive_page_slug . '&paged=$matches[1]',
                'top'
            );
        }

        register_taxonomy(self::TAX_TYPE, self::POST_TYPE, [
            'labels' => [
                'name' => $taxonomy_type_plural,
                'singular_name' => $taxonomy_type_singular,
            ],
            'hierarchical' => true,
            'show_admin_column' => true,
            'rewrite' => ['slug' => 'event-type'],
            'show_in_rest' => true,
        ]);

        register_taxonomy(self::TAX_MODE, self::POST_TYPE, [
            'labels' => [
                'name' => $taxonomy_mode_plural,
                'singular_name' => $taxonomy_mode_singular,
            ],
            'hierarchical' => true,
            'show_admin_column' => true,
            'rewrite' => ['slug' => 'event-mode'],
            'show_in_rest' => true,
        ]);

        register_taxonomy(self::TAX_TAG, self::POST_TYPE, [
            'labels' => [
                'name' => $taxonomy_tag_plural,
                'singular_name' => $taxonomy_tag_singular,
            ],
            'hierarchical' => false,
            'show_admin_column' => true,
            'rewrite' => ['slug' => 'event-tag'],
            'show_in_rest' => true,
        ]);

        register_taxonomy(self::TAX_PRICE, self::POST_TYPE, [
            'labels' => [
                'name' => __('Price Ranges', 'nexdirectory'),
                'singular_name' => __('Price Range', 'nexdirectory'),
            ],
            'hierarchical' => true,
            'show_admin_column' => true,
            'rewrite' => ['slug' => 'event-price-range'],
            'show_in_rest' => true,
        ]);

        foreach ($this->get_custom_taxonomy_definitions() as $taxonomy_row) {
            $taxonomy_slug = sanitize_key((string) ($taxonomy_row['slug'] ?? ''));
            if ($taxonomy_slug === '' || taxonomy_exists($taxonomy_slug)) {
                continue;
            }
            $taxonomy_label = sanitize_text_field((string) ($taxonomy_row['label'] ?? $taxonomy_slug));
            $taxonomy_hierarchical = (string) ($taxonomy_row['hierarchical'] ?? '1') === '1';
            $rewrite_slug = nexdirectory_sanitize_title(str_replace('nexdirectory_', '', $taxonomy_slug));
            if ($rewrite_slug === '') {
                $rewrite_slug = $taxonomy_slug;
            }

            register_taxonomy($taxonomy_slug, self::POST_TYPE, [
                'labels' => [
                    'name' => $taxonomy_label,
                    'singular_name' => $taxonomy_label,
                ],
                'hierarchical' => $taxonomy_hierarchical,
                'show_admin_column' => true,
                'show_ui' => true,
                'rewrite' => ['slug' => $rewrite_slug],
                'show_in_rest' => true,
            ]);
        }

        $this->maybe_flush_rewrite_rules_once();
    }


    private function maybe_flush_rewrite_rules_once() {
        $signature = 'nexdirectory-rewrite-archive-page-pagination-v4';
        $stored = (string) get_option('nexdirectory_rewrite_signature', '');
        if ($stored === $signature) {
            return;
        }
        flush_rewrite_rules(false);
        update_option('nexdirectory_rewrite_signature', $signature, false);
    }


    public function register_shortcodes() {
        $shortcodes = [
            'nexdirectory_events_archive' => 'render_events_archive',
            'nexdirectory_events_map' => 'render_events_map',
            'nexdirectory_events_calendar' => 'render_events_calendar',
            'nexdirectory_listings_archive' => 'render_events_archive',
            'nexdirectory_listings_map' => 'render_events_map',
            'nexdirectory_listings_calendar' => 'render_events_calendar',
            'nexdirectory_events_query_grid' => 'render_events_query_grid',
            'nexdirectory_events_bento_grid' => 'render_events_bento_grid',
            'nexdirectory_events_ajax_search' => 'render_events_ajax_search',
            'nexdirectory_submit_event' => 'render_submit_event',
            'nexdirectory_submit' => 'render_submit_event',
            'nexdirectory_my_events' => 'render_my_events',
            'nexdirectory_my_listings' => 'render_my_events',
            'nexdirectory_my_tickets' => 'render_my_tickets',
            'nexdirectory_saved_listings' => 'render_my_tickets',
            'nexdirectory_whos_attending' => 'render_whos_attending',
            'nexdirectory_attendees' => 'render_whos_attending',
            'nexdirectory_notifications' => 'render_notifications_center',
            'nexdirectory_whats_new' => 'render_whats_new',
            'nexdirectory_account' => 'render_account',
            'nexdirectory_auth' => 'render_auth',
            'nexdirectory_auth_links' => 'render_auth_links',
            'nexdirectory_user_management' => 'render_user_management',
            'nexdirectory_moderation_queue' => 'render_moderation_queue',
            'nexdirectory_organizers' => 'render_organizers',
            'nexdirectory_claim_listing' => 'render_claim_listing',
        ];

        foreach ($shortcodes as $tag => $method) {
            if (method_exists($this, $method)) {
                add_shortcode($tag, [$this, 'render_registered_shortcode']);
            }
        }

        /*
         * Preserve clean output if a page created by the separate Pro add-on
         * remains after Pro is paused. A licensed Pro module registers the real
         * callback later in the init sequence and replaces these empty guards.
         */
        foreach (['nexdirectory_event_chatbot', 'nexdirectory_event_webapp'] as $pro_shortcode) {
            if (!shortcode_exists($pro_shortcode)) {
                add_shortcode($pro_shortcode, [$this, 'render_inactive_pro_shortcode']);
            }
        }

        /**
         * Allows licensed extensions to register their own shortcodes without
         * shipping premium callbacks in the Free plugin.
         */
        do_action('nexdirectory_register_extension_shortcodes', $this);
    }

    /**
     * Prevents raw Pro shortcode text when the separate add-on is unavailable.
     *
     * @param array<string, mixed> $atts Shortcode attributes.
     * @param string|null          $content Enclosed content.
     * @param string               $tag Shortcode tag.
     * @return string
     */
    public function render_inactive_pro_shortcode($atts = [], $content = null, $tag = '') {
        unset($atts, $content, $tag);
        return '';
    }

    /**
     * Central shortcode entrypoint so app wrappers never diverge between shortcode tags.
     *
     * @param array<string, mixed>|array<int, mixed> $atts
     * @param string|null $content
     * @param string $tag
     * @return string
     */
    public function render_registered_shortcode($atts = [], $content = null, $tag = '') {
        $resolved_tag = $this->normalize_shortcode_tag($tag);
        if ($resolved_tag === '') {
            return '';
        }

        $output = $this->render_shortcode_tag($resolved_tag, (array) $atts);
        if (!is_string($output) || $output === '') {
            return '';
        }

        $view = $this->get_shortcode_app_view($resolved_tag);
        $markup = $view === ''
            ? $output
            : $this->wrap_app_shortcode_output($output, $view);

        return $this->escape_registered_callback_markup($markup);
    }

    /**
     * Escapes generated shortcode/filter HTML before returning it to WordPress.
     *
     * The plugin renders complex UI with forms, SVG icons, Alpine bindings, and
     * WordPress-printed inline assets. This keeps that generated structure while
     * still passing it through WordPress' HTML sanitizer before callback return.
     *
     * @param string $markup
     * @return string
     */
    private function escape_registered_callback_markup($markup) {
        return esc_nexdirectory_plugin_markup($markup);
    }

    /**
     * Converts Alpine shorthand attributes to parser-friendly longhand names.
     *
     * @param string $markup
     * @return string
     */
    private function normalize_alpine_shorthand_attributes($markup) {
        return NEXDIRECTORY_Escaping::normalize_alpine_shorthand_attributes($markup);
    }

    /**
     * Converts previous-prefix shortcodes in saved page content before shortcode rendering runs.
     *
     * @param string $content
     * @return string
     */
    public function normalize_legacy_shortcode_content($content) {
        return NEXDIRECTORY_Shortcodes::normalize_legacy_content($content, $this->get_frontend_shortcode_tags());
    }

    /**
     * Converts legacy/builder shortcode aliases to canonical tags.
     *
     * @param string $tag
     * @return string
     */
    private function normalize_shortcode_tag($tag) {
        $tag = NEXDIRECTORY_Shortcodes::normalize_tag($tag);
        if ($tag === '') {
            return '';
        }
        if (strpos($tag, 'nexdirectory_') === 0) {
            return 'nexdirectory_' . substr($tag, strlen('nexdirectory_'));
        }
        return $tag;
    }

    /**
     * App-page shortcode to view-key mapping.
     *
     * @return array<string, string>
     */
    private function get_app_shortcode_view_map() {
        $map = [
            'nexdirectory_events_archive' => 'events',
            'nexdirectory_listings_archive' => 'events',
            'nexdirectory_events_map' => 'map',
            'nexdirectory_listings_map' => 'map',
            'nexdirectory_events_calendar' => 'calendar',
            'nexdirectory_listings_calendar' => 'calendar',
            'nexdirectory_submit_event' => 'submit',
            'nexdirectory_submit' => 'submit',
            'nexdirectory_my_events' => 'my-events',
            'nexdirectory_my_listings' => 'my-events',
            'nexdirectory_my_tickets' => 'my-tickets',
            'nexdirectory_saved_listings' => 'my-tickets',
            'nexdirectory_whos_attending' => 'whos-attending',
            'nexdirectory_attendees' => 'whos-attending',
            'nexdirectory_notifications' => 'notifications',
            'nexdirectory_whats_new' => 'whats-new',
            'nexdirectory_account' => 'account',
            'nexdirectory_auth' => 'auth',
            'nexdirectory_user_management' => 'user-management',
            'nexdirectory_moderation_queue' => 'moderation-queue',
            'nexdirectory_organizers' => 'organizers',
            'nexdirectory_claim_listing' => 'claim-listing',
        ];

        return (array) apply_filters('nexdirectory_extension_app_shortcode_view_map', $map, $this);
    }

    /**
     * Resolves app view key for a shortcode, when applicable.
     *
     * @param string $tag
     * @return string
     */
    private function get_shortcode_app_view($tag) {
        $tag = $this->normalize_shortcode_tag($tag);
        $map = $this->get_app_shortcode_view_map();
        return isset($map[$tag]) ? sanitize_key((string) $map[$tag]) : '';
    }

    /**
     * Wraps app shortcodes in a single canonical root container.
     *
     * @param string $output
     * @param string $view
     * @return string
     */
    private function wrap_app_shortcode_output($output, $view) {
        $view_attr = strtoupper(sanitize_key((string) $view));
        return '<div id="nexdirectory-app" class="nexdirectory-app" data-nexdirectory-view="' . esc_attr($view_attr) . '">' . $output . '</div>';
    }


    public function render_shortcode_tag($tag, $atts = []) {
        $tag = $this->normalize_shortcode_tag($tag);
        switch ($tag) {
            case 'nexdirectory_events_archive':
            case 'nexdirectory_listings_archive':
                return $this->render_events_archive((array) $atts);
            case 'nexdirectory_events_map':
            case 'nexdirectory_listings_map':
                return $this->render_events_map((array) $atts);
            case 'nexdirectory_events_calendar':
            case 'nexdirectory_listings_calendar':
                return $this->render_events_calendar((array) $atts);
            case 'nexdirectory_events_query_grid':
                return $this->render_events_query_grid((array) $atts);
            case 'nexdirectory_events_bento_grid':
                return $this->render_events_bento_grid((array) $atts);
            case 'nexdirectory_events_ajax_search':
                return $this->render_events_ajax_search((array) $atts);
            case 'nexdirectory_submit_event':
            case 'nexdirectory_submit':
                return $this->render_submit_event((array) $atts);
            case 'nexdirectory_my_events':
            case 'nexdirectory_my_listings':
                return $this->render_my_events((array) $atts);
            case 'nexdirectory_my_tickets':
            case 'nexdirectory_saved_listings':
                return $this->render_my_tickets((array) $atts);
            case 'nexdirectory_whos_attending':
            case 'nexdirectory_attendees':
                return $this->render_whos_attending((array) $atts);
            case 'nexdirectory_notifications':
                return $this->render_notifications_center((array) $atts);
            case 'nexdirectory_whats_new':
                return $this->render_whats_new((array) $atts);
            case 'nexdirectory_account':
                return $this->render_account((array) $atts);
            case 'nexdirectory_auth':
                return $this->render_auth((array) $atts);
            case 'nexdirectory_auth_links':
                return $this->render_auth_links((array) $atts);
            case 'nexdirectory_user_management':
                return $this->render_user_management((array) $atts);
            case 'nexdirectory_moderation_queue':
                return $this->render_moderation_queue((array) $atts);
            case 'nexdirectory_organizers':
                return $this->render_organizers((array) $atts);
            case 'nexdirectory_claim_listing':
                return $this->render_claim_listing((array) $atts);
            default:
                return (string) apply_filters(
                    'nexdirectory_render_extension_shortcode',
                    '',
                    $tag,
                    (array) $atts,
                    $this
                );
        }
    }


    private function ensure_default_pages() {
        $ui_labels = method_exists($this, 'get_site_profile_ui_labels') ? (array) $this->get_site_profile_ui_labels() : [];
        $profile_key = method_exists($this, 'get_site_profile_key') ? (string) $this->get_site_profile_key() : 'events';
        $singular_label = sanitize_text_field((string) ($ui_labels['singular'] ?? __('Event', 'nexdirectory')));
        $plural_label = sanitize_text_field((string) ($ui_labels['plural'] ?? __('Events', 'nexdirectory')));
        $owner_plural = sanitize_text_field((string) ($ui_labels['owner_plural'] ?? __('Organizers', 'nexdirectory')));
        /* translators: %s: Placeholder value. */
        $archive_title = sanitize_text_field((string) ($ui_labels['archive_title'] ?? sprintf(__('View %s', 'nexdirectory'), $plural_label)));
        /* translators: %s: Placeholder value. */
        $map_title = sanitize_text_field((string) ($ui_labels['map_title'] ?? sprintf(__('%s Map', 'nexdirectory'), $singular_label)));
        /* translators: %s: Placeholder value. */
        $calendar_title = sanitize_text_field((string) ($ui_labels['calendar_title'] ?? sprintf(__('%s Calendar', 'nexdirectory'), $plural_label)));
        /* translators: %s: Placeholder value. */
        $submit_title = sanitize_text_field((string) ($ui_labels['submit_add_title'] ?? sprintf(__('Add %s', 'nexdirectory'), $singular_label)));
        /* translators: %s: Placeholder value. */
        $my_items_title = sanitize_text_field((string) ($ui_labels['my_items_title'] ?? sprintf(__('My %s', 'nexdirectory'), $plural_label)));
        /* translators: %s: Placeholder value. */
        $attending_title = sanitize_text_field((string) ($ui_labels['attending_title'] ?? sprintf(__('Saved %s', 'nexdirectory'), $plural_label)));
        $attendees_title = sanitize_text_field((string) ($ui_labels['is_events_profile'] ?? '1') === '1'
            ? __('Who\'s Attending', 'nexdirectory')
            /* translators: %s: Placeholder value. */
            : sprintf(__('Who Saved This %s', 'nexdirectory'), $singular_label)
        );
        $pages = [
            [
                'slug' => $this->get_profile_route_slug('events', $profile_key),
                'title' => $archive_title !== '' ? $archive_title : __('Events', 'nexdirectory'),
                'shortcode' => '[nexdirectory_listings_archive]',
                'view' => 'events',
            ],
            [
                'slug' => $this->get_profile_route_slug('map', $profile_key),
                'title' => $map_title !== '' ? $map_title : __('Map View', 'nexdirectory'),
                'shortcode' => '[nexdirectory_listings_map]',
                'view' => 'map',
            ],
            [
                'slug' => $this->get_profile_route_slug('calendar', $profile_key),
                'title' => $calendar_title !== '' ? $calendar_title : __('Events Calendar', 'nexdirectory'),
                'shortcode' => '[nexdirectory_listings_calendar]',
                'view' => 'calendar',
            ],
            ['slug' => 'whats-new', 'title' => 'What\'s New', 'shortcode' => '[nexdirectory_whats_new]', 'view' => 'whats-new'],
            [
                'slug' => $this->get_profile_route_slug('submit', $profile_key),
                /* translators: %s: Placeholder value. */
                'title' => $submit_title !== '' ? $submit_title : sprintf(__('Add %s', 'nexdirectory'), $singular_label),
                'shortcode' => '[nexdirectory_submit]',
                'view' => 'submit',
            ],
            [
                'slug' => $this->get_profile_route_slug('my_items', $profile_key),
                /* translators: %s: Placeholder value. */
                'title' => $my_items_title !== '' ? $my_items_title : sprintf(__('My %s', 'nexdirectory'), $plural_label),
                'shortcode' => '[nexdirectory_my_listings]',
                'view' => 'my-events',
            ],
            [
                'slug' => $this->get_profile_route_slug('attending', $profile_key),
                'title' => $attending_title !== '' ? $attending_title : __('Events I\'m Attending', 'nexdirectory'),
                'shortcode' => '[nexdirectory_saved_listings]',
                'view' => 'my-tickets',
            ],
            [
                'slug' => $this->get_profile_route_slug('attendees', $profile_key),
                'title' => $attendees_title !== '' ? $attendees_title : __('Who\'s Attending', 'nexdirectory'),
                'shortcode' => '[nexdirectory_whos_attending]',
                'view' => 'whos-attending',
            ],
            ['slug' => 'notifications', 'title' => 'Notifications', 'shortcode' => '[nexdirectory_notifications]', 'view' => 'notifications'],
            [
                'slug' => $this->get_profile_route_slug('account', $profile_key),
                'title' => 'Account',
                'shortcode' => '[nexdirectory_account]',
                'view' => 'account',
            ],
            [
                'slug' => $this->get_profile_route_slug('auth', $profile_key),
                'title' => 'Login / Sign Up',
                'shortcode' => '[nexdirectory_auth]',
                'view' => 'auth',
            ],
            ['slug' => 'user-management', 'title' => 'User Management', 'shortcode' => '[nexdirectory_user_management]', 'view' => 'user-management'],
            ['slug' => 'moderation-queue', 'title' => 'Moderation Queue', 'shortcode' => '[nexdirectory_moderation_queue]', 'view' => 'moderation-queue'],
            ['slug' => 'organizers', 'title' => $owner_plural !== '' ? $owner_plural : __('Organizers', 'nexdirectory'), 'shortcode' => '[nexdirectory_organizers]', 'view' => 'organizers'],
            [
                'slug' => $this->get_profile_route_slug('claim', $profile_key),
                'title' => 'Claim Listing',
                'shortcode' => '[nexdirectory_claim_listing]',
                'view' => 'claim-listing',
            ],
        ];
        $pages = (array) apply_filters('nexdirectory_extension_default_pages', $pages, $profile_key, $ui_labels, $this);

        foreach ($pages as $page) {
            if (!is_array($page)) {
                continue;
            }
            $slug = nexdirectory_sanitize_title((string) ($page['slug'] ?? ''));
            if ($slug === '') {
                continue;
            }
            $existing = get_page_by_path($slug);
            if ($existing instanceof WP_Post) {
                $existing_status = (string) get_post_status((int) $existing->ID);
                $expected_shortcode = trim((string) ($page['shortcode'] ?? ''));
                $existing_content = (string) $existing->post_content;
                $has_expected_shortcode = $expected_shortcode !== '' && stripos($existing_content, $expected_shortcode) !== false;
                $page_view = sanitize_key((string) ($page['view'] ?? ''));
                if ($has_expected_shortcode && $page_view !== '') {
                    update_post_meta((int) $existing->ID, '_nexdirectory_is_app_page', '1');
                    update_post_meta((int) $existing->ID, '_nexdirectory_app_view', $page_view);
                } elseif (!$has_expected_shortcode) {
                    delete_post_meta((int) $existing->ID, '_nexdirectory_is_app_page');
                    delete_post_meta((int) $existing->ID, '_nexdirectory_app_view');
                }
                if ($has_expected_shortcode && $existing_status !== 'publish') {
                    wp_update_post([
                        'ID' => (int) $existing->ID,
                        'post_status' => 'publish',
                    ]);
                }
                continue;
            }

            $inserted_page_id = wp_insert_post([
                'post_type' => 'page',
                'post_status' => 'publish',
                'post_title' => $page['title'],
                'post_name' => $slug,
                'post_content' => $page['shortcode'],
            ]);
            if ($inserted_page_id > 0) {
                $page_view = sanitize_key((string) ($page['view'] ?? ''));
                if ($page_view !== '') {
                    update_post_meta((int) $inserted_page_id, '_nexdirectory_is_app_page', '1');
                    update_post_meta((int) $inserted_page_id, '_nexdirectory_app_view', $page_view);
                    update_post_meta((int) $inserted_page_id, '_nexdirectory_created_by_plugin', '1');
                }
            }
        }
    }


    private function get_site_profile_catalog() {
        return [
            'events' => [
                'label' => __('Events (default)', 'nexdirectory'),
                'description' => __('Classic event discovery, map, RSVP, and calendar flows.', 'nexdirectory'),
                'singular' => __('Event', 'nexdirectory'),
                'plural' => __('Events', 'nexdirectory'),
                'owner_singular' => __('Organizer', 'nexdirectory'),
                'owner_plural' => __('Organizers', 'nexdirectory'),
                'classification_entity' => __('Event', 'nexdirectory'),
                'hero_kicker' => __('Discover Events', 'nexdirectory'),
                'demo_profile' => 'events',
            ],
            'directory' => [
                'label' => __('Directory (Listings)', 'nexdirectory'),
                'description' => __('General-purpose listings directory mode for businesses, services, and organizations.', 'nexdirectory'),
                'singular' => __('Listing', 'nexdirectory'),
                'plural' => __('Listings', 'nexdirectory'),
                'owner_singular' => __('Owner', 'nexdirectory'),
                'owner_plural' => __('Owners', 'nexdirectory'),
                'classification_entity' => __('Listing', 'nexdirectory'),
                'hero_kicker' => __('Directory Listing', 'nexdirectory'),
                'demo_profile' => 'directory',
            ],
            'job_board' => [
                'label' => __('Job Board', 'nexdirectory'),
                'description' => __('Open role listings, employers, and recruitment workflows.', 'nexdirectory'),
                'singular' => __('Job', 'nexdirectory'),
                'plural' => __('Jobs', 'nexdirectory'),
                'owner_singular' => __('Employer', 'nexdirectory'),
                'owner_plural' => __('Employers', 'nexdirectory'),
                'classification_entity' => __('Job Listing', 'nexdirectory'),
                'hero_kicker' => __('Job Listing', 'nexdirectory'),
                'demo_profile' => 'job_board',
            ],
            'marketplace' => [
                'label' => __('Marketplace / Classifieds', 'nexdirectory'),
                'description' => __('Classified-style listings for products and local buying/selling.', 'nexdirectory'),
                'singular' => __('Listing', 'nexdirectory'),
                'plural' => __('Listings', 'nexdirectory'),
                'owner_singular' => __('Seller', 'nexdirectory'),
                'owner_plural' => __('Sellers', 'nexdirectory'),
                'classification_entity' => __('Marketplace Listing', 'nexdirectory'),
                'hero_kicker' => __('Marketplace Listing', 'nexdirectory'),
                'demo_profile' => 'marketplace',
            ],
        ];
    }


    private function get_starter_blueprint_catalog() {
        $catalog = [
            'events' => [
                'label' => __('Events', 'nexdirectory'),
                'description' => __('Launch an event discovery site with calendars, maps, submissions, and ticket-ready presentation.', 'nexdirectory'),
                'icon' => 'calendar-alt',
                'site_profile' => 'events',
                'demo_profile' => 'events',
                'sample_count' => 6,
                'features' => [
                    __('Event discovery and calendar routes', 'nexdirectory'),
                    __('Map-first browsing and submissions', 'nexdirectory'),
                    __('Six polished sample events', 'nexdirectory'),
                ],
                'options' => [
                    'nexdirectory_platform_mode' => 'user_generated',
                    'nexdirectory_enable_event_reporting' => '1',
                    'nexdirectory_enable_map_auto_search' => '1',
                    'nexdirectory_enable_duplicate_detection' => '1',
                    'nexdirectory_enable_claim_listings' => '0',
                ],
            ],
            'local_directory' => [
                'label' => __('Local Directory', 'nexdirectory'),
                'description' => __('Create a modern local-business directory with map search, owner submissions, and claim workflows.', 'nexdirectory'),
                'icon' => 'map-marker-alt',
                'site_profile' => 'directory',
                'demo_profile' => 'directory',
                'sample_count' => 6,
                'features' => [
                    __('Business-focused listing language', 'nexdirectory'),
                    __('Local map discovery and claims', 'nexdirectory'),
                    __('Six varied business listings', 'nexdirectory'),
                ],
                'options' => [
                    'nexdirectory_platform_mode' => 'user_generated',
                    'nexdirectory_enable_event_reporting' => '1',
                    'nexdirectory_enable_map_auto_search' => '1',
                    'nexdirectory_enable_duplicate_detection' => '1',
                    'nexdirectory_enable_claim_listings' => '1',
                ],
            ],
            'jobs' => [
                'label' => __('Jobs', 'nexdirectory'),
                'description' => __('Start a focused jobs board with employer submissions, compact results, and role-specific sample content.', 'nexdirectory'),
                'icon' => 'briefcase',
                'site_profile' => 'job_board',
                'demo_profile' => 'job_board',
                'sample_count' => 6,
                'features' => [
                    __('Job and employer terminology', 'nexdirectory'),
                    __('Compact, search-led results layout', 'nexdirectory'),
                    __('Six realistic open roles', 'nexdirectory'),
                ],
                'options' => [
                    'nexdirectory_platform_mode' => 'user_generated',
                    'nexdirectory_enable_event_reporting' => '1',
                    'nexdirectory_enable_map_auto_search' => '0',
                    'nexdirectory_enable_duplicate_detection' => '1',
                    'nexdirectory_enable_claim_listings' => '0',
                ],
            ],
            'marketplace' => [
                'label' => __('Marketplace', 'nexdirectory'),
                'description' => __('Build a clean classifieds marketplace with seller submissions, product-led cards, and sample inventory.', 'nexdirectory'),
                'icon' => 'store',
                'site_profile' => 'marketplace',
                'demo_profile' => 'marketplace',
                'sample_count' => 6,
                'features' => [
                    __('Seller and marketplace terminology', 'nexdirectory'),
                    __('Product-led compact browsing', 'nexdirectory'),
                    __('Six ready-to-edit listings', 'nexdirectory'),
                ],
                'options' => [
                    'nexdirectory_platform_mode' => 'user_generated',
                    'nexdirectory_enable_event_reporting' => '1',
                    'nexdirectory_enable_map_auto_search' => '0',
                    'nexdirectory_enable_duplicate_detection' => '1',
                    'nexdirectory_enable_claim_listings' => '0',
                ],
            ],
        ];

        /**
         * Filters the starter-blueprint catalog.
         *
         * The four built-in presets remain part of Nex Directory Free.
         * Licensed add-ons can append validated custom presets without
         * replacing those Free foundations.
         *
         * @param array<string, array<string, mixed>> $catalog Blueprint definitions.
         * @param object                              $this    Core plugin instance.
         */
        $catalog = apply_filters( 'nexdirectory_starter_blueprints', $catalog, $this );

        return is_array( $catalog ) ? $catalog : [];
    }


    private function normalize_site_profile_key($profile_key) {
        $profile_key = sanitize_key((string) $profile_key);
        if ($profile_key === '') {
            return 'events';
        }

        $map = [
            'events' => 'events',
            'event' => 'events',
            'directory' => 'directory',
            'directories' => 'directory',
            'listing' => 'directory',
            'listings' => 'directory',
            'directory-listings' => 'directory',
            'directory_listings' => 'directory',
            'job_board' => 'job_board',
            'job-board' => 'job_board',
            'jobboard' => 'job_board',
            'jobs' => 'job_board',
            'job' => 'job_board',
            'marketplace' => 'marketplace',
            'classifieds' => 'marketplace',
            'classified' => 'marketplace',
            'market-place' => 'marketplace',
            'classifieds_marketplace' => 'marketplace',
            'local_businesses' => 'directory',
            'trades_contractors' => 'directory',
            'professional_services' => 'directory',
            'clubs_community' => 'directory',
            'venues_directory' => 'directory',
            'property_agents' => 'directory',
            'health_wellness' => 'directory',
            'restaurants_hospitality' => 'directory',
            'education_training' => 'directory',
            'franchises_multilocation' => 'directory',
            'nonprofits_charities' => 'directory',
            'startup_tech' => 'directory',
            'tourism_attractions' => 'directory',
        ];

        if (isset($map[$profile_key])) {
            return (string) $map[$profile_key];
        }
        return $profile_key;
    }


    private function get_site_profile_key() {
        $catalog = $this->get_site_profile_catalog();
        $key = $this->normalize_site_profile_key((string) get_option('nexdirectory_site_profile', 'events'));
        if ($key === '' || !isset($catalog[$key])) {
            $key = 'events';
        }
        return $key;
    }


    private function get_site_profile_config($profile_key = '') {
        $catalog = $this->get_site_profile_catalog();
        $profile_key = $this->normalize_site_profile_key($profile_key);
        if ($profile_key === '' || !isset($catalog[$profile_key])) {
            $profile_key = $this->get_site_profile_key();
        }
        if (!isset($catalog[$profile_key])) {
            $profile_key = 'events';
        }
        $profile = isset($catalog[$profile_key]) && is_array($catalog[$profile_key]) ? $catalog[$profile_key] : [];
        $profile['key'] = $profile_key;
        $profile['singular'] = sanitize_text_field((string) ($profile['singular'] ?? __('Event', 'nexdirectory')));
        $profile['plural'] = sanitize_text_field((string) ($profile['plural'] ?? __('Events', 'nexdirectory')));
        $profile['owner_singular'] = sanitize_text_field((string) ($profile['owner_singular'] ?? __('Organizer', 'nexdirectory')));
        $profile['owner_plural'] = sanitize_text_field((string) ($profile['owner_plural'] ?? __('Organizers', 'nexdirectory')));
        $profile['classification_entity'] = sanitize_text_field((string) ($profile['classification_entity'] ?? $profile['singular']));
        $profile['hero_kicker'] = sanitize_text_field((string) ($profile['hero_kicker'] ?? ($this->get_white_label_brand_name() . ' ' . $profile['classification_entity'])));
        $profile['demo_profile'] = sanitize_key((string) ($profile['demo_profile'] ?? $profile_key));
        return $profile;
    }


    private function get_site_profile_ui_labels($profile_key = '') {
        $profile = $this->get_site_profile_config($profile_key);
        $singular = sanitize_text_field((string) ($profile['singular'] ?? __('Event', 'nexdirectory')));
        $plural = sanitize_text_field((string) ($profile['plural'] ?? __('Events', 'nexdirectory')));
        $owner_singular = sanitize_text_field((string) ($profile['owner_singular'] ?? __('Organizer', 'nexdirectory')));
        $owner_plural = sanitize_text_field((string) ($profile['owner_plural'] ?? __('Organizers', 'nexdirectory')));
        $classification_entity = sanitize_text_field((string) ($profile['classification_entity'] ?? $singular));
        $resolved_profile_key = sanitize_key((string) ($profile['key'] ?? 'events'));

        $singular_lower = function_exists('mb_strtolower') ? mb_strtolower($singular, 'UTF-8') : strtolower($singular);
        $plural_lower = function_exists('mb_strtolower') ? mb_strtolower($plural, 'UTF-8') : strtolower($plural);
        $is_events_profile = $resolved_profile_key === 'events';
        $is_job_profile = $resolved_profile_key === 'job_board';

        return [
            'profile_key' => $resolved_profile_key,
            'is_events_profile' => $is_events_profile ? '1' : '0',
            'is_job_profile' => $is_job_profile ? '1' : '0',
            'singular' => $singular,
            'plural' => $plural,
            'singular_lower' => $singular_lower,
            'plural_lower' => $plural_lower,
            'owner_singular' => $owner_singular,
            'owner_plural' => $owner_plural,
            'classification_entity' => $classification_entity,
            'hero_kicker_default' => sanitize_text_field((string) ($profile['hero_kicker'] ?? ($this->get_white_label_brand_name() . ' ' . $classification_entity))),
            /* translators: %s: Placeholder value. */
            'archive_title' => sprintf(__('View %s', 'nexdirectory'), $plural),
            /* translators: %s: Placeholder value. */
            'map_title' => $is_events_profile ? __('Map View', 'nexdirectory') : sprintf(__('%s Map', 'nexdirectory'), $singular),
            'map_description' => __('Explore events by location with live filters and search-this-area discovery.', 'nexdirectory'),
            /* translators: %s: Placeholder value. */
            'calendar_title' => sprintf(__('%s Calendar', 'nexdirectory'), $plural),
            /* translators: %s: Placeholder value. */
            'submit_add_title' => sprintf(__('Add %s', 'nexdirectory'), $singular),
            /* translators: %s: Placeholder value. */
            'my_items_title' => sprintf(__('My %s', 'nexdirectory'), $plural),
            /* translators: %s: Placeholder value. */
            'attending_title' => $is_events_profile ? __('Events I\'m Attending', 'nexdirectory') : sprintf(__('Saved %s', 'nexdirectory'), $plural),
            'owner_analytics_title' => __('Analytics', 'nexdirectory'),
            /* translators: %s: Placeholder value. */
            'taxonomy_type_singular' => $is_events_profile ? __('Event Type', 'nexdirectory') : sprintf(__('%s Type', 'nexdirectory'), $singular),
            /* translators: %s: Placeholder value. */
            'taxonomy_type_plural' => $is_events_profile ? __('Event Types', 'nexdirectory') : sprintf(__('%s Types', 'nexdirectory'), $singular),
            'taxonomy_mode_singular' => $is_events_profile ? __('Event Mode', 'nexdirectory') : __('Listing Mode', 'nexdirectory'),
            'taxonomy_mode_plural' => $is_events_profile ? __('Event Modes', 'nexdirectory') : __('Listing Modes', 'nexdirectory'),
            'taxonomy_tag_singular' => __('Tag', 'nexdirectory'),
            'taxonomy_tag_plural' => __('Tags', 'nexdirectory'),
            'filter_price_label' => $is_events_profile ? __('Price Range', 'nexdirectory') : __('Pricing', 'nexdirectory'),
            'filter_price_all_label' => $is_events_profile ? __('All Price Ranges', 'nexdirectory') : __('All Pricing', 'nexdirectory'),
            'map_near_me_label' => $is_events_profile ? __('Find Events Near Me', 'nexdirectory') : __('Find Near Me', 'nexdirectory'),
        ];
    }

    /**
     * Returns the shared public kicker with a profile-aware fallback.
     */
    private function get_event_hero_kicker_text() {
        $labels = $this->get_site_profile_ui_labels();
        $fallback = sanitize_text_field((string) ($labels['hero_kicker_default'] ?? ''));
        if ($fallback === '') {
            $fallback = sanitize_text_field((string) $this->get_white_label_brand_name());
        }

        $stored = get_option('nexdirectory_event_hero_kicker_text', null);
        $kicker = $stored === null ? '' : sanitize_text_field((string) $stored);
        if ($kicker === 'Event Event') {
            $kicker = __('Discover Events', 'nexdirectory');
            update_option('nexdirectory_event_hero_kicker_text', $kicker, false);
        }

        return $kicker !== '' ? $kicker : $fallback;
    }


    private function get_profile_route_slugs($route_key, $profile_key = '') {
        $route_key = sanitize_key((string) $route_key);
        $profile_key = $this->normalize_site_profile_key($profile_key);
        if ($profile_key === '') {
            $profile_key = $this->get_site_profile_key();
        }

        $preferred_map = [
            'events' => [
                'events' => 'events',
                'directory' => 'listings',
                'job_board' => 'jobs',
                'marketplace' => 'marketplace',
            ],
            'map' => [
                'events' => 'events-map',
                'directory' => 'listings-map',
                'job_board' => 'jobs-map',
                'marketplace' => 'marketplace-map',
            ],
            'calendar' => [
                'events' => 'events-calendar',
                'directory' => 'listings-calendar',
                'job_board' => 'jobs-calendar',
                'marketplace' => 'marketplace-calendar',
            ],
            'submit' => [
                'events' => 'submit',
                'directory' => 'submit',
                'job_board' => 'submit',
                'marketplace' => 'submit',
            ],
            'my_items' => [
                'events' => 'my-events',
                'directory' => 'my-listings',
                'job_board' => 'my-jobs',
                'marketplace' => 'my-items-selling',
            ],
            'attending' => [
                'events' => 'rsvps',
                'directory' => 'saved-listings',
                'job_board' => 'saved-jobs',
                'marketplace' => 'saved-items',
            ],
            'attendees' => [
                'events' => 'whos-attending',
                'directory' => 'who-saved',
                'job_board' => 'who-applied',
                'marketplace' => 'who-saved',
            ],
            'account' => [
                'events' => 'account',
                'directory' => 'account',
                'job_board' => 'account',
                'marketplace' => 'account',
            ],
            'auth' => [
                'events' => 'login-signup',
                'directory' => 'login-signup',
                'job_board' => 'login-signup',
                'marketplace' => 'login-signup',
            ],
            'claim' => [
                'events' => 'claim-listing',
                'directory' => 'claim-listing',
                'job_board' => 'claim-listing',
                'marketplace' => 'claim-listing',
            ],
            'organizers' => [
                'events' => 'organizers',
                'directory' => 'organizers',
                'job_board' => 'organizers',
                'marketplace' => 'organizers',
            ],
        ];
        $preferred_map = (array) apply_filters(
            'nexdirectory_extension_profile_route_map',
            $preferred_map,
            $profile_key,
            $this
        );

        $slugs = [];
        $preferred_slug = sanitize_title((string) ($preferred_map[$route_key][$profile_key] ?? ''));
        if ($preferred_slug !== '') {
            $slugs[] = $preferred_slug;
        }

        return array_values(array_unique($slugs));
    }


    private function get_profile_route_slug($route_key, $profile_key = '') {
        $slugs = $this->get_profile_route_slugs($route_key, $profile_key);
        return isset($slugs[0]) ? (string) $slugs[0] : '';
    }


    private function get_profile_route_default_url($route_key, $profile_key = '') {
        $slug = $this->get_profile_route_slug($route_key, $profile_key);
        if ($slug === '') {
            return home_url('/');
        }
        return home_url('/' . $slug . '/');
    }


    public function add_query_vars($vars) {
        $vars[] = 'nexdirectory_ics';
        $vars[] = 'nexdirectory_search';
        return $vars;
    }


    /**
     * Keeps legacy archive keyword links on the shortcode page.
     *
     * WordPress treats the core `s` query variable as a whole-site search. On a
     * shortcode archive page that changes the main query before the page can
     * render, so a zero-result archive request can fall through to a theme 404.
     * Only pages that actually contain the archive shortcode are adapted; normal
     * WordPress searches everywhere else retain their native behaviour.
     *
     * @param array<string, mixed> $query_vars Parsed public query variables.
     * @return array<string, mixed>
     */
    public function normalize_archive_search_request($query_vars) {
        if (!is_array($query_vars) || !isset($query_vars['s'])) {
            return $query_vars;
        }

        $page = null;
        $page_id = isset($query_vars['page_id']) ? absint((int) $query_vars['page_id']) : 0;
        if ($page_id > 0) {
            $candidate = get_post($page_id);
            if ($candidate instanceof WP_Post && $candidate->post_type === 'page') {
                $page = $candidate;
            }
        } elseif (!empty($query_vars['pagename']) && is_scalar($query_vars['pagename'])) {
            $page_path = trim(sanitize_text_field((string) $query_vars['pagename']), '/');
            if ($page_path !== '') {
                $candidate = get_page_by_path($page_path, OBJECT, 'page');
                if ($candidate instanceof WP_Post) {
                    $page = $candidate;
                }
            }
        }

        if (!$page instanceof WP_Post || (string) $page->post_status !== 'publish') {
            return $query_vars;
        }

        $content = $this->normalize_legacy_shortcode_content((string) $page->post_content);
        if (
            !has_shortcode($content, 'nexdirectory_events_archive')
            && !has_shortcode($content, 'nexdirectory_listings_archive')
        ) {
            return $query_vars;
        }

        $custom_search = isset($query_vars['nexdirectory_search']) && is_scalar($query_vars['nexdirectory_search'])
            ? sanitize_text_field((string) $query_vars['nexdirectory_search'])
            : '';
        if ($custom_search === '') {
            $custom_search = is_scalar($query_vars['s'])
                ? sanitize_text_field((string) $query_vars['s'])
                : '';
        }
        $query_vars['nexdirectory_search'] = $custom_search;
        unset($query_vars['s']);

        return $query_vars;
    }


    private function schedule_privacy_retention_cleanup() {
        if ($this->should_use_action_scheduler()) {
            if (function_exists('as_unschedule_all_actions')) {
                as_unschedule_all_actions('nexdirectory_privacy_retention_cron', [], 'nexdirectory');
            }
            if (function_exists('as_schedule_recurring_action')) {
                as_schedule_recurring_action(time() + 900, DAY_IN_SECONDS, 'nexdirectory_privacy_retention_cron', [], 'nexdirectory');
            }
            if (wp_next_scheduled('nexdirectory_privacy_retention_cron')) {
                wp_clear_scheduled_hook('nexdirectory_privacy_retention_cron');
            }
            return;
        }

        if (function_exists('as_unschedule_all_actions')) {
            as_unschedule_all_actions('nexdirectory_privacy_retention_cron', [], 'nexdirectory');
        }
        if (!wp_next_scheduled('nexdirectory_privacy_retention_cron')) {
            wp_schedule_event(time() + 900, 'daily', 'nexdirectory_privacy_retention_cron');
        }
    }


    /**
     * Deletes expired custom-table rows in small, fairly distributed batches.
     *
     * Each run has both a query-count and wall-clock ceiling. Rows left behind
     * remain eligible for the next scheduled retention run.
     *
     * @param array<int, array{table:string,column:string}> $targets Table and date-column pairs.
     * @param string                                        $cutoff  UTC cutoff in MySQL format.
     * @return void
     */
    private function delete_expired_rows_in_bounded_batches(array $targets, $cutoff) {
        global $wpdb;

        $batch_size = max(100, min(1000, (int) apply_filters('nexdirectory_privacy_cleanup_batch_size', 500)));
        $max_rounds = max(1, min(20, (int) apply_filters('nexdirectory_privacy_cleanup_max_batches', 8)));
        $time_budget = max(5.0, min(30.0, (float) apply_filters('nexdirectory_privacy_cleanup_time_budget_seconds', 20.0)));
        $deadline = microtime(true) + $time_budget;
        $active_targets = array_values($targets);

        for ($round = 0; $round < $max_rounds && !empty($active_targets); $round++) {
            foreach ($active_targets as $target_index => $target) {
                if (microtime(true) >= $deadline) {
                    return;
                }

                $table = (string) ($target['table'] ?? '');
                $column = (string) ($target['column'] ?? '');
                if ($table === '' || $column === '') {
                    unset($active_targets[$target_index]);
                    continue;
                }

                $deleted = $wpdb->query(
                    $wpdb->prepare(
                        "DELETE FROM {$table} WHERE {$column} < %s ORDER BY id ASC LIMIT %d",
                        $cutoff,
                        $batch_size
                    )
                );
                if ($deleted === false || (int) $deleted < $batch_size) {
                    unset($active_targets[$target_index]);
                }
            }
        }
    }


    public function run_privacy_retention_cleanup() {
        global $wpdb;
        $days = max(0, min(3650, absint((int) get_option('nexdirectory_privacy_retention_days', 730))));
        if ($days <= 0) {
            return;
        }
        $cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));

        $analytics_table = $wpdb->prefix . self::ANALYTICS_TABLE;
        $notifications_table = $wpdb->prefix . self::NOTIFICATIONS_TABLE;
        $this->delete_expired_rows_in_bounded_batches(
            [
                ['table' => $analytics_table, 'column' => 'created_at'],
                ['table' => $notifications_table, 'column' => 'created_at'],
            ],
            $cutoff
        );
        if (method_exists($this, 'cleanup_expired_private_files')) {
            $this->cleanup_expired_private_files($days);
        }
    }


    private function should_use_action_scheduler() {
        return (string) get_option('nexdirectory_use_action_scheduler', '0') === '1'
            && function_exists('as_schedule_single_action')
            && function_exists('as_next_scheduled_action');
    }


    private function get_frontend_shortcode_tags() {
        $tags = [
            'nexdirectory_events_archive',
            'nexdirectory_events_map',
            'nexdirectory_events_calendar',
            'nexdirectory_listings_archive',
            'nexdirectory_listings_map',
            'nexdirectory_listings_calendar',
            'nexdirectory_events_query_grid',
            'nexdirectory_events_bento_grid',
            'nexdirectory_events_ajax_search',
            'nexdirectory_event_webapp',
            'nexdirectory_submit_event',
            'nexdirectory_submit',
            'nexdirectory_my_events',
            'nexdirectory_my_listings',
            'nexdirectory_my_tickets',
            'nexdirectory_saved_listings',
            'nexdirectory_whos_attending',
            'nexdirectory_attendees',
            'nexdirectory_notifications',
            'nexdirectory_whats_new',
            'nexdirectory_account',
            'nexdirectory_auth',
            'nexdirectory_auth_links',
            'nexdirectory_user_management',
            'nexdirectory_moderation_queue',
            'nexdirectory_organizers',
            'nexdirectory_claim_listing',
        ];

        return array_values(array_filter(array_map(
            'sanitize_key',
            (array) apply_filters('nexdirectory_extension_frontend_shortcode_tags', $tags, $this)
        )));
    }


    private function is_frontend_asset_context() {
        if (is_admin()) {
            $screen = function_exists('get_current_screen') ? get_current_screen() : null;
            $screen_id = is_object($screen) && isset($screen->id) ? (string) $screen->id : '';
            $page = isset($_GET['page']) ? sanitize_key((string) wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only asset context detection.
            $allowed_admin_pages = [
                'nexdirectory-dashboard',
                'nexdirectory-settings',
                'nexdirectory-settings-pricing',
                'nexdirectory-user-management',
                'nexdirectory-moderation',
                'nexdirectory-metrics',
                'nexdirectory-onboarding',
                'nexdirectory-diagnostics',
                'nexdirectory-import',
            ];
            return ($page !== '' && in_array($page, $allowed_admin_pages, true))
                || strpos($screen_id, 'nexdirectory') !== false;
        }
        if ((bool) apply_filters('nexdirectory_force_enqueue_frontend_assets', false)) {
            return true;
        }
        if (is_singular(self::POST_TYPE)) {
            return true;
        }
        if (!is_singular()) {
            return false;
        }
        $current_post = get_queried_object();
        if (!$current_post instanceof WP_Post) {
            return false;
        }
        $content = $this->normalize_legacy_shortcode_content((string) $current_post->post_content);
        if ($content === '') {
            return false;
        }
        foreach ($this->get_frontend_shortcode_tags() as $shortcode_tag) {
            if (has_shortcode($content, $shortcode_tag)) {
                return true;
            }
        }
        return false;
    }


    private function current_request_contains_submit_shortcode() {
        if (!is_singular()) {
            return false;
        }
        $current_post = get_queried_object();
        if (!$current_post instanceof WP_Post) {
            return false;
        }
        $content = $this->normalize_legacy_shortcode_content((string) $current_post->post_content);
        if ($content === '') {
            return false;
        }
        foreach (['nexdirectory_submit_event', 'nexdirectory_submit'] as $shortcode_tag) {
            if (has_shortcode($content, $shortcode_tag)) {
                return true;
            }
        }
        if (has_shortcode($content, 'nexdirectory_account')) {
            // These read-only route hints only determine which already-authorized assets are enqueued.
            $requested_tab = sanitize_key((string) wp_unslash($_GET['nexdirectory_tab'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
            $edit_event_id = absint((int) wp_unslash($_GET['edit_event_id'] ?? 0)); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
            return $requested_tab === 'submit' || $edit_event_id > 0;
        }
        return false;
    }


    public function enqueue_assets() {
        if (!$this->is_frontend_asset_context()) {
            return;
        }
        $design = $this->get_platform_design_settings();
        if (!is_array($design)) {
            $design = [];
        }
        $design = wp_parse_args($design, [
            'use_defaults' => false,
            'accent' => '#FF5E14',
            'hover' => '#E04F0D',
            'on_primary' => '#FFFFFF',
            'font_body' => 'Inter, system-ui, -apple-system, sans-serif',
            'font_display' => 'Inter, system-ui, -apple-system, sans-serif',
            'font_size_base' => '16px',
            'h2_size' => '1.55rem',
            'h3_size' => '1.16rem',
            'weight_body' => '400',
            'weight_heading' => '700',
            'space_unit' => '16px',
            'space_scale' => '1',
            'radius_md' => '12px',
            'radius_lg' => '16px',
            'box_style' => 'default',
            'button_style' => 'solid',
            'button_radius_mode' => 'inherit',
            'density_mode' => 'comfortable',
            'border_mode' => 'subtle',
            'shadow_mode' => 'soft',
            'content_width' => 'standard',
            'input_style' => 'outline',
        ]);
        $plugin_path = plugin_dir_path(NEXDIRECTORY_PLUGIN_FILE);
        $plugin_url = plugin_dir_url(NEXDIRECTORY_PLUGIN_FILE);
        $tailwind_path = $plugin_path . 'assets/css/nexdirectory-tailwind-compiled.css';
        $frontend_css_path = $plugin_path . 'assets/css/nexdirectory-frontend.css';
        $experience_css_path = $plugin_path . 'assets/css/nexdirectory-experience.css';
        $frontend_js_path = $plugin_path . 'assets/js/nexdirectory-frontend.js';
        $experience_js_path = $plugin_path . 'assets/js/nexdirectory-experience.js';
        $fontawesome_css_path = $plugin_path . 'assets/vendor/fontawesome/css/all.min.css';
        $alpine_js_path = $plugin_path . 'assets/vendor/alpine/alpine.min.js';
        $alpine_intersect_js_path = $plugin_path . 'assets/vendor/alpine/intersect.min.js';
        $alpine_collapse_js_path = $plugin_path . 'assets/vendor/alpine/collapse.min.js';
        $lenis_js_path = $plugin_path . 'assets/vendor/lenis/lenis.min.js';
        $leaflet_css_path = $plugin_path . 'assets/vendor/leaflet/leaflet.css';
        $leaflet_js_path = $plugin_path . 'assets/vendor/leaflet/leaflet.js';
        $leaflet_cluster_css_path = $plugin_path . 'assets/vendor/leaflet-markercluster/MarkerCluster.css';
        $leaflet_cluster_default_css_path = $plugin_path . 'assets/vendor/leaflet-markercluster/MarkerCluster.Default.css';
        $leaflet_cluster_js_path = $plugin_path . 'assets/vendor/leaflet-markercluster/leaflet.markercluster.js';
        $tailwind_ver = is_readable($tailwind_path) ? self::VERSION . '.' . (string) filemtime($tailwind_path) : self::VERSION;
        $frontend_css_ver = is_readable($frontend_css_path) ? self::VERSION . '.' . (string) filemtime($frontend_css_path) : self::VERSION;
        $experience_css_ver = is_readable($experience_css_path) ? self::VERSION . '.' . (string) filemtime($experience_css_path) : self::VERSION;
        $frontend_js_ver = is_readable($frontend_js_path) ? self::VERSION . '.' . (string) filemtime($frontend_js_path) : self::VERSION;
        $experience_js_ver = is_readable($experience_js_path) ? self::VERSION . '.' . (string) filemtime($experience_js_path) : self::VERSION;
        $fontawesome_css_ver = is_readable($fontawesome_css_path) ? self::VERSION . '.' . (string) filemtime($fontawesome_css_path) : self::VERSION;
        $alpine_ver = is_readable($alpine_js_path) ? self::VERSION . '.' . (string) filemtime($alpine_js_path) : self::VERSION;
        $alpine_intersect_ver = is_readable($alpine_intersect_js_path) ? self::VERSION . '.' . (string) filemtime($alpine_intersect_js_path) : self::VERSION;
        $alpine_collapse_ver = is_readable($alpine_collapse_js_path) ? self::VERSION . '.' . (string) filemtime($alpine_collapse_js_path) : self::VERSION;
        $lenis_ver = is_readable($lenis_js_path) ? self::VERSION . '.' . (string) filemtime($lenis_js_path) : self::VERSION;
        $leaflet_css_ver = is_readable($leaflet_css_path) ? self::VERSION . '.' . (string) filemtime($leaflet_css_path) : self::VERSION;
        $leaflet_js_ver = is_readable($leaflet_js_path) ? self::VERSION . '.' . (string) filemtime($leaflet_js_path) : self::VERSION;
        $leaflet_cluster_css_ver = is_readable($leaflet_cluster_css_path) ? self::VERSION . '.' . (string) filemtime($leaflet_cluster_css_path) : self::VERSION;
        $leaflet_cluster_default_css_ver = is_readable($leaflet_cluster_default_css_path) ? self::VERSION . '.' . (string) filemtime($leaflet_cluster_default_css_path) : self::VERSION;
        $leaflet_cluster_js_ver = is_readable($leaflet_cluster_js_path) ? self::VERSION . '.' . (string) filemtime($leaflet_cluster_js_path) : self::VERSION;
        $is_map_shortcode_context = false;
        $map_shortcode_tags = ['nexdirectory_events_map', 'nexdirectory_listings_map'];
        if (is_singular()) {
            $current_post = get_queried_object();
            if ($current_post instanceof WP_Post) {
                $current_content = $this->normalize_legacy_shortcode_content((string) $current_post->post_content);
                if ($current_content !== '') {
                    foreach ($map_shortcode_tags as $shortcode_tag) {
                        if (has_shortcode($current_content, $shortcode_tag)) {
                            $is_map_shortcode_context = true;
                            break;
                        }
                    }
                }
            }
        }
        if (!$is_map_shortcode_context && method_exists($this, 'get_platform_page_url') && method_exists($this, 'get_profile_route_slugs') && method_exists($this, 'get_profile_route_default_url')) {
            $map_page_url = $this->get_platform_page_url(
                'nexdirectory_page_events_map_url',
                $this->get_profile_route_slugs('map'),
                $this->get_profile_route_default_url('map')
            );
            if ($map_page_url !== '') {
                $map_page_id = absint((int) url_to_postid($map_page_url));
                if ($map_page_id > 0 && is_page($map_page_id)) {
                    $is_map_shortcode_context = true;
                }
            }
        }

        wp_register_style(
            'nexdirectory-tailwind',
            $plugin_url . 'assets/css/nexdirectory-tailwind-compiled.css',
            [],
            $tailwind_ver
        );
        wp_enqueue_style('nexdirectory-tailwind');

        if (is_admin()) {
            wp_register_style(
                'nexdirectory-fontawesome',
                $plugin_url . 'assets/vendor/fontawesome/css/all.min.css',
                [],
                $fontawesome_css_ver
            );
            wp_enqueue_style('nexdirectory-fontawesome');
        }

        wp_register_script(
            'nexdirectory-alpine-intersect',
            $plugin_url . 'assets/vendor/alpine/intersect.min.js',
            [],
            $alpine_intersect_ver,
            true
        );
        wp_enqueue_script('nexdirectory-alpine-intersect');
        wp_add_inline_script(
            'nexdirectory-alpine-intersect',
            'window.__nexdirectoryAlpineIntersectPluginLoaded = true;',
            'after'
        );

        wp_register_script(
            'nexdirectory-alpine-collapse',
            $plugin_url . 'assets/vendor/alpine/collapse.min.js',
            [],
            $alpine_collapse_ver,
            true
        );
        wp_enqueue_script('nexdirectory-alpine-collapse');
        wp_add_inline_script(
            'nexdirectory-alpine-collapse',
            'window.__nexdirectoryAlpineCollapsePluginLoaded = true;',
            'after'
        );

        wp_register_script(
            'nexdirectory-alpine',
            $plugin_url . 'assets/vendor/alpine/alpine.min.js',
            ['nexdirectory-alpine-intersect', 'nexdirectory-alpine-collapse'],
            $alpine_ver,
            true
        );

        wp_register_script(
            'nexdirectory-lenis',
            $plugin_url . 'assets/vendor/lenis/lenis.min.js',
            [],
            $lenis_ver,
            true
        );
        wp_script_add_data('nexdirectory-lenis', 'strategy', 'defer');
        if ($this->is_option_enabled('nexdirectory_enable_smooth_scrolling', '0')) {
            wp_enqueue_script('nexdirectory-lenis');
        }

        wp_register_style(
            'nexdirectory-leaflet',
            $plugin_url . 'assets/vendor/leaflet/leaflet.css',
            [],
            $leaflet_css_ver
        );

        wp_register_script(
            'nexdirectory-leaflet',
            $plugin_url . 'assets/vendor/leaflet/leaflet.js',
            [],
            $leaflet_js_ver,
            true
        );
        wp_register_style(
            'nexdirectory-leaflet-markercluster',
            $plugin_url . 'assets/vendor/leaflet-markercluster/MarkerCluster.css',
            ['nexdirectory-leaflet'],
            $leaflet_cluster_css_ver
        );
        wp_register_style(
            'nexdirectory-leaflet-markercluster-default',
            $plugin_url . 'assets/vendor/leaflet-markercluster/MarkerCluster.Default.css',
            ['nexdirectory-leaflet-markercluster'],
            $leaflet_cluster_default_css_ver
        );
        wp_register_script(
            'nexdirectory-leaflet-markercluster',
            $plugin_url . 'assets/vendor/leaflet-markercluster/leaflet.markercluster.js',
            ['nexdirectory-leaflet'],
            $leaflet_cluster_js_ver,
            true
        );
        if ($is_map_shortcode_context) {
            wp_enqueue_style('nexdirectory-leaflet');
            wp_enqueue_style('nexdirectory-leaflet-markercluster-default');
            wp_enqueue_script('nexdirectory-leaflet');
            wp_enqueue_script('nexdirectory-leaflet-markercluster');
        }

        wp_register_style(
            'nexdirectory-frontend',
            $plugin_url . 'assets/css/nexdirectory-frontend.css',
            [],
            $frontend_css_ver
        );
        wp_register_style(
            'nexdirectory-experience',
            $plugin_url . 'assets/css/nexdirectory-experience.css',
            ['nexdirectory-frontend'],
            $experience_css_ver
        );

        // Load the component factories before Alpine's automatic start so every
        // x-data expression is available during the first hydration pass.
        $frontend_script_deps = ['jquery'];
        if ($is_map_shortcode_context) {
            $frontend_script_deps[] = 'nexdirectory-leaflet';
            $frontend_script_deps[] = 'nexdirectory-leaflet-markercluster';
        }
        if ($this->is_option_enabled('nexdirectory_enable_smooth_scrolling', '0')) {
            $frontend_script_deps[] = 'nexdirectory-lenis';
        }

        wp_register_script(
            'nexdirectory-frontend',
            $plugin_url . 'assets/js/nexdirectory-frontend.js',
            $frontend_script_deps,
            $frontend_js_ver,
            true
        );
        wp_register_script(
            'nexdirectory-experience',
            $plugin_url . 'assets/js/nexdirectory-experience.js',
            ['nexdirectory-frontend'],
            $experience_js_ver,
            true
        );

        wp_enqueue_style('nexdirectory-frontend');
        wp_enqueue_style('nexdirectory-experience');
        $clean_css_value = static function ($value) {
            return str_replace(['{', '}', ';'], '', (string) $value);
        };
        $accent_hex = sanitize_hex_color((string) ($design['accent'] ?? '')) ?: '#FF5E14';
        $hover_hex = sanitize_hex_color((string) ($design['hover'] ?? '')) ?: '#E04F0D';
        $on_primary_hex = sanitize_hex_color((string) ($design['on_primary'] ?? '')) ?: '#FFFFFF';
        $accent_rgb = [255, 94, 20];
        if (preg_match('/^#?([a-f0-9]{6})$/i', $accent_hex, $accent_match) === 1) {
            $accent_value = ltrim((string) $accent_match[1], '#');
            $accent_rgb = [
                (int) hexdec(substr($accent_value, 0, 2)),
                (int) hexdec(substr($accent_value, 2, 2)),
                (int) hexdec(substr($accent_value, 4, 2)),
            ];
        }
        $page_side_padding_desktop = $this->sanitize_page_side_padding_option((string) get_option('nexdirectory_page_side_padding', '10%'));
        $page_side_padding_mobile = $this->sanitize_page_side_padding_mobile_option((string) get_option('nexdirectory_page_side_padding_mobile', '2%'));
        if ($page_side_padding_mobile === '') {
            $page_side_padding_mobile = $page_side_padding_desktop;
        }
        $box_style = sanitize_key((string) ($design['box_style'] ?? 'default'));
        if (!in_array($box_style, ['default', 'gradient', 'bold', 'neumorphic'], true)) {
            $box_style = 'default';
        }
        $button_style = sanitize_key((string) ($design['button_style'] ?? 'solid'));
        if (!in_array($button_style, ['solid', 'outline', 'soft', 'dark'], true)) {
            $button_style = 'solid';
        }
        $button_radius_mode = sanitize_key((string) ($design['button_radius_mode'] ?? 'inherit'));
        if (!in_array($button_radius_mode, ['inherit', 'pill', 'sharp'], true)) {
            $button_radius_mode = 'inherit';
        }
        $density_mode = sanitize_key((string) ($design['density_mode'] ?? 'comfortable'));
        if (!in_array($density_mode, ['compact', 'comfortable', 'loose'], true)) {
            $density_mode = 'comfortable';
        }
        $border_mode = sanitize_key((string) ($design['border_mode'] ?? 'subtle'));
        if (!in_array($border_mode, ['minimal', 'subtle', 'strong'], true)) {
            $border_mode = 'subtle';
        }
        $shadow_mode = sanitize_key((string) ($design['shadow_mode'] ?? 'soft'));
        if (!in_array($shadow_mode, ['none', 'soft', 'medium', 'dramatic'], true)) {
            $shadow_mode = 'soft';
        }
        $content_width = sanitize_key((string) ($design['content_width'] ?? 'standard'));
        if (!in_array($content_width, ['narrow', 'standard', 'wide', 'full'], true)) {
            $content_width = 'standard';
        }
        $input_style = sanitize_key((string) ($design['input_style'] ?? 'outline'));
        if (!in_array($input_style, ['outline', 'filled', 'underlined'], true)) {
            $input_style = 'outline';
        }

        $content_width_max = '1220px';
        $event_page_max_width = '1240px';
        $archive_map_max_width = '1400px';
        if ($content_width === 'narrow') {
            $content_width_max = '1040px';
            $event_page_max_width = '1080px';
            $archive_map_max_width = '1180px';
        } elseif ($content_width === 'wide') {
            $content_width_max = '1380px';
            $event_page_max_width = '1440px';
            $archive_map_max_width = '1600px';
        } elseif ($content_width === 'full') {
            $content_width_max = '100%';
            $event_page_max_width = '100%';
            $archive_map_max_width = '100%';
        }

        $density_scale = '1';
        $button_min_height = '40px';
        $field_pad_y = '10px';
        $field_pad_x = '12px';
        if ($density_mode === 'compact') {
            $density_scale = '0.88';
            $button_min_height = '36px';
            $field_pad_y = '8px';
            $field_pad_x = '10px';
        } elseif ($density_mode === 'loose') {
            $density_scale = '1.18';
            $button_min_height = '44px';
            $field_pad_y = '12px';
            $field_pad_x = '14px';
        }

        $box_background = '#ffffff';
        $box_background_alt = '#f8fafc';
        if ($box_style === 'gradient') {
            $box_background = 'linear-gradient(145deg, #fff7ed 0%, #ffffff 48%, #fffbeb 100%)';
            $box_background_alt = '#fffaf5';
        } elseif ($box_style === 'bold') {
            $box_background = 'color-mix(in srgb, var(--nexdirectory-primary) 10%, #ffffff)';
            $box_background_alt = 'color-mix(in srgb, var(--nexdirectory-primary) 6%, #ffffff)';
        } elseif ($box_style === 'neumorphic') {
            $box_background = '#f3f4f6';
            $box_background_alt = '#eef2f7';
        }

        $border_width = '1px';
        $border_color = '#e5e7eb';
        if ($border_mode === 'minimal') {
            $border_color = 'rgba(148, 163, 184, 0.26)';
        } elseif ($border_mode === 'strong') {
            $border_width = '2px';
            $border_color = '#cbd5e1';
        }

        $shadow_sm = '0 6px 16px rgba(0, 0, 0, 0.04)';
        $shadow_md = '0 12px 30px rgba(11, 18, 33, 0.08)';
        $shadow_lg = '0 22px 45px rgba(11, 18, 33, 0.14)';
        if ($shadow_mode === 'none') {
            $shadow_sm = 'none';
            $shadow_md = 'none';
            $shadow_lg = 'none';
        } elseif ($shadow_mode === 'medium') {
            $shadow_sm = '0 8px 18px rgba(11, 18, 33, 0.09)';
            $shadow_md = '0 14px 34px rgba(11, 18, 33, 0.14)';
            $shadow_lg = '0 24px 50px rgba(11, 18, 33, 0.2)';
        } elseif ($shadow_mode === 'dramatic') {
            $shadow_sm = '0 10px 24px rgba(11, 18, 33, 0.14)';
            $shadow_md = '0 20px 44px rgba(11, 18, 33, 0.2)';
            $shadow_lg = '0 30px 70px rgba(11, 18, 33, 0.3)';
        }

        if ($box_style === 'neumorphic') {
            $shadow_sm = '6px 6px 14px rgba(148, 163, 184, 0.25), -6px -6px 14px rgba(255, 255, 255, 0.95)';
            $shadow_md = '10px 10px 24px rgba(148, 163, 184, 0.27), -10px -10px 24px rgba(255, 255, 255, 0.96)';
            $shadow_lg = '14px 14px 32px rgba(148, 163, 184, 0.28), -14px -14px 32px rgba(255, 255, 255, 0.96)';
            if ($shadow_mode === 'none') {
                $shadow_sm = 'none';
                $shadow_md = 'none';
                $shadow_lg = 'none';
            }
            $border_color = 'transparent';
        }

        $btn_bg = 'var(--nexdirectory-primary)';
        $btn_bg_hover = 'var(--nexdirectory-primary-hover)';
        $btn_color = 'var(--nexdirectory-on-primary, #ffffff)';
        $btn_border = '1px solid transparent';
        $btn_letter_spacing = '0';
        $btn_transform = 'none';
        $btn_weight = '700';
        if ($button_style === 'outline') {
            $btn_bg = 'transparent';
            $btn_bg_hover = 'color-mix(in srgb, var(--nexdirectory-primary) 10%, #ffffff)';
            $btn_color = 'var(--nexdirectory-primary)';
            $btn_border = '2px solid var(--nexdirectory-primary)';
            $btn_letter_spacing = '0';
            $btn_transform = 'none';
        } elseif ($button_style === 'soft') {
            $btn_bg = 'color-mix(in srgb, var(--nexdirectory-primary) 16%, #ffffff)';
            $btn_bg_hover = 'color-mix(in srgb, var(--nexdirectory-primary) 24%, #ffffff)';
            $btn_color = 'color-mix(in srgb, var(--nexdirectory-primary-hover) 66%, #111827)';
            $btn_border = '1px solid color-mix(in srgb, var(--nexdirectory-primary) 26%, #ffffff)';
            $btn_letter_spacing = '0';
            $btn_transform = 'none';
        } elseif ($button_style === 'dark') {
            $btn_bg = '#0f172a';
            $btn_bg_hover = '#111827';
            $btn_color = '#ffffff';
            $btn_border = '1px solid #0f172a';
            $btn_letter_spacing = '0';
            $btn_transform = 'none';
        }

        $btn_radius = '10px';
        if ($button_radius_mode === 'pill') {
            $btn_radius = '999px';
        } elseif ($button_radius_mode === 'sharp') {
            $btn_radius = '6px';
        }

        $input_bg = '#ffffff';
        $input_border = '#e5e7eb';
        $input_border_focus = 'var(--nexdirectory-primary)';
        $input_border_width = '1px';
        $input_radius = 'var(--nexdirectory-radius)';
        if ($input_style === 'filled') {
            $input_bg = '#f8fafc';
            $input_border = '#e2e8f0';
        } elseif ($input_style === 'underlined') {
            $input_bg = 'transparent';
            $input_border = 'transparent transparent #cbd5e1 transparent';
            $input_border_focus = 'transparent transparent var(--nexdirectory-primary) transparent';
            $input_border_width = '0 0 2px 0';
            $input_radius = '0';
        }

        $dynamic_css = ':root{'
            . '--nexdirectory-primary:' . $clean_css_value($accent_hex) . ';'
            . '--nexdirectory-primary-hover:' . $clean_css_value($hover_hex) . ';'
            . '--nexdirectory-on-primary:' . $clean_css_value($on_primary_hex) . ';'
            . '--nexdirectory-font:' . $clean_css_value($design['font_body']) . ';'
            . '--nexdirectory-font-display:' . $clean_css_value($design['font_display']) . ';'
            . '--nexdirectory-base-font-size:' . $clean_css_value($design['font_size_base']) . ';'
            . '--nexdirectory-h2-size:' . $clean_css_value($design['h2_size']) . ';'
            . '--nexdirectory-h3-size:' . $clean_css_value($design['h3_size']) . ';'
            . '--nexdirectory-font-weight-body:' . $clean_css_value($design['weight_body']) . ';'
            . '--nexdirectory-font-weight-heading:' . $clean_css_value($design['weight_heading']) . ';'
            . '--nexdirectory-space-unit:' . $clean_css_value($design['space_unit']) . ';'
            . '--nexdirectory-space-scale:' . $clean_css_value($design['space_scale']) . ';'
            . '--nexdirectory-radius:' . $clean_css_value($design['radius_md']) . ';'
            . '--nexdirectory-radius-lg:' . $clean_css_value($design['radius_lg']) . ';'
            . '--nexdirectory-page-side-padding:' . $clean_css_value($page_side_padding_desktop) . ';'
            . '--nexdirectory-page-side-padding-mobile:' . $clean_css_value($page_side_padding_mobile) . ';'
            . '--nexdirectory-content-max-width:' . $clean_css_value($content_width_max) . ';'
            . '--nexdirectory-event-page-max-width:' . $clean_css_value($event_page_max_width) . ';'
            . '--nexdirectory-archive-max-width:' . $clean_css_value($archive_map_max_width) . ';'
            . '--nexdirectory-density-scale:' . $clean_css_value($density_scale) . ';'
            . '--nexdirectory-field-pad-y:' . $clean_css_value($field_pad_y) . ';'
            . '--nexdirectory-field-pad-x:' . $clean_css_value($field_pad_x) . ';'
            . '--nexdirectory-btn-min-height:' . $clean_css_value($button_min_height) . ';'
            . '--nexdirectory-box-bg:' . $clean_css_value($box_background) . ';'
            . '--nexdirectory-box-bg-alt:' . $clean_css_value($box_background_alt) . ';'
            . '--nexdirectory-box-border-color:' . $clean_css_value($border_color) . ';'
            . '--nexdirectory-box-border-width:' . $clean_css_value($border_width) . ';'
            . '--nexdirectory-shadow-sm:' . $clean_css_value($shadow_sm) . ';'
            . '--nexdirectory-shadow:' . $clean_css_value($shadow_md) . ';'
            . '--nexdirectory-shadow-lg:' . $clean_css_value($shadow_lg) . ';'
            . '--nexdirectory-btn-bg:' . $clean_css_value($btn_bg) . ';'
            . '--nexdirectory-btn-bg-hover:' . $clean_css_value($btn_bg_hover) . ';'
            . '--nexdirectory-btn-color:' . $clean_css_value($btn_color) . ';'
            . '--nexdirectory-btn-border:' . $clean_css_value($btn_border) . ';'
            . '--nexdirectory-btn-letter-spacing:' . $clean_css_value($btn_letter_spacing) . ';'
            . '--nexdirectory-btn-transform:' . $clean_css_value($btn_transform) . ';'
            . '--nexdirectory-btn-font-weight:' . $clean_css_value($btn_weight) . ';'
            . '--nexdirectory-btn-radius:' . $clean_css_value($btn_radius) . ';'
            . '--nexdirectory-input-bg:' . $clean_css_value($input_bg) . ';'
            . '--nexdirectory-input-border:' . $clean_css_value($input_border) . ';'
            . '--nexdirectory-input-border-focus:' . $clean_css_value($input_border_focus) . ';'
            . '--nexdirectory-input-border-width:' . $clean_css_value($input_border_width) . ';'
            . '--nexdirectory-input-radius:' . $clean_css_value($input_radius) . ';'
            . '--nexdirectory-primary-rgb:' . implode(', ', $accent_rgb) . ';'
            . '}';
        $dynamic_css .= '@media (max-width: 767px){:root{--nexdirectory-page-side-padding:var(--nexdirectory-page-side-padding-mobile);}}';
        if (!empty($design['use_defaults'])) {
            $dynamic_css .= '.nexdirectory-theme-shell,.nexdirectory-layout,.nexdirectory-submit-wrap,.nexdirectory-auth-grid,.nexdirectory-my-events,.nexdirectory-form{font-family:inherit!important;}';
        } else {
            $font_scope = '.nexdirectory-theme-shell,.nexdirectory-layout,.nexdirectory-submit-wrap,.nexdirectory-auth-grid,.nexdirectory-my-events,.nexdirectory-form,.nexdirectory-account-shell,.nexdirectory-platform-nav,#nexdirectory-map-default-wrapper,#nexdirectory-map-triple-wrapper';
            $dynamic_css .= $font_scope . '{font-family:var(--nexdirectory-font)!important;}';
            $dynamic_css .= $font_scope . ' :is(p,span,a,li,ul,ol,label,small,strong,em,b,button,input,select,textarea,th,td,caption,figcaption,blockquote,dt,dd,summary,details){font-family:var(--nexdirectory-font)!important;}';
            $dynamic_css .= $font_scope . ' :is(h1,h2,h3,h4,h5,h6,.nexdirectory-event-hero-title,.nexdirectory-map-item-title){font-family:var(--nexdirectory-font-display)!important;}';
        }
        wp_register_style('nexdirectory-runtime-vars', false, [], $frontend_css_ver);
        wp_enqueue_style('nexdirectory-runtime-vars');
        wp_add_inline_style('nexdirectory-runtime-vars', wp_strip_all_tags($dynamic_css));
        wp_enqueue_script('nexdirectory-frontend');
        wp_enqueue_script('nexdirectory-alpine');
        wp_enqueue_script('nexdirectory-experience');
        if (is_user_logged_in() && (is_admin() || $this->current_request_contains_submit_shortcode())) {
            wp_enqueue_media();
        }
        $upload_limits = $this->get_upload_limits();
        $excerpt_max_chars = max(80, min(1000, (int) get_option('nexdirectory_excerpt_max_chars', 220)));
        $ui_labels = method_exists($this, 'get_site_profile_ui_labels') ? (array) $this->get_site_profile_ui_labels() : [];
        $singular_label = sanitize_text_field((string) ($ui_labels['singular'] ?? __('Event', 'nexdirectory')));
        $plural_label = sanitize_text_field((string) ($ui_labels['plural'] ?? __('Events', 'nexdirectory')));
        $singular_lower = sanitize_text_field((string) ($ui_labels['singular_lower'] ?? strtolower((string) $singular_label)));
        $plural_lower = sanitize_text_field((string) ($ui_labels['plural_lower'] ?? strtolower((string) $plural_label)));
        $owner_plural = sanitize_text_field((string) ($ui_labels['owner_plural'] ?? __('Organizers', 'nexdirectory')));
        $spa_route_urls = method_exists($this, 'get_spa_hybrid_route_urls') ? (array) $this->get_spa_hybrid_route_urls() : [];
        $spa_route_urls = array_values(array_unique(array_filter(array_map('esc_url_raw', $spa_route_urls))));
        wp_localize_script('nexdirectory-frontend', 'nexdirectoryConfig', [
            'googleMapsApiKey' => (string) get_option('nexdirectory_google_maps_api_key', ''),
            'mapPinUrl' => (string) get_option('nexdirectory_map_pin_url', ''),
            'recaptchaSiteKey' => (string) get_option('nexdirectory_recaptcha_site_key', ''),
            'recaptchaVersion' => (string) get_option('nexdirectory_recaptcha_version', 'v3'),
            'recaptchaShowSubmit' => $this->is_option_enabled('nexdirectory_recaptcha_show_submit', '0'),
            'recaptchaBypassSubmitForPrivileged' => (is_user_logged_in() && current_user_can('manage_options')),
            'autosaveEndpoint' => admin_url('admin-post.php'),
            'autosaveNonce' => wp_create_nonce('nexdirectory_autosave_event_nonce'),
            'uploadEndpoint' => admin_url('admin-post.php'),
            'uploadNonce' => wp_create_nonce('nexdirectory_upload_image_nonce'),
            'uploadCoverMaxBytes' => (int) ($upload_limits['cover_max_bytes'] ?? 512000),
            'uploadGalleryMaxBytes' => (int) ($upload_limits['gallery_max_bytes'] ?? 2048000),
            'uploadGalleryMaxFiles' => (int) ($upload_limits['gallery_max_files'] ?? 10),
            'uploadFileMaxBytes' => (int) ($upload_limits['file_max_bytes'] ?? 2097152),
            'excerptMaxChars' => $excerpt_max_chars,
            'enableSubmitAutosave' => $this->is_option_enabled('nexdirectory_enable_submit_autosave', '1'),
            'smoothScrolling' => $this->is_option_enabled('nexdirectory_enable_smooth_scrolling', '0'),
            'smoothScrollProfile' => method_exists($this, 'sanitize_smooth_scroll_profile_option')
                ? (string) $this->sanitize_smooth_scroll_profile_option((string) get_option('nexdirectory_smooth_scroll_profile', 'balanced'))
                : (string) get_option('nexdirectory_smooth_scroll_profile', 'balanced'),
            'smoothScrollTouchpadBehavior' => method_exists($this, 'sanitize_smooth_scroll_touchpad_behavior_option')
                ? (string) $this->sanitize_smooth_scroll_touchpad_behavior_option((string) get_option('nexdirectory_smooth_scroll_touchpad_behavior', 'native'))
                : (string) get_option('nexdirectory_smooth_scroll_touchpad_behavior', 'native'),
            'smoothScrollWheelMultiplier' => method_exists($this, 'sanitize_smooth_scroll_wheel_multiplier_option')
                ? (float) $this->sanitize_smooth_scroll_wheel_multiplier_option((string) get_option('nexdirectory_smooth_scroll_wheel_multiplier', '1'))
                : (float) get_option('nexdirectory_smooth_scroll_wheel_multiplier', '1'),
            'smoothScrollEase' => method_exists($this, 'sanitize_smooth_scroll_ease_option')
                ? (float) $this->sanitize_smooth_scroll_ease_option((string) get_option('nexdirectory_smooth_scroll_ease', '1.2'))
                : (float) get_option('nexdirectory_smooth_scroll_ease', '1.2'),
            'smoothScrollMaxStepPx' => method_exists($this, 'sanitize_smooth_scroll_max_step_option')
                ? (int) $this->sanitize_smooth_scroll_max_step_option((int) get_option('nexdirectory_smooth_scroll_max_step_px', 960))
                : (int) get_option('nexdirectory_smooth_scroll_max_step_px', 960),
            'smoothScrollMinStepPx' => method_exists($this, 'sanitize_smooth_scroll_min_step_option')
                ? (float) $this->sanitize_smooth_scroll_min_step_option((string) get_option('nexdirectory_smooth_scroll_min_step_px', '0.16'))
                : (float) get_option('nexdirectory_smooth_scroll_min_step_px', '0.16'),
            'smoothScrollEnableOnTouch' => $this->is_option_enabled('nexdirectory_smooth_scroll_enable_on_touch', '0'),
            'preloaderEnabled' => is_admin() ? false : $this->is_option_enabled('nexdirectory_preloader_enabled', '1'),
            'preloaderSource' => method_exists($this, 'sanitize_preloader_source_option')
                ? (string) $this->sanitize_preloader_source_option((string) get_option('nexdirectory_preloader_source', 'svg'))
                : (string) get_option('nexdirectory_preloader_source', 'svg'),
            'preloaderSvg' => method_exists($this, 'sanitize_preloader_svg_option')
                ? (string) $this->sanitize_preloader_svg_option((string) get_option('nexdirectory_preloader_svg_markup', method_exists($this, 'get_default_preloader_svg_markup') ? (string) $this->get_default_preloader_svg_markup() : ''))
                : (string) get_option('nexdirectory_preloader_svg_markup', ''),
            'preloaderImageUrl' => esc_url_raw((string) get_option('nexdirectory_preloader_image_url', '')),
            'preloaderBackgroundColor' => method_exists($this, 'sanitize_preloader_background_color_option')
                ? (string) $this->sanitize_preloader_background_color_option((string) get_option('nexdirectory_preloader_background_color', '#FFFFFF'))
                : (string) get_option('nexdirectory_preloader_background_color', '#FFFFFF'),
            'preloaderLogoMaxWidth' => method_exists($this, 'sanitize_preloader_logo_max_width_option')
                ? (int) $this->sanitize_preloader_logo_max_width_option((int) get_option('nexdirectory_preloader_logo_max_width', 420))
                : (int) get_option('nexdirectory_preloader_logo_max_width', 420),
            'preloaderMinDurationMs' => method_exists($this, 'sanitize_preloader_min_duration_option')
                ? (int) $this->sanitize_preloader_min_duration_option((int) get_option('nexdirectory_preloader_min_duration_ms', 400))
                : (int) get_option('nexdirectory_preloader_min_duration_ms', 400),
            'preloaderFadeDurationMs' => method_exists($this, 'sanitize_preloader_fade_duration_option')
                ? (int) $this->sanitize_preloader_fade_duration_option((int) get_option('nexdirectory_preloader_fade_duration_ms', 420))
                : (int) get_option('nexdirectory_preloader_fade_duration_ms', 420),
            'preloaderShowOnInitial' => $this->is_option_enabled('nexdirectory_preloader_show_on_initial', '1'),
            'preloaderShowOnSpa' => $this->is_option_enabled('nexdirectory_preloader_show_on_spa', '1'),
            'preloaderShowOnlyHome' => $this->is_option_enabled('nexdirectory_preloader_show_only_home', '0'),
            'preloaderShowOnce' => $this->is_option_enabled('nexdirectory_preloader_show_once', '0'),
            'preloaderHomeUrl' => esc_url_raw(home_url('/')),
            'preloaderIsHomePage' => (bool) (is_front_page() || is_home()),
            'preloaderSeenStorageKey' => 'nexdirectory_preloader_seen_' . md5((string) home_url('/')),
            'preloaderDisableOnMobile' => $this->is_option_enabled('nexdirectory_preloader_disable_mobile', '0'),
            'preloaderRespectReducedMotion' => $this->is_option_enabled('nexdirectory_preloader_respect_motion', '1'),
            'autosaveInterval' => (int) get_option('nexdirectory_autosave_interval_seconds', 60),
            'mapRestUrl' => esc_url_raw(rest_url('nexdirectory/v1/map-events')),
            'geocodeRestUrl' => esc_url_raw(rest_url('nexdirectory/v1/geocode')),
            'addressSuggestRestUrl' => esc_url_raw(rest_url('nexdirectory/v1/address-suggestions')),
            'archiveRestUrl' => esc_url_raw(rest_url('nexdirectory/v1/archive-events')),
            'analyticsTrackUrl' => esc_url_raw(rest_url('nexdirectory/v1/analytics-track')),
            'userSearchRestUrl' => esc_url_raw(rest_url('nexdirectory/v1/user-search')),
            'rsvpRestUrl' => esc_url_raw(rest_url('nexdirectory/v1/rsvp')),
            'saveFiltersRestUrl' => esc_url_raw(rest_url('nexdirectory/v1/save-filters')),
            'inboxPollRestUrl' => esc_url_raw(rest_url('nexdirectory/v1/inbox-poll')),
            'notificationFeedRestUrl' => esc_url_raw(rest_url('nexdirectory/v1/notifications-feed')),
            'moderationRestUrl' => esc_url_raw(rest_url('nexdirectory/v1/moderate-event')),
            'moderationBulkRestUrl' => esc_url_raw(rest_url('nexdirectory/v1/moderate-events-bulk')),
            'inboxPageUrl' => esc_url_raw((string) $this->get_notifications_page_url()),
            'eventsPageUrl' => esc_url_raw((string) $this->get_platform_page_url(
                'nexdirectory_page_events_url',
                $this->get_profile_route_slugs('events'),
                $this->get_profile_route_default_url('events')
            )),
            'mapPageUrl' => esc_url_raw((string) $this->get_platform_page_url(
                'nexdirectory_page_events_map_url',
                $this->get_profile_route_slugs('map'),
                $this->get_profile_route_default_url('map')
            )),
            'spaEnabled' => method_exists($this, 'is_spa_hybrid_enabled') ? (bool) $this->is_spa_hybrid_enabled() : true,
            'spaFeedbackBadge' => (string) get_option('nexdirectory_show_spa_feedback_badge', '0') === '1',
            'spaSelector' => '[data-nexdirectory-spa-main="1"]',
            'spaRoutes' => $spa_route_urls,
            'enableModerationAjax' => (string) get_option('nexdirectory_enable_moderation_ajax', '1') === '1',
            'restNonce' => wp_create_nonce('wp_rest'),
            'currentUserId' => get_current_user_id(),
            'mapAutoSearch' => (string) get_option('nexdirectory_enable_map_auto_search', '1') === '1',
            'mapResultsPerPage' => (int) get_option('nexdirectory_map_results_per_page', 120),
            'mapProvider' => (string) get_option('nexdirectory_map_provider', 'openstreetmap'),
            'mapStyle' => (string) get_option('nexdirectory_map_style', 'osm-standard'),
            'mapFocusRegion' => method_exists($this, 'sanitize_map_focus_region_option')
                ? (string) $this->sanitize_map_focus_region_option((string) get_option('nexdirectory_map_focus_region', 'global'))
                : (string) get_option('nexdirectory_map_focus_region', 'global'),
            'mapboxAccessToken' => (string) get_option('nexdirectory_mapbox_access_token', ''),
            'mapboxStyleId' => method_exists($this, 'sanitize_mapbox_style_id_option')
                ? (string) $this->sanitize_mapbox_style_id_option((string) get_option('nexdirectory_mapbox_style_id', 'mapbox/streets-v12'))
                : (string) get_option('nexdirectory_mapbox_style_id', 'mapbox/streets-v12'),
            'mapCustomTileUrl' => (string) get_option('nexdirectory_map_custom_tile_url', ''),
            'mapCustomTileAttribution' => (string) get_option('nexdirectory_map_custom_tile_attribution', ''),
            'mapCustomTileSubdomains' => (string) get_option('nexdirectory_map_custom_tile_subdomains', 'a,b,c'),
            'fallbackThumbUrl' => esc_url_raw((string) $this->get_default_image_url('thumbnail')),
            'fallbackHeroUrl' => esc_url_raw((string) $this->get_default_image_url('hero')),
            'enableHeroParallax' => (string) get_option('nexdirectory_enable_hero_parallax', '1') === '1',
            'messages' => [
                /* translators: %s: Placeholder value. */
                'noMapResults' => sprintf(__('No matching %s found.', 'nexdirectory'), $plural_lower),
                'enterAddress' => __('Enter an address first.', 'nexdirectory'),
                'geocodeNoResult' => __('No location found for that address.', 'nexdirectory'),
                'geocodeFailed' => __('Could not locate that address right now.', 'nexdirectory'),
                'recaptchaLoadFailed' => __('reCAPTCHA failed to load.', 'nexdirectory'),
                'recaptchaRequired' => __('Please complete the reCAPTCHA challenge before submitting.', 'nexdirectory'),
                /* translators: %s: Placeholder value. */
                'mapLoadFailed' => sprintf(__('Could not load map %s.', 'nexdirectory'), $plural_lower),
                /* translators: %s: Placeholder value. */
                'mapLoading' => sprintf(__('Loading %s...', 'nexdirectory'), $plural_lower),
                'mapLocateUnsupported' => __('Your browser does not support location services.', 'nexdirectory'),
                'mapLocateDenied' => __('Could not access your location.', 'nexdirectory'),
                'mapLocateInProgress' => __('Finding your location...', 'nexdirectory'),
                'mapNearMeLabel' => sanitize_text_field((string) ($ui_labels['map_near_me_label'] ?? __('Find Near Me', 'nexdirectory'))),
                'mapLoadMore' => __('Load more results', 'nexdirectory'),
                'mapLoadMoreDone' => __('No more results', 'nexdirectory'),
                /* translators: %s: Placeholder value. */
                'mapAriaLabel' => sprintf(__('%s locations map', 'nexdirectory'), $plural_label),
                'addressSuggestFailed' => __('Could not load address suggestions right now.', 'nexdirectory'),
                /* translators: %s: Placeholder value. */
                'archiveLoading' => sprintf(__('Updating %s...', 'nexdirectory'), $plural_lower),
                /* translators: %s: Placeholder value. */
                'archiveLoadFailed' => sprintf(__('Could not update %s list.', 'nexdirectory'), $plural_lower),
                'archiveFiltersSaved' => __('Filters saved on this device.', 'nexdirectory'),
                'archiveNoSavedFilters' => __('No saved filters found on this device.', 'nexdirectory'),
                'archiveNoFiltersToSave' => __('Apply at least one filter before saving.', 'nexdirectory'),
                'archiveSaveFailed' => __('Could not save filters on this device.', 'nexdirectory'),
                'widgetSearchNoResults' => __('No matches yet. Try different keywords.', 'nexdirectory'),
                /* translators: %s: Placeholder value. */
                'viewDetails' => sprintf(__('View %s', 'nexdirectory'), $singular_label),
                'boostBadgeLabel' => sanitize_text_field((string) get_option('nexdirectory_boost_featured_badge_text', __('Featured', 'nexdirectory'))),
                'verifiedLabel' => __('Verified', 'nexdirectory'),
                'calendarEvents' => $plural_label,
                /* translators: %s: Placeholder value. */
                'calendarNoEvents' => sprintf(__('No %s found for this day.', 'nexdirectory'), $plural_lower),
                /* translators: %s: Placeholder value. */
                'calendarUntitled' => sprintf(__('Untitled %s', 'nexdirectory'), $singular_lower),
                'userSearchFailed' => __('Could not search users right now.', 'nexdirectory'),
                'pastDateWarning' => __('Start date is in the past. You can still submit if this is intentional.', 'nexdirectory'),
                'pastDateConfirm' => __('The start date is in the past. Are you sure you want to continue?', 'nexdirectory'),
                'pastDateCancelled' => __('Submission cancelled. Adjust the date or submit again to confirm.', 'nexdirectory'),
                'restActionFailed' => __('Request failed. Please try again.', 'nexdirectory'),
                'spaLoading' => __('Loading page...', 'nexdirectory'),
                'spaLoadFailed' => __('Could not load that page. Opening full page view.', 'nexdirectory'),
                'inboxToastTitle' => __('New Inbox Message', 'nexdirectory'),
                'inboxToastSubtitle' => __('Open your inbox to view and reply.', 'nexdirectory'),
                'inboxToastBody' => __('You have a new message in your inbox.', 'nexdirectory'),
                'notificationTitle' => __('Notifications', 'nexdirectory'),
                'notificationEmpty' => __('You are all caught up.', 'nexdirectory'),
                'notificationMarkAll' => __('Mark all as read', 'nexdirectory'),
                'notificationLoadFailed' => __('Notifications could not be loaded right now.', 'nexdirectory'),
                'notificationUnread' => __('Unread notification', 'nexdirectory'),
            ],
        ]);
    }


    public function register_rest_routes() {
        register_rest_route('nexdirectory/v1', '/map-events', [
            'methods' => WP_REST_Server::READABLE,
            'callback' => [$this, 'rest_get_map_events'],
            'permission_callback' => [$this, 'rest_public_events_permission'],
        ]);
        register_rest_route('nexdirectory/v1', '/archive-events', [
            'methods' => WP_REST_Server::READABLE,
            'callback' => [$this, 'rest_get_archive_events'],
            'permission_callback' => [$this, 'rest_public_events_permission'],
        ]);
        register_rest_route('nexdirectory/v1', '/widget-search', [
            'methods' => WP_REST_Server::READABLE,
            'callback' => [$this, 'rest_widget_search'],
            'permission_callback' => [$this, 'rest_public_events_permission'],
        ]);
        register_rest_route('nexdirectory/v1', '/analytics-track', [
            'methods' => WP_REST_Server::CREATABLE,
            'callback' => [$this, 'rest_track_event_analytics'],
            'permission_callback' => [$this, 'rest_public_events_permission'],
        ]);
        register_rest_route('nexdirectory/v1', '/geocode', [
            'methods' => WP_REST_Server::READABLE,
            'callback' => [$this, 'rest_geocode_address'],
            'permission_callback' => [$this, 'rest_geocode_permission'],
        ]);
        register_rest_route('nexdirectory/v1', '/address-suggestions', [
            'methods' => WP_REST_Server::READABLE,
            'callback' => [$this, 'rest_address_suggestions'],
            'permission_callback' => [$this, 'rest_geocode_permission'],
        ]);
        register_rest_route('nexdirectory/v1', '/user-search', [
            'methods' => WP_REST_Server::READABLE,
            'callback' => [$this, 'rest_search_users'],
            'permission_callback' => [$this, 'rest_user_search_permission'],
        ]);
        register_rest_route('nexdirectory/v1', '/rsvp', [
            'methods' => WP_REST_Server::CREATABLE,
            'callback' => [$this, 'rest_rsvp_event'],
            'permission_callback' => [$this, 'rest_authenticated_permission'],
        ]);
        register_rest_route('nexdirectory/v1', '/save-filters', [
            'methods' => WP_REST_Server::CREATABLE,
            'callback' => [$this, 'rest_save_filters'],
            'permission_callback' => [$this, 'rest_authenticated_permission'],
        ]);
        register_rest_route('nexdirectory/v1', '/inbox-poll', [
            'methods' => WP_REST_Server::READABLE,
            'callback' => [$this, 'rest_inbox_poll'],
            'permission_callback' => [$this, 'rest_authenticated_permission'],
        ]);
        register_rest_route('nexdirectory/v1', '/notifications-feed', [
            [
                'methods' => WP_REST_Server::READABLE,
                'callback' => [$this, 'rest_notifications_feed'],
                'permission_callback' => [$this, 'rest_authenticated_permission'],
                'args' => [
                    'per_page' => [
                        'default' => 20,
                        'sanitize_callback' => 'absint',
                        'validate_callback' => static function ($value) {
                            if (!is_numeric($value)) {
                                return false;
                            }
                            $value = absint((int) $value);
                            return $value >= 1 && $value <= 50;
                        },
                    ],
                    'before_id' => [
                        'default' => 0,
                        'sanitize_callback' => 'absint',
                    ],
                ],
            ],
            [
                'methods' => WP_REST_Server::CREATABLE,
                'callback' => [$this, 'rest_notifications_feed'],
                'permission_callback' => [$this, 'rest_authenticated_permission'],
            ],
        ]);
        register_rest_route('nexdirectory/v1', '/moderate-event', [
            'methods' => WP_REST_Server::CREATABLE,
            'callback' => [$this, 'rest_moderate_event'],
            'permission_callback' => [$this, 'rest_moderation_permission'],
        ]);
        register_rest_route('nexdirectory/v1', '/moderate-events-bulk', [
            'methods' => WP_REST_Server::CREATABLE,
            'callback' => [$this, 'rest_moderate_events_bulk'],
            'permission_callback' => [$this, 'rest_moderation_permission'],
        ]);
        register_rest_route('nexdirectory/v1', '/extension/ping', [
            'methods' => WP_REST_Server::CREATABLE,
            'callback' => [$this, 'rest_extension_ping'],
            'permission_callback' => [$this, 'rest_extension_permission'],
        ]);
        register_rest_route('nexdirectory/v1', '/extension/import', [
            'methods' => WP_REST_Server::CREATABLE,
            'callback' => [$this, 'rest_extension_import_event'],
            'permission_callback' => [$this, 'rest_extension_permission'],
        ]);
        register_rest_route('nexdirectory/v1', '/extension/preferences', [
            'methods' => WP_REST_Server::CREATABLE,
            'callback' => [$this, 'rest_extension_update_preferences'],
            'permission_callback' => [$this, 'rest_extension_permission'],
        ]);
    }


    public function rest_geocode_permission() {
        return is_user_logged_in() && $this->user_can_submit_events();
    }


    public function rest_public_events_permission() {
        return true;
    }


    public function rest_track_event_analytics($request) {
        $event_id = absint((int) $request->get_param('event_id'));
        if ($event_id <= 0 || get_post_type($event_id) !== self::POST_TYPE) {
            return new WP_Error('nexdirectory_invalid_event', __('Invalid event selected.', 'nexdirectory'), ['status' => 400]);
        }
        $post_status = (string) get_post_status($event_id);
        if ($post_status !== 'publish' && !current_user_can('edit_post', $event_id)) {
            return new WP_Error('nexdirectory_event_unavailable', __('This listing is not publicly available.', 'nexdirectory'), ['status' => 403]);
        }

        $metric = sanitize_key((string) $request->get_param('metric'));
        $allowed_metrics = ['listing_view', 'click_phone', 'click_website', 'click_whatsapp'];
        if (!in_array($metric, $allowed_metrics, true)) {
            return new WP_Error('nexdirectory_invalid_metric', __('Invalid analytics metric.', 'nexdirectory'), ['status' => 400]);
        }

        $source = sanitize_key((string) $request->get_param('source'));
        $allowed_sources = [
            'single',
            'map',
            'archive',
            'calendar',
            'account',
            'my_items',
            'owner_analytics',
        ];
        if (!in_array($source, $allowed_sources, true)) {
            $source = 'single';
        }
        $identity_seed = is_user_logged_in()
            ? 'u:' . (string) get_current_user_id()
            : 'ip:' . sanitize_text_field((string) wp_unslash($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'));
        $rate_key = 'nexdirectory_track_rate_' . md5($identity_seed);
        $rate_count = (int) get_transient($rate_key);
        if ($rate_count >= 60) {
            return rest_ensure_response([
                'ok' => true,
                'recorded' => false,
                'reason' => 'rate_limited',
            ]);
        }
        set_transient($rate_key, (string) ($rate_count + 1), MINUTE_IN_SECONDS);
        $throttle_key = 'nexdirectory_track_' . md5($metric . '|' . (string) $event_id . '|' . $identity_seed);
        if (get_transient($throttle_key)) {
            return rest_ensure_response([
                'ok' => true,
                'recorded' => false,
                'reason' => 'throttled',
            ]);
        }
        $throttle_ttl = $metric === 'listing_view' ? 30 * MINUTE_IN_SECONDS : 15;
        set_transient($throttle_key, '1', $throttle_ttl);

        $recorded = $this->record_analytics_event($event_id, $metric, [
            'context' => $source,
            'user_id' => is_user_logged_in() ? get_current_user_id() : 0,
        ]);

        return rest_ensure_response([
            'ok' => true,
            'recorded' => (bool) $recorded,
        ]);
    }


    private function build_geocode_query_from_parts($address = '', $city = '', $country = '', $venue = '') {
        $parts = array_filter(array_map('trim', [
            sanitize_text_field((string) $venue),
            sanitize_text_field((string) $address),
            sanitize_text_field((string) $city),
            sanitize_text_field((string) $country),
        ]), static function ($value) {
            return $value !== '';
        });
        if (empty($parts)) {
            return '';
        }
        $unique = [];
        foreach ($parts as $part) {
            $key = strtolower((string) $part);
            if (!isset($unique[$key])) {
                $unique[$key] = $part;
            }
        }
        return trim((string) implode(', ', array_values($unique)));
    }


    private function geocode_event_location_from_parts($address = '', $city = '', $country = '', $venue = '') {
        $query = $this->build_geocode_query_from_parts($address, $city, $country, $venue);
        if ($query === '') {
            return null;
        }

        $headers = [
            'Accept' => 'application/json',
            'User-Agent' => 'NexDirectoryFree/' . self::VERSION . ' (' . home_url('/') . ')',
        ];
        $google_key = (string) get_option('nexdirectory_google_maps_api_key', '');
        if ($google_key !== '') {
            $google_url = nexdirectory_add_query_arg([
                'address' => $query,
                'key' => $google_key,
            ], 'https://maps.googleapis.com/maps/api/geocode/json');
            $google_response = wp_remote_get($google_url, [
                'timeout' => 12,
                'headers' => $headers,
            ]);
            if (!is_wp_error($google_response)) {
                $payload = json_decode((string) wp_remote_retrieve_body($google_response), true);
                $row = is_array($payload) && !empty($payload['results'][0]) ? (array) $payload['results'][0] : [];
                $location = !empty($row['geometry']['location']) && is_array($row['geometry']['location']) ? (array) $row['geometry']['location'] : [];
                if (isset($location['lat'], $location['lng']) && is_numeric($location['lat']) && is_numeric($location['lng'])) {
                    $components = !empty($row['address_components']) && is_array($row['address_components']) ? (array) $row['address_components'] : [];
                    $city_value = '';
                    $country_value = '';
                    foreach ($components as $component) {
                        if (!is_array($component) || empty($component['types']) || empty($component['long_name'])) {
                            continue;
                        }
                        $types = (array) $component['types'];
                        if ($city_value === '' && (in_array('locality', $types, true) || in_array('postal_town', $types, true) || in_array('administrative_area_level_2', $types, true))) {
                            $city_value = sanitize_text_field((string) $component['long_name']);
                        }
                        if ($country_value === '' && in_array('country', $types, true)) {
                            $country_value = sanitize_text_field((string) $component['long_name']);
                        }
                    }
                    return [
                        'lat' => (float) $location['lat'],
                        'lng' => (float) $location['lng'],
                        'city' => $city_value,
                        'country' => $country_value,
                        'address' => sanitize_text_field((string) ($row['formatted_address'] ?? $query)),
                    ];
                }
            }
        }

        $osm_url = nexdirectory_add_query_arg([
            'format' => 'json',
            'addressdetails' => 1,
            'limit' => 1,
            'q' => $query,
        ], 'https://nominatim.openstreetmap.org/search');
        $osm_response = wp_remote_get($osm_url, [
            'timeout' => 12,
            'headers' => $headers,
        ]);
        if (is_wp_error($osm_response)) {
            return null;
        }
        $payload = json_decode((string) wp_remote_retrieve_body($osm_response), true);
        if (!is_array($payload) || empty($payload[0]['lat']) || empty($payload[0]['lon'])) {
            return null;
        }
        $first = (array) $payload[0];
        $address_bits = isset($first['address']) && is_array($first['address']) ? (array) $first['address'] : [];
        $city_value = '';
        foreach (['city', 'town', 'village', 'municipality', 'county', 'state_district', 'state'] as $city_key) {
            if (!empty($address_bits[$city_key])) {
                $city_value = sanitize_text_field((string) $address_bits[$city_key]);
                break;
            }
        }
        $country_value = !empty($address_bits['country']) ? sanitize_text_field((string) $address_bits['country']) : '';
        return [
            'lat' => (float) $payload[0]['lat'],
            'lng' => (float) $payload[0]['lon'],
            'city' => $city_value,
            'country' => $country_value,
            'address' => sanitize_text_field((string) ($first['display_name'] ?? $query)),
        ];
    }


    private function haversine_distance_km($lat1, $lng1, $lat2, $lng2) {
        $earth_radius = 6371.0;
        $lat_delta = deg2rad((float) $lat2 - (float) $lat1);
        $lng_delta = deg2rad((float) $lng2 - (float) $lng1);
        $a = sin($lat_delta / 2) * sin($lat_delta / 2)
            + cos(deg2rad((float) $lat1)) * cos(deg2rad((float) $lat2))
            * sin($lng_delta / 2) * sin($lng_delta / 2);
        $c = 2 * atan2(sqrt($a), sqrt(1 - $a));
        return $earth_radius * $c;
    }


    private function get_rsvp_table_name() {
        global $wpdb;
        return $wpdb->prefix . self::RSVP_TABLE;
    }


    private function get_event_attendees_by_status($post_id, $status) {
        global $wpdb;
        $table = $this->get_rsvp_table_name();
        $status = sanitize_key((string) $status);
        if (!in_array($status, ['yes', 'waitlist'], true)) {
            $status = 'yes';
        }
        $rows = $wpdb->get_col($wpdb->prepare(
            "SELECT user_id FROM {$table} WHERE post_id = %d AND status = %s ORDER BY id ASC",
            (int) $post_id,
            $status
        ));
        if (!is_array($rows)) {
            return [];
        }
        return array_values(array_unique(array_map('intval', $rows)));
    }


    /**
     * Checks one RSVP membership through the unique post/user table index.
     *
     * @param int    $post_id Listing ID.
     * @param int    $user_id User ID.
     * @param string $status  RSVP status.
     * @return bool
     */
    private function event_attendee_has_status($post_id, $user_id, $status) {
        global $wpdb;

        $post_id = absint((int) $post_id);
        $user_id = absint((int) $user_id);
        $status = sanitize_key((string) $status);
        if ($post_id <= 0 || $user_id <= 0 || !in_array($status, ['yes', 'waitlist'], true)) {
            return false;
        }

        $table = $this->get_rsvp_table_name();
        $row_id = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT id
                 FROM {$table}
                 WHERE post_id = %d
                   AND user_id = %d
                   AND status = %s
                 LIMIT 1",
                $post_id,
                $user_id,
                $status
            )
        );

        return absint((int) $row_id) > 0;
    }


    private function sync_rsvp_meta_from_table($post_id) {
        update_post_meta($post_id, 'nexdirectory_rsvp_yes', $this->get_event_attendees_by_status($post_id, 'yes'));
        update_post_meta($post_id, 'nexdirectory_waitlist', $this->get_event_attendees_by_status($post_id, 'waitlist'));
    }


    private function build_archive_query($filters, $posts_per_page, $paged, $organizer_id = 0) {
        $ui_labels = method_exists($this, 'get_site_profile_ui_labels') ? (array) $this->get_site_profile_ui_labels() : [];
        $profile_key = sanitize_key((string) ($ui_labels['profile_key'] ?? 'events'));
        $is_events_profile = $profile_key === 'events';
        $is_job_profile = $profile_key === 'job_board';
        $type = sanitize_text_field((string) ($filters['event_type'] ?? ''));
        $mode = sanitize_text_field((string) ($filters['event_mode'] ?? ''));
        $price_range = sanitize_text_field((string) ($filters['event_price'] ?? ''));
        $tag = sanitize_text_field((string) ($filters['event_tag'] ?? ''));
        $city = sanitize_text_field((string) ($filters['city'] ?? ''));
        $date_from = sanitize_text_field((string) ($filters['date_from'] ?? ''));
        $official = sanitize_text_field((string) ($filters['official'] ?? ''));
        $search = sanitize_text_field((string) ($filters['s'] ?? ''));
        $archive_orderby = sanitize_key((string) ($filters['orderby'] ?? ''));
        $dynamic_filters = isset($filters['nex_filter']) && is_array($filters['nex_filter'])
            ? $filters['nex_filter']
            : [];
        if ($is_job_profile) {
            $price_range = '';
        }
        if (!$is_events_profile) {
            $official = '';
        }

        $tax_query = [];
        if ($type !== '') {
            $tax_query[] = [
                'taxonomy' => self::TAX_TYPE,
                'field' => 'slug',
                'terms' => $type,
            ];
        }
        if ($mode !== '') {
            $tax_query[] = [
                'taxonomy' => self::TAX_MODE,
                'field' => 'slug',
                'terms' => $mode,
            ];
        }
        if ($price_range !== '') {
            $tax_query[] = [
                'taxonomy' => self::TAX_PRICE,
                'field' => 'slug',
                'terms' => $price_range,
            ];
        }
        if ($tag !== '') {
            $tax_query[] = [
                'taxonomy' => self::TAX_TAG,
                'field' => 'slug',
                'terms' => $tag,
            ];
        }
        $dynamic_query = method_exists($this, 'builder_checkin_compile_dynamic_query')
            ? $this->builder_checkin_compile_dynamic_query($dynamic_filters, 'archive')
            : ['tax_query' => [], 'field_filters' => []];
        if (!empty($dynamic_query['tax_query']) && is_array($dynamic_query['tax_query'])) {
            $tax_query = array_merge($tax_query, $dynamic_query['tax_query']);
        }

        $meta_query = [];
        if ($city !== '') {
            $meta_query[] = [
                'key' => 'nexdirectory_city',
                'value' => $city,
                'compare' => 'LIKE',
            ];
        }
        if ($date_from !== '') {
            $meta_query[] = [
                'key' => 'nexdirectory_start',
                'value' => $date_from,
                'compare' => '>=',
                'type' => 'CHAR',
            ];
        }
        if (in_array($official, ['0', '1'], true)) {
            $meta_query[] = [
                'key' => 'nexdirectory_official_event',
                'value' => $official,
                'compare' => '=',
            ];
        }

        $args = [
            'post_type' => self::POST_TYPE,
            'post_status' => 'publish',
            'posts_per_page' => max(1, (int) $posts_per_page),
            'paged' => max(1, (int) $paged),
            's' => $search,
            'meta_key' => 'nexdirectory_start',
            'orderby' => 'meta_value',
            'order' => 'ASC',
            'nexdirectory_apply_boost_order' => 1,
        ];
        if (!empty($dynamic_query['field_filters']) && is_array($dynamic_query['field_filters'])) {
            $args['nexdirectory_field_index_filters'] = $dynamic_query['field_filters'];
        }
        if ($archive_orderby === 'date') {
            unset($args['meta_key']);
            $args['orderby'] = 'date';
            $args['order'] = 'DESC';
        }
        if ($organizer_id > 0) {
            $args['author'] = (int) $organizer_id;
        }
        if (!empty($tax_query)) {
            $args['tax_query'] = $tax_query;
        }
        if (!empty($meta_query)) {
            $args['meta_query'] = $meta_query;
        }
        return new WP_Query($args);
    }


    private function render_archive_results_markup($query, $paged, $filters, $organizer_id = 0) {
        $ui_labels = method_exists($this, 'get_site_profile_ui_labels') ? (array) $this->get_site_profile_ui_labels() : [];
        $singular_label = sanitize_text_field((string) ($ui_labels['singular'] ?? __('Event', 'nexdirectory')));
        $plural_label = sanitize_text_field((string) ($ui_labels['plural'] ?? __('Events', 'nexdirectory')));
        $singular_lower = sanitize_text_field((string) ($ui_labels['singular_lower'] ?? strtolower((string) $singular_label)));
        $plural_lower = sanitize_text_field((string) ($ui_labels['plural_lower'] ?? strtolower((string) $plural_label)));
        $profile_key = sanitize_key((string) ($ui_labels['profile_key'] ?? 'events'));
        $is_events_profile = $profile_key === 'events';
        $is_job_profile = $profile_key === 'job_board';
        $is_directory_profile = $profile_key === 'directory';
        $is_marketplace_profile = $profile_key === 'marketplace';
        ob_start();
        ?>
        <div class="nexdirectory-grid">
            <?php if ($query->have_posts()) : ?>
                <?php while ($query->have_posts()) : $query->the_post(); ?>
                    <?php
                    $post_id = get_the_ID();
                    $card_date_label = $this->format_event_date($post_id);
                    $secondary_meta_label = '';
                    if ($is_job_profile) {
                        $closing_raw = trim((string) get_post_meta($post_id, 'nexdirectory_job_closing_date', true));
                        $closing_ts = $closing_raw !== '' ? strtotime($closing_raw . ' 23:59:00') : false;
                        if ($closing_ts) {
                            $card_date_label = sprintf(
                                /* translators: %s: date */
                                __('Closes %s', 'nexdirectory'),
                                wp_date(get_option('date_format'), (int) $closing_ts)
                            );
                        }
                        $salary = trim((string) get_post_meta($post_id, 'nexdirectory_job_salary', true));
                        if ($salary !== '') {
                            $secondary_meta_label = $salary;
                        } else {
                            $company = trim((string) get_post_meta($post_id, 'nexdirectory_company_name', true));
                            if ($company !== '') {
                                $secondary_meta_label = $company;
                            }
                        }
                    } elseif ($is_directory_profile) {
                        $updated_ts = strtotime((string) get_post_field('post_modified', $post_id));
                        if ($updated_ts) {
                            $card_date_label = sprintf(
                                /* translators: %s: date */
                                __('Updated %s', 'nexdirectory'),
                                wp_date(get_option('date_format'), (int) $updated_ts)
                            );
                        }
                        $type_terms = wp_get_post_terms($post_id, self::TAX_TYPE, ['fields' => 'names']);
                        if (!is_wp_error($type_terms) && !empty($type_terms[0])) {
                            $secondary_meta_label = sanitize_text_field((string) $type_terms[0]);
                        }
                    } elseif ($is_marketplace_profile) {
                        $listed_ts = strtotime((string) get_post_field('post_date', $post_id));
                        if ($listed_ts) {
                            $card_date_label = sprintf(
                                /* translators: %s: date */
                                __('Listed %s', 'nexdirectory'),
                                wp_date(get_option('date_format'), (int) $listed_ts)
                            );
                        }
                        $condition = trim((string) get_post_meta($post_id, 'nexdirectory_market_condition', true));
                        $price = trim((string) get_post_meta($post_id, 'nexdirectory_price', true));
                        if ($condition !== '') {
                            $secondary_meta_label = $condition;
                        } elseif ($price !== '') {
                            $secondary_meta_label = $price;
                        }
                    }
                    $card_location = trim((string) get_post_meta($post_id, 'nexdirectory_city', true));
                    if ($card_location === '') {
                        $card_location = trim((string) get_post_meta($post_id, 'nexdirectory_venue', true));
                    }
                    if ($card_location === '') {
                        $card_location = trim((string) get_post_meta($post_id, 'nexdirectory_country', true));
                    }
                    $boost_badge = method_exists($this, 'get_boost_badge_config') ? (array) $this->get_boost_badge_config() : [];
                    $is_boosted = method_exists($this, 'is_listing_boosted') ? (bool) $this->is_listing_boosted($post_id) : false;
                    $show_featured_badge = $is_boosted && !empty($boost_badge['show']);
                    $featured_badge_text = sanitize_text_field((string) ($boost_badge['text'] ?? __('Featured', 'nexdirectory')));
                    if ($featured_badge_text === '') {
                        $featured_badge_text = __('Featured', 'nexdirectory');
                    }
                    $featured_badge_image = esc_url((string) ($boost_badge['image'] ?? ''));
                    $apply_boost_highlight = $is_boosted && !empty($boost_badge['highlight']);
                    $show_verified_badge = !empty($boost_badge['verified_badge']) && get_user_meta((int) get_post_field('post_author', $post_id), 'nexdirectory_verified_organizer', true) === '1';
                    $article_classes = 'nexdirectory-card nexdirectory-archive-event-card';
                    if ($apply_boost_highlight) {
                        $article_classes .= ' nexdirectory-card-boosted';
                    }
                    if ($show_verified_badge) {
                        $article_classes .= ' nexdirectory-card-verified';
                    }
                    ?>
                    <article class="<?php echo esc_attr($article_classes); ?>">
                        <?php $cover = $this->get_event_cover_image_url($post_id, 'medium_large', 'thumbnail'); ?>
                        <div class="nexdirectory-card-image-wrap">
                            <?php if ($show_featured_badge) : ?>
                                <span class="nexdirectory-boost-badge" aria-label="<?php echo esc_attr($featured_badge_text); ?>">
                                    <?php if ($featured_badge_image !== '') : ?>
                                        <img src="<?php echo esc_url($featured_badge_image); ?>" alt="<?php echo esc_attr($featured_badge_text); ?>" loading="lazy" decoding="async">
                                    <?php else : ?>
                                        <?php echo esc_html($featured_badge_text); ?>
                                    <?php endif; ?>
                                </span>
                            <?php endif; ?>
                            <?php if ($show_verified_badge) : ?>
                                <span class="nexdirectory-verified-badge"><?php esc_html_e('Verified', 'nexdirectory'); ?></span>
                            <?php endif; ?>
                            <?php if ($cover) : ?>
                                <a class="nexdirectory-card-cover-link" href="<?php echo esc_url(get_permalink($post_id)); ?>" aria-label="<?php echo esc_attr(get_the_title($post_id)); ?>">
                                    <img class="nexdirectory-card-cover" src="<?php echo esc_url($cover); ?>" alt="<?php echo esc_attr(get_the_title($post_id)); ?>" loading="lazy" decoding="async" fetchpriority="low">
                                </a>
                            <?php else : ?>
                                <div class="nexdirectory-card-cover nexdirectory-card-cover-fallback">
                                    <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
                                </div>
                            <?php endif; ?>
                        </div>

                        <div class="nexdirectory-archive-card-content">
                            <h3 class="nexdirectory-card-title"><a href="<?php echo esc_url(get_permalink($post_id)); ?>"><?php echo esc_html(get_the_title($post_id)); ?></a></h3>

                            <div class="nexdirectory-meta nexdirectory-card-meta-row">
                                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
                                <span><?php echo esc_html((string) $card_date_label); ?></span>
                            </div>

                            <?php if ($secondary_meta_label !== '') : ?>
                                <div class="nexdirectory-meta nexdirectory-card-meta-row">
                                    <span><?php echo esc_html($secondary_meta_label); ?></span>
                                </div>
                            <?php endif; ?>
                            <?php if ($card_location !== '') : ?>
                                <div class="nexdirectory-meta nexdirectory-card-meta-row">
                                    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
                                    <span><?php echo esc_html($card_location); ?></span>
                                </div>
                            <?php endif; ?>

                            <div class="nexdirectory-archive-event-card-footer">
                                <a class="nexdirectory-btn nexdirectory-btn-secondary nexdirectory-archive-view-btn" href="<?php echo esc_url(get_permalink($post_id)); ?>">
                                    <?php /* translators: %s: Placeholder value. */ ?>
                                    <?php echo esc_html(sprintf(__('View %s', 'nexdirectory'), $singular_label)); ?>
                                </a>
                            </div>
                        </div>
                    </article>
                <?php endwhile; ?>
            <?php else : ?>
                <div class="nexdirectory-empty-state nexdirectory-archive-empty-state">
                    <?php /* translators: %s: Placeholder value. */ ?>
                    <h3><?php echo esc_html(sprintf(__('No %s found', 'nexdirectory'), $plural_lower)); ?></h3>
                    <?php /* translators: %s: Placeholder value. */ ?>
                    <p><?php echo esc_html(sprintf(__('Try broadening your filters or selecting a different date to find %s.', 'nexdirectory'), $plural_lower)); ?></p>
                </div>
            <?php endif; ?>
        </div>
        <?php /* translators: %s: Plural directory content label, such as events or listings. */ ?>
        <nav class="nexdirectory-pagination" aria-label="<?php echo esc_attr(sprintf(__('%s pagination', 'nexdirectory'), $plural_label)); ?>">
            <?php
            $archive_url = $this->get_platform_page_url(
                'nexdirectory_page_events_url',
                $this->get_profile_route_slugs('events', $profile_key),
                $this->get_profile_route_default_url('events', $profile_key)
            );
            $pagination_placeholder = 999999999;
            $pagination_base = str_replace(
                (string) $pagination_placeholder,
                '%#%',
                nexdirectory_add_query_arg('paged', $pagination_placeholder, nexdirectory_remove_query_arg('paged', $archive_url))
            );
            $pagination_html = (string) paginate_links([
                // The post type uses /events/{listing}/, so query pagination
                // avoids treating /events/page/2/ as a single listing slug.
                'base' => $pagination_base,
                'format' => '',
                'current' => max(1, (int) $paged),
                'total' => max(1, (int) $query->max_num_pages),
                'type' => 'list',
                'prev_text' => __('← Previous', 'nexdirectory'),
                'next_text' => __('Next →', 'nexdirectory'),
                'add_args' => array_filter([
                    'nexdirectory_search' => sanitize_text_field((string) ($filters['s'] ?? '')),
                    'event_type' => sanitize_text_field((string) ($filters['event_type'] ?? '')),
                    'event_mode' => sanitize_text_field((string) ($filters['event_mode'] ?? '')),
                    'event_price' => sanitize_text_field((string) ($filters['event_price'] ?? '')),
                    'event_tag' => sanitize_text_field((string) ($filters['event_tag'] ?? '')),
                    'city' => sanitize_text_field((string) ($filters['city'] ?? '')),
                    'date_from' => sanitize_text_field((string) ($filters['date_from'] ?? '')),
                    'official' => sanitize_text_field((string) ($filters['official'] ?? '')),
                    'orderby' => sanitize_key((string) ($filters['orderby'] ?? '')) === 'date' ? 'date' : '',
                    'organizer' => $organizer_id > 0 ? (int) $organizer_id : '',
                    'nex_filter' => !empty($filters['nex_filter']) && is_array($filters['nex_filter'])
                        && method_exists($this, 'builder_checkin_dynamic_filters_to_query_args')
                        ? $this->builder_checkin_dynamic_filters_to_query_args($filters['nex_filter'])
                        : [],
                ]),
            ]);
            if ($pagination_html !== '') {
                echo wp_kses_post($pagination_html);
            }
            ?>
        </nav>
        <?php
        $html = ob_get_clean();
        wp_reset_postdata();
        return $html;
    }


    public function rest_get_archive_events($request) {
        $ui_labels = method_exists($this, 'get_site_profile_ui_labels') ? (array) $this->get_site_profile_ui_labels() : [];
        $profile_key = sanitize_key((string) ($ui_labels['profile_key'] ?? 'events'));
        $is_events_profile = $profile_key === 'events';
        $is_job_profile = $profile_key === 'job_board';
        $filters = [
            's' => sanitize_text_field((string) $request->get_param('s')),
            'event_type' => sanitize_text_field((string) $request->get_param('event_type')),
            'event_mode' => sanitize_text_field((string) $request->get_param('event_mode')),
            'event_price' => sanitize_text_field((string) $request->get_param('event_price')),
            'event_tag' => sanitize_text_field((string) $request->get_param('event_tag')),
            'city' => sanitize_text_field((string) $request->get_param('city')),
            'date_from' => sanitize_text_field((string) $request->get_param('date_from')),
            'official' => sanitize_text_field((string) $request->get_param('official')),
            'orderby' => sanitize_key((string) $request->get_param('orderby')) === 'date' ? 'date' : '',
            'nex_filter' => method_exists($this, 'parse_builder_checkin_dynamic_filters')
                ? $this->parse_builder_checkin_dynamic_filters($request, 'archive')
                : [],
        ];
        $show_search_filter = (string) get_option('nexdirectory_map_filter_show_search', '1') === '1';
        $show_type_filter = (string) get_option('nexdirectory_map_filter_show_type', '1') === '1';
        $show_mode_filter = (string) get_option('nexdirectory_map_filter_show_mode', '1') === '1';
        $show_tag_filter = (string) get_option('nexdirectory_map_filter_show_tag', '0') === '1';
        $show_price_filter = (string) get_option('nexdirectory_map_filter_show_price', '1') === '1';
        $show_official_filter = (string) get_option('nexdirectory_map_filter_show_official', '1') === '1';
        $show_date_filter = (string) get_option('nexdirectory_map_filter_show_date', '1') === '1';
        if (!$show_search_filter) {
            $filters['s'] = '';
        }
        if (!$show_type_filter) {
            $filters['event_type'] = '';
        }
        if (!$show_mode_filter) {
            $filters['event_mode'] = '';
        }
        if (!$show_tag_filter) {
            $filters['event_tag'] = '';
        }
        if (!$show_price_filter) {
            $filters['event_price'] = '';
        }
        if (!$show_date_filter) {
            $filters['date_from'] = '';
        }
        if (!$show_official_filter) {
            $filters['official'] = '';
        }
        if ($is_job_profile) {
            $filters['event_price'] = '';
        }
        if (!$is_events_profile) {
            $filters['official'] = '';
        }
        $posts_per_page = max(1, min(60, (int) $request->get_param('posts_per_page')));
        $paged = max(1, (int) $request->get_param('paged'));
        $organizer_id = max(0, (int) $request->get_param('organizer'));
        if ($organizer_id <= 0) {
            $organizer_id = max(0, (int) $request->get_param('author'));
        }

        $query = $this->build_archive_query($filters, $posts_per_page, $paged, $organizer_id);
        $html = $this->render_archive_results_markup($query, $paged, $filters, $organizer_id);
        $archive_event_ids = [];
        foreach ((array) $query->posts as $post_item) {
            $candidate_id = is_object($post_item) ? (int) ($post_item->ID ?? 0) : (int) $post_item;
            if ($candidate_id > 0) {
                $archive_event_ids[] = $candidate_id;
            }
        }
        $archive_event_ids = array_values(array_unique($archive_event_ids));
        $has_active_filters = false;
        foreach ($filters as $value) {
            if ((is_array($value) && !empty($value)) || (!is_array($value) && trim((string) $value) !== '')) {
                $has_active_filters = true;
                break;
            }
        }
        if ($has_active_filters) {
            $post_ids_for_filter_options = $archive_event_ids;
            $filter_options = $this->build_archive_filter_options($post_ids_for_filter_options, $filters);
        } else {
            $filter_options = $this->build_global_archive_filter_options($filters);
        }

        return rest_ensure_response([
            'html' => $html,
            'max_pages' => (int) $query->max_num_pages,
            'count' => (int) $query->found_posts,
            'has_active_filters' => $has_active_filters,
            'filters' => $filter_options,
        ]);
    }


    public function rest_get_map_events($request) {
        $map_ui_labels = method_exists($this, 'get_site_profile_ui_labels') ? (array) $this->get_site_profile_ui_labels() : [];
        $map_profile_key = sanitize_key((string) ($map_ui_labels['profile_key'] ?? 'events'));
        $is_events_profile = $map_profile_key === 'events';
        $is_job_profile = $map_profile_key === 'job_board';
        $north = is_numeric($request->get_param('north')) ? (float) $request->get_param('north') : 90.0;
        $south = is_numeric($request->get_param('south')) ? (float) $request->get_param('south') : -90.0;
        $east = is_numeric($request->get_param('east')) ? (float) $request->get_param('east') : 180.0;
        $west = is_numeric($request->get_param('west')) ? (float) $request->get_param('west') : -180.0;
        $north = max(-90, min(90, $north));
        $south = max(-90, min(90, $south));
        $east = max(-180, min(180, $east));
        $west = max(-180, min(180, $west));
        if ($south > $north) {
            $tmp = $south;
            $south = $north;
            $north = $tmp;
        }

        $max_events = max(12, min(500, (int) $request->get_param('max_events')));
        $page = max(1, (int) $request->get_param('page'));
        $per_page = max(1, min(250, (int) $request->get_param('per_page')));
        if ($per_page <= 1) {
            $per_page = min($max_events, max(20, (int) get_option('nexdirectory_map_results_per_page', 120)));
        }
        $search = sanitize_text_field((string) $request->get_param('s'));
        $type = sanitize_key((string) $request->get_param('event_type'));
        $mode = sanitize_key((string) $request->get_param('event_mode'));
        $tag = sanitize_key((string) $request->get_param('event_tag'));
        $price = sanitize_key((string) $request->get_param('event_price'));
        $date_from = sanitize_text_field((string) $request->get_param('date_from'));
        $official_param = (string) $request->get_param('official');
        $dynamic_filters = method_exists($this, 'parse_builder_checkin_dynamic_filters')
            ? $this->parse_builder_checkin_dynamic_filters($request, 'map')
            : [];
        if ($is_job_profile) {
            $price = '';
        }
        if (!$is_events_profile) {
            $official_param = '';
        }
        $distance_km = is_numeric($request->get_param('distance_km')) ? (float) $request->get_param('distance_km') : 0.0;
        $center_lat = is_numeric($request->get_param('center_lat')) ? (float) $request->get_param('center_lat') : null;
        $center_lng = is_numeric($request->get_param('center_lng')) ? (float) $request->get_param('center_lng') : null;
        $distance_filter_active = $distance_km > 0 && $center_lat !== null && $center_lng !== null;
        $include_unlocated = (string) $request->get_param('include_unlocated') === '1' && $distance_km <= 0;

        $tax_query = [];
        if ($type !== '') {
            $tax_query[] = [
                'taxonomy' => self::TAX_TYPE,
                'field' => 'slug',
                'terms' => $type,
            ];
        }
        if ($mode !== '') {
            $tax_query[] = [
                'taxonomy' => self::TAX_MODE,
                'field' => 'slug',
                'terms' => $mode,
            ];
        }
        if ($tag !== '') {
            $tax_query[] = [
                'taxonomy' => self::TAX_TAG,
                'field' => 'slug',
                'terms' => $tag,
            ];
        }
        if ($price !== '') {
            $tax_query[] = [
                'taxonomy' => self::TAX_PRICE,
                'field' => 'slug',
                'terms' => $price,
            ];
        }
        $dynamic_query = method_exists($this, 'builder_checkin_compile_dynamic_query')
            ? $this->builder_checkin_compile_dynamic_query($dynamic_filters, 'map')
            : ['tax_query' => [], 'field_filters' => []];
        if (!empty($dynamic_query['tax_query']) && is_array($dynamic_query['tax_query'])) {
            $tax_query = array_merge($tax_query, $dynamic_query['tax_query']);
        }

        $meta_query = ['relation' => 'AND'];
        if (!$include_unlocated) {
            $meta_query[] = [
                'key' => 'nexdirectory_lat',
                'value' => '',
                'compare' => '!=',
            ];
            $meta_query[] = [
                'key' => 'nexdirectory_lng',
                'value' => '',
                'compare' => '!=',
            ];
            $meta_query[] = [
                'key' => 'nexdirectory_lat',
                'value' => [$south, $north],
                'compare' => 'BETWEEN',
                'type' => 'DECIMAL',
            ];
            if ($west <= $east) {
                $meta_query[] = [
                    'key' => 'nexdirectory_lng',
                    'value' => [$west, $east],
                    'compare' => 'BETWEEN',
                    'type' => 'DECIMAL',
                ];
            } else {
                $meta_query[] = [
                    'relation' => 'OR',
                    [
                        'key' => 'nexdirectory_lng',
                        'value' => [$west, 180],
                        'compare' => 'BETWEEN',
                        'type' => 'DECIMAL',
                    ],
                    [
                        'key' => 'nexdirectory_lng',
                        'value' => [-180, $east],
                        'compare' => 'BETWEEN',
                        'type' => 'DECIMAL',
                    ],
                ];
            }
        }
        if ($date_from !== '') {
            $meta_query[] = [
                'key' => 'nexdirectory_start',
                'value' => $date_from,
                'compare' => '>=',
                'type' => 'CHAR',
            ];
        }
        if ($official_param !== '' && in_array($official_param, ['0', '1'], true)) {
            $meta_query[] = [
                'key' => 'nexdirectory_official_event',
                'value' => $official_param,
                'compare' => '=',
            ];
        }

        $post_statuses = ['publish'];
        if (is_user_logged_in() && current_user_can('manage_options')) {
            $post_statuses = ['publish', 'pending', 'draft', 'future'];
        }

        $args = [
            'post_type' => self::POST_TYPE,
            'post_status' => $post_statuses,
            'posts_per_page' => $distance_filter_active ? $max_events : min($max_events, $per_page),
            'paged' => $distance_filter_active ? 1 : $page,
            'fields' => 'ids',
            's' => $search,
            'meta_key' => 'nexdirectory_start',
            'orderby' => 'meta_value',
            'order' => 'ASC',
            'nexdirectory_apply_boost_order' => 1,
            'no_found_rows' => false,
            'update_post_meta_cache' => true,
            'update_post_term_cache' => true,
        ];
        if (!empty($dynamic_query['field_filters']) && is_array($dynamic_query['field_filters'])) {
            $args['nexdirectory_field_index_filters'] = $dynamic_query['field_filters'];
        }
        if (count($meta_query) > 1) {
            $args['meta_query'] = $meta_query;
        }
        if (!empty($tax_query)) {
            $args['tax_query'] = $tax_query;
        }

        $query = new WP_Query($args);
        $ids = is_array($query->posts) ? array_map('intval', $query->posts) : [];
        if (empty($ids)) {
            return rest_ensure_response([
                'events' => [],
                'pagination' => [
                    'page' => $page,
                    'per_page' => min($max_events, $per_page),
                    'max_pages' => 0,
                    'total' => 0,
                    'has_more' => false,
                ],
            ]);
        }
        update_meta_cache('post', $ids);
        update_object_term_cache($ids, self::POST_TYPE);
        $is_directory_profile = $map_profile_key === 'directory';
        $is_marketplace_profile = $map_profile_key === 'marketplace';

        $events = [];
        foreach ($ids as $event_id) {
            $lat_raw = get_post_meta($event_id, 'nexdirectory_lat', true);
            $lng_raw = get_post_meta($event_id, 'nexdirectory_lng', true);
            $has_coordinates = is_numeric($lat_raw) && is_numeric($lng_raw);
            if (!$has_coordinates && !$include_unlocated) {
                continue;
            }
            $lat = $has_coordinates ? (float) $lat_raw : null;
            $lng = $has_coordinates ? (float) $lng_raw : null;
            if ($has_coordinates && $distance_filter_active) {
                $distance = $this->haversine_distance_km($center_lat, $center_lng, $lat, $lng);
                if ($distance > $distance_km) {
                    continue;
                }
            }
            $cover = $this->get_event_cover_image_url($event_id, 'medium_large', 'thumbnail');
            $start_raw = (string) get_post_meta($event_id, 'nexdirectory_start', true);
            $start_ts = $start_raw !== '' ? strtotime($start_raw) : false;
            $map_date = $this->format_event_datetime_for_display($start_raw);
            if ($start_ts && $this->event_datetime_has_time($start_raw)) {
                $map_date = wp_date(get_option('date_format'), (int) $start_ts) . ' • ' . wp_date(get_option('time_format'), (int) $start_ts);
            }
            if ($map_date === '') {
                $map_date = $this->format_event_date($event_id);
            }
            $summary_line = '';
            if ($is_job_profile) {
                $closing_raw = trim((string) get_post_meta($event_id, 'nexdirectory_job_closing_date', true));
                $closing_ts = $closing_raw !== '' ? strtotime($closing_raw . ' 23:59:00') : false;
                if ($closing_ts) {
                    $map_date = sprintf(
                        /* translators: %s: date */
                        __('Closes %s', 'nexdirectory'),
                        wp_date(get_option('date_format'), (int) $closing_ts)
                    );
                }
                $summary_line = trim((string) get_post_meta($event_id, 'nexdirectory_job_salary', true));
                if ($summary_line === '') {
                    $summary_line = trim((string) get_post_meta($event_id, 'nexdirectory_company_name', true));
                }
            } elseif ($is_directory_profile) {
                $updated_ts = strtotime((string) get_post_field('post_modified', $event_id));
                if ($updated_ts) {
                    $map_date = sprintf(
                        /* translators: %s: date */
                        __('Updated %s', 'nexdirectory'),
                        wp_date(get_option('date_format'), (int) $updated_ts)
                    );
                }
                $type_terms = wp_get_post_terms($event_id, self::TAX_TYPE, ['fields' => 'names']);
                if (!is_wp_error($type_terms) && !empty($type_terms[0])) {
                    $summary_line = sanitize_text_field((string) $type_terms[0]);
                }
            } elseif ($is_marketplace_profile) {
                $listed_ts = strtotime((string) get_post_field('post_date', $event_id));
                if ($listed_ts) {
                    $map_date = sprintf(
                        /* translators: %s: date */
                        __('Listed %s', 'nexdirectory'),
                        wp_date(get_option('date_format'), (int) $listed_ts)
                    );
                }
                $summary_line = trim((string) get_post_meta($event_id, 'nexdirectory_market_condition', true));
                if ($summary_line === '') {
                    $summary_line = trim((string) get_post_meta($event_id, 'nexdirectory_price', true));
                }
            }
            $excerpt = get_the_excerpt($event_id);
            if (!is_string($excerpt) || trim($excerpt) === '') {
                $excerpt = wp_trim_words(wp_strip_all_tags((string) get_post_field('post_content', $event_id)), 20);
            }
            $location_line = implode(', ', array_filter([
                (string) get_post_meta($event_id, 'nexdirectory_venue', true),
                (string) get_post_meta($event_id, 'nexdirectory_address', true),
                (string) get_post_meta($event_id, 'nexdirectory_city', true),
                (string) get_post_meta($event_id, 'nexdirectory_country', true),
            ], static function ($value) {
                return trim((string) $value) !== '';
            }));
            $event_type_names = (array) wp_get_post_terms($event_id, self::TAX_TYPE, ['fields' => 'names']);
            if (is_wp_error($event_type_names)) {
                $event_type_names = [];
            }
            $event_type_slugs = (array) wp_get_post_terms($event_id, self::TAX_TYPE, ['fields' => 'slugs']);
            if (is_wp_error($event_type_slugs)) {
                $event_type_slugs = [];
            }
            $event_mode_names = (array) wp_get_post_terms($event_id, self::TAX_MODE, ['fields' => 'names']);
            if (is_wp_error($event_mode_names)) {
                $event_mode_names = [];
            }
            $event_mode_slugs = (array) wp_get_post_terms($event_id, self::TAX_MODE, ['fields' => 'slugs']);
            if (is_wp_error($event_mode_slugs)) {
                $event_mode_slugs = [];
            }
            $event_price_names = (array) wp_get_post_terms($event_id, self::TAX_PRICE, ['fields' => 'names']);
            if (is_wp_error($event_price_names)) {
                $event_price_names = [];
            }
            $event_price_slugs = (array) wp_get_post_terms($event_id, self::TAX_PRICE, ['fields' => 'slugs']);
            if (is_wp_error($event_price_slugs)) {
                $event_price_slugs = [];
            }
            if ($is_job_profile) {
                $job_type_label = trim((string) get_post_meta($event_id, 'nexdirectory_job_type', true));
                if ($job_type_label !== '') {
                    $event_type_names = [$job_type_label];
                }
                $job_work_mode = sanitize_key((string) get_post_meta($event_id, 'nexdirectory_job_work_mode', true));
                if ($job_work_mode === 'remote') {
                    $event_mode_names = [__('Remote', 'nexdirectory')];
                } elseif ($job_work_mode === 'hybrid') {
                    $event_mode_names = [__('Hybrid', 'nexdirectory')];
                } elseif ($job_work_mode === 'on-site') {
                    $event_mode_names = [__('On-site', 'nexdirectory')];
                }
                if ($summary_line !== '') {
                    $event_price_names = [$summary_line];
                } else {
                    $event_price_names = [];
                    $event_price_slugs = [];
                }
            } elseif ($is_marketplace_profile) {
                if ($summary_line !== '') {
                    $event_price_names = [$summary_line];
                }
            }
            $events[] = [
                'id' => $event_id,
                'title' => get_the_title($event_id),
                'url' => get_permalink($event_id),
                'city' => (string) get_post_meta($event_id, 'nexdirectory_city', true),
                'location' => trim((string) preg_replace('/\s+/u', ' ', $location_line)),
                'date' => $this->format_event_date($event_id),
                'date_short' => $map_date,
                'start' => (string) get_post_meta($event_id, 'nexdirectory_start', true),
                'date_key' => substr((string) get_post_meta($event_id, 'nexdirectory_start', true), 0, 10),
                'summary' => $summary_line,
                'excerpt' => trim((string) preg_replace('/\s+/u', ' ', (string) $excerpt)),
                'cover' => $cover,
                'type' => implode(', ', array_map('nexdirectory_sanitize_text', $event_type_names)),
                'type_slug' => implode(',', array_map('nexdirectory_sanitize_title', $event_type_slugs)),
                'mode' => implode(', ', array_map('nexdirectory_sanitize_text', $event_mode_names)),
                'mode_slug' => implode(',', array_map('nexdirectory_sanitize_title', $event_mode_slugs)),
                'tag' => implode(', ', wp_get_post_terms($event_id, self::TAX_TAG, ['fields' => 'names'])),
                'tag_slug' => implode(',', wp_get_post_terms($event_id, self::TAX_TAG, ['fields' => 'slugs'])),
                'price' => implode(', ', array_map('nexdirectory_sanitize_text', $event_price_names)),
                'price_slug' => implode(',', array_map('nexdirectory_sanitize_title', $event_price_slugs)),
                'official' => $this->get_official_event_flag($event_id) ? 1 : 0,
                'boosted' => method_exists($this, 'is_listing_boosted') && $this->is_listing_boosted($event_id) ? 1 : 0,
                'boost_badge' => sanitize_text_field((string) get_option('nexdirectory_boost_featured_badge_text', __('Featured', 'nexdirectory'))),
                'boost_highlight' => (string) get_option('nexdirectory_boost_highlight_style', '1') === '1' ? 1 : 0,
                'verified_badge' => (string) get_option('nexdirectory_boost_verified_badge_enabled', '0') === '1'
                    && get_user_meta((int) get_post_field('post_author', $event_id), 'nexdirectory_verified_organizer', true) === '1'
                    ? 1
                    : 0,
                'lat' => $lat,
                'lng' => $lng,
                'has_coordinates' => $has_coordinates ? 1 : 0,
            ];
        }

        $pagination_total = $distance_filter_active ? count($events) : (int) $query->found_posts;
        $pagination_max_pages = $distance_filter_active
            ? (int) max(1, (int) ceil($pagination_total / max(1, min($max_events, $per_page))))
            : (int) max(1, (int) $query->max_num_pages);
        if ($distance_filter_active) {
            $events = array_slice(
                $events,
                ($page - 1) * min($max_events, $per_page),
                min($max_events, $per_page)
            );
        }

        return rest_ensure_response([
            'events' => $events,
            'pagination' => [
                'page' => $page,
                'per_page' => min($max_events, $per_page),
                'max_pages' => $pagination_max_pages,
                'total' => $pagination_total,
                'has_more' => $page < $pagination_max_pages,
            ],
        ]);
    }


    public function rest_geocode_address($request) {
        $address = sanitize_text_field((string) $request->get_param('address'));
        if ($address === '') {
            return new WP_Error('nexdirectory_address_missing', __('Address is required.', 'nexdirectory'), ['status' => 400]);
        }

        $ip = sanitize_text_field((string) wp_unslash($_SERVER['REMOTE_ADDR'] ?? ''));
        $rate_key = 'nexdirectory_geo_' . md5($ip !== '' ? $ip : 'unknown');
        $rate_count = (int) get_transient($rate_key);
        if ($rate_count > 20) {
            return new WP_Error('nexdirectory_geocode_rate', __('Too many geocode attempts. Please wait and retry.', 'nexdirectory'), ['status' => 429]);
        }
        set_transient($rate_key, $rate_count + 1, HOUR_IN_SECONDS);

        $geo = $this->geocode_event_location_from_parts($address);
        if (!is_array($geo) || !isset($geo['lat'], $geo['lng']) || !is_numeric($geo['lat']) || !is_numeric($geo['lng'])) {
            return new WP_Error('nexdirectory_geocode_empty', __('No location found for that address.', 'nexdirectory'), ['status' => 404]);
        }
        return rest_ensure_response([
            'lat' => (float) $geo['lat'],
            'lng' => (float) $geo['lng'],
            'city' => sanitize_text_field((string) ($geo['city'] ?? '')),
            'country' => sanitize_text_field((string) ($geo['country'] ?? '')),
            'address' => sanitize_text_field((string) ($geo['address'] ?? $address)),
        ]);
    }


    public function rest_address_suggestions($request) {
        $query = sanitize_text_field((string) $request->get_param('q'));
        if ($query === '' || strlen($query) < 3) {
            return rest_ensure_response(['suggestions' => []]);
        }
        $limit = absint((int) $request->get_param('limit'));
        if ($limit < 1) {
            $limit = 6;
        }
        if ($limit > 10) {
            $limit = 10;
        }

        $ip = sanitize_text_field((string) wp_unslash($_SERVER['REMOTE_ADDR'] ?? ''));
        $rate_key = 'nexdirectory_suggest_' . md5($ip !== '' ? $ip : 'unknown');
        $rate_count = (int) get_transient($rate_key);
        if ($rate_count > 60) {
            return new WP_Error('nexdirectory_suggest_rate', __('Too many lookups. Please pause and try again.', 'nexdirectory'), ['status' => 429]);
        }
        set_transient($rate_key, $rate_count + 1, HOUR_IN_SECONDS);

        $headers = [
            'Accept' => 'application/json',
            'User-Agent' => 'NexDirectoryFree/' . self::VERSION . ' (' . home_url('/') . ')',
        ];
        $suggestions = [];
        $google_key = (string) get_option('nexdirectory_google_maps_api_key', '');
        if ($google_key !== '') {
            $url = nexdirectory_add_query_arg([
                'input' => $query,
                'types' => 'geocode',
                'language' => get_locale(),
                'key' => $google_key,
            ], 'https://maps.googleapis.com/maps/api/place/autocomplete/json');
            $response = wp_remote_get($url, [
                'timeout' => 10,
                'headers' => $headers,
            ]);
            if (!is_wp_error($response)) {
                $payload = json_decode((string) wp_remote_retrieve_body($response), true);
                $predictions = is_array($payload) && !empty($payload['predictions']) && is_array($payload['predictions'])
                    ? (array) $payload['predictions']
                    : [];
                foreach (array_slice($predictions, 0, $limit) as $row) {
                    if (!is_array($row) || empty($row['description'])) {
                        continue;
                    }
                    $label = sanitize_text_field((string) $row['description']);
                    if ($label === '') {
                        continue;
                    }
                    $city = '';
                    $country = '';
                    if (!empty($row['terms']) && is_array($row['terms'])) {
                        $terms = array_values((array) $row['terms']);
                        if (count($terms) >= 2 && !empty($terms[count($terms) - 2]['value'])) {
                            $city = sanitize_text_field((string) $terms[count($terms) - 2]['value']);
                        }
                        if (count($terms) >= 1 && !empty($terms[count($terms) - 1]['value'])) {
                            $country = sanitize_text_field((string) $terms[count($terms) - 1]['value']);
                        }
                    }
                    $suggestions[] = [
                        'label' => $label,
                        'address' => $label,
                        'city' => $city,
                        'country' => $country,
                    ];
                }
            }
        }

        if (empty($suggestions)) {
            $url = nexdirectory_add_query_arg([
                'format' => 'json',
                'addressdetails' => 1,
                'limit' => $limit,
                'q' => $query,
            ], 'https://nominatim.openstreetmap.org/search');
            $response = wp_remote_get($url, [
                'timeout' => 10,
                'headers' => $headers,
            ]);
            if (is_wp_error($response)) {
                return new WP_Error('nexdirectory_suggest_failed', __('Could not load address suggestions.', 'nexdirectory'), ['status' => 500]);
            }
            $payload = json_decode((string) wp_remote_retrieve_body($response), true);
            if (!is_array($payload)) {
                $payload = [];
            }
            foreach (array_slice($payload, 0, $limit) as $row) {
                if (!is_array($row)) {
                    continue;
                }
                $label = sanitize_text_field((string) ($row['display_name'] ?? ''));
                if ($label === '') {
                    continue;
                }
                $address_bits = !empty($row['address']) && is_array($row['address']) ? (array) $row['address'] : [];
                $city = '';
                foreach (['city', 'town', 'village', 'municipality', 'county', 'state'] as $city_key) {
                    if (!empty($address_bits[$city_key])) {
                        $city = sanitize_text_field((string) $address_bits[$city_key]);
                        break;
                    }
                }
                $country = !empty($address_bits['country']) ? sanitize_text_field((string) $address_bits['country']) : '';
                $suggestions[] = [
                    'label' => $label,
                    'address' => $label,
                    'city' => $city,
                    'country' => $country,
                    'lat' => isset($row['lat']) ? (float) $row['lat'] : null,
                    'lng' => isset($row['lon']) ? (float) $row['lon'] : null,
                ];
            }
        }

        $unique = [];
        foreach ($suggestions as $suggestion) {
            $label = sanitize_text_field((string) ($suggestion['label'] ?? ''));
            if ($label === '' || isset($unique[$label])) {
                continue;
            }
            $unique[$label] = [
                'label' => $label,
                'address' => sanitize_text_field((string) ($suggestion['address'] ?? $label)),
                'city' => sanitize_text_field((string) ($suggestion['city'] ?? '')),
                'country' => sanitize_text_field((string) ($suggestion['country'] ?? '')),
                'lat' => isset($suggestion['lat']) && is_numeric($suggestion['lat']) ? (float) $suggestion['lat'] : null,
                'lng' => isset($suggestion['lng']) && is_numeric($suggestion['lng']) ? (float) $suggestion['lng'] : null,
            ];
        }

        return rest_ensure_response([
            'suggestions' => array_values($unique),
        ]);
    }


    public function filter_frontend_media_library($query) {
        if (!wp_doing_ajax()) {
            return $query;
        }
        $action = isset($_REQUEST['action']) ? sanitize_key(wp_unslash($_REQUEST['action'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only media query filtering.
        if ($action !== 'query-attachments') {
            return $query;
        }
        $referer = wp_get_referer();
        if ($referer && strpos($referer, admin_url()) === 0) {
            return $query;
        }
        if (!is_user_logged_in()) {
            $query['author'] = 0;
            return $query;
        }
        if ($this->user_can_moderate_events()) {
            return $query;
        }
        $query['author'] = get_current_user_id();
        return $query;
    }

}
