<?php
/**
 * Dashboard Class
 *
 * Modern React-based dashboard for advanced analytics and session recording.
 * Updated: 2025-12-29 - Fixed session counting bug
 *
 * @package opti-behavior
 * @copyright 2025 OptiUser
 * @version 1.0.4
 */

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}
// Load view helper functions.
require_once OPTI_BEHAVIOR_HEATMAP_PLUGIN_DIR . 'views/dashboard-views.php';
require_once OPTI_BEHAVIOR_HEATMAP_PLUGIN_DIR . 'views/sessions-views.php';
require_once OPTI_BEHAVIOR_HEATMAP_PLUGIN_DIR . 'views/heatmaps-views.php';
require_once OPTI_BEHAVIOR_HEATMAP_INCLUDES_DIR . 'trait-opti-behavior-sessions-views.php';
require_once OPTI_BEHAVIOR_HEATMAP_INCLUDES_DIR . 'trait-opti-behavior-heatmaps-views.php';
require_once OPTI_BEHAVIOR_HEATMAP_INCLUDES_DIR . 'trait-opti-behavior-dashboard-views.php';
require_once OPTI_BEHAVIOR_HEATMAP_INCLUDES_DIR . 'trait-opti-behavior-ajax-handlers.php';
require_once OPTI_BEHAVIOR_HEATMAP_INCLUDES_DIR . 'trait-opti-behavior-data-helpers.php';
require_once OPTI_BEHAVIOR_HEATMAP_INCLUDES_DIR . 'trait-opti-behavior-assets.php';
require_once OPTI_BEHAVIOR_HEATMAP_INCLUDES_DIR . 'trait-opti-behavior-exports.php';
require_once OPTI_BEHAVIOR_HEATMAP_INCLUDES_DIR . 'trait-opti-behavior-maintenance.php';
require_once OPTI_BEHAVIOR_HEATMAP_INCLUDES_DIR . 'trait-opti-behavior-settings-views.php';
require_once OPTI_BEHAVIOR_HEATMAP_INCLUDES_DIR . 'trait-opti-behavior-ai-insights-views.php';

/**
 * Dashboard Class
 *
 * Provides modern dashboard functionality with analytics and session recording.
 *
 * @since 1.0.0
 */
class Opti_Behavior_Heatmap_Dashboard {
	use Opti_Behavior_Sessions_Views_Trait;
	use Opti_Behavior_Dashboard_Views_Trait;
	use Opti_Behavior_Heatmaps_Views_Trait;
	use Opti_Behavior_Ajax_Handlers_Trait;
	use Opti_Behavior_Data_Helpers_Trait;
	use Opti_Behavior_Assets_Trait;
	use Opti_Behavior_Settings_Views_Trait;
	use Opti_Behavior_Exports_Trait;
	use Opti_Behavior_Maintenance_Trait;
	use Opti_Behavior_AI_Insights_Views_Trait;



	/**
	 * opti-behavior Heatmap instance
	 *
	 * @var Opti_Behavior_Heatmap_Core
	 */
	private $heatmap;

	/**
	 * Funnel page instance
	 *
	 * @var Opti_Behavior_Funnel_Page
	 */
	private $funnel_page;

	/**
	 * Constructor
	 *
	 * @param Opti_Behavior_Heatmap_Core $heatmap opti-behavior Heatmap instance.
	 */
	public function __construct( $heatmap ) {
		$this->heatmap = $heatmap;
		$this->init_hooks();
		// Funnel page is now initialized globally in Core class (needed for frontend tracking)
		$this->funnel_page = $this->heatmap->get_funnel_page();
	}

	/**
	 * Initialize hooks
	 */
	private function init_hooks() {
		add_action( 'admin_menu', array( $this, 'add_dashboard_menu' ), 10 );
		add_action( 'admin_menu', array( $this, 'add_ai_insights_menu' ), 15 );
		add_action( 'admin_menu', array( $this, 'add_settings_menu' ), 20 );
		add_action( 'admin_menu', array( $this, 'reorder_submenu' ), 999 );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_dashboard_assets' ) );
		add_filter( 'script_loader_tag', array( $this, 'filter_script_loader_tag' ), 10, 3 );
		add_action( 'wp_ajax_optibehavior_dashboard_data', array( $this, 'ajax_dashboard_data' ) );
		add_action( 'wp_ajax_optibehavior_session_data', array( $this, 'ajax_session_data' ) );
		add_action( 'wp_ajax_optibehavior_analytics_data', array( $this, 'ajax_analytics_data' ) );
		add_action( 'wp_ajax_optibehavior_top_users', array( $this, 'ajax_top_users' ) );
		add_action( 'wp_ajax_optibehavior_visitor_heatmap', array( $this, 'ajax_visitor_heatmap' ) );
		// Spam recalculation AJAX handlers
		add_action( 'wp_ajax_optibehavior_recalculate_spam', array( $this, 'ajax_recalculate_spam' ) );
		add_action( 'wp_ajax_optibehavior_recalculate_spam_status', array( $this, 'ajax_recalculate_spam_status' ) );
		// Delete all data AJAX handler
		add_action( 'wp_ajax_optibehavior_delete_all_data', array( $this, 'ajax_delete_all_data' ) );
		// Delete data by date range AJAX handler
		add_action( 'wp_ajax_optibehavior_delete_data_by_range', array( $this, 'ajax_delete_data_by_range' ) );
		// Smart Data Cleanup AJAX handlers
		add_action( 'wp_ajax_optibehavior_smart_cleanup_preview', array( $this, 'ajax_smart_cleanup_preview' ) );
		add_action( 'wp_ajax_optibehavior_smart_cleanup_execute', array( $this, 'ajax_smart_cleanup_execute' ) );
		add_action( 'wp_ajax_optibehavior_bot_cleanup', array( $this, 'ajax_bot_cleanup' ) );
		add_action( 'wp_ajax_optibehavior_save_auto_cleanup', array( $this, 'ajax_save_auto_cleanup_settings' ) );
		// Suppress third-party admin notices on opti-behavior Analytics admin pages only
		add_action( 'admin_head', array( $this, 'suppress_admin_notices' ), 1 );
		// Add custom menu icon CSS
		add_action( 'admin_head', array( $this, 'add_menu_icon_css' ) );
		// AJAX endpoint for Heatmaps table (sorting/pagination)
		add_action( 'wp_ajax_optibehavior_heatmaps_sessions', array( $this, 'ajax_heatmaps_sessions' ) );
		add_action( 'wp_ajax_optibehavior_heatmaps_table', array( $this, 'ajax_heatmaps_table' ) );
		// Debug log AJAX handlers
		add_action( 'wp_ajax_opti_behavior_get_debug_log', array( $this, 'ajax_get_debug_log' ) );
		add_action( 'admin_init', array( $this, 'handle_debug_log_download' ) );
		// Cleanup AJAX handlers
		add_action( 'wp_ajax_opti_behavior_cleanup_by_date', array( $this, 'ajax_cleanup_by_date' ) );
		add_action( 'wp_ajax_opti_behavior_cleanup_by_duration', array( $this, 'ajax_cleanup_by_duration' ) );
		add_action( 'wp_ajax_opti_behavior_cleanup_orphaned', array( $this, 'ajax_cleanup_orphaned' ) );
		// Maintenance AJAX handlers
		add_action( 'wp_ajax_opti_behavior_fix_android_os', array( $this, 'ajax_fix_android_os' ) );
		// Scheduled Reports AJAX handlers
		add_action( 'wp_ajax_optibehavior_get_schedule', array( $this, 'ajax_get_schedule' ) );
		add_action( 'wp_ajax_optibehavior_save_schedule', array( $this, 'ajax_save_schedule' ) );
		add_action( 'wp_ajax_optibehavior_toggle_schedule', array( $this, 'ajax_toggle_schedule' ) );
		add_action( 'wp_ajax_optibehavior_delete_schedule', array( $this, 'ajax_delete_schedule' ) );
		add_action( 'wp_ajax_optibehavior_test_report', array( $this, 'ajax_test_report' ) );
		add_action( 'wp_ajax_optibehavior_save_email_settings', array( $this, 'ajax_save_email_settings' ) );
		add_action( 'wp_ajax_optibehavior_test_email_connection', array( $this, 'ajax_test_email_connection' ) );
		// Export handlers (admin-post)
		add_action( 'admin_post_opti_behavior_export', array( $this, 'handle_export' ) );
		// Throttled DB index enforcement for performance on admin pages
		add_action( 'admin_init', array( $this, 'maybe_ensure_db_indexes' ) );
		// Throttled referrers backfill (parse utm_source from entry_page) — only on our analytics screens
		add_action( 'load-toplevel_page_opti-behavior-analytics', array( $this, 'maybe_backfill_referrers' ) );
		// Pro trial banner AJAX handlers
		add_action( 'wp_ajax_opti_behavior_start_pro_trial', array( $this, 'ajax_start_pro_trial' ) );
		add_action( 'wp_ajax_opti_behavior_dismiss_trial_banner', array( $this, 'ajax_dismiss_trial_banner' ) );
	}

		public function handle_export(){
			// Admin-post handler to stream CSV or SQL download
			if ( ! function_exists('current_user_can') || ! current_user_can('manage_options') ) { if (function_exists('status_header')) status_header(403); exit('Permission denied'); }
			if ( ! isset($_REQUEST['_wpnonce']) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ), 'opti_behavior_export') ) { if (function_exists('status_header')) status_header(403); exit('Invalid nonce'); }
			$mode = isset($_REQUEST['mode']) ? sanitize_text_field( wp_unslash( $_REQUEST['mode'] ) ) : 'csv';
			// End all active output buffers to avoid stray output
			while (ob_get_level()) { ob_end_clean(); }
			if ( function_exists('nocache_headers') ) { nocache_headers(); }
			if ($mode === 'sql') { $this->export_analytics_sql(); } else { $this->export_analytics_csv(); }
			exit;
		}



		/**
		 * Suppress third-party admin notices on opti-behavior Analytics pages
		 */
		public function suppress_admin_notices() {
		return $this->suppress_admin_notices_impl();
	}

	/**
	 * AJAX: Start Pro free trial.
	 *
	 * @since 1.1.3
	 */
	public function ajax_start_pro_trial() {
		return $this->ajax_start_pro_trial_impl();
	}

	/**
	 * AJAX: Dismiss trial banner.
	 *
	 * @since 1.1.3
	 */
	public function ajax_dismiss_trial_banner() {
		return $this->ajax_dismiss_trial_banner_impl();
	}


	/**
	 * Add dashboard menu
	 */
	public function add_dashboard_menu() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		// Use custom PNG icon (WordPress handles PNG better than ICO for menu icons)
		$icon_url = plugins_url( 'assets/images/256x256.png', dirname( __FILE__ ) );

		add_menu_page(
			__( 'Opti-Behavior ', 'opti-behavior' ),
			__( 'Opti-Behavior ', 'opti-behavior' ),
			'manage_options',
			'opti-behavior-analytics',
			array( $this, 'render_dashboard' ),
			$icon_url,
			30
		);

		// Lucide "layout-dashboard" icon (20x20)
		$dashboard_icon = '<span style="display:inline-flex;align-items:center;margin-right:6px;"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg></span>';
		add_submenu_page(
			'opti-behavior-analytics',
			__( 'Dashboard', 'opti-behavior' ),
			$dashboard_icon . __( 'Dashboard', 'opti-behavior' ),
			'manage_options',
			'opti-behavior-analytics',
			array( $this, 'render_dashboard' )
		);

		// Lucide "flame" icon for Heatmaps (20x20)
		$heatmaps_icon = '<span style="display:inline-flex;align-items:center;margin-right:6px;"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg></span>';
		add_submenu_page(
			'opti-behavior-analytics',
			__( 'Heatmaps', 'opti-behavior' ),
			$heatmaps_icon . __( 'Heatmaps', 'opti-behavior' ),
			'manage_options',
			'opti-behavior-heatmaps',
			array( $this, 'render_heatmaps' )
		);

		// Lucide "video" icon for Recordings (20x20) — placed before Funnels for logical flow
		$recordings_icon = '<span style="display:inline-flex;align-items:center;margin-right:6px;"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5"/><rect x="2" y="6" width="14" height="12" rx="2"/></svg></span>';
		$recordings_pro_badge = '';
		if ( ! opti_behavior_pro_active() ) {
			$recordings_pro_badge = '<span class="opti-menu-pro-badge">PRO</span>';
		}
		// Session Recordings menu - always visible
		// If PRO is not active, shows upgrade page
		// If PRO is active, PRO plugin will override this with actual recordings page
		add_submenu_page(
			'opti-behavior-analytics',
			__( 'Session Recordings', 'opti-behavior' ),
			$recordings_icon . __( 'Recordings', 'opti-behavior' ) . $recordings_pro_badge,
			'manage_options',
			'opti-behavior-recordings',
			array( $this, 'render_recordings_upgrade' )
		);

		// Lucide "filter" icon for Funnels (20x20)
		$funnels_icon = '<span style="display:inline-flex;align-items:center;margin-right:6px;"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg></span>';
		// Funnel Analytics menu
		add_submenu_page(
			'opti-behavior-analytics',
			__( 'Funnel Analytics', 'opti-behavior' ),
			$funnels_icon . __( 'Funnels', 'opti-behavior' ),
			'manage_options',
			'opti-behavior-funnels',
			array( $this->funnel_page, 'render_funnels' )
		);

		// PRO feature menus: User Journeys, Errors Tracking, Form Analytics
		// Show upgrade placeholder pages when:
		// - PRO plugin is not active at all, OR
		// - PRO plugin is active but manifest validation fails (API unreachable, expired, etc.)
		// When manifest validates successfully, the PRO plugin's page classes register their own menus at priority 13.
		$show_pro_upgrade_menus = ! opti_behavior_pro_active()
			|| ! function_exists( 'opti_behavior_pro_validate_env' )
			|| ! opti_behavior_pro_validate_env();

		if ( $show_pro_upgrade_menus ) {
			// User Journeys menu - placeholder when PRO is not active
			$journey_icon = '<span style="display:inline-flex;align-items:center;margin-right:6px;"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg></span>';
			add_submenu_page(
				'opti-behavior-analytics',
				__( 'User Journeys', 'opti-behavior' ),
				$journey_icon . __( 'User Journeys', 'opti-behavior' ) . '<span class="opti-menu-pro-badge">PRO</span>',
				'manage_options',
				'opti-behavior-user-journey',
				array( $this, 'render_user_journey_upgrade' )
			);

			$errors_icon = '<span style="display:inline-flex;align-items:center;margin-right:6px;"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 2 1.88 1.88"/><path d="M14.12 3.88 16 2"/><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6"/><path d="M12 20v-9"/><path d="M6.53 9C4.6 8.8 3 7.1 3 5"/><path d="M6 13H2"/><path d="M3 21c0-2.1 1.7-3.9 3.8-4"/><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"/><path d="M22 13h-4"/><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"/></svg></span>';
			add_submenu_page(
				'opti-behavior-analytics',
				__( 'Errors Tracking', 'opti-behavior' ),
				$errors_icon . __( 'Errors Tracking', 'opti-behavior' ) . '<span class="opti-menu-pro-badge">PRO</span>',
				'manage_options',
				'opti-behavior-errors',
				array( $this, 'render_errors_upgrade' )
			);

			// Form Analytics menu - placeholder when PRO is not active
			$form_icon = '<span style="display:inline-flex;align-items:center;margin-right:6px;"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h14a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v4"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M2 15h10"/><path d="m9 18 3-3-3-3"/></svg></span>';
			add_submenu_page(
				'opti-behavior-analytics',
				__( 'Form Analytics', 'opti-behavior' ),
				$form_icon . __( 'Forms', 'opti-behavior' ) . '<span class="opti-menu-pro-badge">PRO</span>',
				'manage_options',
				'opti-behavior-form-analytics',
				array( $this, 'render_form_analytics_upgrade' )
			);
		}

	}

	/**
	 * Add Settings menu
	 */
	public function add_settings_menu() {
		// Lucide "settings" icon (20x20)
		$settings_icon = '<span style="display:inline-flex;align-items:center;margin-right:6px;"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></span>';
		add_submenu_page(
			'opti-behavior-analytics',
			__( 'Settings', 'opti-behavior' ),
			$settings_icon . __( 'Settings', 'opti-behavior' ),
			'manage_options',
			'opti-behavior-settings',
			array( $this, 'render_settings' )
		);
	}

	/**
	 * Add AI Insights menu
	 */
	public function add_ai_insights_menu() {
		// Lucide "sparkles" icon for AI (20x20)
		$ai_icon = '<span style="display:inline-flex;align-items:center;margin-right:6px;"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg></span>';
		add_submenu_page(
			'opti-behavior-analytics',
			__( 'AI Insights', 'opti-behavior' ),
			$ai_icon . __( 'AI Insights', 'opti-behavior' ),
			'manage_options',
			'opti-behavior-ai-insights',
			array( $this, 'render_ai_insights' )
		);
	}

	/**
	 * Reorder Opti-Behavior submenu items for a logical flow.
	 * Runs at priority 999 so all plugins (free + pro) have registered their items.
	 *
	 * Target order:
	 * Dashboard → Heatmaps → Recordings → Funnels → User Journeys →
	 * Errors Tracking → Forms → AI Insights → Settings
	 */
	public function reorder_submenu() {
		global $submenu;
		if ( ! isset( $submenu['opti-behavior-analytics'] ) ) {
			return;
		}

		$items = $submenu['opti-behavior-analytics'];

		// Desired slug order
		$slug_order = array(
			'opti-behavior-analytics',       // Dashboard
			'opti-behavior-heatmaps',        // Heatmaps
			'opti-behavior-recordings',      // Recordings
			'opti-behavior-funnels',         // Funnels
			'opti-behavior-ab-testing',      // A/B Testing
			'opti-behavior-user-journey',    // User Journeys
			'opti-behavior-errors',          // Errors Tracking
			'opti-behavior-form-analytics',  // Forms
			'opti-behavior-ai-insights',     // AI Insights
			'opti-behavior-settings',        // Settings (last)
		);

		$ordered  = array();
		$leftover = array();

		// Index items by slug (position [2] in the submenu array)
		$by_slug = array();
		foreach ( $items as $item ) {
			$slug = $item[2];
			$by_slug[ $slug ] = $item;
		}

		// Place items in desired order
		foreach ( $slug_order as $slug ) {
			if ( isset( $by_slug[ $slug ] ) ) {
				$ordered[] = $by_slug[ $slug ];
				unset( $by_slug[ $slug ] );
			}
		}

		// Append any remaining items (hidden pages, etc.)
		foreach ( $by_slug as $item ) {
			$ordered[] = $item;
		}

		$submenu['opti-behavior-analytics'] = $ordered; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
	}

	/**
	 * Enqueue dashboard assets
	 *
	 * @param string $hook_suffix Current admin page.
	 */
	public function enqueue_dashboard_assets( $hook_suffix ) {
		return $this->enqueue_dashboard_assets_impl( $hook_suffix );
	}


		/**
		 * Ensure critical scripts are not delayed by Cloudflare Rocket Loader
		 */
		public function filter_script_loader_tag( $tag, $handle, $src ) {
		return $this->filter_script_loader_tag_impl( $tag, $handle, $src );
	}

	/**
	 * Add custom CSS for menu icon sizing
	 * Note: This is now handled in enqueue_dashboard_assets_impl()
	 */
	public function add_menu_icon_css() {
		// Styles are now enqueued properly in trait-opti-behavior-assets.php
	}

	/**
	 * Get localized strings
	 *
	 * @return array
	 */
	private function get_localized_strings() {
		return array(
			'dashboard'        => __( 'Dashboard', 'opti-behavior' ),
			'sessions'         => __( 'Sessions', 'opti-behavior' ),
			'visitors'         => __( 'Visitors', 'opti-behavior' ),
			'pageviews'        => __( 'Page Views', 'opti-behavior' ),
			'bounceRate'       => __( 'Bounce Rate', 'opti-behavior' ),
			'avgSessionTime'   => __( 'Avg. Session Time', 'opti-behavior' ),
			'topPages'         => __( 'Top Pages', 'opti-behavior' ),
			'countries'        => __( 'Countries', 'opti-behavior' ),
			'browsers'         => __( 'Browsers', 'opti-behavior' ),
			'realTimeVisitors' => __( 'Real-time Visitors', 'opti-behavior' ),
			'sessionRecordings' => __( 'Session Recordings', 'opti-behavior' ),
			'duration'         => __( 'Duration', 'opti-behavior' ),
			'pages'            => __( 'Pages', 'opti-behavior' ),
			'country'          => __( 'Country', 'opti-behavior' ),
			'device'           => __( 'Device', 'opti-behavior' ),
			'browser'          => __( 'Browser', 'opti-behavior' ),
			'viewRecording'    => __( 'View Recording', 'opti-behavior' ),
			'today'            => __( 'Today', 'opti-behavior' ),
			'yesterday'        => __( 'Yesterday', 'opti-behavior' ),
			'last7Days'        => __( 'Last 7 Days', 'opti-behavior' ),
			'last30Days'       => __( 'Last 30 Days', 'opti-behavior' ),
			'thisMonth'        => __( 'This Month', 'opti-behavior' ),
			'lastMonth'        => __( 'Last Month', 'opti-behavior' ),
			'loading'          => __( 'Loading...', 'opti-behavior' ),
			'noData'           => __( 'No data available', 'opti-behavior' ),
			'error'            => __( 'Error loading data', 'opti-behavior' ),
			// Dashboard widget strings
			'newVisitors'      => __( 'New Visitors', 'opti-behavior' ),
			'returningVisitors' => __( 'Returning Visitors', 'opti-behavior' ),
			'totalVisitors'    => __( 'Total Visitors', 'opti-behavior' ),
			'loggedInVisitors' => __( 'Logged In Visitors', 'opti-behavior' ),
			'newRegistrations' => __( 'New Registrations', 'opti-behavior' ),
			'homePage'         => __( 'Home Page', 'opti-behavior' ),
			'posts'            => __( 'Posts', 'opti-behavior' ),
			// User intent
			'lowIntent'        => __( 'Low intent', 'opti-behavior' ),
			'mediumIntent'     => __( 'Medium intent', 'opti-behavior' ),
			'highIntent'       => __( 'High intent', 'opti-behavior' ),
			// Device types
			'desktop'          => __( 'Desktop', 'opti-behavior' ),
			'mobile'           => __( 'Mobile', 'opti-behavior' ),
			'tablet'           => __( 'Tablet', 'opti-behavior' ),
			'pc'               => __( 'PC', 'opti-behavior' ),
			// Operating systems
			'windows'          => __( 'Windows', 'opti-behavior' ),
			'chrome'           => __( 'Chrome', 'opti-behavior' ),
			// Empty state messages
			'noReferrerData'   => __( 'No referrer data available', 'opti-behavior' ),
			'noCountryData'    => __( 'No country data available', 'opti-behavior' ),
			'noScreenResolutionData' => __( 'No screen resolution data available', 'opti-behavior' ),
			'tryBroadeningDateRange' => __( 'Try broadening the date range or check back later.', 'opti-behavior' ),
		'noPageData'              => __( 'No page data available', 'opti-behavior' ),
		'noVisitorData'           => __( 'No visitor data available', 'opti-behavior' ),
		'noBrowserData'           => __( 'No browser data available', 'opti-behavior' ),
		'noDeviceData'            => __( 'No device data available', 'opti-behavior' ),
		'dataWillAppear'          => __( 'Data will appear as visitors interact with your site.', 'opti-behavior' ),
		'noDataYet'               => __( 'No data yet', 'opti-behavior' ),
		);
	}

	/**
	 * Get dashboard settings
	 *
	 * @return array
	 */
	private function get_dashboard_settings() {
		return array(
			'refreshInterval' => 30000, // 30 seconds
			'dateFormat'      => get_option( 'date_format' ),
			'timeFormat'      => get_option( 'time_format' ),
			'timezone'        => wp_timezone_string(),
			'currency'        => get_option( 'woocommerce_currency', 'USD' ),
		);
	}

	/**
	 * Render main dashboard
	 */
	public function render_dashboard() {
		// Determine initial filter from querystring
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- GET parameters for filtering dashboard view (read-only operation)
		$period = isset($_GET['period']) ? sanitize_text_field( wp_unslash( $_GET['period'] ) ) : 'last30days';
		$start_q = isset($_GET['start_date']) ? sanitize_text_field( wp_unslash( $_GET['start_date'] ) ) : null;
		$end_q   = isset($_GET['end_date']) ? sanitize_text_field( wp_unslash( $_GET['end_date'] ) ) : null;
		$exclude_spam = isset($_GET['exclude_spam']) && sanitize_text_field( wp_unslash( $_GET['exclude_spam'] ) ) === '1';
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		// Store exclude_spam flag globally for use in queries
		$GLOBALS['opti_behavior_exclude_spam'] = $exclude_spam;

		// PERFORMANCE: Calculate date range only (fast) - stats will load via AJAX
		if ( 'custom' === $period && $start_q && $end_q ) {
			$start_date = sanitize_text_field( $start_q ) . ' 00:00:00';
			$end_date   = sanitize_text_field( $end_q ) . ' 23:59:59';
			$date_range = array(
				'start'      => $start_date,
				'end'        => $end_date,
				'start_date' => new DateTime( $start_date, wp_timezone() ),
				'end_date'   => new DateTime( $end_date, wp_timezone() ),
			);
		} else {
			$date_range = $this->get_date_range( $period );
		}

		// Minimal data for immediate render - everything else loads via AJAX
		$dashboard_data = array(
			'stats'      => array(
				'sessions'         => 0,
				'visitors'         => 0,
				'pageviews'        => 0,
				'avg_session_time' => 0,
				'avg_scroll_depth' => 0,
				'bounce_rate'      => 0,
			),
			'period'     => $period,
			'date_range' => $date_range,
			'changes'    => array(
				'visitors'         => 0,
				'sessions'         => 0,
				'pageviews'        => 0,
				'avg_session_time' => 0,
				'avg_scroll_depth' => 0,
				'bounce_rate'      => 0,
			),
			'daily_history' => array(),
			'charts'        => array(),
			'realtime'      => array( 'active_visitors' => array() ),
			// Empty data for widgets that render conditionally - will be populated via AJAX
			'traffic_classification' => array(),
			'bot_traffic'            => array(),
			'new_vs_returning'       => array(),
			'visited_directories'    => array(),
			'new_registered_users'   => array(),
		);

		?>
		<div class="wrap opti-behavior-dashboard-page">
			<?php $this->render_pro_trial_banner(); ?>
			<?php $this->render_dashboard_header($dashboard_data['date_range'], $period); ?>
			<?php if ( function_exists( 'opti_behavior_pro_sodium_banner' ) ) { opti_behavior_pro_sodium_banner(); } ?>
			<?php $this->render_dashboard_stats($dashboard_data['stats'], $dashboard_data['changes']); ?>
			<?php $this->render_dashboard_content($dashboard_data); ?>
		</div>
		<?php
		// Add minimal dashboard data - stats will be loaded via AJAX for instant page render
		wp_add_inline_script( 'chart-js', '
			window.opti_behaviorData = window.opti_behaviorData || {};
			window.opti_behaviorData.dashboard = ' . wp_json_encode($dashboard_data) . ';
			window.opti_behaviorData.period = "' . esc_js($period) . '";
			window.opti_behaviorData.startDate = "' . esc_js($start_q ?: '') . '";
			window.opti_behaviorData.endDate = "' . esc_js($end_q ?: '') . '";
			window.opti_behaviorData.excludeSpam = "' . ($exclude_spam ? '1' : '0') . '";
		', 'before' );
	}

	/**
	 * Render dashboard header
	 */

	/**
	 * Render dashboard stats
	 */

	/**
	 * Render dashboard content
	 */
	private function render_dashboard_content($dashboard_data) {

			// Delegate rendering to trait (refactor extraction)
			return $this->render_dashboard_content_view($dashboard_data);

		?>
		<div class="dashboard-content">
			<div class="dashboard-grid">
				<!-- Sessions Over Time Chart -->
				<div class="dashboard-widget chart-widget">
					<div class="widget-header">
						<h3 class="widget-title">
							<span class="widget-icon">📈</span>
							Traffic Overview
						</h3>
					</div>
					<div class="widget-content">
						<canvas id="sessions-chart" width="400" height="200"></canvas>
					</div>
				</div>


				<!-- Browsers Widget -->
				<div class="dashboard-widget browsers-widget">
					<div class="widget-header">
						<h3 class="widget-title"><span class="widget-icon">🧭</span> Browsers</h3>
					</div>
					<div class="widget-content compact">
						<canvas id="browsers-chart"></canvas>
					</div>
				</div>
					<!-- Device Types Widget -->
					<div class="dashboard-widget device-widget">
						<div class="widget-header">
							<h3 class="widget-title"><span class="widget-icon">📱</span> Device Types</h3>
						</div>
						<div class="widget-content compact">
							<canvas id="device-types-pie"></canvas>
						</div>
					</div>


				<!-- Operating Systems Widget -->
				<div class="dashboard-widget os-widget">
					<div class="widget-header">
						<h3 class="widget-title"><span class="widget-icon">💻</span> Operating Systems</h3>
					</div>
					<div class="widget-content compact">
						<canvas id="os-chart"></canvas>
					</div>





				</div>

					<!-- Top Engaged Users (bottom-right) -->
					<?php $optibehavior_tu_nonce = wp_create_nonce('optibehavior_top_users'); $optibehavior_tu_ajax = admin_url('admin-ajax.php'); ?>
					<div class="dashboard-widget top-users-widget">
						<div class="widget-header">
							<h3 class="widget-title"><span class="widget-icon">👤</span> <?php echo esc_html__('Top Engaged Users','opti-behavior'); ?></h3>
						</div>
						<div class="widget-content">
							<div class="optibehavior-table-wrap">
								<table class="widefat striped">
									<thead>
									<tr>
										<th>#</th>
										<th><?php echo esc_html__('Visitor','opti-behavior'); ?></th>
										<th title="<?php echo esc_attr__('Average sessions per active day', 'opti-behavior'); ?>"><?php esc_html_e('Daily Freq', 'opti-behavior'); ?></th>
										<th title="<?php echo esc_attr__('Average time per session', 'opti-behavior'); ?>"><?php esc_html_e('Avg Session', 'opti-behavior'); ?></th>
										<th><?php echo esc_html__('Total Time','opti-behavior'); ?></th>
										<th><?php echo esc_html__('Sessions','opti-behavior'); ?></th>
										<th title="<?php echo esc_attr__('Pages per session', 'opti-behavior'); ?>"><?php esc_html_e('Pages/Sess', 'opti-behavior'); ?></th>
										<th><?php echo esc_html__('Country','opti-behavior'); ?></th>
										<th><?php echo esc_html__('Last Seen','opti-behavior'); ?></th>
									</tr>
									</thead>
									<tbody id="optibehavior-tu2-body">
										<tr><td colspan="9">
											<div class="optibehavior-loading-state">
												<span class="spinner is-active"></span>
												<span><?php echo esc_html__('Loading top users…','opti-behavior'); ?></span>
											</div>
										</td></tr>
									</tbody>
								</table>
							</div>
						</div>
					</div>
					<!-- Note: Top users loading script is now properly enqueued via wp_add_inline_script() in get_dashboard_scripts() method -->
					<?php ?>


				<!-- Top Countries Widget -->
				<div class="dashboard-widget countries-widget">
					<div class="widget-header">
						<h3 class="widget-title"><span class="widget-icon">🌍</span> Top Countries</h3>
					</div>
					<div class="widget-content compact">
						<canvas id="countries-chart"></canvas>
					</div>
				</div>

				<!-- Real-time Visitors -->
				<div class="dashboard-widget realtime-widget">
					<div class="widget-header">
						<h3 class="widget-title">
							<span class="live-badge" aria-label="<?php echo esc_attr__('Live', 'opti-behavior'); ?>"><span class="live-dot" aria-hidden="true"></span><?php esc_html_e('Live', 'opti-behavior'); ?></span>
							<?php esc_html_e('Real-time Visitors', 'opti-behavior'); ?>
							<span class="visitor-count"><?php echo count($dashboard_data['realtime']['active_visitors']); ?></span>
						</h3>
					</div>
					<div class="widget-content">
						<div class="realtime-visitors" id="realtime-visitors">
							<?php foreach ($dashboard_data['realtime']['active_visitors'] as $visitor): ?>
							<div class="visitor-item grid">
								<span class="visitor-datetime"><?php echo esc_html($visitor['visited_at'] ?? ''); ?><?php if(!empty($visitor['time_ago'])): ?> - <span class="ago"><?php echo esc_html($visitor['time_ago']); ?></span><?php endif; ?></span>
								<span class="visitor-flag-country"><span class="visitor-flag"><?php echo $visitor['flag']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span> <span class="visitor-location"><?php echo esc_html($visitor['country']); ?></span></span>
								<span class="visitor-pageblock"><span class="visitor-title"><?php echo esc_html($visitor['page_title'] ?? ''); ?></span><a class="visitor-url" href="<?php echo esc_url($visitor['current_url'] ?? ''); ?>" target="_blank" rel="noopener"><?php echo esc_html($visitor['current_url'] ?? ''); ?></a></span>
								<span class="visitor-ip-col"><?php
								$ip_raw = isset( $visitor['ip'] ) ? trim( (string) $visitor['ip'] ) : '';
								if ( '' === $ip_raw ) {
									echo '<span class="visitor-ip-pill">-</span>';
								} elseif ( 'Anonymous' === $ip_raw ) {
									echo '<span class="visitor-ip-pill visitor-ip-anon"><i data-lucide="shield-check" style="width:13px;height:13px;display:inline-block;vertical-align:-2px;margin-right:3px;"></i>' . esc_html__( 'Anonymous', 'opti-behavior' ) . '</span>';
								} elseif ( false !== strpos( $ip_raw, ':' ) ) {
									echo '<span class="visitor-ip-pill">' . esc_html( substr( $ip_raw, 0, 7 ) . '…' . substr( $ip_raw, -7 ) ) . '</span>';
								} else {
									echo '<span class="visitor-ip-pill">' . esc_html( $ip_raw ) . '</span>';
								}
								?></span>
							</div>
							<?php endforeach; ?>

							<?php if (empty($dashboard_data['realtime']['active_visitors'])): ?>
							<div class="optibehavior-empty-state is-visible">
								<svg viewBox='0 0 24 24' fill='none' stroke='#9ca3af' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><path d='M16 11a4 4 0 1 0-8 0'/><path d='M3 21a7 7 0 0 1 18 0'/></svg>
								<div class="optibehavior-empty-title"><?php esc_html_e('No active visitors right now', 'opti-behavior'); ?></div>
								<div class="optibehavior-empty-sub"><?php esc_html_e('Traffic updates in real-time.', 'opti-behavior'); ?></div>
							</div>
							<?php endif; ?>
						</div>
					</div>
				</div>

					<!-- Real-time Visitor Map -->
					<div class="dashboard-widget realtime-map-widget">
						<div class="widget-header">
							<h3 class="widget-title"><span class="widget-icon">🗺️</span> Real-time Visitor Map</h3>
						</div>
						<div class="widget-content">
							<div id="realtime-map" class="realtime-map" aria-label="<?php echo esc_attr__('Interactive world map of live visitors', 'opti-behavior'); ?>"></div>
						</div>
					</div>

						<?php
						// Realtime map styles are now enqueued in trait-opti-behavior-assets.php
						// Realtime map initialization script is now in get_dashboard_scripts_impl()
						?>
						<!-- Note: Realtime map initialization script is now properly enqueued via wp_add_inline_script() in get_dashboard_scripts_impl() method -->
						<?php  ?>



				<!-- Top Pages -->
				<div class="dashboard-widget pages-widget">
					<div class="widget-header">
						<h3 class="widget-title">
							<span class="widget-icon" aria-hidden="true">
								<svg class="widget-icon-svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#4f46e5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><path d="M14 2v6h6"></path></svg>
							</span>
							Top Pages
						</h3>
					</div>
					<div class="widget-content">
						<div class="top-pages">
							<?php if (empty($dashboard_data['charts']['top_pages'])): ?>
								<div class="optibehavior-empty-state is-visible">
									<svg viewBox='0 0 24 24' fill='none' stroke='#9ca3af' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><path d='M4 3h14a2 2 0 0 1 2 2v14l-4-4H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z'/></svg>
									<div class="optibehavior-empty-title"><?php esc_html_e('No page views yet', 'opti-behavior'); ?></div>
									<div class="optibehavior-empty-sub"><?php esc_html_e('Once visitors view pages, you’ll see them here.', 'opti-behavior'); ?></div>
								</div>
							<?php else: ?>
							<?php foreach ($dashboard_data['charts']['top_pages'] as $page): ?>
							<div class="page-item">
								<div class="page-info">
									<div class="page-title"><?php echo esc_html($page['title']); ?></div>
									<div class="page-url" title="<?php echo esc_attr($page['url']); ?>"><?php $u=$page['url']; if(strlen($u)>80){$keep=79; $left=(int)ceil($keep/2); $right=(int)floor($keep/2); $u=substr($u,0,$left).'…'.substr($u,-$right);} echo esc_html($u); ?></div>
								</div>
								<div class="page-stats">
									<span class="page-views"><?php echo esc_html($page['views']); ?></span>
									<a class="optibehavior-heatmap-btn" target="_blank" href="<?php echo esc_url($page['pc_heatmap']); ?>" aria-label="<?php
										// translators: %s: page title
										echo esc_attr( sprintf( __('Open PC heatmap for %s', 'opti-behavior'), $page['title'] ) ); ?>">🖥️ PC</a>
									<a class="optibehavior-heatmap-btn alt" target="_blank" href="<?php echo esc_url($page['mobile_heatmap']); ?>" aria-label="<?php
										// translators: %s: page title
										echo esc_attr( sprintf( __('Open Mobile heatmap for %s', 'opti-behavior'), $page['title'] ) ); ?>">📱 Mobile</a>
								</div>
							</div>
							<?php endforeach; ?>
							<?php endif; ?>
						</div>
					</div>
				</div>

					<!-- Top Referrers -->
					<div class="dashboard-widget referrers-widget">
						<div class="widget-header">
							<h3 class="widget-title"><span class="widget-icon">🔗</span> Top Referrers</h3>
						</div>
						<div class="widget-content compact">
							<canvas id="referrers-chart"></canvas>
						</div>
					</div>






			</div>
		</div>
		<?php
	}

	/**
	 * Render modern heatmaps page
	 */
	public function render_heatmaps() {
		echo '<div class="wrap opti-behavior-heatmaps-modern">';
		$this->render_pro_trial_banner();
		$this->render_heatmaps_header();
		if ( function_exists( 'opti_behavior_pro_sodium_banner' ) ) { opti_behavior_pro_sodium_banner(); }
		$this->render_heatmaps_stats();
		$this->render_heatmaps_content();
		echo '</div>';

		// Note: Heatmaps page scripts are now enqueued from external file assets/js/heatmaps.js
		// Enqueuing is handled in includes/trait-opti-behavior-assets.php (lines 87-94)
	}

	/**
	 * Render recordings upgrade page
	 * Shows promotional page when PRO is not active
	 * If PRO is active, this will be overridden by the PRO plugin
	 */
	public function render_recordings_upgrade() {
		// If PRO is active, this shouldn't be called (PRO plugin overrides the menu).
		// To avoid duplicate headers when both callbacks are attached, bail out silently.
		if ( opti_behavior_pro_active() ) {
			return;
		}

		// Show upgrade/promotional page
		$template_path = plugin_dir_path( dirname( __FILE__ ) ) . 'admin/recordings-upgrade-page.php';

		// Template path validation
		$debug_manager = $this->heatmap->get_debug_manager();
		$debug_manager->log( 'Template path: ' . $template_path, 'debug', 'recordings' );
		$debug_manager->log( 'Template exists: ' . ( file_exists( $template_path ) ? 'YES' : 'NO' ), 'debug', 'recordings' );

		if ( file_exists( $template_path ) ) {
			require_once $template_path;
		} else {
			echo '<div class="wrap"><h1>' . esc_html__( 'Session Recordings', 'opti-behavior' ) . '</h1>';
			echo '<p>' . esc_html__( 'Template file not found.', 'opti-behavior' ) . '</p></div>';
		}
	}

	/**
	 * Render errors tracking upgrade page
	 * Shows promotional page when PRO is not active
	 * If PRO is active, PRO plugin registers its own errors page
	 */
	public function render_errors_upgrade() {
		$template_path = plugin_dir_path( dirname( __FILE__ ) ) . 'admin/errors-upgrade-page.php';

		if ( file_exists( $template_path ) ) {
			require_once $template_path;
		} else {
			echo '<div class="wrap"><h1>' . esc_html__( 'Errors Tracking', 'opti-behavior' ) . '</h1>';
			echo '<p>' . esc_html__( 'Template file not found.', 'opti-behavior' ) . '</p></div>';
		}
	}

	/**
	 * Render user journey upgrade page
	 * Shows promotional page when PRO is not active
	 * If PRO is active, PRO plugin registers its own user journey page
	 */
	public function render_user_journey_upgrade() {
		$template_path = plugin_dir_path( dirname( __FILE__ ) ) . 'admin/user-journey-upgrade-page.php';

		if ( file_exists( $template_path ) ) {
			require_once $template_path;
		} else {
			echo '<div class="wrap"><h1>' . esc_html__( 'User Journeys', 'opti-behavior' ) . '</h1>';
			echo '<p>' . esc_html__( 'Template file not found.', 'opti-behavior' ) . '</p></div>';
		}
	}

	/**
	 * Render Form Analytics upgrade page (placeholder when PRO is not active).
	 */
	public function render_form_analytics_upgrade() {
		$template_path = plugin_dir_path( dirname( __FILE__ ) ) . 'admin/form-analytics-upgrade-page.php';

		if ( file_exists( $template_path ) ) {
			require_once $template_path;
		} else {
			echo '<div class="wrap"><h1>' . esc_html__( 'Form Analytics', 'opti-behavior' ) . '</h1>';
			echo '<p>' . esc_html__( 'Upgrade to Opti-Behavior Pro to unlock Form Analytics.', 'opti-behavior' ) . '</p></div>';
		}
	}

	/**
	 * Render heatmaps page header
	 */

	/**
	 * Render heatmaps statistics section
	 */

	/**
	 * Render heatmaps content section
	 */

	/**
	 * Render simplified heatmap table
	 */
	private function render_simplified_heatmap_table() {
		// Handle delete action
		if (!empty($_POST['optibehavior_delete_page_id'])) {
			$del_id = intval( wp_unslash( $_POST['optibehavior_delete_page_id'] ) );
			if (isset($_POST['_wpnonce']) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'optibehavior_delete_heatmap_' . $del_id)) {
				$this->heatmap->get_database()->delete_data(array($del_id));
				echo '<div class="notice notice-success"><p>' . esc_html__('Heatmap data deleted.', 'opti-behavior') . '</p></div>';
			}
		}

			// Performance: render shell only; populate via AJAX
			?>
			<div class="simplified-heatmap-table" data-optibehavior-table>
				<div class="table-skeleton">
					<span class="spinner"></span>
					<span><?php esc_html_e('Loading analytics data...', 'opti-behavior'); ?></span>
				</div>
			</div>
		<?php
		// Inline styles moved to assets/css/dashboard.css for WordPress compliance
		// Heatmap table styles are now in dashboard.css
		// Note: Heatmaps page scripts are now properly enqueued via wp_add_inline_script() in render_heatmaps() method
	}

	/**
	 * AJAX: Heatmaps table HTML (sorting + pagination)
	 */
	public function ajax_heatmaps_table() {
		return $this->ajax_heatmaps_table_impl();
	}

		/**
		 * Render only the inner table + compact pagination
		 */
		private function render_heatmap_table_inner( $args ) {
			$per_page = isset($args['per_page']) ? $args['per_page'] : 10;
			$orderby = isset($args['orderby']) ? $args['orderby'] : 'last_updated';
			$order   = isset($args['order']) ? $args['order'] : 'desc';
			$page    = isset($args['page']) ? $args['page'] : 1;
			$period  = isset($args['period']) ? $args['period'] : 'last30days';
			$start   = isset($args['start']) ? $args['start'] : '';
			$end     = isset($args['end']) ? $args['end'] : '';
		$search  = isset($args['search']) ? $args['search'] : '';

			// Date range resolution for efficient queries
			if ($period === 'custom' && !empty($start) && !empty($end)) {
				$start_date = $start . ' 00:00:00';
				$end_date   = $end   . ' 23:59:59';
			} else {
				$range = $this->get_date_range($period);
				$start_date = $range['start'];
				$end_date   = $range['end'];
			}

			// Optimized: fetch only the current page from DB (no full-scan)
				// Debug log for AJAX parameters


			$allowed = array('interactions','sessions','last_updated');
			if (!in_array($orderby,$allowed,true)) { $orderby='last_updated'; }
			list($heatmaps, $total) = $this->get_heatmap_rows_paged($orderby, $order, (int)$page, (int)$per_page, $start_date, $end_date, $search);
			// Fallback: if filtered date range yields zero, show all-time so table is not empty
			if ($total === 0 && $start_date && $end_date && empty($search)) {
				list($heatmaps, $total) = $this->get_heatmap_rows_paged($orderby, $order, (int)$page, (int)$per_page, null, null, $search);
			}
			$pages = max(1,(int)ceil($total/$per_page));
			$page = min($page,$pages);
			$offset = ($page-1)*$per_page;

			$qs = function($p) use($orderby,$order){ return esc_attr( http_build_query( array_merge(['orderby'=>$orderby,'order'=>$order],$p) ) ); };
			$toggle = function($col) use ($order){ return $order==='asc'?'desc':'asc'; };
			$arrow = function($col) use ($orderby,$order){ if($orderby!==$col) return ''; return $order==='asc'?'↑':'↓'; };
			$heatmap_tooltips = opti_behavior_get_heatmaps_tooltips();
			?>
			<div class="simplified-heatmap-table" data-optibehavior-table>
				<table class="heatmap-table heatmaps-modern-table">
					<thead>
						<tr>
							<th><?php esc_html_e('Page', 'opti-behavior'); ?></th>
							<?php $current = function($col) use ($orderby,$order){ return $orderby===$col ? $order : ''; }; ?>
							<th><?php opti_behavior_tooltip_e( $heatmap_tooltips['interactions']['title'], $heatmap_tooltips['interactions']['content'], $heatmap_tooltips['interactions']['simple'], '', array( 'position' => 'bottom' ) ); ?><a href="#" data-sort="interactions" data-order="<?php echo esc_attr($toggle('interactions')); ?>" data-current="<?php echo esc_attr($current('interactions')); ?>" data-active="<?php echo ($orderby==='interactions')?'1':'0'; ?>"><?php esc_html_e('Interactions', 'opti-behavior'); ?> <?php echo esc_html($arrow('interactions')); ?></a></th>
							<th><?php opti_behavior_tooltip_e( $heatmap_tooltips['sessions']['title'], $heatmap_tooltips['sessions']['content'], $heatmap_tooltips['sessions']['simple'], '', array( 'position' => 'bottom' ) ); ?><a href="#" data-sort="sessions" data-order="<?php echo esc_attr($toggle('sessions')); ?>" data-current="<?php echo esc_attr($current('sessions')); ?>" data-active="<?php echo ($orderby==='sessions')?'1':'0'; ?>"><?php esc_html_e('Sessions', 'opti-behavior'); ?> <?php echo esc_html($arrow('sessions')); ?></a></th>
							<th><?php esc_html_e('Device Split', 'opti-behavior'); ?></th>
							<th><a href="#" data-sort="last_updated" data-order="<?php echo esc_attr($toggle('last_updated')); ?>" data-current="<?php echo esc_attr($current('last_updated')); ?>" data-active="<?php echo ($orderby==='last_updated')?'1':'0'; ?>"><?php esc_html_e('Last Updated', 'opti-behavior'); ?> <?php echo esc_html($arrow('last_updated')); ?></a></th>
							<th><?php esc_html_e('Actions', 'opti-behavior'); ?></th>
							<?php echo apply_filters( 'opti_behavior_heatmap_list_columns_after', '' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped by the filter callback ?>
						</tr>
					</thead>
					<tbody>
						<?php foreach ($heatmaps as $heatmap): $heatmap = (array) $heatmap; ?>
						<tr class="heatmap-row heatmap-table-row" data-page-id="<?php echo intval($heatmap['id']); ?>">
							<td class="page-info">
								<a href="<?php echo esc_url( admin_url( 'admin.php?page=opti-behavior-heatmap-detail&page_id=' . intval($heatmap['id']) . '&type=click&device=desktop' ) ); ?>" class="page-title-link" title="<?php echo esc_attr__( 'View click heatmap', 'opti-behavior' ); ?>">
									<div class="page-title" title="<?php echo esc_attr($heatmap['title']); ?>"><?php echo esc_html($heatmap['title']); ?></div>
								</a>
								<a href="<?php echo esc_url($heatmap['url']); ?>" target="_blank" rel="noopener noreferrer" class="page-url heatmap-page-url" title="<?php echo esc_attr($heatmap['url']); ?>"><?php echo esc_html($heatmap['url']); ?></a>
							</td>

							<td class="interactions-count"><strong><?php echo esc_html($heatmap['interactions']); ?></strong></td>
							<td class="sessions-count"><strong><?php echo esc_html($heatmap['sessions']); ?></strong></td>
							<td class="device-info"><span class="mobile-percentage"><?php echo esc_html($heatmap['mobile_percentage']); ?>% <i data-lucide="smartphone" style="width:14px;height:14px;vertical-align:middle;"></i> / <?php echo esc_html(100 - intval($heatmap['mobile_percentage'])); ?>% <i data-lucide="monitor" style="width:14px;height:14px;vertical-align:middle;"></i></span></td>
							<td class="last-updated"><?php echo esc_html($heatmap['last_updated']); ?></td>
							<td class="actions">
								<a href="<?php echo esc_url($heatmap['view_pc_url']); ?>" class="button button-primary button-small optibehavior-btn optibehavior-btn-desktop heatmap-action-btn" target="_blank" title="<?php echo esc_attr__('Open desktop heatmap', 'opti-behavior'); ?>"><i data-lucide="monitor" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i> <?php
									// translators: %s: number of desktop sessions
									printf( esc_html__('Desktop (%s)', 'opti-behavior'), esc_html(number_format($heatmap['sessions_desktop'])) ); ?></a>
								<a href="<?php echo esc_url($heatmap['view_mobile_url']); ?>" class="button button-secondary button-small optibehavior-btn optibehavior-btn-mobile heatmap-action-btn" target="_blank" title="<?php echo esc_attr__('Open mobile heatmap', 'opti-behavior'); ?>"><i data-lucide="smartphone" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i> <?php
									// translators: %s: number of mobile sessions
									printf( esc_html__('Mobile (%s)', 'opti-behavior'), esc_html(number_format($heatmap['sessions_mobile'])) ); ?></a>
							</td>
							<?php echo apply_filters( 'opti_behavior_heatmap_list_row_after', '', $heatmap ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped by the filter callback ?>
						</tr>
						<?php endforeach; ?>
					</tbody>
				</table>

				<?php if (empty($heatmaps)): ?><div class="no-heatmaps"><p><?php esc_html_e('No heatmaps available.', 'opti-behavior'); ?></p></div><?php endif; ?>

				<?php
				// Compact pagination window with ellipses
				$window = 5; $half = floor($window/2);
				$start = max(1, $page - $half); $end = min($pages, $start + $window - 1); $start = max(1, $end - $window + 1);
				?>
				<div class="table-pagination table-pagination--center" data-total-pages="<?php echo esc_attr($pages); ?>" data-orderby="<?php echo esc_attr($orderby); ?>" data-order="<?php echo esc_attr($order); ?>">
					<div class="entries-info"><?php
						// translators: %1$s: start range, %2$s: end range, %3$s: total items, %4$s: current page, %5$s: total pages
						printf( esc_html__('Showing %1$s–%2$s of %3$s · Page %4$s / %5$s', 'opti-behavior'), esc_html($total ? ($offset+1) : 0), esc_html(min($offset + $per_page, $total)), esc_html($total), esc_html($page), esc_html($pages) ); ?></div>
					<div class="pagination-controls" data-paging>
						<button class="pagination-btn" data-first <?php disabled($page<=1); ?>><?php esc_html_e('First', 'opti-behavior'); ?></button>
						<button class="pagination-btn" data-prev <?php disabled($page<=1); ?>>‹ <?php esc_html_e('Prev', 'opti-behavior'); ?></button>
						<?php if ($start > 1): ?><span class="pagination-ellipsis">…</span><?php endif; ?>
						<?php for($i=$start;$i<=$end;$i++): ?>
							<?php if ($i===$page): ?>
								<span class="pagination-current"><?php echo esc_html($i); ?></span>
							<?php else: ?>
								<a href="#" class="pagination-btn" data-page="<?php echo esc_attr($i); ?>"><?php echo esc_html($i); ?></a>
							<?php endif; ?>
						<?php endfor; ?>

					<!-- Note: Sessions lazy load script is now properly enqueued via wp_add_inline_script() in render_heatmaps() method -->
					<?php /* Removed duplicate inline script - already in get_heatmaps_page_scripts() as optibehaviorUpdateSessionsCounts */ ?>

						<?php if ($end < $pages): ?><span class="pagination-ellipsis">…</span><?php endif; ?>
						<button class="pagination-btn" data-next <?php disabled($page>=$pages); ?>><?php esc_html_e('Next', 'opti-behavior'); ?> ›</button>
						<button class="pagination-btn" data-last <?php disabled($page>=$pages); ?>><?php esc_html_e('Last', 'opti-behavior'); ?></button>
					</div>
				</div>

		</div>
		<?php
	}



	/**
	 * Get heatmap statistics from database or file storage
	 */
	private function get_heatmap_statistics() {
		global $wpdb;

		// Get date ranges for calculations
		$today = gmdate('Y-m-d');
		$week_ago = gmdate('Y-m-d', strtotime('-7 days'));
		$two_weeks_ago = gmdate('Y-m-d', strtotime('-14 days'));
		$month_ago = gmdate('Y-m-d', strtotime('-30 days'));

		// Check storage mode
		$file_storage = $this->heatmap->get_file_storage();
		$storage_settings = $file_storage ? $file_storage->get_settings() : array( 'storage_mode' => 'database' );

		// Always use file storage if it exists and has data
		// Force file storage detection based on actual data availability
		$use_file_storage = false;
		if ( $file_storage ) {
			$test_data = $file_storage->get_pages_with_heatmap_data();
			$use_file_storage = ! empty( $test_data );
		}

		if ( $use_file_storage ) {
			// Get statistics from file storage
			$pages_data = $file_storage->get_pages_with_heatmap_data();

			// Total heatmaps (unique pages with events)
			$total_heatmaps = count( $pages_data );
		} else {
			// Total heatmaps (pages with events) from database
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, event numbers are hardcoded integers
			$total_heatmaps = $wpdb->get_var(
				"SELECT COUNT(DISTINCT page_id2)
				FROM " . $wpdb->prefix . "optibehavior_events
				WHERE event IN (16, 17, 32, 33, 48, 49)"
			);
		}

		// Heatmaps growth (this week vs all time)
		// Compare: How many NEW pages got heatmaps this week
		if ( $use_file_storage ) {
			// Get this week's heatmaps from file storage
			$pages_data_this_week = $file_storage->get_pages_with_heatmap_data();

			// Filter pages that have events from this week
			$week_ago_timestamp = strtotime( $week_ago );
			$this_week_heatmaps = 0;
			foreach ( $pages_data_this_week as $page_data ) {
				if ( isset( $page_data['last_event_time'] ) && strtotime( $page_data['last_event_time'] ) >= $week_ago_timestamp ) {
					$this_week_heatmaps++;
				}
			}
		} else {
			// Get this week's heatmaps from database
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			$this_week_heatmaps = $wpdb->get_var( $wpdb->prepare(
				"SELECT COUNT(DISTINCT page_id2)
				FROM " . $wpdb->prefix . "optibehavior_events
				WHERE event IN (16, 17, 32, 33, 48, 49)
				AND insert_at >= %s",
				$week_ago
			));
		}

		// Calculate: (this week count / all-time count) * 100
		// Shows what % of all-time heatmaps were created this week
		// Example: 5 this week / 13 all-time = 38% (meaning 38% of all heatmaps are from this week)
		$heatmaps_growth = $total_heatmaps > 0 ?
			round(($this_week_heatmaps / $total_heatmaps) * 100) : 0;

		// Total clicks (click events only)
		if ( $use_file_storage ) {
			// Get total clicks from file storage
			// Retrieve pages data again to ensure we have all the data
			$all_pages_data = $file_storage->get_pages_with_heatmap_data();
			$total_clicks = 0;
			foreach ( $all_pages_data as $page_data ) {
				$total_clicks += ( $page_data['click_pc'] + $page_data['click_mobile'] );
			}
		} else{
			// Get total clicks from database
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, event numbers are hardcoded integers
			$total_clicks = $wpdb->get_var(
				"SELECT COUNT(*)
				FROM " . $wpdb->prefix . "optibehavior_events
				WHERE event IN (16, 17)"
			);
		}

		// Clicks growth (this week vs all time)
		if ( $use_file_storage ) {
			// Get this week's clicks from file storage
			$this_week_clicks = $file_storage->get_total_clicks( $week_ago, gmdate('Y-m-d') );
		} else {
			// Get this week's clicks from database
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			$this_week_clicks = $wpdb->get_var( $wpdb->prepare(
				"SELECT COUNT(*)
				FROM " . $wpdb->prefix . "optibehavior_events
				WHERE event IN (16, 17)
				AND insert_at >= %s",
				$week_ago
			));
		}

		// Calculate: (this week clicks / all-time clicks) * 100
		// Shows what % of all-time clicks happened this week
		// Example: 50 this week / 200 all-time = 25% (meaning 25% of all clicks are from this week)
		$clicks_growth = $total_clicks > 0 ?
			round(($this_week_clicks / $total_clicks) * 100) : 0;

		// Mobile percentage - Calculate from sessions/recordings data (more accurate than events)
		// Uses the same data source as the mobile session counts shown in the heatmap table

		if ( $use_file_storage ) {
			// File storage mode - calculate mobile percentage from recording session counts
			// Use batch_recording_counts_by_device which reads device info from JSON filenames
			$pages_data = $file_storage->get_pages_with_heatmap_data();
			$page_ids = array_map( function( $page ) { return $page['page_id']; }, $pages_data );

			if ( ! empty( $page_ids ) ) {
				// Get desktop and mobile session counts from file storage (reads JSON files with device info)
				$desktop_map = $this->batch_recording_counts_by_device( $page_ids, 'desktop' );
				$mobile_map = $this->batch_recording_counts_by_device( $page_ids, 'mobile' );

				$total_desktop = array_sum( $desktop_map );
				$total_mobile = array_sum( $mobile_map );
				$total_sessions = $total_desktop + $total_mobile;

				$mobile_percentage = $total_sessions > 0 ? round( ( $total_mobile / $total_sessions ) * 100 ) : 0;
			} else {
				$mobile_percentage = 0;
			}
		} else {
			// Database mode - calculate mobile percentage from sessions data
			// This uses the same data source as the mobile button counts in the heatmap table
			$recordings_table = $wpdb->prefix . 'optibehavior_recordings';
			$sessions_table = $wpdb->prefix . 'optibehavior_sessions';
			$visitors_table = $wpdb->prefix . 'optibehavior_visitors';

			// Check if Pro tables exist
			$has_pro_tables = function_exists('opti_behavior_pro_active') && opti_behavior_pro_active();

			if ( $has_pro_tables ) {
				// Count mobile sessions using device_type from visitors table (via sessions join)
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix
				// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
				$mobile_sessions = $wpdb->get_var(
					"SELECT COUNT(DISTINCT r.session_id)
					FROM " . $recordings_table . " r
					INNER JOIN " . $sessions_table . " s ON r.session_id = s.id
					INNER JOIN " . $visitors_table . " v ON s.visitor_id = v.id
					WHERE LOWER(TRIM(v.device_type)) IN ('mobile', 'phone', 'smartphone', 'tablet', 'ipad')"
				);

				// Count total sessions
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix
				// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
				$total_sessions = $wpdb->get_var(
					"SELECT COUNT(DISTINCT r.session_id)
					FROM " . $recordings_table . " r"
				);

				$mobile_percentage = $total_sessions > 0 ? round( ( $mobile_sessions / $total_sessions ) * 100 ) : 0;
			} else {
				// Fallback to events-based calculation for Free version
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix
				$mobile_pages = $wpdb->get_var(
					"SELECT COUNT(DISTINCT page_id2)
					FROM " . $wpdb->prefix . "optibehavior_events
					WHERE event IN (17, 33, 49)"
				);

				// Total pages with any heatmap events
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix
				$total_pages = $wpdb->get_var(
					"SELECT COUNT(DISTINCT page_id2)
					FROM " . $wpdb->prefix . "optibehavior_events
					WHERE event IN (16, 17, 32, 33, 48, 49)"
				);

				$mobile_percentage = $total_pages > 0 ? round( ( $mobile_pages / $total_pages ) * 100 ) : 0;
			}
		}

		// Mobile change - compare THIS WEEK vs ALL TIME mobile session percentage
		// This shows how this week's mobile traffic compares to the overall average
		// Calculate this week's mobile percentage
		if ( $use_file_storage ) {
			// For file storage mode, we use the same mobile percentage for "this week"
			// since batch_recording_counts_by_device doesn't support date filtering
			// This means mobile_change will be 0 for file storage mode
			$this_week_mobile_pct = $mobile_percentage;
		} else {
			// Database mode - calculate this week's mobile percentage from sessions
			if ( isset( $has_pro_tables ) && $has_pro_tables ) {
				// Count this week's mobile sessions using device_type from visitors table
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix
				$this_week_mobile_sessions = $wpdb->get_var( $wpdb->prepare(
					"SELECT COUNT(DISTINCT r.session_id)
					FROM " . $recordings_table . " r
					INNER JOIN " . $sessions_table . " s ON r.session_id = s.id
					INNER JOIN " . $visitors_table . " v ON s.visitor_id = v.id
					WHERE LOWER(TRIM(v.device_type)) IN ('mobile', 'phone', 'smartphone', 'tablet', 'ipad')
					AND r.start_time >= %s",
					$week_ago
				));

				// Count this week's total sessions
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix
				$this_week_total_sessions = $wpdb->get_var( $wpdb->prepare(
					"SELECT COUNT(DISTINCT r.session_id)
					FROM " . $recordings_table . " r
					WHERE r.start_time >= %s",
					$week_ago
				));

				$this_week_mobile_pct = $this_week_total_sessions > 0 ? round( ( $this_week_mobile_sessions / $this_week_total_sessions ) * 100 ) : 0;
			} else {
				// Fallback to events-based calculation for Free version
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
				$this_week_mobile_pages = $wpdb->get_var( $wpdb->prepare(
					"SELECT COUNT(DISTINCT page_id2)
					FROM " . $wpdb->prefix . "optibehavior_events
					WHERE event IN (17, 33, 49)
					AND insert_at >= %s",
					$week_ago
				));

				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
				$this_week_total_pages = $wpdb->get_var( $wpdb->prepare(
					"SELECT COUNT(DISTINCT page_id2)
					FROM " . $wpdb->prefix . "optibehavior_events
					WHERE event IN (16, 17, 32, 33, 48, 49)
					AND insert_at >= %s",
					$week_ago
				));

				$this_week_mobile_pct = $this_week_total_pages > 0 ? round( ( $this_week_mobile_pages / $this_week_total_pages ) * 100 ) : 0;
			}
		}

		// Calculate the change: this week's mobile % compared to all-time mobile %
		// Positive means this week has MORE mobile traffic than overall average
		// Negative means this week has LESS mobile traffic than overall average
		$mobile_change = $this_week_mobile_pct - $mobile_percentage;

		// Average time on page - calculate total duration divided by total page views
		// This gives the true average time spent per page view across all sessions
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		$avg_time = $wpdb->get_var(
			"SELECT ROUND(SUM(duration) / SUM(page_views))
			FROM " . $wpdb->prefix . "optibehavior_sessions
			WHERE duration > 0 AND page_views > 0"
		);
		$avg_time = $avg_time ? intval($avg_time) : 0;

		// Time improvement (this week vs all time average)
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		$this_week_avg_time = $wpdb->get_var( $wpdb->prepare(
			"SELECT ROUND(SUM(duration) / SUM(page_views))
			FROM " . $wpdb->prefix . "optibehavior_sessions
			WHERE duration > 0 AND page_views > 0
			AND start_time >= %s",
			$week_ago
		));

		// Compare this week's avg time to all-time avg time
		$time_improvement = ($avg_time > 0 && $this_week_avg_time > 0) ?
			round((($this_week_avg_time - $avg_time) / $avg_time) * 100) : 0;

		// Hottest page (page with most clicks)
		if ( $use_file_storage ) {
			// Get hottest page from file storage
			$pages_data = $file_storage->get_pages_with_heatmap_data();

			// Find page with most clicks
			$hottest_page_id = null;
			$max_clicks = 0;

			foreach ( $pages_data as $page_data ) {
				$page_clicks = $page_data['click_pc'] + $page_data['click_mobile'];
				if ( $page_clicks > $max_clicks ) {
					$max_clicks = $page_clicks;
					$hottest_page_id = $page_data['page_id'];
				}
			}

			// Get page title from database
			if ( $hottest_page_id ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
				$hottest_page = $wpdb->get_row( $wpdb->prepare(
					"SELECT title, url
					FROM " . $wpdb->prefix . "optibehavior_pages
					WHERE id = %d",
					$hottest_page_id
				) );

				$hottest_page_clicks = $max_clicks;
				$hottest_page_name = $hottest_page ? $hottest_page->title : 'No data';
			} else {
				$hottest_page_clicks = 0;
				$hottest_page_name = 'No data';
			}
		} else {
			// Get hottest page from database
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			$hottest_page = $wpdb->get_row(
				"SELECT p.title, p.url, COUNT(*) as clicks
				FROM " . $wpdb->prefix . "optibehavior_events e
				LEFT JOIN " . $wpdb->prefix . "optibehavior_pages p ON e.page_id2 = p.id
				WHERE e.event IN (16, 17)
				GROUP BY e.page_id2
				ORDER BY clicks DESC
				LIMIT 1"
			);

			$hottest_page_clicks = $hottest_page ? $hottest_page->clicks : 0;
			$hottest_page_name = $hottest_page ? $hottest_page->title : 'No data';
		}

		// Click-through Rate (CTR) - all time
		// Correct semantics: % of pageviews that resulted in at least one click.
		//   numerator   = number of unique (session, page) pairs that recorded
		//                 a click event (event IN 16,17).
		//   denominator = total pageview rows.
		//
		// The earlier formula divided TOTAL click events by pageviews, which
		// produced nonsensical values (e.g. 1166.7 %) whenever the average
		// clicks-per-pageview exceeded 1. CTR is by definition a ratio in
		// [0 %, 100 %]; the new formula guarantees that.

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		$pageviews_with_click = (int) $wpdb->get_var(
			"SELECT COUNT(DISTINCT CONCAT_WS('|', session_id, page_id2))
			FROM " . $wpdb->prefix . "optibehavior_events
			WHERE event IN (16, 17)"
		);

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		$total_pageviews = (int) $wpdb->get_var(
			"SELECT COUNT(*)
			FROM " . $wpdb->prefix . "optibehavior_pageviews"
		);

		$conversion_rate = $total_pageviews > 0 ?
			min( 100.0, round( ( $pageviews_with_click / $total_pageviews ) * 100, 1 ) ) : 0;

		// CTR improvement (this week vs all time) — same reconciled semantics:
		// numerator is unique (session, page) pairs with clicks in the window,
		// denominator is pageview rows in the window. Result always ≤ 100 %.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		$this_week_pageviews_with_click = (int) $wpdb->get_var( $wpdb->prepare(
			"SELECT COUNT(DISTINCT CONCAT_WS('|', session_id, page_id2))
			FROM " . $wpdb->prefix . "optibehavior_events
			WHERE event IN (16, 17)
			AND insert_at >= %s",
			$week_ago
		));

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		$this_week_views = (int) $wpdb->get_var( $wpdb->prepare(
			"SELECT COUNT(*)
			FROM " . $wpdb->prefix . "optibehavior_pageviews
			WHERE view_time >= %s",
			$week_ago
		));

		$this_week_ctr = $this_week_views > 0 ?
			min( 100.0, round( ( $this_week_pageviews_with_click / $this_week_views ) * 100, 1 ) ) : 0;

		// Compare this week's CTR to all-time CTR
		$ctr_improvement = $conversion_rate > 0 ?
			round((($this_week_ctr - $conversion_rate) / $conversion_rate) * 100) : 0;

		return array(
			'total_heatmaps' => intval($total_heatmaps),
			'heatmaps_growth' => intval($heatmaps_growth),
			'total_clicks' => intval($total_clicks),
			'clicks_growth' => intval($clicks_growth),
			'mobile_percentage' => intval($mobile_percentage),
			'mobile_change' => intval($mobile_change),
			'avg_time' => intval($avg_time),
			'time_improvement' => intval($time_improvement),
			'hottest_page_clicks' => intval($hottest_page_clicks),
			'hottest_page_name' => $hottest_page_name,
			'conversion_rate' => floatval($conversion_rate),
			'ctr_improvement' => intval($ctr_improvement)
		);
	}

	/**
	 * Get enhanced heatmap data from database or file storage
	 */
	private function get_enhanced_heatmap_data() {
		global $wpdb;

		// Check storage mode
		$file_storage = $this->heatmap->get_file_storage();
		$storage_settings = $file_storage ? $file_storage->get_settings() : array( 'storage_mode' => 'database' );

		if ( $storage_settings['storage_mode'] === 'file' && $file_storage ) {
			// Get pages with heatmap data from file storage
			$pages_data = $file_storage->get_pages_with_heatmap_data();

			// Convert to objects for compatibility with database results
			$results = array();
			foreach ( $pages_data as $page_data ) {

				// Get page info from database
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
				$page_info = $wpdb->get_row( $wpdb->prepare(
					"SELECT id, title, url, update_at
					FROM " . $wpdb->prefix . "optibehavior_pages
					WHERE id = %d",
					$page_data['page_id']
				) );

				if ( $page_info ) {
					$obj = new stdClass();
					$obj->id = $page_info->id;
					$obj->title = $page_info->title;
					$obj->url = $page_info->url;
					$obj->update_at = $page_info->update_at;
					$obj->click_pc = $page_data['click_pc'];
					$obj->click_mobile = $page_data['click_mobile'];
					$obj->breakaway_pc = $page_data['breakaway_pc'];
					$obj->breakaway_mobile = $page_data['breakaway_mobile'];
					$obj->attention_pc = $page_data['attention_pc'];
					$obj->attention_mobile = $page_data['attention_mobile'];
					$obj->last_event_time = $page_data['last_event_time'];
					$results[] = $obj;
				}
			}

			// Sort by last event time descending
			usort( $results, function( $a, $b ) {
				return strtotime( $b->last_event_time ) - strtotime( $a->last_event_time );
			});
		} else {
			// Get pages with heatmap data from database
			$query = "SELECT
				p.id,
				p.title,
				p.url,
				p.update_at,
				COUNT(CASE WHEN e.event = 16 THEN 1 END) as click_pc,
				COUNT(CASE WHEN e.event = 17 THEN 1 END) as click_mobile,
				COUNT(CASE WHEN e.event = 32 THEN 1 END) as breakaway_pc,
				COUNT(CASE WHEN e.event = 33 THEN 1 END) as breakaway_mobile,
				COUNT(CASE WHEN e.event = 48 THEN 1 END) as attention_pc,
				COUNT(CASE WHEN e.event = 49 THEN 1 END) as attention_mobile,
				MAX(e.insert_at) as last_event_time
			FROM " . $wpdb->prefix . "optibehavior_pages p
			LEFT JOIN " . $wpdb->prefix . "optibehavior_events e ON p.id = e.page_id2
			WHERE e.event IN (16, 17, 32, 33, 48, 49)
			GROUP BY p.id
			HAVING (click_pc + click_mobile + breakaway_pc + breakaway_mobile + attention_pc + attention_mobile) > 0
			ORDER BY last_event_time DESC";

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Static query with no user input, no dynamic parameters
			$results = $wpdb->get_results( $query );
		}

		$heatmaps = array();
		$thumbnail_placeholder = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjEyMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMGYwIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPkhFQVRNQVA8L3RleHQ+PC9zdmc+';

		foreach ($results as $row) {
			// Get session count for this page URL using pageviews table
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			$session_count = $wpdb->get_var($wpdb->prepare(
				"SELECT COUNT(DISTINCT pv.session_id)
				FROM " . $wpdb->prefix . "optibehavior_pageviews pv
				WHERE pv.url = %s OR pv.url LIKE %s",
				$row->url,
				$row->url . '%'
			));

			// If no pageviews data, try sessions table with entry_page/exit_page
			if (!$session_count) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
				$session_count = $wpdb->get_var($wpdb->prepare(
					"SELECT COUNT(DISTINCT id)
					FROM " . $wpdb->prefix . "optibehavior_sessions
					WHERE entry_page = %s OR exit_page = %s OR entry_page LIKE %s OR exit_page LIKE %s",
					$row->url,
					$row->url,
					$row->url . '%',
					$row->url . '%'
				));
			}

			$session_count = $session_count ? intval($session_count) : 0;
			// Calculate total interactions
			$total_interactions = $row->click_pc + $row->click_mobile +
								$row->breakaway_pc + $row->breakaway_mobile +
								$row->attention_pc + $row->attention_mobile;

			// Determine primary heatmap type based on highest count
			$type = 'click';
			$type_icon = '👆';
			$max_count = max($row->click_pc + $row->click_mobile,
							$row->breakaway_pc + $row->breakaway_mobile,
							$row->attention_pc + $row->attention_mobile);

			if ($max_count == ($row->breakaway_pc + $row->breakaway_mobile)) {
				$type = 'scroll';
				$type_icon = '📜';
			} elseif ($max_count == ($row->attention_pc + $row->attention_mobile)) {
				$type = 'attention';
				$type_icon = '👁️';
			}

			// Calculate mobile percentage
			$mobile_interactions = $row->click_mobile + $row->breakaway_mobile + $row->attention_mobile;
			$mobile_percentage = $total_interactions > 0 ?
				round(($mobile_interactions / $total_interactions) * 100) : 0;

			// Calculate time since last update
			$last_updated = $this->time_elapsed_string($row->last_event_time);

			// Determine status based on recent activity
			$hours_since_update = (time() - strtotime($row->last_event_time)) / 3600;
			$status = 'active';
			$status_icon = '🟢';

			if ($hours_since_update > 168) { // 1 week
				$status = 'inactive';
				$status_icon = '🔴';
			} elseif ($hours_since_update > 24) { // 1 day
				$status = 'processing';
				$status_icon = '🟡';
			}

			// Generate proper heatmap view URL based on actual data
			$event_name = 'click_pc'; // Default to PC clicks
			$has_mixed_devices = false;

			// Determine the correct event type and check for mixed device data
			if ($type === 'click') {
				$has_pc = $row->click_pc > 0;
				$has_mobile = $row->click_mobile > 0;
				$has_mixed_devices = $has_pc && $has_mobile;

				if ($has_mobile && !$has_pc) {
					$event_name = 'click_mobile';
				} else {
					// Default to PC view for mixed devices or PC-only
					$event_name = 'click_pc';
				}
			} elseif ($type === 'scroll') {
				$has_pc = $row->breakaway_pc > 0;
				$has_mobile = $row->breakaway_mobile > 0;
				$has_mixed_devices = $has_pc && $has_mobile;

				if ($has_mobile && !$has_pc) {
					$event_name = 'breakaway_mobile';
				} else {
					// Default to PC view for mixed devices or PC-only
					$event_name = 'breakaway_pc';
				}
			} elseif ($type === 'attention') {
				$has_pc = $row->attention_pc > 0;
				$has_mobile = $row->attention_mobile > 0;
				$has_mixed_devices = $has_pc && $has_mobile;

				if ($has_mobile && !$has_pc) {
					$event_name = 'attention_mobile';
				} else {
					// Default to PC view for mixed devices or PC-only
					$event_name = 'attention_pc';
				}
			}

			// Create proper heatmap URL that opens the page with overlay
			$page_url = $row->url;
			$param = (false === strpos($page_url, '?')) ? '?' : '&';
			$param .= 'opti-behavior=' . $event_name . '-' . $row->id;

			// Insert query string
			if (false === strpos($page_url, '#')) {
				$view_url = $page_url . $param;
			} else {
				$s = explode('#', $page_url, 2);
				$view_url = $s[0] . $param . '#' . $s[1];
			}

			// Create PC-only and Mobile-only URLs
			$pc_param = (false === strpos($page_url, '?')) ? '?' : '&';
			$pc_param .= 'opti-behavior=' . $type . '_pc-' . $row->id;

			// Mobile URLs include viewport simulation parameters for accurate display
			$mobile_param = (false === strpos($page_url, '?')) ? '?' : '&';
			$mobile_param .= 'opti-behavior=' . $type . '_mobile-' . $row->id . '&mobile_view=1&vw=375&vh=667';

			// Generate PC and Mobile URLs
			if (false === strpos($page_url, '#')) {
				$view_pc_url = $page_url . $pc_param;
				$view_mobile_url = $page_url . $mobile_param;
			} else {
				$s = explode('#', $page_url, 2);
				$view_pc_url = $s[0] . $pc_param . '#' . $s[1];
				$view_mobile_url = $s[0] . $mobile_param . '#' . $s[1];
			}

			// Clean up title and URL for display
			$title = $row->title ? $row->title : 'Untitled Page';
			$url = $row->url;

			// Truncate long titles
			if (strlen($title) > 50) {
				$title = substr($title, 0, 47) . '...';
			}

			$heatmaps[] = array(
				'id' => $row->id,
				'title' => $title,
				'url' => $url,
				'type' => $type,
				'type_icon' => $type_icon,
				'interactions' => $total_interactions,
				'sessions' => $session_count,
				'device_split' => $mobile_percentage . '%',
					'mobile_percentage' => intval($mobile_percentage),

			);
			// close previous function properly
		}

			// Return compiled rows for dashboard
			return $heatmaps;
		}



		/**
		 * Optimized paginated fetch for heatmap rows
		 * Returns array( $rows, $total ) where $rows are already mapped to UI fields
		 */
		private function get_heatmap_rows_paged($orderby, $order, $page, $per_page, $start_date = null, $end_date = null, $search = '') {
			global $wpdb;

			// Check storage mode
			$file_storage = $this->heatmap->get_file_storage();
			$storage_settings = $file_storage ? $file_storage->get_settings() : array( 'storage_mode' => 'database' );

			if ( $storage_settings['storage_mode'] === 'file' && $file_storage ) {
				// Use file storage implementation
				return $this->get_heatmap_rows_paged_from_files( $orderby, $order, $page, $per_page, $start_date, $end_date, $search );
			}
			// Database storage implementation (original code)
			$order = strtolower($order) === 'asc' ? 'ASC' : 'DESC';
			$offset = max(0, ($page - 1) * $per_page);
			$allowed_map = array(
				'interactions' => 'interactions',
				'sessions'     => 'sessions',
				'last_updated' => 'last_event_time'
			);
			$order_by_sql = isset($allowed_map[$orderby]) ? $allowed_map[$orderby] : 'last_event_time';

			// Validate offset and per_page are integers
			$offset = absint($offset);
			$per_page = absint($per_page);

			// Build search filter — matches page title OR URL so users can find
			// a page by either (the search input placeholder "Search heatmaps..."
			// implies both; previously URL-only searches returned zero rows).
			$search_filter = '';
			$search_params = array();
			if (!empty($search)) {
				$search_like = '%' . $wpdb->esc_like($search) . '%';
				$search_filter = " AND (p.title LIKE %s OR p.url LIKE %s)";
				$search_params[] = $search_like;
				$search_params[] = $search_like;
			}

			// Total distinct pages with heatmap events (fast count) with optional date range and search
			if (!empty($search)) {
				// With search, need to join pages table
				if ($start_date && $end_date) {
					$count_params = array_merge(array($start_date, $end_date), $search_params);
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
					$total = (int) $wpdb->get_var( $wpdb->prepare(
						"SELECT COUNT(DISTINCT e.page_id2) FROM " . $wpdb->prefix . "optibehavior_events e INNER JOIN " . $wpdb->prefix . "optibehavior_pages p ON e.page_id2 = p.id WHERE e.event IN (16,17,32,33,48,49) AND e.insert_at BETWEEN %s AND %s AND (p.title LIKE %s OR p.url LIKE %s)",
						$count_params
					));
				} else {
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
					$total = (int) $wpdb->get_var( $wpdb->prepare(
						"SELECT COUNT(DISTINCT e.page_id2) FROM " . $wpdb->prefix . "optibehavior_events e INNER JOIN " . $wpdb->prefix . "optibehavior_pages p ON e.page_id2 = p.id WHERE e.event IN (16,17,32,33,48,49) AND (p.title LIKE %s OR p.url LIKE %s)",
						$search_params
					));
				}
			} else {
				// Without search, original query
				if ($start_date && $end_date) {
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
					$total = (int) $wpdb->get_var( $wpdb->prepare(
						"SELECT COUNT(DISTINCT page_id2) FROM " . $wpdb->prefix . "optibehavior_events e WHERE e.event IN (16,17,32,33,48,49) AND e.insert_at BETWEEN %s AND %s",
						$start_date, $end_date
					));
				} else {
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
					$total = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT page_id2) FROM " . $wpdb->prefix . "optibehavior_events WHERE event IN (16,17,32,33,48,49)" );
				}
			}
			if ($total === 0) { return array(array(), 0); }

				// Disabled preselect path; use unified aggregated query for all orderings
				if (false && $order_by_sql === 'last_event_time') {
					// Build query safely with ORDER validated above
					$events_table = $wpdb->prefix . 'optibehavior_events';
					$id_params = array();

					// Build base query
					$ids_sql_base = "SELECT e.page_id2 AS id, MAX(e.insert_at) AS last_event_time FROM " . $events_table . " e WHERE e.event IN (16,17,32,33,48,49)";

					// Add date filter if needed
					$date_clause = '';
					if ($start_date && $end_date) {
						$date_clause = " AND e.insert_at BETWEEN %s AND %s";
						$id_params[] = $start_date;
						$id_params[] = $end_date;
					}

					// Complete query with ORDER BY clause (already validated)
					$ids_sql = $ids_sql_base . $date_clause . " GROUP BY e.page_id2 ORDER BY last_event_time " . $order . " LIMIT %d OFFSET %d";
					$id_params[] = $per_page;
					$id_params[] = $offset;

					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is prefixed safely, ORDER is validated, all user inputs are properly parameterized
					$ids = $wpdb->get_results( $wpdb->prepare($ids_sql, $id_params) );
					if (!$ids) { return array(array(), $total); }
					$selected_ids = array_map(function($o){ return (int)$o->id; }, $ids);
					$placeholders = implode(',', array_fill(0, count($selected_ids), '%d'));

					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic SQL with proper prepare
					$pages_map = array(); $p_sql = "SELECT id, url, title FROM " . $wpdb->prefix . "optibehavior_pages WHERE id IN ($placeholders)"; $p_rows = $wpdb->get_results( $wpdb->prepare($p_sql, $selected_ids) ); foreach ($p_rows as $p) { $pages_map[(int)$p->id] = $p; }

					// Build aggregation query
					$agg_params = $selected_ids;
					$a_sql_base = "SELECT e.page_id2 AS id, SUM(CASE WHEN e.event = 16 THEN 1 ELSE 0 END) AS click_pc, SUM(CASE WHEN e.event = 17 THEN 1 ELSE 0 END) AS click_mobile, SUM(CASE WHEN e.event = 32 THEN 1 ELSE 0 END) AS breakaway_pc, SUM(CASE WHEN e.event = 33 THEN 1 ELSE 0 END) AS breakaway_mobile, SUM(CASE WHEN e.event = 48 THEN 1 ELSE 0 END) AS attention_pc, SUM(CASE WHEN e.event = 49 THEN 1 ELSE 0 END) AS attention_mobile FROM " . $events_table . " e WHERE e.event IN (16,17,32,33,48,49) AND e.page_id2 IN ($placeholders)";

					// Add date filter if needed
					$a_date_clause = '';
					if ($start_date && $end_date) {
						$a_date_clause = " AND e.insert_at BETWEEN %s AND %s";
						$agg_params[] = $start_date;
						$agg_params[] = $end_date;
					}

					// Complete aggregation query
					$a_sql = $a_sql_base . $a_date_clause . " GROUP BY e.page_id2";
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query built with table name variable and placeholders, all user inputs properly parameterized
					$agg_rows = $wpdb->get_results( $wpdb->prepare($a_sql, $agg_params) );
					$agg_map = array();
					foreach ($agg_rows as $r) {
						$agg_map[(int)$r->id] = $r;
					}

					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic SQL with proper prepare
					$results = array(); foreach ($ids as $o) { $id = (int)$o->id; $page = isset($pages_map[$id]) ? $pages_map[$id] : null; $a = isset($agg_map[$id]) ? $agg_map[$id] : null; if (!$page || !$a) { continue; } $session_sql = "SELECT COUNT(DISTINCT pv.session_id) FROM " . $wpdb->prefix . "optibehavior_pageviews pv WHERE pv.url LIKE CONCAT(%s, '%%')" . ($start_date && $end_date ? " AND pv.view_time BETWEEN %s AND %s" : ""); $s_params = array($page->url); if ($start_date && $end_date) { $s_params[] = $start_date; $s_params[] = $end_date; } $sessions = (int)$wpdb->get_var( $wpdb->prepare($session_sql, $s_params) ); $results[] = (object) array('id'=>$id,'title'=>$page->title,'url'=>$page->url,'click_pc'=>(int)$a->click_pc,'click_mobile'=>(int)$a->click_mobile,'breakaway_pc'=>(int)$a->breakaway_pc,'breakaway_mobile'=>(int)$a->breakaway_mobile,'attention_pc'=>(int)$a->attention_pc,'attention_mobile'=>(int)$a->attention_mobile,'last_event_time'=>$o->last_event_time,'sessions'=>$sessions); }

					// Normalize to associative rows expected by the renderer
					$rows = array();
					$thumbnail_placeholder = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjEyMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMGYwIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPkhFQVRNQVA8L3RleHQ+PC9zdmc+';
					// Build base rows first; sessions will be updated via lazy AJAX after render
				foreach ($results as $row) {
						$click_pc = (int)$row->click_pc; $click_mobile = (int)$row->click_mobile;
						$break_pc = (int)$row->breakaway_pc; $break_mobile = (int)$row->breakaway_mobile;
						$att_pc = (int)$row->attention_pc; $att_mobile = (int)$row->attention_mobile;
						$total_interactions = $click_pc + $click_mobile + $break_pc + $break_mobile + $att_pc + $att_mobile;
						$type = 'click';
						$max_bucket = max($click_pc + $click_mobile, $break_pc + $break_mobile, $att_pc + $att_mobile);
						if ($max_bucket === ($break_pc + $break_mobile)) { $type='scroll'; }
						elseif ($max_bucket === ($att_pc + $att_mobile)) { $type='attention'; }
						$mobile_interactions = $click_mobile + $break_mobile + $att_mobile;
						$mobile_percentage = $total_interactions > 0 ? round(($mobile_interactions / $total_interactions) * 100) : 0;
						$last_updated = $this->time_elapsed_string($row->last_event_time);
						$page_url = $row->url; $id = (int)$row->id;
						$pc_url         = admin_url( 'admin.php?page=opti-behavior-heatmap-detail&page_id=' . $id . '&type=' . $type . '&device=desktop' );
						$view_url       = $pc_url; // Use admin heatmap detail URL
						$mobile_url     = admin_url( 'admin.php?page=opti-behavior-heatmap-detail&page_id=' . $id . '&type=' . $type . '&device=mobile' );
						$rows[] = array(
							'id' => $id,
							'title' => $row->title ?: 'Untitled Page',
							'url' => $row->url,
							'interactions' => (int)$total_interactions,
							'sessions' => isset($row->sessions) ? (int)$row->sessions : 0,
							'mobile_percentage' => (int)$mobile_percentage,
							'last_updated' => $last_updated,
							'views_total' => (int)($click_pc + $click_mobile),
							'views_pc' => (int)$click_pc,
							'views_mobile' => (int)$click_mobile,
							'last_event_time' => (int)strtotime($row->last_event_time),
							'view_url' => $view_url,
							'view_pc_url' => $pc_url,
							'view_mobile_url' => $mobile_url,
						// Session counts by device type (not available in this query path)
						'sessions_desktop' => 0,
						'sessions_mobile' => 0,
							'thumbnail' => $thumbnail_placeholder,
						);
					}
					return array($rows, $total);


						// Note: Do not return early; normalize to associative array rows below
					}

				// Fallback path for other orderings



			// else branch begins
				$pages_table = $wpdb->prefix . 'optibehavior_pages';
				$events_table = $wpdb->prefix . 'optibehavior_events';
				$pageviews_table = $wpdb->prefix . 'optibehavior_pageviews';

				// Check if Pro version tables exist (for seamless Free/Pro compatibility)
				$recordings_table = $wpdb->prefix . 'optibehavior_recordings';
				$sessions_table = $wpdb->prefix . 'optibehavior_sessions';
				$visitors_table = $wpdb->prefix . 'optibehavior_visitors';
				$session_pages_table = $wpdb->prefix . 'optibehavior_session_pages';

				// Check if Pro plugin is active (not just if table exists)
				$has_pro_tables = function_exists('opti_behavior_pro_active') && opti_behavior_pro_active();

				// Aggregated current slice with sessions via subquery (leverages url index)
				$date_filter_events = ($start_date && $end_date) ? " AND e.insert_at BETWEEN %s AND %s" : '';
				$date_filter_pv     = ($start_date && $end_date) ? " WHERE pv.view_time BETWEEN %s AND %s" : '';
				$date_filter_events_sub = ($start_date && $end_date) ? " AND e2.insert_at BETWEEN '" . $start_date . "' AND '" . $end_date . "'" : '';

				// Count sessions by device from events table (works for both Free and Pro)
				// Desktop events: 16 (click_pc), 32 (breakaway_pc), 48 (attention_pc)
				// Mobile events: 17 (click_mobile), 33 (breakaway_mobile), 49 (attention_mobile)
				$select_sessions = ", COALESCE(sess.total_sessions,0) AS sessions, COALESCE(sess.desktop_sessions,0) AS desktop_sessions, COALESCE(sess.mobile_sessions,0) AS mobile_sessions";

				$join_sessions = " LEFT JOIN (
					SELECT e2.page_id2 as page_id,
						COUNT(DISTINCT e2.session_id) AS total_sessions,
						COUNT(DISTINCT CASE WHEN e2.event IN (16,32,48) THEN e2.session_id END) AS desktop_sessions,
						COUNT(DISTINCT CASE WHEN e2.event IN (17,33,49) THEN e2.session_id END) AS mobile_sessions
					FROM " . $events_table . " e2
					WHERE e2.event IN (16,17,32,33,48,49)" . $date_filter_events_sub . "
					GROUP BY e2.page_id2
				) sess ON sess.page_id = p.id";

				$all_joins = $join_sessions;

				$sql =
				"SELECT p.id, p.title, p.url,
					SUM(CASE WHEN e.event = 16 THEN 1 ELSE 0 END) AS click_pc,
					SUM(CASE WHEN e.event = 17 THEN 1 ELSE 0 END) AS click_mobile,
					SUM(CASE WHEN e.event = 32 THEN 1 ELSE 0 END) AS breakaway_pc,
					SUM(CASE WHEN e.event = 33 THEN 1 ELSE 0 END) AS breakaway_mobile,
					SUM(CASE WHEN e.event = 48 THEN 1 ELSE 0 END) AS attention_pc,
					SUM(CASE WHEN e.event = 49 THEN 1 ELSE 0 END) AS attention_mobile,
					SUM(CASE WHEN e.event IN (16,17,32,33,48,49) THEN 1 ELSE 0 END) AS interactions,
					MAX(e.insert_at) AS last_event_time" . $select_sessions . "
				FROM " . $pages_table . " p
				JOIN " . $events_table . " e ON p.id = e.page_id2 AND e.event IN (16,17,32,33,48,49)" . $date_filter_events . $search_filter . $all_joins . "
				GROUP BY p.id
				HAVING interactions > 0
				ORDER BY " . $order_by_sql . " " . $order . "
				LIMIT %d OFFSET %d";
			$params = array();
			if ($start_date && $end_date) { $params[] = $start_date; $params[] = $end_date; }
			// Add search params if searching
			if (!empty($search)) { $params = array_merge($params, $search_params); }
			// Note: sessions subquery uses hardcoded dates, no extra placeholders needed
			$params[] = $per_page; $params[] = $offset;
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names are prefixed safely, ORDER BY/direction are validated, all user inputs properly parameterized
			$results = $wpdb->get_results( $wpdb->prepare($sql, $params) );

			$rows = array();
			$thumbnail_placeholder = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjEyMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMGYwIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPkhFQVRNQVA8L3RleHQ+PC9zdmc+';
			foreach ($results as $row) {
					$click_pc = (int)$row->click_pc; $click_mobile = (int)$row->click_mobile;
					$break_pc = (int)$row->breakaway_pc; $break_mobile = (int)$row->breakaway_mobile;
					$att_pc = (int)$row->attention_pc; $att_mobile = (int)$row->attention_mobile;
					$total_interactions = $click_pc + $click_mobile + $break_pc + $break_mobile + $att_pc + $att_mobile;
					$type = 'click'; $max_bucket = max($click_pc + $click_mobile, $break_pc + $break_mobile, $att_pc + $att_mobile);
					if ($max_bucket === ($break_pc + $break_mobile)) { $type='scroll'; }
					elseif ($max_bucket === ($att_pc + $att_mobile)) { $type='attention'; }
					$last_updated = $this->time_elapsed_string($row->last_event_time);
					$page_url = $row->url; $id = (int)$row->id;
					$pc_url         = admin_url( 'admin.php?page=opti-behavior-heatmap-detail&page_id=' . $id . '&type=' . $type . '&device=desktop' );
					$view_url       = $pc_url; // Use admin heatmap detail URL
					$mobile_url     = admin_url( 'admin.php?page=opti-behavior-heatmap-detail&page_id=' . $id . '&type=' . $type . '&device=mobile' );

					// Get session counts by device from query results
					$total_sessions = isset($row->sessions) ? (int)$row->sessions : 0;
					$desktop_sessions = isset($row->desktop_sessions) ? (int)$row->desktop_sessions : 0;
					$mobile_sessions = isset($row->mobile_sessions) ? (int)$row->mobile_sessions : 0;

					// Calculate mobile percentage from sessions (for DEVICE SPLIT column)
					$mobile_percentage = $total_sessions > 0 ? round(($mobile_sessions / $total_sessions) * 100) : 0;

					$rows[] = array(
						'id' => $id,
						'title' => $row->title ?: 'Untitled Page',
						'url' => $row->url,
						'interactions' => (int)$total_interactions,
						'sessions' => $total_sessions,
						'mobile_percentage' => (int)$mobile_percentage,
						'last_updated' => $last_updated,
						'views_total' => $total_sessions,
						'views_pc' => $desktop_sessions,
						'views_mobile' => $mobile_sessions,
						'last_event_time' => (int)strtotime($row->last_event_time),
						'view_url' => $view_url,
						'view_pc_url' => $pc_url,
						'view_mobile_url' => $mobile_url,
						// Session counts by device type (for Desktop/Mobile buttons)
						'sessions_desktop' => $desktop_sessions,
						'sessions_mobile' => $mobile_sessions,
						'thumbnail' => $thumbnail_placeholder,
						// Pro breakaway/attention event counts (event 32/33/48/49)
						'breakaway_pc'     => (int)$break_pc,
						'breakaway_mobile' => (int)$break_mobile,
						'attention_pc'     => (int)$att_pc,
						'attention_mobile' => (int)$att_mobile,
					);
				}
				return array($rows, $total);

		}

		/**
		 * Get heatmap rows from file storage with pagination and sorting
		 *
		 * @param string $orderby Column to order by
		 * @param string $order ASC or DESC
		 * @param int $page Current page number
		 * @param int $per_page Items per page
		 * @param string|null $start_date Start date filter
		 * @param string|null $end_date End date filter
		 * @return array Array of [rows, total]
		 */
		private function get_heatmap_rows_paged_from_files($orderby, $order, $page, $per_page, $start_date = null, $end_date = null, $search = '') {
			global $wpdb;

			$file_storage = $this->heatmap->get_file_storage();
			if ( ! $file_storage ) {
				return array( array(), 0 );
			}

			// Get all pages with heatmap data from file storage
			$pages_data = $file_storage->get_pages_with_heatmap_data();

			if ( empty( $pages_data ) ) {
				return array( array(), 0 );
			}

			// Filter by date range if provided
			if ( $start_date && $end_date ) {
				$start_timestamp = strtotime( $start_date );
				$end_timestamp = strtotime( $end_date );
				$pages_data = array_filter( $pages_data, function( $page ) use ( $start_timestamp, $end_timestamp ) {
					if ( empty( $page['last_event_time'] ) ) {
						return false;
					}
					$event_time = strtotime( $page['last_event_time'] );
					return $event_time >= $start_timestamp && $event_time <= $end_timestamp;
				});
			}

			// Filter by search term if provided — matches title OR URL so
			// users can search by either (matches the placeholder "Search heatmaps...").
			if ( ! empty( $search ) ) {
				$pages_table = $wpdb->prefix . 'optibehavior_pages';
				$all_page_ids = array_map( function( $page ) {
					return $page['page_id'];
				}, $pages_data );

				if ( ! empty( $all_page_ids ) ) {
					$placeholders = implode( ',', array_fill( 0, count( $all_page_ids ), '%d' ) );
					$search_like = '%' . $wpdb->esc_like( $search ) . '%';

					// Build query to get page IDs that match the search term in title OR url.
					$query_string = "SELECT id FROM " . $pages_table . " WHERE id IN (" . $placeholders . ") AND (title LIKE %s OR url LIKE %s)";
					$query_params = array_merge( $all_page_ids, array( $search_like, $search_like ) );

					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query string built with table prefix and placeholders
					$matching_ids = $wpdb->get_col( $wpdb->prepare( $query_string, $query_params ) );
					$matching_ids = array_map( 'intval', $matching_ids );

					// Filter pages_data to only include matching pages
					$pages_data = array_filter( $pages_data, function( $page ) use ( $matching_ids ) {
						return in_array( (int) $page['page_id'], $matching_ids, true );
					});
					$pages_data = array_values( $pages_data ); // Re-index array
				}
			}

			$total = count( $pages_data );
			if ( $total === 0 ) {
				return array( array(), 0 );
			}

			// If sorting by sessions, we need to fetch sessions data first
			$sessions_map_for_sort = array();
			if ( $orderby === 'sessions' ) {
				// Get all page IDs
				$all_page_ids = array_map( function( $page ) {
					return $page['page_id'];
				}, $pages_data );

				// Use file-based device counts for sorting (same source as displayed Desktop/Mobile values).
				// This ensures sort order matches what users see in the Sessions column.
				$desktop_sort_map = $this->batch_recording_counts_by_device( $all_page_ids, 'desktop', $start_date, $end_date );
				$mobile_sort_map = $this->batch_recording_counts_by_device( $all_page_ids, 'mobile', $start_date, $end_date );

				// Add sessions data to pages_data array (Desktop + Mobile from files)
				foreach ( $pages_data as &$page_data ) {
					$pid = $page_data['page_id'];
					$page_data['sessions'] = ( isset( $desktop_sort_map[ $pid ] ) ? (int) $desktop_sort_map[ $pid ] : 0 )
						+ ( isset( $mobile_sort_map[ $pid ] ) ? (int) $mobile_sort_map[ $pid ] : 0 );
				}
				unset( $page_data ); // Break reference
			}

			// Sort based on orderby parameter
			$order_lower = strtolower( $order );
			$is_asc = ( $order_lower === 'asc' );

			usort( $pages_data, function( $a, $b ) use ( $orderby, $is_asc ) {
				$val_a = 0;
				$val_b = 0;

				switch ( $orderby ) {
					case 'interactions':
					// Sum all interaction types
					$val_a = ( $a['click_pc'] + $a['click_mobile'] + $a['breakaway_pc'] + $a['breakaway_mobile'] + $a['attention_pc'] + $a['attention_mobile'] );
					$val_b = ( $b['click_pc'] + $b['click_mobile'] + $b['breakaway_pc'] + $b['breakaway_mobile'] + $b['attention_pc'] + $b['attention_mobile'] );
					break;
				case 'sessions':
					$val_a = isset( $a['sessions'] ) ? (int)$a['sessions'] : 0;
					$val_b = isset( $b['sessions'] ) ? (int)$b['sessions'] : 0;
					break;
				case 'clicks':
						$val_a = ( $a['click_pc'] + $a['click_mobile'] );
						$val_b = ( $b['click_pc'] + $b['click_mobile'] );
						break;
					case 'last_updated':
					default:
						// Sort by last event time
						$val_a = ! empty( $a['last_event_time'] ) ? strtotime( $a['last_event_time'] ) : 0;
						$val_b = ! empty( $b['last_event_time'] ) ? strtotime( $b['last_event_time'] ) : 0;
						break;
				}

				if ( $val_a === $val_b ) {
					return 0;
				}

				if ( $is_asc ) {
					return ( $val_a < $val_b ) ? -1 : 1;
				} else {
					return ( $val_a > $val_b ) ? -1 : 1;
				}
			});

			// Implement pagination
			$offset = max( 0, ( $page - 1 ) * $per_page );
			$pages_data_paged = array_slice( $pages_data, $offset, $per_page );

			// Get page IDs for database queries
			$page_ids = array_map( function( $page ) {
				return $page['page_id'];
			}, $pages_data_paged );

			// Query database for page info (title, url)
			$pages_table = $wpdb->prefix . 'optibehavior_pages';
			$placeholders = implode( ',', array_fill( 0, count( $page_ids ), '%d' ) );

			// Build query string using sprintf to avoid interpolation warnings
			$query_string = "SELECT id, title, url FROM " . $pages_table . " WHERE id IN (" . $placeholders . ")";

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query string built with table prefix and placeholders, all IDs are properly parameterized
			$page_info_results = $wpdb->get_results( $wpdb->prepare( $query_string, $page_ids ), 'OBJECT_K' );

			// Check if events table exists (works in both free and pro versions)
			$events_table = $wpdb->prefix . 'optibehavior_events';
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
			$has_events_table = ( $wpdb->get_var( "SHOW TABLES LIKE '" . $events_table . "'" ) === $events_table );

			$desktop_recordings_map = array();
			$mobile_recordings_map = array();

			if ( $has_events_table ) {
				// Batch query for desktop session counts using events table
				$desktop_recordings_map = $this->batch_recording_counts_by_device( $page_ids, 'desktop', $start_date, $end_date );

				// Batch query for mobile session counts using events table
				$mobile_recordings_map = $this->batch_recording_counts_by_device( $page_ids, 'mobile', $start_date, $end_date );
			}

			// Format rows for the UI
			$rows = array();
			foreach ( $pages_data_paged as $page_data ) {
				$page_id = $page_data['page_id'];

				// Get page info from database
				if ( ! isset( $page_info_results[ $page_id ] ) ) {
					continue;
				}

				$page_info = $page_info_results[ $page_id ];
				$title = $page_info->title ? $page_info->title : 'Untitled Page';
				$url = $page_info->url;

				// Title truncation handled by CSS text-overflow: ellipsis
				// Full title preserved for hover tooltip via title attribute

				// Calculate totals
				$click_pc = intval( $page_data['click_pc'] );
				$click_mobile = intval( $page_data['click_mobile'] );
				$breakaway_pc = intval( $page_data['breakaway_pc'] );
				$breakaway_mobile = intval( $page_data['breakaway_mobile'] );
				$attention_pc = intval( $page_data['attention_pc'] );
				$attention_mobile = intval( $page_data['attention_mobile'] );

				$total_clicks = $click_pc + $click_mobile;
				$total_breakaway = $breakaway_pc + $breakaway_mobile;
				$total_attention = $attention_pc + $attention_mobile;

				// Determine primary type
				$max_count = max( $total_clicks, $total_breakaway, $total_attention );
				$type = 'click';
				$type_icon = '🖱️';

				if ( $max_count === $total_breakaway ) {
					$type = 'scroll';
					$type_icon = '📜';
				} elseif ( $max_count === $total_attention ) {
					$type = 'attention';
					$type_icon = '👁️';
				}

				// Calculate mobile percentage from recording session counts (more accurate)
				// Use desktop/mobile recording maps which are fetched from JSON files with device info
				$desktop_sessions_for_page = isset( $desktop_recordings_map[ $page_id ] ) ? (int) $desktop_recordings_map[ $page_id ] : 0;
				$mobile_sessions_for_page = isset( $mobile_recordings_map[ $page_id ] ) ? (int) $mobile_recordings_map[ $page_id ] : 0;
				$total_sessions_for_device = $desktop_sessions_for_page + $mobile_sessions_for_page;

				if ( $total_sessions_for_device > 0 ) {
					$mobile_percentage = round( ( $mobile_sessions_for_page / $total_sessions_for_device ) * 100 );
				} else {
					// Fallback to event-based calculation if no recording session data
					$total_events = $click_pc + $click_mobile + $breakaway_pc + $breakaway_mobile + $attention_pc + $attention_mobile;
					$mobile_events = $click_mobile + $breakaway_mobile + $attention_mobile;
					$mobile_percentage = $total_events > 0 ? round( ( $mobile_events / $total_events ) * 100 ) : 0;
				}

				// Determine status based on last event time
				$status = 'active';
				$status_icon = '🟢';

				if ( ! empty( $page_data['last_event_time'] ) ) {
					$hours_since_update = ( time() - strtotime( $page_data['last_event_time'] ) ) / 3600;

					if ( $hours_since_update > 168 ) { // 7 days
						$status = 'inactive';
						$status_icon = '🔴';
					} elseif ( $hours_since_update > 24 ) { // 1 day
						$status = 'processing';
						$status_icon = '🟡';
					}
				}

				// Generate proper heatmap view URL
				$event_name = 'click_pc';

				if ( $type === 'click' ) {
					$has_pc = $click_pc > 0;
					$has_mobile = $click_mobile > 0;

					if ( $has_mobile && ! $has_pc ) {
						$event_name = 'click_mobile';
					} else {
						$event_name = 'click_pc';
					}
				} elseif ( $type === 'scroll' ) {
					$has_pc = $breakaway_pc > 0;
					$has_mobile = $breakaway_mobile > 0;

					if ( $has_mobile && ! $has_pc ) {
						$event_name = 'breakaway_mobile';
					} else {
						$event_name = 'breakaway_pc';
					}
				} elseif ( $type === 'attention' ) {
					$has_pc = $attention_pc > 0;
					$has_mobile = $attention_mobile > 0;

					if ( $has_mobile && ! $has_pc ) {
						$event_name = 'attention_mobile';
					} else {
						$event_name = 'attention_pc';
					}
				}

				// Create proper heatmap URL
				$page_url = $url;
				$param = '?opti_behavior_heatmap=' . $event_name;

				if ( false === strpos( $page_url, '#' ) ) {
					$view_url = $page_url . $param;
				} else {
					$s = explode( '#', $page_url, 2 );
					$view_url = $s[0] . $param . '#' . $s[1];
				}

				// Create PC-only and Mobile-only admin URLs - always default to 'click' heatmap
				$view_pc_url = admin_url( 'admin.php?page=opti-behavior-heatmap-detail&page_id=' . $page_id . '&type=click&device=desktop' );
				$view_mobile_url = admin_url( 'admin.php?page=opti-behavior-heatmap-detail&page_id=' . $page_id . '&type=click&device=mobile' );

				// Format last updated time
				$last_updated = 'Never';
				if ( ! empty( $page_data['last_event_time'] ) ) {
					$timestamp = strtotime( $page_data['last_event_time'] );
					if ( $timestamp !== false ) {
						$last_updated = $this->time_ago( $timestamp );
					}
				}

				// Placeholder thumbnail
				$thumbnail_placeholder = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100" height="60" viewBox="0 0 100 60"%3E%3Crect fill="%23f3f4f6" width="100" height="60"/%3E%3Ctext x="50" y="30" font-family="Arial" font-size="12" fill="%239ca3af" text-anchor="middle" dominant-baseline="middle"%3EPreview%3C/text%3E%3C/svg%3E';

				// Calculate total interactions per device type (clicks + scroll + attention)
				$total_interactions = $total_clicks + $total_breakaway + $total_attention;

				// Sessions = Desktop + Mobile from file storage (same source as the Desktop/Mobile buttons).
				// This ensures the Sessions column matches the sum of the device buttons in the Actions column.
				$total_sessions = $desktop_sessions_for_page + $mobile_sessions_for_page;

				$rows[] = array(
					'id' => $page_id,  // Use 'id' to match template expectations
					'page_id' => $page_id,
					'title' => $title,
					'url' => $url,
					'type' => $type,
					'type_icon' => $type_icon,
					'clicks' => $total_clicks,
					'interactions' => $total_interactions, // Sum ALL interaction types
					'sessions' => $total_sessions,
					'status' => $status,
					'status_icon' => $status_icon,
					'last_updated' => $last_updated,
					'mobile_percentage' => $mobile_percentage,
					// Session counts by device type
					'views_pc' => $desktop_sessions_for_page,
					'views_mobile' => $mobile_sessions_for_page,
					// Session counts by device type (for Desktop/Mobile buttons)
					'sessions_desktop' => $desktop_sessions_for_page,
					'sessions_mobile' => $mobile_sessions_for_page,
					'view_url' => $view_url,
					'view_pc_url' => $view_pc_url,
					'view_mobile_url' => $view_mobile_url,
					'thumbnail' => $thumbnail_placeholder,
					// Pro breakaway/attention event counts (event 32/33/48/49)
					'breakaway_pc'     => $breakaway_pc,
					'breakaway_mobile' => $breakaway_mobile,
					'attention_pc'     => $attention_pc,
					'attention_mobile' => $attention_mobile,
				);
			}

			return array( $rows, $total );
		}

		private function batch_sessions_counts_by_page($page_ids, $start_date=null, $end_date=null){
			// UNIFIED STATS FIX: When Pro is active, use recordings-based counting
			// This ensures heatmap sessions match recording sessions exactly
			$is_pro_active = function_exists( 'opti_behavior_pro_active' ) && opti_behavior_pro_active();

			if ( $is_pro_active ) {
				return $this->batch_sessions_counts_by_page_from_recordings($page_ids, $start_date, $end_date);
			}

			// Check if using file storage mode (free version)
			$file_storage = $this->heatmap->get_file_storage();
			$storage_settings = $file_storage ? $file_storage->get_settings() : array( 'storage_mode' => 'database' );

			if ( $storage_settings['storage_mode'] === 'file' && $file_storage ) {
				// Use file-based counting to match device split counting
				return $this->batch_sessions_counts_by_page_from_files($page_ids, $start_date, $end_date);
			}

			// Fall back to database implementation
			return $this->batch_sessions_counts_by_page_impl($page_ids, $start_date, $end_date);
		}

		/**
		 * Count sessions from recordings table (unified counting for Pro).
		 * This ensures heatmap sessions match recording sessions exactly.
		 *
		 * @param array $page_ids Array of page IDs.
		 * @param string|null $start_date Optional start date filter.
		 * @param string|null $end_date Optional end date filter.
		 * @return array Map of page_id => session count.
		 */
		private function batch_sessions_counts_by_page_from_recordings($page_ids, $start_date = null, $end_date = null) {
			global $wpdb;

			if ( empty( $page_ids ) ) {
				return array();
			}

			$map = array();

			// Initialize all page IDs with 0
			foreach ( $page_ids as $page_id ) {
				$map[ $page_id ] = 0;
			}

			$table_recordings    = $wpdb->prefix . 'optibehavior_recordings';
			$table_session_pages = $wpdb->prefix . 'optibehavior_session_pages';

			// Build placeholders for page IDs
			$placeholders = implode( ',', array_fill( 0, count( $page_ids ), '%d' ) );

			// Build date filter
			$date_filter = '';
			$params = $page_ids;
			if ( $start_date && $end_date ) {
				$date_filter = ' AND r.start_time BETWEEN %s AND %s';
				$params[] = $start_date;
				$params[] = $end_date;
			}

			// Count unique sessions that have recordings for each page
			// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Dynamic placeholders built safely with array_fill
			$query = $wpdb->prepare(
				"SELECT sp.page_id, COUNT(DISTINCT r.session_id) as session_count
				FROM " . $table_recordings . " r
				INNER JOIN " . $table_session_pages . " sp ON sp.session_id = r.session_id
				WHERE sp.page_id IN (" . $placeholders . ")
				" . $date_filter . "
				GROUP BY sp.page_id",
				$params
			);

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query is safely constructed from prepared parts above.
			$results = $wpdb->get_results( $query );

			foreach ( $results as $row ) {
				$map[ $row->page_id ] = intval( $row->session_count );
			}

			return $map;
		}

		/**
		 * Batch query to get recording counts by device for multiple pages
		 *
		 * @param array $page_ids Array of page IDs
		 * @param string $device Device type ('desktop' or 'mobile')
		 * @param string|null $start_date Optional start date filter
		 * @param string|null $end_date Optional end date filter
		 * @return array Map of page_id => recording count
		 */
	private function batch_recording_counts_by_device( $page_ids, $device = 'desktop', $start_date = null, $end_date = null ) {
		global $wpdb;

		if ( empty( $page_ids ) ) {
			return array();
		}

		// Build map of page_id => session count
		$map = array();
		$upload_dir = wp_upload_dir();
		$base_upload_dir = $upload_dir['basedir'] . '/opti-behavior-data/';

		foreach ( $page_ids as $page_id ) {
			// Get URL for this page
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			$url = $wpdb->get_var( $wpdb->prepare( "SELECT url FROM " . $wpdb->prefix . "optibehavior_pages WHERE id = %d", $page_id ) );
			if ( ! $url ) {
				continue;
			}

			// Try URL variations for backwards compatibility
			$url_variations = array(
				md5( $url ),
				md5( rtrim( $url, '/' ) ),
				md5( $url . '/' ),
			);

			$url_hash_dir = null;
			foreach ( $url_variations as $url_hash ) {
				$test_dir = $base_upload_dir . $url_hash;
				if ( is_dir( $test_dir ) ) {
					$url_hash_dir = $test_dir;
					break;
				}
			}

			if ( ! $url_hash_dir ) {
				continue;
			}

			// Count unique sessions for this device
			$sessions = array();

			// Check all event type folders
			$type_folders = array( 'clicks', 'moves', 'scrolls' );
			foreach ( $type_folders as $folder_name ) {
				$dir = $url_hash_dir . '/' . $folder_name;
				if ( ! is_dir( $dir ) ) {
					continue;
				}

				$files = glob( $dir . '/*.json' );
				if ( empty( $files ) ) {
					continue;
				}

				foreach ( $files as $file ) {
					$filename = basename( $file );
					// Parse filename: timestamp_country_browser_count_device_visitortype_count_session.json
					$parts = explode( '_', str_replace( '.json', '', $filename ) );
					if ( count( $parts ) < 7 ) {
						continue;
					}

					$file_device = $parts[4]; // Device is 5th element (index 4)
					$session = end( $parts ); // Session is last element

					// Match device type
					if ( strtolower( $file_device ) === strtolower( $device ) ) {
						$sessions[ $session ] = true;
					}
				}
			}

			// Store count of unique sessions
			$map[ $page_id ] = count( $sessions );
		}

		return $map;
	}

	/**
	 * Get session counts from file storage for multiple pages
	 * Counts total unique sessions across all devices and event types
	 *
	 * @param array $page_ids Array of page IDs
	 * @param string|null $start_date Optional start date filter
	 * @param string|null $end_date Optional end date filter
	 * @return array Map of page_id => session count
	 */
	private function batch_sessions_counts_by_page_from_files( $page_ids, $start_date = null, $end_date = null ) {
		global $wpdb;

		if ( empty( $page_ids ) ) {
			return array();
		}

		// Build map of page_id => total session count (all devices)
		$map = array();
		$upload_dir = wp_upload_dir();
		$base_upload_dir = $upload_dir['basedir'] . '/opti-behavior-data/';

		foreach ( $page_ids as $page_id ) {
			// Get URL for this page
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			$url = $wpdb->get_var( $wpdb->prepare( "SELECT url FROM " . $wpdb->prefix . "optibehavior_pages WHERE id = %d", $page_id ) );
			if ( ! $url ) {
				continue;
			}

			// Try URL variations for backwards compatibility
			$url_variations = array(
				md5( $url ),
				md5( rtrim( $url, '/' ) ),
				md5( $url . '/' ),
			);

			$url_hash_dir = null;
			foreach ( $url_variations as $url_hash ) {
				$test_dir = $base_upload_dir . $url_hash;
				if ( is_dir( $test_dir ) ) {
					$url_hash_dir = $test_dir;
					break;
				}
			}

			if ( ! $url_hash_dir ) {
				continue;
			}

			// Count unique sessions across ALL devices and event types
			$sessions = array();

			// Check all event type folders
			$type_folders = array( 'clicks', 'moves', 'scrolls' );
			foreach ( $type_folders as $folder_name ) {
				$dir = $url_hash_dir . '/' . $folder_name;
				if ( ! is_dir( $dir ) ) {
					continue;
				}

				$files = glob( $dir . '/*.json' );
				if ( empty( $files ) ) {
					continue;
				}

				foreach ( $files as $file ) {
					$filename = basename( $file );
					// Parse filename: timestamp_country_browser_count_device_visitortype_count_session.json
					$parts = explode( '_', str_replace( '.json', '', $filename ) );
					if ( count( $parts ) < 7 ) {
						continue;
					}

					$session = end( $parts ); // Session is last element
					$sessions[ $session ] = true; // Count session regardless of device
				}
			}

			// Store count of unique sessions (all devices combined)
			$map[ $page_id ] = count( $sessions );
		}

		return $map;
	}

		public function ajax_heatmaps_sessions(){
			return $this->ajax_heatmaps_sessions_impl();
		}



		private function batch_sessions_counts($urls, $start_date=null, $end_date=null){
			global $wpdb;
			if (empty($urls)) {
				return array();
			}

			// Use esc_sql() for table name to satisfy WordPress Plugin Check
			$pv_table = esc_sql( $wpdb->prefix . 'optibehavior_pageviews' );
			$map = array();

			// Process each URL individually to avoid SQL injection from dynamic WHERE clause
			// This is safer than using implode() on WHERE clauses
			foreach ($urls as $url) {
				if ($start_date && $end_date) {
					$count = $wpdb->get_var(
						$wpdb->prepare(
							"SELECT COUNT(DISTINCT pv.session_id)
							FROM " . $pv_table . " pv
							WHERE pv.url LIKE CONCAT(%s, '%%')
							AND pv.view_time BETWEEN %s AND %s",
							$url,
							$start_date,
							$end_date
						)
					);
				} else {
					$count = $wpdb->get_var(
						$wpdb->prepare(
							"SELECT COUNT(DISTINCT pv.session_id)
							FROM " . $pv_table . " pv
							WHERE pv.url LIKE CONCAT(%s, '%%')",
							$url
						)
					);
				}

				// Extract base URL (without query string)
				$base_url = strtok($url, '?');
				$map[$base_url] = (int)$count;
			}

			return $map;
		}



	/**
	 * Get heatmap insights from database
	 */
	private function get_heatmap_insights() {
		global $wpdb;

		$week_ago = gmdate('Y-m-d', strtotime('-7 days'));
		$two_weeks_ago = gmdate('Y-m-d', strtotime('-14 days'));

		// Top performing pages by pageviews (more reliable than clicks)
		// Try pageviews first
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static query with prepare
		$top_pages_query = "SELECT
			COALESCE(NULLIF(pv.title, ''), 'Untitled Page') as title,
			pv.url,
			COUNT(DISTINCT pv.session_id) as views,
			COUNT(DISTINCT CASE WHEN pv.view_time >= %s THEN pv.session_id END) as recent_views,
			COUNT(DISTINCT CASE WHEN pv.view_time BETWEEN %s AND %s THEN pv.session_id END) as previous_views
		FROM " . $wpdb->prefix . "optibehavior_pageviews pv
		WHERE pv.view_time IS NOT NULL AND pv.url IS NOT NULL AND pv.url != ''
		GROUP BY pv.url
		ORDER BY views DESC
		LIMIT 5";

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Static query with prepare
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
		$top_pages_results = $wpdb->get_results( $wpdb->prepare(
			$top_pages_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static query variable
			$week_ago,
			$two_weeks_ago,
			$week_ago
		));

		// If no pageviews data, fallback to events data
		if ( empty( $top_pages_results ) ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static query
			$top_pages_query_fallback = "SELECT
				COALESCE(NULLIF(p.title, ''), 'Untitled Page') as title,
				p.url,
				COUNT(*) as views,
				0 as recent_views,
				0 as previous_views
			FROM " . $wpdb->prefix . "optibehavior_events e
			JOIN " . $wpdb->prefix . "optibehavior_pages p ON e.page_id2 = p.id
			WHERE p.url IS NOT NULL AND p.url != ''
			GROUP BY p.url
			ORDER BY views DESC
			LIMIT 5";

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Static query
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
			$top_pages_results = $wpdb->get_results( $top_pages_query_fallback );
		}

		// Get click data from file storage for all pages
		$file_storage = $this->heatmap->get_file_storage();
		$clicks_map = array();

		if ( $file_storage ) {
			$pages_with_heatmap_data = $file_storage->get_pages_with_heatmap_data();
			foreach ( $pages_with_heatmap_data as $heatmap_page ) {
				$clicks_map[ $heatmap_page['url'] ] = intval( $heatmap_page['click_pc'] ) + intval( $heatmap_page['click_mobile'] );
			}
		}

		$top_pages = array();
		foreach ($top_pages_results as $page) {
			// Calculate trend
			$trend = 'neutral';
			$trend_icon = '➡️';
			$change = 0;

			if ($page->previous_views > 0) {
				$change = round((($page->recent_views - $page->previous_views) / $page->previous_views) * 100);
				if ($change > 0) {
					$trend = 'positive';
					$trend_icon = '📈';
				} elseif ($change < 0) {
					$trend = 'negative';
					$trend_icon = '📉';
				}
			} elseif ($page->recent_views > 0) {
				$trend = 'positive';
				$trend_icon = '📈';
				$change = 100;
			}

			$title = $page->title ? $page->title : 'Untitled Page';
			if (strlen($title) > 30) {
				$title = substr($title, 0, 27) . '...';
			}

			// Get actual click count from heatmap file storage
			$clicks = isset( $clicks_map[ $page->url ] ) ? $clicks_map[ $page->url ] : 0;

			$top_pages[] = array(
				'title' => $title,
				'url' => $page->url,
				'clicks' => $clicks,
				'trend' => $trend,
				'trend_icon' => $trend_icon,
				'change' => abs($change)
			);
		}

		// Device breakdown from visitor data
		$device_query = "SELECT
			v.device_type,
			COUNT(DISTINCT s.id) as sessions
		FROM " . $wpdb->prefix . "optibehavior_sessions s
		LEFT JOIN " . $wpdb->prefix . "optibehavior_visitors v ON s.visitor_id = v.id
		WHERE v.device_type IS NOT NULL
		GROUP BY v.device_type
		ORDER BY sessions DESC";

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Static query, no user input
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$device_results = $wpdb->get_results($device_query);
		$total_sessions = array_sum(array_column($device_results, 'sessions'));

		$devices = array();
		foreach ($device_results as $device) {
			$percentage = $total_sessions > 0 ?
				round(($device->sessions / $total_sessions) * 100) : 0;

			$icon = '🖥️'; // Default desktop
			$name = ucfirst($device->device_type);

			if (strtolower($device->device_type) === 'mobile') {
				$icon = '📱';
			} elseif (strtolower($device->device_type) === 'tablet') {
				$icon = '📱';
			}

			$devices[] = array(
				'name' => $name,
				'icon' => $icon,
				'percentage' => $percentage
			);
		}

		// Peak activity time (hour with most events)
		$peak_time_query = "SELECT
			HOUR(insert_at) as hour,
			COUNT(*) as events
		FROM " . $wpdb->prefix . "optibehavior_events
		WHERE event IN (16, 17, 32, 33, 48, 49)
		AND insert_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
		GROUP BY HOUR(insert_at)
		ORDER BY events DESC
		LIMIT 1";

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Static query, no user input
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$peak_result = $wpdb->get_row($peak_time_query);
		$peak_time = $peak_result ?
			gmdate('g:i A', mktime($peak_result->hour, 0, 0)) : 'No data';

		// Average intensity (events per page)
		$intensity_query = "SELECT
			AVG(event_count) as avg_intensity
		FROM (
			SELECT page_id2, COUNT(*) as event_count
			FROM " . $wpdb->prefix . "optibehavior_events
			WHERE event IN (16, 17, 32, 33, 48, 49)
			GROUP BY page_id2
		) as page_events";

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Static query, no user input
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$intensity_result = $wpdb->get_var($intensity_query);
		$avg_intensity = $intensity_result ? round($intensity_result, 1) : 0;

		return array(
			'top_pages' => $top_pages,
			'devices' => $devices,
			'peak_time' => $peak_time,
			'avg_intensity' => $avg_intensity
		);
	}

	/**
	 * Get total heatmaps count from database
	 */
	private function get_total_heatmaps() {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$count = $wpdb->get_var(
			"SELECT COUNT(DISTINCT page_id2)
			FROM " . $wpdb->prefix . "optibehavior_events
			WHERE event IN (16, 17, 32, 33, 48, 49)"
		);

		return intval($count);
	}

	/**
	 * Helper method to calculate time elapsed string
	 */
	private function time_elapsed_string($datetime) {
		$time = time() - strtotime($datetime);

		if ($time < 60) {
			return __( 'just now', 'opti-behavior' );
		} elseif ($time < 3600) {
			$minutes = floor($time / 60);
			/* translators: %s: number of minutes */
			return sprintf( __( '%sm ago', 'opti-behavior' ), $minutes );
		} elseif ($time < 86400) {
			$hours = floor($time / 3600);
			/* translators: %s: number of hours */
			return sprintf( __( '%sh ago', 'opti-behavior' ), $hours );
		} elseif ($time < 2592000) {
			$days = floor($time / 86400);
			/* translators: %s: number of days */
			return sprintf( __( '%sd ago', 'opti-behavior' ), $days );
		} else {
			$months = floor($time / 2592000);
			/* translators: %s: number of months */
			return sprintf( __( '%smo ago', 'opti-behavior' ), $months );
		}
	}



	/**
	 * AJAX handler for dashboard data
	 */
	public function ajax_dashboard_data() {
		return $this->ajax_dashboard_data_impl();
	}

	/**
	 * AJAX handler for session data
	 */
	public function ajax_session_data() {
		return $this->ajax_session_data_impl();
	}

	/**
	 * AJAX handler for analytics data
	 */
	public function ajax_analytics_data() {
		return $this->ajax_analytics_data_impl();
	}

	/**
	 * AJAX handler for Top Engaged Users widget
	 */
	public function ajax_top_users() {
		return $this->ajax_top_users_impl();
	}

	/**
	 * AJAX handler for Visitor Heatmap widget
	 */
	public function ajax_visitor_heatmap() {
		return $this->ajax_visitor_heatmap_impl();
	}

	/**
	 * Get screen resolution data
	 *
	 * @param string $start_date Start date.
	 * @param string $end_date End date.
	 * @return array
	 */
	private function get_screen_resolution_data( $start_date, $end_date ) {
		global $wpdb;

		// Check cache first (5-minute TTL for performance)
		$exclude_spam = $this->is_spam_excluded();
		$cache_key = 'opti_behavior_screen_res_' . md5( $start_date . $end_date . ( $exclude_spam ? '1' : '0' ) );
		$cached = get_transient( $cache_key );
		if ( false !== $cached ) {
			return $cached;
		}

		$spam_clause = $this->get_spam_exclusion_clause( 's' );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
		$results = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT
					CASE
						WHEN sampled.screen_width IS NULL OR sampled.screen_height IS NULL THEN NULL
						WHEN sampled.screen_width = 0 OR sampled.screen_height = 0 THEN NULL
						ELSE CONCAT(sampled.screen_width, 'x', sampled.screen_height)
					END AS resolution,
					COUNT(*) AS count
				FROM (
					SELECT v.screen_width, v.screen_height
					FROM " . $wpdb->prefix . "optibehavior_sessions s
					LEFT JOIN " . $wpdb->prefix . "optibehavior_visitors v ON s.visitor_id = v.id
					WHERE s.start_time BETWEEN %s AND %s" . $spam_clause . "
					LIMIT 10000
				) sampled
				GROUP BY resolution
				HAVING resolution IS NOT NULL
				ORDER BY count DESC
				LIMIT 10",
				$start_date,
				$end_date
			)
		);

		// Process results and filter out invalid entries
		$map = array();
		foreach ( $results as $row ) {
			$label = isset( $row->resolution ) ? trim( (string) $row->resolution ) : '';
			// Skip empty or null entries
			if ( $label === '' || $label === 'NULL' ) {
				continue;
			}
			if ( ! isset( $map[ $label ] ) ) {
				$map[ $label ] = 0;
			}
			$map[ $label ] += intval( $row->count );
		}
		$data = array();
		foreach ( $map as $label => $count ) {
			$data[] = array(
				'resolution' => $label,
				'count'      => $count,
			);
		}

		// Cache for 5 minutes
		set_transient( $cache_key, $data, 900 ); // 15 minutes for dashboard performance

		return $data;
	}

	/**
	 * Get dashboard data
	 *
	 * @param string $period Time period.
	 * @return array
	 */
	private function get_dashboard_data( $period, $start_override = null, $end_override = null ) {
		return $this->get_dashboard_data_impl($period, $start_override, $end_override);
	}

	/**
	 * Get dashboard stats only (for fast initial page load)
	 *
	 * @param string $period Time period.
	 * @param string|null $start_override Optional start date override.
	 * @param string|null $end_override Optional end date override.
	 * @return array Minimal dashboard data with stats only.
	 */
	private function get_dashboard_stats_only( $period, $start_override = null, $end_override = null ) {
		return $this->get_dashboard_stats_only_impl($period, $start_override, $end_override);
	}

	/**
	 * Get session data
	 *
	 * @param int   $page Current page.
	 * @param int   $per_page Items per page.
	 * @param array $filters Filters.
	 * @return array
	 */
	private function get_session_data( $page, $per_page, $filters ) {
		return $this->get_session_data_impl($page, $per_page, $filters);
	}

	/**
	 * Get analytics data
	 *
	 * @param string $type Analytics type.
	 * @param string $period Time period.
	 * @return array
	 */
	private function get_analytics_data( $type, $period ) {
		return $this->get_analytics_data_impl($type, $period);
	}

	/**
	 * Get date range for period
	 *
	 * @param string $period Period string.
	 * @return array
	 */
	private function get_date_range( $period ) {
		return $this->get_date_range_impl($period);
	}

	/**
	 * Get the first available data date from the database.
	 *
	 * @return string|null
	 */
	private function get_first_available_data_date() {
		return $this->get_first_available_data_date_impl();
	}

	/**
	 * Get sessions count
	 * UNIFIED STATS FIX: When Pro is active, count from recordings table
	 * to match the recordings list exactly.
	 *
	 * @param string $start_date Start date.
	 * @param string $end_date End date.
	 * @return int
	 */
	private function get_sessions_count( $start_date, $end_date ) {
		global $wpdb;

		// Try to use pre-aggregated daily_stats for performance (works for both Pro and Free)
		$daily_stats_table = $wpdb->prefix . 'optibehavior_daily_stats';
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $daily_stats_table ) );
		if ( $table_exists ) {
			$start_d = substr( $start_date, 0, 10 );
			$end_d = substr( $end_date, 0, 10 );
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
			$sum = $wpdb->get_var( $wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT SUM(sessions) FROM " . $daily_stats_table . " WHERE stat_date BETWEEN %s AND %s",
				$start_d,
				$end_d
			) );
			if ( $sum !== null ) {
				return intval( $sum );
			}
		}

		// Fallback: count from sessions table with LIMIT for safety
		$spam_clause = $this->get_spam_exclusion_clause();
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
		return intval( $wpdb->get_var( $wpdb->prepare(
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			"SELECT COUNT(*) FROM " . $wpdb->prefix . "optibehavior_sessions WHERE start_time BETWEEN %s AND %s" . $spam_clause,
			$start_date,
			$end_date
		) ) );
	}

	/**
	 * Get visitors count
	 * UNIFIED STATS FIX: When Pro is active, count visitors from recordings
	 * to ensure consistency with session counts.
	 *
	 * @param string $start_date Start date.
	 * @param string $end_date End date.
	 * @return int
	 */
	private function get_visitors_count( $start_date, $end_date ) {
		global $wpdb;

		// Try to use pre-aggregated daily_stats for performance (works for both Pro and Free)
		$daily_stats_table = $wpdb->prefix . 'optibehavior_daily_stats';
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $daily_stats_table ) );
		if ( $table_exists ) {
			$start_d = substr( $start_date, 0, 10 );
			$end_d = substr( $end_date, 0, 10 );
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
			$sum = $wpdb->get_var( $wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT SUM(visitors) FROM " . $daily_stats_table . " WHERE stat_date BETWEEN %s AND %s",
				$start_d,
				$end_d
			) );
			if ( $sum !== null ) {
				return intval( $sum );
			}
		}

		// Fallback: count from sessions table
		$spam_clause_no_alias = $this->get_spam_exclusion_clause();
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
		return intval( $wpdb->get_var( $wpdb->prepare(
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			"SELECT COUNT(DISTINCT visitor_id) FROM " . $wpdb->prefix . "optibehavior_sessions WHERE start_time BETWEEN %s AND %s" . $spam_clause_no_alias,
			$start_date,
			$end_date
		) ) );
	}

	/**
	 * Get pageviews count
	 *
	 * @param string $start_date Start date.
	 * @param string $end_date End date.
	 * @return int
	 */
	private function get_pageviews_count( $start_date, $end_date ) {
		global $wpdb;

		// Check if spam exclusion is enabled
		if ( $this->is_spam_excluded() ) {
			// Join with sessions to filter out spam pageviews
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			return intval( $wpdb->get_var( $wpdb->prepare(
				"SELECT COUNT(*) FROM " . $wpdb->prefix . "optibehavior_pageviews pv
				INNER JOIN " . $wpdb->prefix . "optibehavior_sessions s ON pv.session_id = s.id
				WHERE pv.view_time BETWEEN %s AND %s AND s.traffic_type IN ('human', 'automated', 'bot')",
				$start_date,
				$end_date
			) ) );
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		return intval( $wpdb->get_var( $wpdb->prepare(
			"SELECT COUNT(*) FROM " . $wpdb->prefix . "optibehavior_pageviews WHERE view_time BETWEEN %s AND %s",
			$start_date,
			$end_date
		) ) );
	}

	/**
	 * Get bounce rate
	 * UNIFIED STATS FIX: When Pro is active, calculate from sessions with recordings.
	 *
	 * @param string $start_date Start date.
	 * @param string $end_date End date.
	 * @return float
	 */
	private function get_bounce_rate( $start_date, $end_date ) {
		global $wpdb;

		// Always calculate bounce rate from sessions table for consistency with daily history charts.
		$spam_clause_no_alias = $this->get_spam_exclusion_clause();
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
		$total_sessions = $wpdb->get_var( $wpdb->prepare(
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			"SELECT COUNT(*) FROM " . $wpdb->prefix . "optibehavior_sessions WHERE start_time BETWEEN %s AND %s" . $spam_clause_no_alias,
			$start_date,
			$end_date
		) );

		if ( ! $total_sessions ) {
			return 0;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
		$bounce_sessions = $wpdb->get_var( $wpdb->prepare(
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			"SELECT COUNT(*) FROM " . $wpdb->prefix . "optibehavior_sessions WHERE start_time BETWEEN %s AND %s AND is_bounce = 1" . $spam_clause_no_alias,
			$start_date,
			$end_date
		) );

		return round( ( $bounce_sessions / $total_sessions ) * 100, 2 );
	}

	/**
	 * Get average session time
	 * UNIFIED STATS FIX: When Pro is active, calculate from sessions with recordings.
	 *
	 * @param string $start_date Start date.
	 * @param string $end_date End date.
	 * @return int
	 */
	private function get_avg_session_time( $start_date, $end_date ) {
		global $wpdb;

		// Try to use pre-aggregated daily_stats for performance
		$daily_stats_table = $wpdb->prefix . 'optibehavior_daily_stats';
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $daily_stats_table ) );
		if ( $table_exists ) {
			$start_d = substr( $start_date, 0, 10 );
			$end_d = substr( $end_date, 0, 10 );
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
			$stats = $wpdb->get_row( $wpdb->prepare(
				"SELECT SUM(total_duration) as total_duration, SUM(sessions) as sessions
				FROM " . $daily_stats_table . " WHERE stat_date BETWEEN %s AND %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$start_d,
				$end_d
			) );
			if ( $stats && $stats->sessions > 0 ) {
				return intval( $stats->total_duration / $stats->sessions );
			}
		}

		// Fallback: average from sessions table with sampling
		$spam_clause_no_alias = $this->get_spam_exclusion_clause();
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
		$avg_duration = $wpdb->get_var( $wpdb->prepare(
			"SELECT AVG(duration) FROM (
				SELECT duration FROM " . $wpdb->prefix . "optibehavior_sessions
				WHERE start_time BETWEEN %s AND %s AND duration > 0" . $spam_clause_no_alias . "
				LIMIT 10000
			) sampled",
			$start_date,
			$end_date
		) );

		return intval( $avg_duration ?: 0 );
	}

		/**
		 * Get average scroll depth (percentage across pageviews)
		 *
		 * @param string $start_date Start date.
		 * @param string $end_date   End date.
		 * @return float
		 */
		private function get_avg_scroll_depth( $start_date, $end_date ) {
			global $wpdb;

			// Check if spam exclusion is enabled
			if ( $this->is_spam_excluded() ) {
				// Join with sessions to filter out spam
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$avg = $wpdb->get_var( $wpdb->prepare(
					"SELECT AVG(scroll_depth) FROM (
						SELECT pv.scroll_depth FROM " . $wpdb->prefix . "optibehavior_pageviews pv
						INNER JOIN " . $wpdb->prefix . "optibehavior_sessions s ON pv.session_id = s.id
						WHERE pv.view_time BETWEEN %s AND %s AND pv.scroll_depth > 0 AND s.traffic_type IN ('human', 'automated', 'bot')
						LIMIT 10000
					) sampled",
					$start_date,
					$end_date
				) );
				return $avg !== null ? round( floatval( $avg ), 1 ) : 0;
			}

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$avg = $wpdb->get_var( $wpdb->prepare(
				"SELECT AVG(scroll_depth) FROM (
					SELECT scroll_depth FROM " . $wpdb->prefix . "optibehavior_pageviews WHERE view_time BETWEEN %s AND %s AND scroll_depth > 0 LIMIT 10000
				) sampled",
				$start_date,
				$end_date
			) );
			return $avg !== null ? round( floatval( $avg ), 1 ) : 0;

	}

	/**
	 * Get sessions chart data
	 *
	 * @param string $start_date Start date.
	 * @param string $end_date End date.
	 * @return array
	 */
	private function get_sessions_chart_data( $start_date, $end_date ) {
		global $wpdb;

		// Check cache first (5-minute TTL for performance)
		$exclude_spam = $this->is_spam_excluded();
		$cache_key = 'opti_behavior_sessions_chart_' . md5( $start_date . $end_date . ( $exclude_spam ? '1' : '0' ) );
		$cached = get_transient( $cache_key );
		if ( false !== $cached ) {
			return $cached;
		}

		$spam_clause = $this->get_spam_exclusion_clause();

		// PERFORMANCE OPTIMIZED: Single query approach
		// When spam filter is ON, avoid slow JOIN with pageviews table (2.47M rows)
		// Instead, use page_views column from sessions table (already filtered)
		if ( $this->is_spam_excluded() ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
			$rows = $wpdb->get_results( $wpdb->prepare(
				"SELECT DATE(start_time) as d,
				        COUNT(*) as sessions,
				        COUNT(DISTINCT visitor_id) as visitors,
				        COALESCE(SUM(page_views), 0) as pageviews
				FROM " . $wpdb->prefix . "optibehavior_sessions
				WHERE start_time BETWEEN %s AND %s" . $spam_clause . "
				GROUP BY DATE(start_time)
				ORDER BY d",
				$start_date,
				$end_date
			) );

			$map = array();
			foreach ( $rows as $r ) {
				$map[$r->d] = array(
					'date'     => $r->d,
					'sessions' => intval( $r->sessions ),
					'visitors' => intval( $r->visitors ),
					'pageviews'=> intval( $r->pageviews ),
				);
			}
		} else {
			// Without spam filter: use sessions table for all metrics
			// This ensures consistency with stats cards which use SUM(page_views) from sessions table
			// The pageviews table only stores unique (session, page) combinations, not total page views

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$rows = $wpdb->get_results( $wpdb->prepare(
				"SELECT DATE(start_time) as d,
				        COUNT(*) as sessions,
				        COUNT(DISTINCT visitor_id) as visitors,
				        COALESCE(SUM(page_views), 0) as pageviews
				FROM " . $wpdb->prefix . "optibehavior_sessions
				WHERE start_time BETWEEN %s AND %s
				GROUP BY DATE(start_time)
				ORDER BY d",
				$start_date,
				$end_date
			) );

			$map = array();
			foreach ( $rows as $r ) {
				$map[$r->d] = array(
					'date'     => $r->d,
					'sessions' => intval( $r->sessions ),
					'visitors' => intval( $r->visitors ),
					'pageviews'=> intval( $r->pageviews ),
				);
			}
		}

		// Return sorted by date
		ksort( $map );
		$result = array_values( $map );

		// Cache for 15 minutes for dashboard performance
		set_transient( $cache_key, $result, 900 );

		return $result;
	}

	/**
	 * Get device types data
	 *
	 * @param string $start_date Start date.
	 * @param string $end_date End date.
	 * @return array
	 */
		private function get_device_types_data( $start_date, $end_date ) {
			global $wpdb;

			// Check cache first (5-minute TTL for performance)
			$exclude_spam = $this->is_spam_excluded();
			$cache_key = 'opti_behavior_device_types_' . md5( $start_date . $end_date . ( $exclude_spam ? '1' : '0' ) );
			$cached = get_transient( $cache_key );
			if ( false !== $cached ) {
				return $cached;
			}

			$spam_clause = $this->get_spam_exclusion_clause( 's' );

			// PERFORMANCE: Use sampling subquery to handle 1M+ records efficiently
			// Sample 50K sessions max for accurate distribution while staying fast
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
			$results = $wpdb->get_results( $wpdb->prepare(
				"SELECT
					CASE
						WHEN sampled.device_type IS NULL OR TRIM(sampled.device_type) = '' THEN NULL
						WHEN LOWER(TRIM(sampled.device_type)) IN ('unknown','undefined','other') THEN NULL
						WHEN LOWER(TRIM(sampled.device_type)) IN ('mobile','phone','smartphone') THEN 'Mobile'
						WHEN LOWER(TRIM(sampled.device_type)) IN ('tablet','ipad') THEN 'Tablet'
						WHEN LOWER(TRIM(sampled.device_type)) IN ('desktop','pc','computer','laptop') THEN 'Desktop'
						ELSE NULL
					END AS device,
					COUNT(*) AS count
				FROM (
					SELECT v.device_type
					FROM " . $wpdb->prefix . "optibehavior_sessions s
					INNER JOIN " . $wpdb->prefix . "optibehavior_visitors v ON s.visitor_id = v.id
					WHERE s.start_time BETWEEN %s AND %s" . $spam_clause . "
					LIMIT 50000
				) sampled
				GROUP BY device
				HAVING device IS NOT NULL
				ORDER BY count DESC",
				$start_date,
				$end_date
			) );

			// Ensure exactly three categories and stable ordering
			$map = array( 'Desktop' => 0, 'Mobile' => 0, 'Tablet' => 0 );
			foreach ( $results as $row ) {
				$label = isset( $row->device ) ? trim( (string) $row->device ) : '';
				if ( $label !== '' && isset( $map[ $label ] ) ) {
					$map[ $label ] += intval( $row->count );
				}
			}

			$data = array();
			foreach ( array('Desktop','Mobile','Tablet') as $label ) {
				// Only include items with actual data (count > 0)
				if ( $map[$label] > 0 ) {
					$data[] = array( 'name' => $label, 'count' => $map[$label] );
				}
			}

			// Cache for 5 minutes
			set_transient( $cache_key, $data, 900 ); // 15 minutes for dashboard performance

			return $data;
		}


	/**
	 * Get top pages data
	 *
	 * @param string $start_date Start date.
	 * @param string $end_date End date.
	 * @return array
	 */
	private function get_top_pages_data( $start_date, $end_date ) {
		global $wpdb;

		// Check cache first (5-minute TTL for performance)
		$exclude_spam = $this->is_spam_excluded();
		$cache_key = 'opti_behavior_top_pages_' . md5( $start_date . $end_date . ( $exclude_spam ? '1' : '0' ) );
		$cached = get_transient( $cache_key );
		if ( false !== $cached ) {
			return $cached;
		}

		// Build spam filter join and where clause for queries using session_id
		$spam_join = '';
		$spam_where = '';
		if ( $exclude_spam ) {
			$spam_join = "INNER JOIN " . $wpdb->prefix . "optibehavior_sessions sess ON e.session_id = sess.id";
			$spam_where = " AND sess.traffic_type IN ('human', 'automated', 'bot')";
		}

		// Get file storage instance
		$file_storage = $this->heatmap->get_file_storage();
		$use_file_storage = false;
		$results = array();

		// Only use file storage when spam filter is OFF (file storage doesn't support spam filtering)
		// When spam filter is ON, we need to query the database to filter properly
		if ( $file_storage && ! $exclude_spam ) {
			$pages_data = $file_storage->get_pages_with_heatmap_data();

			if ( ! empty( $pages_data ) ) {
				$use_file_storage = true;

				// Get page IDs from file storage
				$page_ids = array();
				foreach ( $pages_data as $page_data ) {
					$page_ids[] = intval( $page_data['page_id'] );
				}

				// Fetch page metadata from database
				if ( ! empty( $page_ids ) ) {
					$placeholders = implode( ',', array_fill( 0, count( $page_ids ), '%d' ) );
					$query = "SELECT id as page_id, url, title
						FROM " . $wpdb->prefix . "optibehavior_pages
						WHERE id IN ($placeholders)";
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Dynamic placeholders are built safely with array_fill and implode, table prefix is from wpdb
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
					$pages_info = $wpdb->get_results(
						// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query variable with dynamic placeholders
						$wpdb->prepare(
							$query,
							...$page_ids
						),
						OBJECT_K
					);
				} else {
					$pages_info = array();
				}

				// Convert file storage data to expected format
				foreach ( $pages_data as $page_data ) {
					$page_id = intval( $page_data['page_id'] );

					// Skip pages without database metadata
					if ( ! isset( $pages_info[ $page_id ] ) ) {
						continue;
					}

					$result = new stdClass();
					$result->page_id = $page_id;
					$result->url = $pages_info[ $page_id ]->url;
					$result->title = $pages_info[ $page_id ]->title;

					// Calculate total interactions (all event types)
					$result->interactions = intval( $page_data['click_pc'] ) +
											intval( $page_data['click_mobile'] ) +
											intval( $page_data['breakaway_pc'] ) +
											intval( $page_data['breakaway_mobile'] ) +
											intval( $page_data['attention_pc'] ) +
											intval( $page_data['attention_mobile'] );

					$results[] = $result;
				}

				// Sort by interactions descending and limit to top 5
				usort( $results, function( $a, $b ) {
					return $b->interactions - $a->interactions;
				} );
				$results = array_slice( $results, 0, 5 );
			}
		}

		// Fall back to database if file storage didn't return data
		if ( empty( $results ) ) {
			// Try to get data from events first (heatmap interactions).
			// Get all-time data (no date filter) to match heatmaps list behavior
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
			$results = $wpdb->get_results(
				"SELECT p.id as page_id, p.url, p.title, COUNT(*) as interactions
				FROM " . $wpdb->prefix . "optibehavior_events e
				LEFT JOIN " . $wpdb->prefix . "optibehavior_pages p ON e.page_id2 = p.id
				" . $spam_join . "
				WHERE e.event IN (16,17,32,33,48,49)" . $spam_where . "
				GROUP BY p.id
				ORDER BY interactions DESC
				LIMIT 5"
			);
		}

		// If no events data, fall back to pageviews table (page views).
		if ( empty( $results ) ) {
			// Build spam filter join and where for pageviews
			$pv_spam_join = '';
			$pv_spam_where = '';
			if ( $this->is_spam_excluded() ) {
				$pv_spam_join = "INNER JOIN " . $wpdb->prefix . "optibehavior_sessions sess ON pv.session_id = sess.id";
				$pv_spam_where = " AND sess.traffic_type IN ('human', 'automated', 'bot')";
			}

			// Get top pages from pageviews table (page views).
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
			$results = $wpdb->get_results(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
				$wpdb->prepare(
					"SELECT p.id as page_id, p.url, p.title, COUNT(*) as interactions
					FROM " . $wpdb->prefix . "optibehavior_pageviews pv
					LEFT JOIN " . $wpdb->prefix . "optibehavior_pages p ON pv.page_id = p.id
					" . $pv_spam_join . "
					WHERE pv.view_time BETWEEN %s AND %s" . $pv_spam_where . "
					GROUP BY p.id
					ORDER BY interactions DESC
					LIMIT 5",
					$start_date,
					$end_date
				)
			);

			// If still empty, try without date filter to check if there's ANY data.
			if ( empty( $results ) ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
				$results = $wpdb->get_results(
					"SELECT p.id as page_id, p.url, p.title, COUNT(*) as interactions
					FROM " . $wpdb->prefix . "optibehavior_pageviews pv
					LEFT JOIN " . $wpdb->prefix . "optibehavior_pages p ON pv.page_id = p.id
					" . $pv_spam_join . "
					WHERE 1=1" . $pv_spam_where . "
					GROUP BY p.id
					ORDER BY interactions DESC
					LIMIT 5"
				);
			}
		}

		// Final fallback: try events table without date filter if we still have no results.
		// Use all events, not just specific types, to ensure we capture all data.
		if ( empty( $results ) ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
			$results = $wpdb->get_results(
				"SELECT p.id as page_id, p.url, p.title, COUNT(*) as interactions
				FROM " . $wpdb->prefix . "optibehavior_events e
				LEFT JOIN " . $wpdb->prefix . "optibehavior_pages p ON e.page_id2 = p.id
				" . $spam_join . "
				WHERE p.id IS NOT NULL" . $spam_where . "
				GROUP BY p.id
				ORDER BY interactions DESC
				LIMIT 5"
			);
		}

		if ( empty( $results ) ) {
			// Cache empty result to prevent repeated expensive queries
			set_transient( $cache_key, array(), 900 ); // 15 minutes for dashboard performance
			return array();
		}

		// Compute previous period window for trend comparison.
		$duration   = max( 1, strtotime( $end_date ) - strtotime( $start_date ) + 1 );
		$prev_end   = gmdate( 'Y-m-d H:i:s', strtotime( $start_date ) - 1 );
		$prev_start = gmdate( 'Y-m-d H:i:s', strtotime( $start_date ) - $duration );

		// Collect page IDs for secondary queries.
		$page_ids = array();
		foreach ( $results as $row ) {
			$page_ids[] = intval( $row->page_id );
		}
		$page_ids = array_values( array_unique( $page_ids ) );

		$click_stats           = array();
		$views_previous_stats  = array();
		$total_clicks_current  = 0;
		$sessions_map          = array();

		if ( ! empty( $page_ids ) ) {
			// Get per-page session counts for current period (heatmap sessions per page).
			$sessions_map = $this->batch_sessions_counts_by_page( $page_ids, $start_date, $end_date );

			// Build placeholders once — used by both click-count and previous-period queries.
			$placeholders = implode( ',', array_fill( 0, count( $page_ids ), '%d' ) );

			if ( $use_file_storage ) {
				// Get click data from file storage
				$pages_with_data = $file_storage->get_pages_with_heatmap_data();
				foreach ( $pages_with_data as $page_data ) {
					$page_id = $page_data['page_id'];
					if ( in_array( $page_id, $page_ids ) ) {
						$current = intval( $page_data['click_pc'] ) + intval( $page_data['click_mobile'] );
						$previous = 0; // No previous period comparison available in file storage yet
						$click_stats[ $page_id ] = array(
							'current'  => $current,
							'previous' => $previous,
						);
						$total_clicks_current += $current;
					}
				}
			} else {
				// Get click counts (events 16,17) for current and previous periods for the same pages.
				// Try page_id2 first, then fall back to page_id for compatibility
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				$sql = "
					SELECT COALESCE(e.page_id2, e.page_id) AS page_id,
						COUNT(*) AS current_clicks
					FROM " . $wpdb->prefix . "optibehavior_events e
					WHERE e.event IN (16,17)
						AND (e.page_id2 IN ( $placeholders ) OR e.page_id IN ( $placeholders ))
					GROUP BY COALESCE(e.page_id2, e.page_id)
				";

				// No date params needed for all-time click count
				$params   = array_merge( $page_ids, $page_ids );

				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$rows = $wpdb->get_results( $wpdb->prepare( $sql, $params ) );
				foreach ( $rows as $row ) {
					$page_id  = intval( $row->page_id );
					$current   = intval( $row->current_clicks );
					$previous  = 0; // No previous period comparison for all-time clicks
					$click_stats[ $page_id ] = array(
						'current'  => $current,
						'previous' => $previous,
					);
					$total_clicks_current += $current;
				}
			}

			// Get interactions for previous period for trend on heatmap views.
			// Try page_id2 first, then fall back to page_id for compatibility
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$sql_prev = "
				SELECT COALESCE(e.page_id2, e.page_id) AS page_id,
					COUNT(*) AS previous_views
				FROM " . $wpdb->prefix . "optibehavior_events e
				WHERE e.insert_at BETWEEN %s AND %s
					AND e.event IN (16,17,32,33,48,49)
					AND (e.page_id2 IN ( $placeholders ) OR e.page_id IN ( $placeholders ))
				GROUP BY COALESCE(e.page_id2, e.page_id)
			";

			$params_prev   = array( $prev_start, $prev_end );
			$params_prev   = array_merge( $params_prev, $page_ids, $page_ids );

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$rows_prev = $wpdb->get_results( $wpdb->prepare( $sql_prev, $params_prev ) );
			foreach ( (array) $rows_prev as $row_prev ) {
				$page_id_prev = intval( $row_prev->page_id );
				$views_previous_stats[ $page_id_prev ] = intval( $row_prev->previous_views );
			}
		}

		$data  = array();
		$total = 0;
		foreach ( $results as $row ) {
			$total += intval( $row->interactions );
		}

		foreach ( $results as $row ) {
			$page_id2 = intval( $row->page_id );
			$pc_url   = $page_id2 ? $row->url . ( ( strpos( $row->url, '?' ) === false ? '?' : '&' ) . 'opti-behavior=click_pc-' . $page_id2 ) : '';
			$mb_url   = $page_id2 ? $row->url . ( ( strpos( $row->url, '?' ) === false ? '?' : '&' ) . 'opti-behavior=click_mobile-' . $page_id2 . '&mobile_view=1&vw=375&vh=667' ) : '';
			$views    = intval( $row->interactions );
			$percentage = $total > 0 ? round( ( $views / $total ) * 100 ) : 0;
			$sessions = isset( $sessions_map[ $page_id2 ] ) ? intval( $sessions_map[ $page_id2 ] ) : 0;

			$views_previous = isset( $views_previous_stats[ $page_id2 ] ) ? intval( $views_previous_stats[ $page_id2 ] ) : 0;

			if ( $views_previous <= 0 ) {
				$views_change = $views > 0 ? 100 : 0;
			} else {
				$views_change = round( ( ( $views - $views_previous ) / $views_previous ) * 100 );
			}

			if ( $views_change > 0 ) {
				$views_trend = 'up';
			} elseif ( $views_change < 0 ) {
				$views_trend = 'down';
			} else {
				$views_trend = 'neutral';
			}

			$click_current  = isset( $click_stats[ $page_id2 ] ) ? intval( $click_stats[ $page_id2 ]['current'] ) : 0;
			$click_previous = isset( $click_stats[ $page_id2 ] ) ? intval( $click_stats[ $page_id2 ]['previous'] ) : 0;

			if ( $click_previous <= 0 ) {
				$click_change = $click_current > 0 ? 100 : 0;
			} else {
				$click_change = round( ( ( $click_current - $click_previous ) / $click_previous ) * 100 );
			}

			if ( $click_change > 0 ) {
				$click_trend = 'up';
			} elseif ( $click_change < 0 ) {
				$click_trend = 'down';
			} else {
				$click_trend = 'neutral';
			}

			$click_share = $total_clicks_current > 0 ? round( ( $click_current / $total_clicks_current ) * 100 ) : 0;

			$data[] = array(
				'url'               => $row->url,
				'title'             => $row->title ?: 'Untitled',
				'views'             => $views,
				'sessions'          => $sessions,
				'percentage'        => $percentage,
				'views_change'      => $views_change,
				'views_trend'       => $views_trend,
				'pc_heatmap'        => $pc_url,
				'mobile_heatmap'    => $mb_url,
				'clicks'            => $click_current,
				'clicks_percentage' => $click_share,
				'clicks_change'     => $click_change,
				'clicks_trend'      => $click_trend,
			);
		}

		// Cache for 5 minutes
		set_transient( $cache_key, $data, 900 ); // 15 minutes for dashboard performance

		return $data;
	}

	/**
	 * Get countries data
	 *
	 * @param string $start_date Start date.
	 * @param string $end_date End date.
	 * @return array
	 */
	private function get_countries_data( $start_date, $end_date ) {
		global $wpdb;

		// Check cache first (5-minute TTL for performance)
		$exclude_spam = $this->is_spam_excluded();
		$cache_key = 'opti_behavior_countries_' . md5( $start_date . $end_date . ( $exclude_spam ? '1' : '0' ) );
		$cached = get_transient( $cache_key );
		if ( false !== $cached ) {
			return $cached;
		}

		$spam_clause = $this->get_spam_exclusion_clause( 's' );

		// PERFORMANCE: Use sampling subquery to handle 1M+ records efficiently
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
		$results = $wpdb->get_results( $wpdb->prepare(
			"SELECT UPPER(COALESCE(NULLIF(TRIM(sampled.country),''), '')) AS country,
			        sampled.country_name,
			        COUNT(*) AS count
			FROM (
				SELECT v.country, v.country_name
				FROM " . $wpdb->prefix . "optibehavior_sessions s
				INNER JOIN " . $wpdb->prefix . "optibehavior_visitors v ON s.visitor_id = v.id
				WHERE s.start_time BETWEEN %s AND %s
				  AND COALESCE(NULLIF(TRIM(v.country),''),'UN') <> 'UN'" . $spam_clause . "
				LIMIT 50000
			) sampled
			GROUP BY country, sampled.country_name
			ORDER BY count DESC
			LIMIT 20",
			$start_date,
			$end_date
		) );

		$data = array();
		foreach ( $results as $row ) {
			$code = strtoupper( $row->country ?: '' );
			$name = $row->country_name ?: ( $code ? $this->get_country_name( $code ) : 'Global' );
			if ( ! $name ) { $name = 'Global'; }
			$data[] = array(
				'country' => $code ?: 'Global',
				'country_name' => $name,
				'count' => intval( $row->count ),
			);
		}

		// Count visitors with unknown/NULL country for this period
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$unknown_count = $wpdb->get_var( $wpdb->prepare(
			"SELECT COUNT(*) FROM (
				SELECT v.id
				FROM " . $wpdb->prefix . "optibehavior_sessions s
				INNER JOIN " . $wpdb->prefix . "optibehavior_visitors v ON s.visitor_id = v.id
				WHERE s.start_time BETWEEN %s AND %s
				  AND (v.country IS NULL OR TRIM(v.country) = '' OR UPPER(TRIM(v.country)) = 'UN')" . $spam_clause . "
				LIMIT 50000
			) unknown_sampled",
			$start_date,
			$end_date
		) );

		if ( $unknown_count > 0 ) {
			$data[] = array(
				'country'      => 'UN',
				'country_name' => __( 'Unknown', 'opti-behavior' ),
				'count'        => intval( $unknown_count ),
			);
		}

		// Cache for 15 minutes for dashboard performance
		set_transient( $cache_key, $data, 900 );

		return $data;
	}

	/**
	 * Get browsers data
	 *
	 * @param string $start_date Start date.
	 * @param string $end_date End date.
	 * @return array
	 */
	private function get_browsers_data( $start_date, $end_date ) {
		global $wpdb;

		// Check cache first (5-minute TTL for performance)
		$exclude_spam = $this->is_spam_excluded();
		$cache_key = 'opti_behavior_browsers_' . md5( $start_date . $end_date . ( $exclude_spam ? '1' : '0' ) );
		$cached = get_transient( $cache_key );
		if ( false !== $cached ) {
			return $cached;
		}

		$spam_clause = $this->get_spam_exclusion_clause( 's' );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		// Normalize browser names in SQL to avoid duplicate "Unknown" (NULL/empty/variant casing)
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
		$results = $wpdb->get_results( $wpdb->prepare(
			"SELECT
				CASE
					WHEN sampled.browser IS NULL OR TRIM(sampled.browser) = '' THEN 'Unknown'
					WHEN LOWER(sampled.browser) IN ('unknown','undefined','other') THEN 'Unknown'
					ELSE sampled.browser
				END AS browser,
				COUNT(*) AS count
			FROM (
				SELECT v.browser
				FROM " . $wpdb->prefix . "optibehavior_sessions s
				LEFT JOIN " . $wpdb->prefix . "optibehavior_visitors v ON s.visitor_id = v.id
				WHERE s.start_time BETWEEN %s AND %s" . $spam_clause . "
				LIMIT 10000
			) sampled
			GROUP BY browser
			HAVING browser <> 'Unknown'
			ORDER BY count DESC
			LIMIT 10",
			$start_date,
			$end_date
		) );

		$data = array();
		foreach ( $results as $row ) {
			$data[] = array(
				'browser' => $row->browser ?: 'Unknown',
				'count' => intval( $row->count ),
			);
		}

		// Cache for 5 minutes
		set_transient( $cache_key, $data, 900 ); // 15 minutes for dashboard performance

		return $data;
	}

	/**
	 * Extract main domain from a hostname (removes subdomains)
	 * Examples: api.pfbaza.website -> pfbaza.website, www.example.com -> example.com
	 *
	 * @param string $hostname The hostname to extract from.
	 * @return string The main domain.
	 */
	private function extract_main_domain( $hostname ) {
		if ( empty( $hostname ) ) {
			return '';
		}

		// Remove 'www.' prefix if present
		$hostname = preg_replace( '/^www\./i', '', $hostname );

		// Split by dots
		$parts = explode( '.', $hostname );
		$count = count( $parts );

		// If only 2 parts (e.g., example.com), return as-is
		if ( $count <= 2 ) {
			return $hostname;
		}

		// Known two-part TLDs (e.g., .co.uk, .com.au)
		$two_part_tlds = array( 'co.uk', 'com.au', 'co.za', 'co.nz', 'com.br', 'co.in', 'co.jp' );
		$last_two = $parts[ $count - 2 ] . '.' . $parts[ $count - 1 ];

		if ( in_array( $last_two, $two_part_tlds, true ) ) {
			// Return domain.co.uk format (3 parts)
			if ( $count >= 3 ) {
				return $parts[ $count - 3 ] . '.' . $parts[ $count - 2 ] . '.' . $parts[ $count - 1 ];
			}
			return $hostname;
		}

		// Standard TLD: return last 2 parts (domain.tld)
		return $parts[ $count - 2 ] . '.' . $parts[ $count - 1 ];
	}

	/**
	 * Get top referrers data
	 *
	 * @param string $start_date Start date.
	 * @param string $end_date End date.
	 * @return array
	 */
	private function get_referrers_data( $start_date, $end_date ) {
		global $wpdb;

		// Check cache first (5-minute TTL for performance)
		$exclude_spam = $this->is_spam_excluded();
		$cache_key = 'opti_behavior_referrers_' . md5( $start_date . $end_date . ( $exclude_spam ? '1' : '0' ) );
		$cached = get_transient( $cache_key );
		if ( false !== $cached ) {
			return $cached;
		}

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Fallback for HTTP_HOST, sanitized by wp_parse_url
		$site_host = function_exists('home_url') ? wp_parse_url( home_url(), PHP_URL_HOST ) : ( isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '' );

		$spam_clause = $this->get_spam_exclusion_clause( 's' );

		// Pull sessions in the range. We fetch referrer, entry_page (for UTM/click ids), and basic counts.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
		$rows = $wpdb->get_results( $wpdb->prepare(
			"SELECT s.referrer AS raw_ref, s.utm_source AS raw_utm, s.utm_medium AS raw_med, s.entry_page AS entry_url, COUNT(*) AS cnt
			 FROM " . $wpdb->prefix . "optibehavior_sessions s
			 WHERE s.start_time BETWEEN %s AND %s" . $spam_clause . "
			 GROUP BY s.referrer, s.utm_source, s.utm_medium, s.entry_page
			 ORDER BY cnt DESC
			 LIMIT 5000",
			$start_date, $end_date
		) );

		$agg = array();

		// Strip www. prefix from site host for robust comparison (handles www vs non-www mismatch)
		$site_host_clean = $site_host ? preg_replace( '/^www\./i', '', $site_host ) : '';

		// Known classifiers
		$search_map = array(
			'google.' => 'Google', 'bing.com' => 'Bing', 'duckduckgo.com' => 'DuckDuckGo',
			'yahoo.' => 'Yahoo', 'yandex.' => 'Yandex', 'baidu.' => 'Baidu', 'naver.com' => 'Naver',
			'ecosia.org' => 'Ecosia', 'startpage.com' => 'Startpage', 'qwant.com' => 'Qwant'
		);
		$social_map = array(
			'facebook.com' => 'Facebook', 'fb.com' => 'Facebook', 'instagram.com' => 'Instagram',
			'twitter.com' => 'Twitter/X', 'x.com' => 'Twitter/X', 't.co' => 'Twitter/X',
			'linkedin.com' => 'LinkedIn', 'pinterest.' => 'Pinterest', 'reddit.com' => 'Reddit',
			'tiktok.com' => 'TikTok', 'snapchat.com' => 'Snapchat', 'youtube.com' => 'YouTube'
		);
		$paid_click_ids = array('gclid','msclkid','fbclid','twclid','ttclid','yclid','gbraid','wbraid','mc_eid');

		foreach ( (array)$rows as $r ) {
			$ref = is_string($r->raw_ref) ? trim($r->raw_ref) : '';
			$utm = is_string($r->raw_utm) ? trim($r->raw_utm) : '';
			$med = is_string($r->raw_med) ? trim($r->raw_med) : '';
			$entry = is_string($r->entry_url) ? trim($r->entry_url) : '';
			$host = '';
			if ($ref !== '') { $host = wp_parse_url($ref, PHP_URL_HOST) ?: ''; }
			// Same-site referrer => direct (strip www. for robust comparison)
			$host_clean = $host ? preg_replace( '/^www\./i', '', $host ) : '';
			if ($host_clean && $site_host_clean && strcasecmp($host_clean, $site_host_clean) === 0) { $host = ''; }

			// Inspect entry_page query for click IDs and UTM
			$qs = $entry ? wp_parse_url($entry, PHP_URL_QUERY) : '';
			$params = array();
			if ($qs) { parse_str($qs, $params); }
			$has_click_id = false;
			foreach ($paid_click_ids as $cid) { if (!empty($params[$cid])) { $has_click_id = true; break; } }

			$label = 'Direct / None';

			if ($host !== '') {
				// Classify by known search/social domains first
				$classified = false;
				foreach ($search_map as $needle => $name) { if (stripos($host, $needle) !== false) { $label = $name; $classified = true; break; } }
				if (!$classified) {
					foreach ($social_map as $needle => $name) { if (stripos($host, $needle) !== false) { $label = $name; $classified = true; break; } }
				}
				if (!$classified) { $label = $host; }
			} else {
				// No referrer; check click IDs and UTM
				if ($has_click_id) {
					if (!empty($params['gclid']))      { $label = 'Google Ads'; }
					elseif (!empty($params['msclkid'])) { $label = 'Microsoft Ads'; }
					elseif (!empty($params['fbclid']))  { $label = 'Facebook Ads'; }
					elseif (!empty($params['twclid']))  { $label = 'Twitter Ads'; }
					elseif (!empty($params['ttclid']))  { $label = 'TikTok Ads'; }
					else { $label = 'Paid Ads'; }
				} elseif ($utm !== '' || $med !== '') {
					// Prefer utm_source; fall back to utm_medium to categorize
					$src = $utm !== '' ? $utm : $med;
					// normalize some common values
					$src_l = strtolower($src);
					if (in_array($src_l, array('cpc','ppc','paid','paid_search'))) { $label = 'Paid Search'; }
					elseif (in_array($src_l, array('email','newsletter'))) { $label = 'Email'; }
					elseif (in_array($src_l, array('social','social_media'))) { $label = 'Social'; }
					else { $label = $src; }
				} else {
					$label = 'Direct / None';
				}
			}

			$agg[$label] = ($agg[$label] ?? 0) + intval($r->cnt);
		}

		arsort($agg);
		$out = array();
		foreach ( array_keys($agg) as $k ) {
			// Extract domain for favicon
			$domain = '';
			$favicon_url = '';

			// Try to extract domain from referrer label
			if ( $k !== 'Direct / None' && $k !== 'Paid Ads' && $k !== 'Paid Search' && $k !== 'Email' && $k !== 'Social' ) {
				// Check if it's a known service (e.g., "Google", "Facebook")
				$known_domains = array(
					'Google' => 'google.com',
					'Bing' => 'bing.com',
					'DuckDuckGo' => 'duckduckgo.com',
					'Yahoo' => 'yahoo.com',
					'Yandex' => 'yandex.com',
					'Baidu' => 'baidu.com',
					'Naver' => 'naver.com',
					'Ecosia' => 'ecosia.org',
					'Startpage' => 'startpage.com',
					'Qwant' => 'qwant.com',
					'Facebook' => 'facebook.com',
					'Instagram' => 'instagram.com',
					'Twitter/X' => 'twitter.com',
					'LinkedIn' => 'linkedin.com',
					'Pinterest' => 'pinterest.com',
					'Reddit' => 'reddit.com',
					'TikTok' => 'tiktok.com',
					'Snapchat' => 'snapchat.com',
					'YouTube' => 'youtube.com',
					'Google Ads' => 'google.com',
					'Microsoft Ads' => 'bing.com',
					'Facebook Ads' => 'facebook.com',
					'Twitter Ads' => 'twitter.com',
					'TikTok Ads' => 'tiktok.com',
				);

				if ( isset( $known_domains[ $k ] ) ) {
					$domain = $known_domains[ $k ];
				} else {
					// It's a domain or subdomain (e.g., "api.pfbaza.website")
					// Extract main domain for favicon (api.pfbaza.website -> pfbaza.website)
					$domain = $this->extract_main_domain( $k );
				}

				// Build favicon URL using the main domain
				if ( $domain ) {
					$favicon_url = 'https://' . $domain . '/favicon.ico';
				}
			}

			$display_label = $k;
			if ( $k === 'Direct / None' ) {
				$display_label = __( 'Direct / None', 'opti-behavior' );
			}
			$out[] = array(
				'referrer' => $display_label,
				'count' => intval($agg[$k]),
				'domain' => $domain,
				'favicon_url' => $favicon_url
			);
		}

		// Cache for 15 minutes for dashboard performance
		set_transient( $cache_key, $out, 900 );

		return $out;
	}

	/**
	 * Get operating systems data
	 *
	 * @param string $start_date Start date.
	 * @param string $end_date End date.
	 * @return array
	 */
	private function get_operating_systems_data( $start_date, $end_date ) {
		global $wpdb;

		// Check cache first (5-minute TTL for performance)
		$exclude_spam = $this->is_spam_excluded();
		$cache_key = 'opti_behavior_os_' . md5( $start_date . $end_date . ( $exclude_spam ? '1' : '0' ) );
		$cached = get_transient( $cache_key );
		if ( false !== $cached ) {
			return $cached;
		}

		$spam_clause = $this->get_spam_exclusion_clause( 's' );

		// Use sampling subquery to avoid full table scan on large datasets
		// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery -- Static LIKE patterns with no user input
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
		$results = $wpdb->get_results( $wpdb->prepare(
			"SELECT
				CASE
					WHEN sampled.os IS NULL OR TRIM(sampled.os) = '' THEN NULL
					WHEN LOWER(TRIM(sampled.os)) IN ('unknown','undefined','other','') THEN NULL
					WHEN LOWER(TRIM(sampled.os)) IN ('mac os','macos','os x','mac os x','macintosh') OR LOWER(TRIM(sampled.os)) LIKE 'mac%%' THEN 'macOS'
					WHEN LOWER(TRIM(sampled.os)) IN ('ios','iphone os','ipados') OR LOWER(TRIM(sampled.os)) LIKE 'ios%%' THEN 'iOS'
					WHEN LOWER(TRIM(sampled.os)) LIKE 'windows%%' OR LOWER(TRIM(sampled.os)) LIKE 'win %%' OR LOWER(TRIM(sampled.os)) LIKE 'win10%%' OR LOWER(TRIM(sampled.os)) LIKE 'win11%%' THEN 'Windows'
					WHEN LOWER(TRIM(sampled.os)) LIKE 'android%%' THEN 'Android'
					WHEN LOWER(TRIM(sampled.os)) IN ('ubuntu','debian','fedora','centos','red hat','suse','freebsd','openbsd','netbsd') OR LOWER(TRIM(sampled.os)) LIKE 'linux%%' THEN 'Linux'
					WHEN LOWER(TRIM(sampled.os)) IN ('chromeos','chrome os','cros') THEN 'Chrome OS'
					ELSE NULL
				END AS os,
				COUNT(*) AS count
			FROM (
				SELECT v.os
				FROM " . $wpdb->prefix . "optibehavior_sessions s
				LEFT JOIN " . $wpdb->prefix . "optibehavior_visitors v ON s.visitor_id = v.id
				WHERE s.start_time BETWEEN %s AND %s" . $spam_clause . "
				LIMIT 10000
			) sampled
			GROUP BY os
			HAVING os IS NOT NULL
			ORDER BY count DESC
			LIMIT 10",
			$start_date,
			$end_date
		) );
		// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery

		// Process results and filter out Unknown/NULL entries completely
		$map = array();
		foreach ( $results as $row ) {
			$label = isset( $row->os ) ? trim( (string) $row->os ) : '';
			// Skip empty, null, or unknown entries completely
			if ( $label === '' || $label === 'Unknown' || $label === 'NULL' ) {
				continue;
			}
			if ( ! isset( $map[ $label ] ) ) { $map[ $label ] = 0; }
			$map[ $label ] += intval( $row->count );
		}
		$data = array();
		foreach ( $map as $label => $count ) {
			$data[] = array( 'os' => $label, 'count' => $count );
		}

		// Cache for 5 minutes
		set_transient( $cache_key, $data, 900 ); // 15 minutes for dashboard performance

		return $data;
	}



	/**
	 * Get active visitors
	 *
	 * @return int
	 */
	private function get_active_visitors() {
		global $wpdb;

		// Consider visitors active if they had activity in the last 10 minutes
		$cutoff = gmdate( 'Y-m-d H:i:s', time() - 600 );

		// Get spam exclusion clause
		$spam_clause = $this->get_spam_exclusion_clause( 's' );

		// Get active visitors with their CURRENT page (most recent pageview), not just entry page.
		// Uses a subquery on optibehavior_pageviews to find the last page viewed per session.
		// Falls back to entry_page if no pageview exists yet (e.g., session just started).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
		$results = $wpdb->get_results( $wpdb->prepare(
			"SELECT
				ls.session_id,
				ls.visitor_id,
				ls.entry_page,
				ls.start_time,
				ls.end_time,
				ls.ip,
				COALESCE(NULLIF(TRIM(v.country),''), 'UN') AS country,
				v.country_name,
				v.device_type,
				COALESCE(latest_pv.url, ls.entry_page) as current_page,
				COALESCE(latest_pv.title, p.title, 'Unknown Page') as page_title,
				COALESCE(ls.end_time, ls.start_time) as last_activity
			FROM (
				SELECT s.id as session_id, s.visitor_id, s.entry_page, s.start_time, s.end_time, s.ip,
					ROW_NUMBER() OVER (PARTITION BY s.visitor_id ORDER BY COALESCE(s.end_time, s.start_time) DESC) as rn
				FROM " . $wpdb->prefix . "optibehavior_sessions s
				WHERE (s.end_time > %s OR s.start_time > %s)" . $spam_clause . "
			) ls
			LEFT JOIN " . $wpdb->prefix . "optibehavior_visitors v ON ls.visitor_id = v.id
			LEFT JOIN " . $wpdb->prefix . "optibehavior_pages p ON p.id = (
				SELECT MIN(p2.id) FROM " . $wpdb->prefix . "optibehavior_pages p2
				WHERE p2.url = ls.entry_page OR p2.url2 = ls.entry_page
			)
			LEFT JOIN (
				SELECT pv1.session_id, pv1.url, pv1.title
				FROM " . $wpdb->prefix . "optibehavior_pageviews pv1
				INNER JOIN (
					SELECT session_id, MAX(view_time) as max_view_time
					FROM " . $wpdb->prefix . "optibehavior_pageviews
					GROUP BY session_id
				) pv2 ON pv1.session_id = pv2.session_id AND pv1.view_time = pv2.max_view_time
				WHERE pv1.id = (
					SELECT MAX(pv3.id) FROM " . $wpdb->prefix . "optibehavior_pageviews pv3
					WHERE pv3.session_id = pv1.session_id AND pv3.view_time = pv2.max_view_time
				)
			) latest_pv ON ls.session_id = latest_pv.session_id
			WHERE ls.rn = 1
			ORDER BY last_activity DESC
			LIMIT 50",
			$cutoff,
			$cutoff
		) );

		// Generate flag emoji dynamically from ISO country code (fallback to globe)
		// This avoids missing flags like ZA (South Africa), etc.
		$country_flag = function(string $code) {
			$code = strtoupper(trim($code));
			if (preg_match('/^[A-Z]{2}$/', $code)) {
				$base = 127397; // regional indicator base
				$chars = str_split($code);
				$emoji = '';
				foreach ($chars as $ch) { $emoji .= mb_convert_encoding('&#' . ($base + ord($ch)) . ';', 'UTF-8', 'HTML-ENTITIES'); }
				return $emoji;
			}
			return '🌍';
		};

		$visitors = array();
		foreach ( $results as $row ) {
			$last_activity = strtotime($row->end_time ?: $row->start_time);
			$time_ago = $this->time_ago($last_activity);

			// Get page title with proper fallback logic
			$page_title = $row->page_title;
			$current_page = (string) $row->current_page;
			$visitor_ip = (string) ($row->ip ?? '');
			// Normalize empty/null IP to 'Anonymous' -- semantically equivalent: no IP was stored (GDPR-safe)
			if ( '' === $visitor_ip ) {
				$visitor_ip = 'Anonymous';
			}

			// Skip visitors without both a valid URL and IP address
			if ( empty( $current_page ) && empty( $visitor_ip ) ) {
				continue;
			}

			// If we still have "Unknown Page", try to extract a meaningful title from the URL
			if ( empty( $page_title ) || $page_title === 'Unknown Page' ) {
				if ( ! empty( $current_page ) ) {
					// Parse the URL to get a readable title
					$parsed_url = wp_parse_url( $current_page );
					$path = isset( $parsed_url['path'] ) ? trim( $parsed_url['path'], '/' ) : '';

					if ( empty( $path ) ) {
						$page_title = 'Homepage';
					} else {
						// Get the last segment of the path
						$segments = explode( '/', $path );
						$last_segment = end( $segments );

						// Convert slug to readable title
						$page_title = ucwords( str_replace( array( '-', '_' ), ' ', $last_segment ) );

						// If still empty, use a generic title
						if ( empty( $page_title ) ) {
							$page_title = 'Page';
						}
					}
				} else {
					$page_title = 'Unknown Page';
				}
			}

			$country_code = strtoupper($row->country ?: 'UN');
			$country_name = $row->country_name ?: ($country_code !== 'UN' ? $this->get_country_name($country_code) : 'Unknown');
			$visitors[] = array(
				'session_id'   => $row->session_id,
				'country'      => $country_name,
				'country_code' => $country_code,
				'flag'         => $country_flag($country_code),
				'page_title'   => $page_title,
				'current_url'  => $current_page,
				'visited_at'   => date_i18n('j-M H:i', $last_activity),
				'time_ago'     => $time_ago,
				'ip'           => $visitor_ip,
				'device_type'  => $row->device_type ?: 'Desktop'
			);
		}

		return $visitors;
	}

	/**
	 * Get time ago string
	 */
	private function time_ago($timestamp) {
		$time_diff = time() - $timestamp;

		if ($time_diff < 60) {
			return __( 'just now', 'opti-behavior' );
		} elseif ($time_diff < 3600) {
			$minutes = floor($time_diff / 60);
			/* translators: %s: number of minutes */
			return sprintf( __( '%sm ago', 'opti-behavior' ), $minutes );
		} elseif ($time_diff < 86400) {
			$hours = floor($time_diff / 3600);
			/* translators: %s: number of hours */
			return sprintf( __( '%sh ago', 'opti-behavior' ), $hours );
		} else {
			$days = floor($time_diff / 86400);
			/* translators: %s: number of days */
			return sprintf( __( '%sd ago', 'opti-behavior' ), $days );
		}
	}

	/**
	 * Get country name from country code
	 */
	private function get_country_name($country_code) {
		$country_code = strtoupper(trim((string)$country_code));
		$countries = array('AF'=>'Afghanistan','AX'=>'Åland Islands','AL'=>'Albania','DZ'=>'Algeria','AS'=>'American Samoa','AD'=>'Andorra','AO'=>'Angola','AI'=>'Anguilla','AQ'=>'Antarctica','AG'=>'Antigua and Barbuda','AR'=>'Argentina','AM'=>'Armenia','AW'=>'Aruba','AU'=>'Australia','AT'=>'Austria','AZ'=>'Azerbaijan','BS'=>'Bahamas','BH'=>'Bahrain','BD'=>'Bangladesh','BB'=>'Barbados','BY'=>'Belarus','BE'=>'Belgium','BZ'=>'Belize','BJ'=>'Benin','BM'=>'Bermuda','BT'=>'Bhutan','BO'=>'Bolivia','BQ'=>'Bonaire, Sint Eustatius and Saba','BA'=>'Bosnia and Herzegovina','BW'=>'Botswana','BV'=>'Bouvet Island','BR'=>'Brazil','IO'=>'British Indian Ocean Territory','BN'=>'Brunei','BG'=>'Bulgaria','BF'=>'Burkina Faso','BI'=>'Burundi','CV'=>'Cabo Verde','KH'=>'Cambodia','CM'=>'Cameroon','CA'=>'Canada','KY'=>'Cayman Islands','CF'=>'Central African Republic','TD'=>'Chad','CL'=>'Chile','CN'=>'China','CX'=>'Christmas Island','CC'=>'Cocos (Keeling) Islands','CO'=>'Colombia','KM'=>'Comoros','CG'=>'Congo','CD'=>'Congo, Democratic Republic of the','CK'=>'Cook Islands','CR'=>'Costa Rica','CI'=>'Côte d’Ivoire','HR'=>'Croatia','CU'=>'Cuba','CW'=>'Curaçao','CY'=>'Cyprus','CZ'=>'Czech Republic','DK'=>'Denmark','DJ'=>'Djibouti','DM'=>'Dominica','DO'=>'Dominican Republic','EC'=>'Ecuador','EG'=>'Egypt','SV'=>'El Salvador','GQ'=>'Equatorial Guinea','ER'=>'Eritrea','EE'=>'Estonia','SZ'=>'Eswatini','ET'=>'Ethiopia','FK'=>'Falkland Islands','FO'=>'Faroe Islands','FJ'=>'Fiji','FI'=>'Finland','FR'=>'France','GF'=>'French Guiana','PF'=>'French Polynesia','TF'=>'French Southern Territories','GA'=>'Gabon','GM'=>'Gambia','GE'=>'Georgia','DE'=>'Germany','GH'=>'Ghana','GI'=>'Gibraltar','GR'=>'Greece','GL'=>'Greenland','GD'=>'Grenada','GP'=>'Guadeloupe','GU'=>'Guam','GT'=>'Guatemala','GG'=>'Guernsey','GN'=>'Guinea','GW'=>'Guinea-Bissau','GY'=>'Guyana','HT'=>'Haiti','HM'=>'Heard Island and McDonald Islands','VA'=>'Holy See','HN'=>'Honduras','HK'=>'Hong Kong','HU'=>'Hungary','IS'=>'Iceland','IN'=>'India','ID'=>'Indonesia','IR'=>'Iran','IQ'=>'Iraq','IE'=>'Ireland','IM'=>'Isle of Man','IL'=>'Israel','IT'=>'Italy','JM'=>'Jamaica','JP'=>'Japan','JE'=>'Jersey','JO'=>'Jordan','KZ'=>'Kazakhstan','KE'=>'Kenya','KI'=>'Kiribati','KP'=>'North Korea','KR'=>'South Korea','KW'=>'Kuwait','KG'=>'Kyrgyzstan','LA'=>'Laos','LV'=>'Latvia','LB'=>'Lebanon','LS'=>'Lesotho','LR'=>'Liberia','LY'=>'Libya','LI'=>'Liechtenstein','LT'=>'Lithuania','LU'=>'Luxembourg','MO'=>'Macao','MG'=>'Madagascar','MW'=>'Malawi','MY'=>'Malaysia','MV'=>'Maldives','ML'=>'Mali','MT'=>'Malta','MH'=>'Marshall Islands','MQ'=>'Martinique','MR'=>'Mauritania','MU'=>'Mauritius','YT'=>'Mayotte','MX'=>'Mexico','FM'=>'Micronesia','MD'=>'Moldova','MC'=>'Monaco','MN'=>'Mongolia','ME'=>'Montenegro','MS'=>'Montserrat','MA'=>'Morocco','MZ'=>'Mozambique','MM'=>'Myanmar','NA'=>'Namibia','NR'=>'Nauru','NP'=>'Nepal','NL'=>'Netherlands','NC'=>'New Caledonia','NZ'=>'New Zealand','NI'=>'Nicaragua','NE'=>'Niger','NG'=>'Nigeria','NU'=>'Niue','NF'=>'Norfolk Island','MK'=>'North Macedonia','MP'=>'Northern Mariana Islands','NO'=>'Norway','OM'=>'Oman','PK'=>'Pakistan','PW'=>'Palau','PS'=>'Palestine, State of','PA'=>'Panama','PG'=>'Papua New Guinea','PY'=>'Paraguay','PE'=>'Peru','PH'=>'Philippines','PN'=>'Pitcairn','PL'=>'Poland','PT'=>'Portugal','PR'=>'Puerto Rico','QA'=>'Qatar','RE'=>'Réunion','RO'=>'Romania','RU'=>'Russia','RW'=>'Rwanda','BL'=>'Saint Barthélemy','SH'=>'Saint Helena, Ascension and Tristan da Cunha','KN'=>'Saint Kitts and Nevis','LC'=>'Saint Lucia','MF'=>'Saint Martin','PM'=>'Saint Pierre and Miquelon','VC'=>'Saint Vincent and the Grenadines','WS'=>'Samoa','SM'=>'San Marino','ST'=>'São Tomé and Príncipe','SA'=>'Saudi Arabia','SN'=>'Senegal','RS'=>'Serbia','SC'=>'Seychelles','SL'=>'Sierra Leone','SG'=>'Singapore','SX'=>'Sint Maarten','SK'=>'Slovakia','SI'=>'Slovenia','SB'=>'Solomon Islands','SO'=>'Somalia','ZA'=>'South Africa','GS'=>'South Georgia and the South Sandwich Islands','SS'=>'South Sudan','ES'=>'Spain','LK'=>'Sri Lanka','SD'=>'Sudan','SR'=>'Suriname','SJ'=>'Svalbard and Jan Mayen','SE'=>'Sweden','CH'=>'Switzerland','SY'=>'Syria','TW'=>'Taiwan','TJ'=>'Tajikistan','TZ'=>'Tanzania','TH'=>'Thailand','TL'=>'Timor-Leste','TG'=>'Togo','TK'=>'Tokelau','TO'=>'Tonga','TT'=>'Trinidad and Tobago','TN'=>'Tunisia','TR'=>'Türkiye','TM'=>'Turkmenistan','TC'=>'Turks and Caicos Islands','TV'=>'Tuvalu','UG'=>'Uganda','UA'=>'Ukraine','AE'=>'United Arab Emirates','GB'=>'United Kingdom','US'=>'United States','UM'=>'U.S. Outlying Islands','UY'=>'Uruguay','UZ'=>'Uzbekistan','VU'=>'Vanuatu','VE'=>'Venezuela','VN'=>'Vietnam','VG'=>'British Virgin Islands','VI'=>'U.S. Virgin Islands','WF'=>'Wallis and Futuna','EH'=>'Western Sahara','YE'=>'Yemen','ZM'=>'Zambia','ZW'=>'Zimbabwe');

		// Fallback: if code not in map, show the code itself (never "Unknown")
		return $countries[$country_code] ?? ($country_code ?: 'Other');
	}

	/**
	 * Get recent sessions
	 *
	 * @param int $limit Number of sessions to return.
	 * @return array
	 */
	private function get_recent_sessions( $limit = 10 ) {
		global $wpdb;

		// Get spam exclusion clause
		$spam_clause = $this->get_spam_exclusion_clause( 's' );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data requires direct DB queries; table name from $wpdb->prefix.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
		$results = $wpdb->get_results( $wpdb->prepare(
			"SELECT
				s.id,
				s.start_time,
				s.entry_page,
				v.device_type,
				v.country_name
			FROM " . $wpdb->prefix . "optibehavior_sessions s
			LEFT JOIN " . $wpdb->prefix . "optibehavior_visitors v ON s.visitor_id = v.id
			WHERE 1=1" . $spam_clause . "
			ORDER BY s.start_time DESC
			LIMIT %d",
			$limit
		) );

		$data = array();
		foreach ( $results as $row ) {
			$data[] = array(
				'id' => $row->id,
				'start_time' => $row->start_time,
				'entry_page' => $row->entry_page,
				'device_type' => $row->device_type,
				'country_name' => $row->country_name,
			);
		}

		return $data;
	}

	/**
	 * Get funnel data
	 *
	 * @param array $date_range Date range.
	 * @return array
	 */
	private function get_funnel_data( $date_range ) {
		// Placeholder for funnel analysis
		return array(
			'steps' => array(),
			'conversion_rate' => 0,
		);
	}

	/**
	 * Get user journey data
	 *
	 * @param array $date_range Date range.
	 * @return array
	 */
	private function get_user_journey_data( $date_range ) {
		// Placeholder for user journey analysis
		return array(
			'paths' => array(),
			'common_flows' => array(),
		);
	}

	/**
	 * Get conversion data
	 *
	 * @param array $date_range Date range.
	 * @return array
	 */
	private function get_conversion_data( $date_range ) {
		// Placeholder for conversion analysis
		return array(
			'goals' => array(),
			'conversion_rate' => 0,
		);
	}

	/**
	 * Get retention data
	 *
	 * @param array $date_range Date range.
	 * @return array
	 */
	private function get_retention_data( $date_range ) {
		// Placeholder for retention analysis
		return array(
			'cohorts' => array(),
			'retention_rate' => 0,
		);
	}

	/**
	 * Get overview analytics
	 *
	 * @param array $date_range Date range.
	 * @return array
	 */
	private function get_overview_analytics( $date_range ) {
		return array(
			'summary' => $this->get_dashboard_data( 'last30days' ),
			'trends' => array(),
		);
	}

	/**
	 * Render modern settings page
	 */
	public function render_settings() {
		return $this->render_settings_impl();
	}

	/**
	 * Render settings page header
	 */
	private function render_settings_header() {
		return $this->render_settings_header_impl();
	}

	/**
	 * Render settings content
	 */
	private function render_settings_content($settings) {
		return $this->render_settings_content_impl($settings);
	}

	/**
	 * Get current settings from database
	 */
	private function get_current_settings() {
		return $this->get_current_settings_impl();
	}

	/**
	 * Handle settings form submission
	 */
	private function delete_all_analytics_data() {
		return $this->delete_all_analytics_data_impl();
	}

	private function delete_logs_in_date_range($start, $end){
		return $this->delete_logs_in_date_range_impl($start, $end);
	}

	private function export_analytics_csv(){
		return $this->export_analytics_csv_impl();
	}

	private function export_analytics_sql(){
		return $this->export_analytics_sql_impl();
	}

	private function handle_settings_save() {
		return $this->handle_settings_save_impl();
	}

		/**
		 * Conditionally ensure helpful DB indexes for performance, at most once per day
		 */
		public function maybe_ensure_db_indexes(){
			return $this->maybe_ensure_db_indexes_impl();
		}

		/**
		 * Create missing DB indexes used by dashboard queries
		 */
		private function ensure_db_indexes(){
			return $this->ensure_db_indexes_impl();
		}

		/**
		 * Conditionally backfill referrers (utm_source) from entry_page for recent sessions
		 * Runs at most once per day and only for admins
		 */
		public function maybe_backfill_referrers(){
			return $this->maybe_backfill_referrers_impl();
		}

		private function backfill_referrers_from_entry_page($days = 30, $limit = 2000){
			return $this->backfill_referrers_from_entry_page_impl($days, $limit);
		}

		/**
		 * AJAX handler: Cleanup recordings by date
		 */
		public function ajax_cleanup_by_date() {
			return $this->ajax_cleanup_by_date_impl();
		}

		/**
		 * AJAX handler: Cleanup recordings by duration
		 */
		public function ajax_cleanup_by_duration() {
			return $this->ajax_cleanup_by_duration_impl();
		}

		/**
		 * AJAX handler: Cleanup orphaned recording files
		 */
		public function ajax_cleanup_orphaned() {
			return $this->ajax_cleanup_orphaned_files_impl();
		}

		/**
		 * AJAX handler to get debug log contents
		 */
		public function ajax_get_debug_log() {
			// Verify nonce
			if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'opti_behavior_view_log' ) ) {
				wp_send_json_error( 'Security check failed' );
			}

			// Check permissions
			if ( ! current_user_can( 'manage_options' ) ) {
				wp_send_json_error( 'Insufficient permissions' );
			}

			$debug_manager = $this->heatmap->get_debug_manager();
			$log_contents = $debug_manager->get_log_contents( 500 ); // Last 500 lines

			wp_send_json_success( array( 'log_content' => $log_contents ) );
		}

		/**
		 * Handle debug log download
		 */
		public function handle_debug_log_download() {
			// phpcs:disable WordPress.Security.NonceVerification.Recommended -- GET parameter check for download action (permission check follows)
			// Check if download is requested
			if ( ! isset( $_GET['opti_behavior_download_log'] ) ) {
				return;
			}

			// Check permissions
			if ( ! current_user_can( 'manage_options' ) ) {
				wp_die( 'Insufficient permissions' );
			}

			// Check we're on the settings page
			if ( ! isset( $_GET['page'] ) || sanitize_text_field( wp_unslash( $_GET['page'] ) ) !== 'opti-behavior-settings' ) {
				// phpcs:enable WordPress.Security.NonceVerification.Recommended
				return;
			}
			// phpcs:enable WordPress.Security.NonceVerification.Recommended

			$debug_manager = $this->heatmap->get_debug_manager();
			$log_file = $debug_manager->get_log_file_path();

			if ( ! file_exists( $log_file ) ) {
				wp_die( 'Log file not found' );
			}

			// Set headers for download
			header( 'Content-Type: text/plain' );
			header( 'Content-Disposition: attachment; filename="opti-behavior-heatmap-debug-' . gmdate( 'Y-m-d-H-i-s' ) . '.log"' );
			header( 'Content-Length: ' . filesize( $log_file ) );

			// Stream file contents directly -- readfile() is correct for local file downloads
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Streaming local log file for admin download
			readfile( $log_file );
			exit;
		}

		/**
		 * AJAX: Get schedule data
		 */
		public function ajax_get_schedule() {
			check_ajax_referer( 'opti_behavior_schedule_action', 'nonce' );

			if ( ! current_user_can( 'manage_options' ) ) {
				wp_send_json_error( 'Permission denied' );
			}

			$schedule_id = isset( $_POST['schedule_id'] ) ? intval( $_POST['schedule_id'] ) : 0;
			if ( ! $schedule_id ) {
				wp_send_json_error( 'Invalid schedule ID' );
			}

			$scheduler = new Opti_Behavior_Report_Scheduler( $this->heatmap );
			$schedule = $scheduler->get_schedule( $schedule_id );

			if ( ! $schedule ) {
				wp_send_json_error( 'Schedule not found' );
			}

			wp_send_json_success( $schedule );
		}

		/**
		 * AJAX: Save schedule
		 */
		public function ajax_save_schedule() {
			check_ajax_referer( 'opti_behavior_schedule_action', 'schedule_nonce' );

			if ( ! current_user_can( 'manage_options' ) ) {
				wp_send_json_error( 'Permission denied' );
			}

			$schedule_id = isset( $_POST['schedule_id'] ) ? intval( $_POST['schedule_id'] ) : 0;

			// Collect form data
			$data = array(
				'name'                      => isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : '',
				'frequency'                 => isset( $_POST['frequency'] ) ? sanitize_key( $_POST['frequency'] ) : 'weekly',
				'day_of_week'               => isset( $_POST['day_of_week'] ) ? intval( $_POST['day_of_week'] ) : 1,
				'day_of_month'              => isset( $_POST['day_of_month'] ) ? intval( $_POST['day_of_month'] ) : 1,
				'send_time'                 => isset( $_POST['send_time'] ) ? sanitize_text_field( wp_unslash( $_POST['send_time'] ) ) . ':00' : '09:00:00',
				'report_period'             => isset( $_POST['report_period'] ) ? sanitize_key( $_POST['report_period'] ) : 'last7days',
				'include_kpis'              => isset( $_POST['include_kpis'] ) ? 1 : 0,
				'include_top_pages'         => isset( $_POST['include_top_pages'] ) ? 1 : 0,
				'include_top_referrers'     => isset( $_POST['include_top_referrers'] ) ? 1 : 0,
				'include_traffic_breakdown' => isset( $_POST['include_traffic_breakdown'] ) ? 1 : 0,
				'include_funnels'           => isset( $_POST['include_funnels'] ) ? 1 : 0,
				'include_heatmap_summary'   => isset( $_POST['include_heatmap_summary'] ) ? 1 : 0,
				'include_geographic'        => isset( $_POST['include_geographic'] ) ? 1 : 0,
				'include_recordings_stats'  => isset( $_POST['include_recordings_stats'] ) ? 1 : 0,
				'include_errors'            => isset( $_POST['include_errors'] ) ? 1 : 0,
				'include_friction'          => isset( $_POST['include_friction'] ) ? 1 : 0,
				'include_performance'       => isset( $_POST['include_performance'] ) ? 1 : 0,
				'include_broken_links'      => isset( $_POST['include_broken_links'] ) ? 1 : 0,
				'include_user_journeys'     => isset( $_POST['include_user_journeys'] ) ? 1 : 0,
				'include_form_analytics'    => isset( $_POST['include_form_analytics'] ) ? 1 : 0,
				'recipients'                => isset( $_POST['recipients'] ) ? sanitize_textarea_field( wp_unslash( $_POST['recipients'] ) ) : '',
			);

			$scheduler = new Opti_Behavior_Report_Scheduler( $this->heatmap );

			if ( $schedule_id ) {
				// Update existing
				$result = $scheduler->update_schedule( $schedule_id, $data );
			} else {
				// Create new
				$result = $scheduler->create_schedule( $data );
			}

			if ( $result ) {
				wp_send_json_success( array( 'id' => $result ) );
			} else {
				wp_send_json_error( 'Failed to save schedule. Make sure you have valid email recipients.' );
			}
		}

		/**
		 * AJAX: Toggle schedule enabled/disabled
		 */
		public function ajax_toggle_schedule() {
			check_ajax_referer( 'opti_behavior_schedule_action', 'nonce' );

			if ( ! current_user_can( 'manage_options' ) ) {
				wp_send_json_error( 'Permission denied' );
			}

			$schedule_id = isset( $_POST['schedule_id'] ) ? intval( $_POST['schedule_id'] ) : 0;
			$enabled = isset( $_POST['enabled'] ) ? intval( $_POST['enabled'] ) : 0;

			if ( ! $schedule_id ) {
				wp_send_json_error( 'Invalid schedule ID' );
			}

			$scheduler = new Opti_Behavior_Report_Scheduler( $this->heatmap );
			$result = $scheduler->toggle_schedule( $schedule_id, $enabled );

			if ( $result ) {
				wp_send_json_success();
			} else {
				wp_send_json_error( 'Failed to toggle schedule' );
			}
		}

		/**
		 * AJAX: Delete schedule
		 */
		public function ajax_delete_schedule() {
			check_ajax_referer( 'opti_behavior_schedule_action', 'nonce' );

			if ( ! current_user_can( 'manage_options' ) ) {
				wp_send_json_error( 'Permission denied' );
			}

			$schedule_id = isset( $_POST['schedule_id'] ) ? intval( $_POST['schedule_id'] ) : 0;

			if ( ! $schedule_id ) {
				wp_send_json_error( 'Invalid schedule ID' );
			}

			$scheduler = new Opti_Behavior_Report_Scheduler( $this->heatmap );
			$result = $scheduler->delete_schedule( $schedule_id );

			if ( $result ) {
				wp_send_json_success();
			} else {
				wp_send_json_error( 'Failed to delete schedule' );
			}
		}

		/**
		 * AJAX: Send test report email
		 */
		public function ajax_test_report() {
			check_ajax_referer( 'opti_behavior_schedule_action', 'nonce' );

			if ( ! current_user_can( 'manage_options' ) ) {
				wp_send_json_error( 'Permission denied' );
			}

			$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
			$schedule_id = isset( $_POST['schedule_id'] ) ? intval( $_POST['schedule_id'] ) : 0;

			if ( ! is_email( $email ) ) {
				wp_send_json_error( 'Invalid email address' );
			}

			// Get schedule if provided
			$schedule = array();
			if ( $schedule_id ) {
				$scheduler = new Opti_Behavior_Report_Scheduler( $this->heatmap );
				$schedule = $scheduler->get_schedule( $schedule_id );
			}

			try {
				// Send test
				$mailer = new Opti_Behavior_Report_Mailer( $this->heatmap );
				$result = $mailer->send_test( $email, $schedule );

				if ( is_wp_error( $result ) ) {
					wp_send_json_error( $result->get_error_message() );
				}

				wp_send_json_success();
			} catch ( \Throwable $opti_behavior_err ) {
				wp_send_json_error( 'Exception: ' . $opti_behavior_err->getMessage() . ' in ' . $opti_behavior_err->getFile() . ':' . $opti_behavior_err->getLine() );
			}
		}

		/**
		 * AJAX: Save report email settings
		 */
		public function ajax_save_email_settings() {
			check_ajax_referer( 'opti_behavior_schedule_action', 'nonce' );

			if ( ! current_user_can( 'manage_options' ) ) {
				wp_send_json_error( 'Permission denied' );
			}

			$from_name = isset( $_POST['from_name'] ) ? sanitize_text_field( wp_unslash( $_POST['from_name'] ) ) : '';
			$from_email = isset( $_POST['from_email'] ) ? sanitize_email( wp_unslash( $_POST['from_email'] ) ) : '';
			$email_method = isset( $_POST['email_method'] ) && in_array( $_POST['email_method'], array( 'wp_mail', 'smtp' ), true )
				? sanitize_text_field( wp_unslash( $_POST['email_method'] ) )
				: 'wp_mail';

			// Validate email if provided
			if ( ! empty( $from_email ) && ! is_email( $from_email ) ) {
				wp_send_json_error( __( 'Invalid email address', 'opti-behavior' ) );
			}

			// Build a plain array of changed values to pass to save()
			$new_options = array(
				'reports_from_name'  => $from_name,
				'reports_from_email' => $from_email,
				'email_method'       => $email_method,
			);

			// Save SMTP settings if method is smtp
			if ( 'smtp' === $email_method ) {
				$new_options['smtp_host']       = isset( $_POST['smtp_host'] ) ? sanitize_text_field( wp_unslash( $_POST['smtp_host'] ) ) : '';
				$new_options['smtp_port']       = isset( $_POST['smtp_port'] ) ? absint( $_POST['smtp_port'] ) : 587;
				$new_options['smtp_encryption'] = isset( $_POST['smtp_encryption'] ) && in_array( $_POST['smtp_encryption'], array( 'none', 'ssl', 'tls' ), true )
					? sanitize_text_field( wp_unslash( $_POST['smtp_encryption'] ) )
					: 'tls';
				$new_options['smtp_username']   = isset( $_POST['smtp_username'] ) ? sanitize_text_field( wp_unslash( $_POST['smtp_username'] ) ) : '';
				// Only update password if a new one was provided (not the placeholder)
				$smtp_password = isset( $_POST['smtp_password'] ) ? wp_unslash( $_POST['smtp_password'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
				if ( ! empty( $smtp_password ) ) {
					$new_options['smtp_password'] = $smtp_password;
				}
			}

			$this->heatmap->get_options()->save( $new_options );

			wp_send_json_success( array(
				'message' => __( 'Email settings saved', 'opti-behavior' ),
			) );
		}

		/**
		 * AJAX: Test email connection (sends a simple test email using saved settings)
		 */
		public function ajax_test_email_connection() {
			check_ajax_referer( 'opti_behavior_schedule_action', 'nonce' );

			if ( ! current_user_can( 'manage_options' ) ) {
				wp_send_json_error( 'Permission denied' );
			}

			$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
			if ( ! is_email( $email ) ) {
				wp_send_json_error( __( 'Invalid email address', 'opti-behavior' ) );
			}

			$options = $this->heatmap->get_options();
			$email_method = isset( $options['email_method'] ) ? $options['email_method'] : 'wp_mail';
			$from_name = ! empty( $options['reports_from_name'] ) ? $options['reports_from_name'] : get_bloginfo( 'name' );
			$from_email = ! empty( $options['reports_from_email'] ) ? $options['reports_from_email'] : get_option( 'admin_email' );

			$subject = sprintf(
				/* translators: %s: site name */
				__( '[%s] Opti-Behavior Test Email', 'opti-behavior' ),
				get_bloginfo( 'name' )
			);
			$body = sprintf(
				/* translators: 1: site name, 2: email method */
				__( 'This is a test email from %1$s sent via %2$s. If you received this, your email configuration is working correctly.', 'opti-behavior' ),
				get_bloginfo( 'name' ),
				'smtp' === $email_method ? 'Custom SMTP' : 'WordPress Default (wp_mail)'
			);

			try {
				if ( 'smtp' === $email_method ) {
					$result = Opti_Behavior_Report_Mailer::send_via_smtp(
						$email,
						$subject,
						$body,
						$from_name,
						$from_email,
						$options
					);
					if ( true !== $result ) {
						wp_send_json_error( $result );
					}
				} else {
					$headers = array(
						'Content-Type: text/html; charset=UTF-8',
						sprintf( 'From: %s <%s>', $from_name, $from_email ),
					);
					$sent = wp_mail( $email, $subject, $body, $headers );
					if ( ! $sent ) {
						wp_send_json_error( __( 'wp_mail() failed — check your server mail configuration', 'opti-behavior' ) );
					}
				}
				wp_send_json_success();
			} catch ( \Throwable $opti_behavior_err ) {
				wp_send_json_error( $opti_behavior_err->getMessage() );
			}
		}

}
