<?php
/**
 * Heatmap AJAX Handler
 *
 * Handles AJAX requests for heatmap detail page data.
 * Processes session recordings to generate heatmap coordinates and statistics.
 *
 * @package OptiBehaviorPro
 * @since 1.5.0
 * @modified 2025-12-16 - Added ajax_get_filter_options endpoint
 */

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

if ( class_exists( 'Opti_Behavior_Heatmap_Ajax', false ) ) {
	return;
}

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

/**
 * Class Opti_Behavior_Heatmap_Ajax
 *
 * Handles AJAX endpoints for heatmap data retrieval and processing.
 */
class Opti_Behavior_Heatmap_Ajax {

	/**
	 * Parser instance.
	 *
	 * @var Opti_Behavior_Heatmap_Parser
	 */
	private $parser;

	/**
	 * Cache instance.
	 *
	 * @var Opti_Behavior_Heatmap_Cache
	 */
	private $cache;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->parser = new Opti_Behavior_Heatmap_Parser();
		$this->cache  = new Opti_Behavior_Heatmap_Cache();

		// Register AJAX handlers.
		add_action( 'wp_ajax_opti_behavior_get_heatmap_data', array( $this, 'ajax_get_heatmap_data' ) );
		add_action( 'wp_ajax_opti_behavior_get_heatmap_stats', array( $this, 'ajax_get_heatmap_stats' ) );
		add_action( 'wp_ajax_opti_behavior_refresh_heatmap_cache', array( $this, 'ajax_refresh_cache' ) );
		add_action( 'wp_ajax_opti_behavior_get_device_counts', array( $this, 'ajax_get_device_counts' ) );
		add_action( 'wp_ajax_opti_behavior_get_total_recordings', array( $this, 'ajax_get_total_recordings' ) );
		add_action( 'wp_ajax_opti_behavior_get_heatmap_batch', array( $this, 'ajax_get_heatmap_batch' ) );
		add_action( 'wp_ajax_opti_behavior_get_filter_options', array( $this, 'ajax_get_filter_options' ) );
		add_action( 'wp_ajax_opti_behavior_get_dependent_filter_options', array( $this, 'ajax_get_dependent_filter_options' ) );
		add_action( 'wp_ajax_opti_behavior_proxy_page', array( $this, 'ajax_proxy_page' ) );
	}

	/**
	 * Debug logging helper - only logs if debug mode is enabled.
	 *
	 * @param string $message Message to log.
	 * @return void
	 */
	private function debug_log( $message ) {
		$core = Opti_Behavior_Heatmap_Core::get_instance();
		if ( $core ) {
			$debug_manager = $core->get_debug_manager();
			if ( $debug_manager ) {
				$debug_manager->log( $message, 'debug', 'heatmap-ajax' );
			}
		}
	}

	/**
	 * Get heatmap data AJAX handler.
	 */
	public function ajax_get_heatmap_data() {
		// Verify nonce.
		check_ajax_referer( 'opti_heatmap_detail', 'nonce' );

		// Verify permissions.
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'opti-behavior' ) ) );
		}

		// Get parameters.
		$page_id      = isset( $_POST['page_id'] ) ? absint( $_POST['page_id'] ) : 0;
		$device       = isset( $_POST['device'] ) ? sanitize_text_field( wp_unslash( $_POST['device'] ) ) : 'desktop';
		$type         = isset( $_POST['type'] ) ? sanitize_text_field( wp_unslash( $_POST['type'] ) ) : 'click';
		$date_range   = isset( $_POST['date_range'] ) ? sanitize_text_field( wp_unslash( $_POST['date_range'] ) ) : 'all';
		$country      = isset( $_POST['country'] ) ? sanitize_text_field( wp_unslash( $_POST['country'] ) ) : '';
		$browser      = isset( $_POST['browser'] ) ? sanitize_text_field( wp_unslash( $_POST['browser'] ) ) : '';
		$visitor_type = isset( $_POST['visitor_type'] ) ? sanitize_text_field( wp_unslash( $_POST['visitor_type'] ) ) : '';

		// A/B test variant filtering (Pro feature): restrict heatmap to a specific variant's sessions.
		$ab_test_id = isset( $_POST['ab_test_id'] ) ? absint( $_POST['ab_test_id'] ) : 0;
		$variant_id = isset( $_POST['variant_id'] ) ? absint( $_POST['variant_id'] ) : 0;

		// Build filters array
		$filters = array(
			'country'      => $country,
			'browser'      => $browser,
			'visitor_type' => $visitor_type,
		);

		// Filter by A/B variant using direct filename metadata matching.
		if ( $ab_test_id && $variant_id ) {
			$filters['ab_test_id'] = $ab_test_id;
			$filters['variant_id'] = $variant_id;
		}

		if ( ! $page_id ) {
			wp_send_json_error( array( 'message' => __( 'Invalid page ID', 'opti-behavior' ) ) );
		}

		// Create cache key that includes filters
		$cache_key = $this->build_cache_key( $page_id, $device, $type, $date_range, $filters );

		// Check cache first, but validate it's not stale.
		$cached_data = $this->cache->get( $page_id, $device, $type, $date_range, $cache_key );

		// Validate cache freshness: check if recording count has changed.
		$current_recording_count = $this->get_recording_count( $page_id, $device, $date_range );

		if ( false !== $cached_data ) {
			$cached_recording_count = isset( $cached_data['_cache_metadata']['recording_count'] ) ? $cached_data['_cache_metadata']['recording_count'] : 0;

			// If recording count has changed, invalidate the cache.
			if ( $cached_recording_count !== $current_recording_count ) {
				$this->debug_log( "[Opti-Behavior-Heatmap] Cache invalidated - recording count changed from {$cached_recording_count} to {$current_recording_count}" );
				$this->cache->clear( $page_id, $device, $type );
				$cached_data = false;
			}
		}

		if ( false !== $cached_data ) {
			// Remove metadata before sending to client.
			unset( $cached_data['_cache_metadata'] );
			wp_send_json_success( $cached_data );
		}

		// Generate heatmap data with filters.
		$data = $this->generate_heatmap_data( $page_id, $device, $type, $date_range, $filters );

		if ( is_wp_error( $data ) ) {
			wp_send_json_error( array( 'message' => $data->get_error_message() ) );
		}

		// Add cache metadata including recording count for validation.
		$data['_cache_metadata'] = array(
			'recording_count' => $current_recording_count,
			'cached_at'       => time(),
		);

		// Cache the data.
		$this->cache->set( $page_id, $device, $type, $date_range, $data, $cache_key );

		// Remove metadata before sending to client.
		unset( $data['_cache_metadata'] );

		wp_send_json_success( $data );
	}

	/**
	 * Build a cache key that includes filter parameters.
	 *
	 * @param int    $page_id Page ID.
	 * @param string $device Device type.
	 * @param string $type Heatmap type.
	 * @param string $date_range Date range.
	 * @param array  $filters Filter parameters.
	 * @return string Cache key.
	 */
	private function build_cache_key( $page_id, $device, $type, $date_range, $filters ) {
		$key_parts = array(
			'page_' . $page_id,
			'device_' . $device,
			'type_' . $type,
			'range_' . $date_range,
		);

		if ( ! empty( $filters['country'] ) ) {
			$key_parts[] = 'country_' . $filters['country'];
		}
		if ( ! empty( $filters['browser'] ) ) {
			$key_parts[] = 'browser_' . sanitize_title( $filters['browser'] );
		}
		if ( ! empty( $filters['visitor_type'] ) ) {
			$key_parts[] = 'visitor_' . $filters['visitor_type'];
		}
		if ( ! empty( $filters['session_ids'] ) ) {
			// Include a hash of session IDs to differentiate per-variant caches.
			$key_parts[] = 'sessions_' . md5( implode( ',', $filters['session_ids'] ) );
		}
		if ( ! empty( $filters['ab_test_id'] ) ) {
			$key_parts[] = 'ab_' . $filters['ab_test_id'];
		}
		if ( ! empty( $filters['variant_id'] ) ) {
			$key_parts[] = 'var_' . $filters['variant_id'];
		}

		return implode( '_', $key_parts );
	}

	/**
	 * Get heatmap statistics AJAX handler.
	 */
	public function ajax_get_heatmap_stats() {
		// Verify nonce.
		check_ajax_referer( 'opti_heatmap_detail', 'nonce' );

		// Verify permissions.
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'opti-behavior' ) ) );
		}

		// Get parameters.
		$page_id      = isset( $_POST['page_id'] ) ? absint( $_POST['page_id'] ) : 0;
		$device       = isset( $_POST['device'] ) ? sanitize_text_field( wp_unslash( $_POST['device'] ) ) : 'desktop';
		$type         = isset( $_POST['type'] ) ? sanitize_text_field( wp_unslash( $_POST['type'] ) ) : 'click';
		$date_range   = isset( $_POST['date_range'] ) ? sanitize_text_field( wp_unslash( $_POST['date_range'] ) ) : 'all';
		$country      = isset( $_POST['country'] ) ? sanitize_text_field( wp_unslash( $_POST['country'] ) ) : '';
		$browser      = isset( $_POST['browser'] ) ? sanitize_text_field( wp_unslash( $_POST['browser'] ) ) : '';
		$visitor_type = isset( $_POST['visitor_type'] ) ? sanitize_text_field( wp_unslash( $_POST['visitor_type'] ) ) : '';

		// A/B test variant filtering.
		$ab_test_id = isset( $_POST['ab_test_id'] ) ? absint( $_POST['ab_test_id'] ) : 0;
		$variant_id = isset( $_POST['variant_id'] ) ? absint( $_POST['variant_id'] ) : 0;

		// Build filters array
		$filters = array(
			'country'      => $country,
			'browser'      => $browser,
			'visitor_type' => $visitor_type,
		);

		// Filter by A/B variant using direct filename metadata matching.
		if ( $ab_test_id && $variant_id ) {
			$filters['ab_test_id'] = $ab_test_id;
			$filters['variant_id'] = $variant_id;
		}

		if ( ! $page_id ) {
			wp_send_json_error( array( 'message' => __( 'Invalid page ID', 'opti-behavior' ) ) );
		}

		// Generate statistics with filters.
		$stats = $this->generate_statistics( $page_id, $device, $date_range, $type, $filters );

		if ( is_wp_error( $stats ) ) {
			wp_send_json_error( array( 'message' => $stats->get_error_message() ) );
		}

		wp_send_json_success( $stats );
	}

	/**
	 * Refresh heatmap cache AJAX handler.
	 */
	public function ajax_refresh_cache() {
		// Verify nonce.
		check_ajax_referer( 'opti_heatmap_detail', 'nonce' );

		// Verify permissions.
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'opti-behavior' ) ) );
		}

		// Get parameters.
		$page_id = isset( $_POST['page_id'] ) ? absint( $_POST['page_id'] ) : 0;

		if ( ! $page_id ) {
			wp_send_json_error( array( 'message' => __( 'Invalid page ID', 'opti-behavior' ) ) );
		}

		// Clear cache for this page.
		$this->cache->clear( $page_id );

		wp_send_json_success( array( 'message' => __( 'Cache refreshed', 'opti-behavior' ) ) );
	}

	/**
	 * Get device counts AJAX handler.
	 */
	public function ajax_get_device_counts() {
		// Verify nonce.
		check_ajax_referer( 'opti_heatmap_detail', 'nonce' );

		// Verify permissions.
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'opti-behavior' ) ) );
		}

		// Get parameters.
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified above via check_ajax_referer.
		$page_id      = isset( $_POST['page_id'] ) ? absint( $_POST['page_id'] ) : 0;
		$type         = isset( $_POST['type'] ) ? sanitize_text_field( wp_unslash( $_POST['type'] ) ) : 'click';
		$visitor_type = isset( $_POST['visitor_type'] ) ? sanitize_text_field( wp_unslash( $_POST['visitor_type'] ) ) : '';
		$date_range   = isset( $_POST['date_range'] ) ? sanitize_text_field( wp_unslash( $_POST['date_range'] ) ) : 'all';
		$country      = isset( $_POST['country'] ) ? sanitize_text_field( wp_unslash( $_POST['country'] ) ) : '';
		$browser      = isset( $_POST['browser'] ) ? sanitize_text_field( wp_unslash( $_POST['browser'] ) ) : '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		if ( ! $page_id ) {
			wp_send_json_error( array( 'message' => __( 'Invalid page ID', 'opti-behavior' ) ) );
		}

		// Build filters array — mirrors the filters used by loadHeatmap() in the JS,
		// so device count badges always reflect the currently applied filters.
		$filters = array(
			'visitor_type' => $visitor_type,
			'date_range'   => $date_range,
			'country'      => $country,
			'browser'      => $browser,
		);

		// Get counts filtered by heatmap type and all active filters.
		$counts = $this->get_recordings_by_device( $page_id, $type, $filters );

		wp_send_json_success( $counts );
	}

	/**
	 * Get total recordings count AJAX handler (unfiltered).
	 * Returns the total number of sessions for a page WITHOUT any filters applied.
	 * Used for the header "(X sessions)" display which should always show the total.
	 */
	public function ajax_get_total_recordings() {
		// Verify nonce.
		check_ajax_referer( 'opti_heatmap_detail', 'nonce' );

		// Verify permissions.
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'opti-behavior' ) ) );
		}

		// Get parameters.
		$page_id = isset( $_POST['page_id'] ) ? absint( $_POST['page_id'] ) : 0;

		if ( ! $page_id ) {
			wp_send_json_error( array( 'message' => __( 'Invalid page ID', 'opti-behavior' ) ) );
		}

		// Get unfiltered counts (no type, no filters — all sessions for this page).
		$counts = $this->get_recordings_by_device( $page_id, 'click' );

		wp_send_json_success( array( 'total' => isset( $counts['total'] ) ? $counts['total'] : 0 ) );
	}

	/**
	 * Generate heatmap data from recordings.
	 *
	 * @param int    $page_id Page ID.
	 * @param string $device Device type.
	 * @param string $type Heatmap type.
	 * @param string $date_range Date range.
	 * @param array  $filters Optional filters (country, browser, visitor_type).
	 * @return array|WP_Error Heatmap data or error.
	 */
	private function generate_heatmap_data( $page_id, $device, $type, $date_range, $filters = array() ) {
		global $wpdb;

		// Log the request for debugging
		$this->debug_log( "[Opti-Behavior-Heatmap] Generating heatmap - Page: $page_id, Device: $device, Type: $type" );

		// Try fast heatmap storage first (pre-extracted data)
		$fast_result = $this->generate_heatmap_data_fast( $page_id, $device, $type, $date_range, $filters );
		if ( $fast_result !== false && ! empty( $fast_result['coordinates'] ) ) {
			$this->debug_log( "[Opti-Behavior-Heatmap] Using fast heatmap storage - found " . count( $fast_result['coordinates'] ) . " coordinates" );
			return $fast_result;
		}

		// Fall back to traditional method (parsing recordings)
		$this->debug_log( "[Opti-Behavior-Heatmap] Fast storage empty or unavailable, falling back to traditional method" );

		// Get recordings for this page and device with filters.
		$recordings = $this->get_recordings( $page_id, $device, $date_range, $filters );

		if ( empty( $recordings ) ) {
			$this->debug_log( "[Opti-Behavior-Heatmap] No recordings found for page $page_id and device $device" );
			return array(
				'coordinates'         => array(),
				'viewport'            => array( 'width' => 1920, 'height' => 1080 ),
				'recordings_processed' => 0,
				'message'             => __( 'No recordings found for this page and device combination', 'opti-behavior' ),
			);
		}

		$this->debug_log( "[Opti-Behavior-Heatmap] Found " . count( $recordings ) . " recordings to process" );
		$this->debug_log( "[Opti-Behavior-Heatmap] Recording IDs: " . implode( ', ', array_map( function( $r ) { return $r->id; }, $recordings ) ) );

		// Get the page URL for filtering multi-page recordings
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix; analytics queries.
		$page_url = $wpdb->get_var( $wpdb->prepare( "SELECT url FROM {$wpdb->prefix}optibehavior_pages WHERE id = %d", $page_id ) );
		$this->debug_log( "[Opti-Behavior-Heatmap] Filtering events for page URL: {$page_url}" );

		$all_parsed_data = array();
		$viewport        = array( 'width' => 1920, 'height' => 1080 );

		// Process each recording.
		$events_loaded = 0;
		$events_parsed = 0;
		foreach ( $recordings as $recording ) {
			$events = $this->get_recording_events( $recording );

			if ( empty( $events ) ) {
				$this->debug_log( "[Opti-Behavior-Heatmap] No events found for recording ID: {$recording->id}" );
				continue;
			}

			// Filter events to only include those from the target page
			// For multi-page recordings, we need to find navigation events and filter subsequent events
			$filtered_events = $this->filter_events_by_page( $events, $page_url );

			if ( empty( $filtered_events ) ) {
				$this->debug_log( "[Opti-Behavior-Heatmap] No events for page {$page_url} in recording ID: {$recording->id}" );
				continue;
			}

			$events_loaded++;
			$this->debug_log( "[Opti-Behavior-Heatmap] Processing recording {$recording->id} with " . count( $filtered_events ) . " filtered events (from " . count( $events ) . " total)" );

			$parsed = $this->parser->parse_events( $filtered_events, $type );

			if ( ! empty( $parsed ) ) {
				$all_parsed_data[] = $parsed;
				$events_parsed++;

				// Update viewport if available.
				if ( ! empty( $parsed['viewport'] ) ) {
					$viewport = $parsed['viewport'];
				}

				// Log parsed data stats
				$clicks_count = isset( $parsed['clicks'] ) ? count( $parsed['clicks'] ) : 0;
				$moves_count = isset( $parsed['moves'] ) ? count( $parsed['moves'] ) : 0;
				$scrolls_count = isset( $parsed['scrolls'] ) ? count( $parsed['scrolls'] ) : 0;
				$this->debug_log( "[Opti-Behavior-Heatmap] Parsed - Clicks: $clicks_count, Moves: $moves_count, Scrolls: $scrolls_count" );
			}
		}

		$this->debug_log( "[Opti-Behavior-Heatmap] Events loaded: $events_loaded, Events parsed: $events_parsed" );
		$this->debug_log( "[Opti-Behavior-Heatmap] Using viewport: " . $viewport['width'] . 'x' . $viewport['height'] );

		// IMPORTANT: For scroll heatmaps, if someone viewed the page but never scrolled,
		// we need to show that they viewed the TOP of the page (Y=0).
		// Add synthetic initial scroll event at Y=0 for each recording that has no scroll events.
		if ( $type === 'scroll' ) {
			$total_scrolls = 0;
			foreach ( $all_parsed_data as $parsed_data ) {
				if ( isset( $parsed_data['scrolls'] ) ) {
					$total_scrolls += count( $parsed_data['scrolls'] );
				}
			}

			// If there are NO scroll events across ALL recordings, but we have parsed data (page was viewed),
			// inject a synthetic initial scroll event at Y=0 for EACH parsed recording.
			// Use $events_loaded to check if we actually have events for this page (not just recordings in general)
			if ( $total_scrolls === 0 && $events_loaded > 0 ) {
				$this->debug_log( "[Opti-Behavior-Heatmap] No scroll events found, but page was viewed ($events_loaded recordings with events). Adding synthetic scroll at Y=0" );

				// Add one synthetic scroll event per parsed recording to represent initial page load position
				foreach ( $all_parsed_data as &$parsed_data ) {
					if ( ! isset( $parsed_data['scrolls'] ) ) {
						$parsed_data['scrolls'] = array();
					}

					// Add initial scroll position at top of page (Y=0)
					$parsed_data['scrolls'][] = array(
						'x'         => 0,
						'y'         => 0,
						'timestamp' => 0,
					);
				}
				unset( $parsed_data ); // Break reference

				$this->debug_log( "[Opti-Behavior-Heatmap] Added " . count( $all_parsed_data ) . " synthetic scroll events at Y=0" );
			}
		}

		// Aggregate all parsed data with viewport information.
		$aggregated_result = $this->aggregate_all_data( $all_parsed_data, $type, $viewport );

		// Extract coordinates and reference dimensions if using normalized system
		if ( is_array( $aggregated_result ) && isset( $aggregated_result['coordinates'] ) ) {
			// New normalized system returns coordinates + reference dimensions
			$aggregated = $aggregated_result['coordinates'];
			$reference_width = isset( $aggregated_result['reference_width'] ) ? $aggregated_result['reference_width'] : $viewport['width'];
			$reference_height = isset( $aggregated_result['reference_height'] ) ? $aggregated_result['reference_height'] : $viewport['height'];
		} else {
			// Legacy system returns coordinates only
			$aggregated = $aggregated_result;
			$reference_width = $viewport['width'];
			$reference_height = $viewport['height'];
		}

		$this->debug_log( "[Opti-Behavior-Heatmap] Aggregated " . count( $aggregated ) . " coordinates for type: $type" );
		$this->debug_log( "[Opti-Behavior-Heatmap] Reference dimensions: {$reference_width}x{$reference_height}" );

		return array(
			'coordinates'         => $aggregated,
			'viewport'            => $viewport,
			'reference_width'     => $reference_width,
			'reference_height'    => $reference_height,
			'recordings_processed' => count( $recordings ),
			'events_loaded'       => $events_loaded,
			'events_parsed'       => $events_parsed,
		);
	}

	/**
	 * Generate heatmap data using fast file storage.
	 *
	 * This method reads pre-extracted heatmap data from files instead of
	 * parsing recordings on every request. Much faster for pages with many recordings.
	 *
	 * @param int    $page_id Page ID.
	 * @param string $device Device type (desktop, mobile).
	 * @param string $type Heatmap type (click, move, scroll).
	 * @param string $date_range Date range.
	 * @param array  $filters Optional filters (country, browser, visitor_type, batch, batch_size).
	 * @return array|false Heatmap data or false if fast storage is not available.
	 */
	private function generate_heatmap_data_fast( $page_id, $device, $type, $date_range, $filters = array() ) {
		// Check if heatmap storage is available (from Free version)
		if ( ! class_exists( 'Opti_Behavior_Heatmap_Storage' ) ) {
			$this->debug_log( "[Opti-Behavior-Heatmap-Fast] Heatmap storage class not available" );
			return false;
		}

		$heatmap_storage = Opti_Behavior_Heatmap_Storage::get_instance();
		if ( ! $heatmap_storage ) {
			return false;
		}

		// Get URL hash for this page
		$url_hash = $heatmap_storage->get_url_hash_for_page( $page_id );
		if ( ! $url_hash ) {
			$this->debug_log( "[Opti-Behavior-Heatmap-Fast] URL hash not found for page $page_id" );
			return false;
		}

		$this->debug_log( "[Opti-Behavior-Heatmap-Fast] Found URL hash: $url_hash for page $page_id" );

		// Map type to storage folder name
		$type_map = array(
			'click'     => 'clicks',
			'move'      => 'moves',
			'scroll'    => 'scrolls',
			'attention' => 'scrolls', // Attention heatmap uses scroll/viewposition data to show dwell time zones
		);
		$storage_type = isset( $type_map[ $type ] ) ? $type_map[ $type ] : 'clicks';

		// Build filters for file reading
		$file_filters = array(
			'device'     => $device,
			'date_range' => $date_range,
		);

		// Add optional filters
		if ( ! empty( $filters['country'] ) ) {
			$file_filters['country'] = $filters['country'];
		}
		if ( ! empty( $filters['browser'] ) ) {
			$file_filters['browser'] = $filters['browser'];
		}
		if ( ! empty( $filters['visitor_type'] ) ) {
			$file_filters['visitor_type'] = $filters['visitor_type'];
		}
		if ( ! empty( $filters['session_ids'] ) ) {
			$file_filters['session_ids'] = $filters['session_ids'];
		}
		if ( ! empty( $filters['ab_test_id'] ) ) {
			$file_filters['ab_test_id'] = $filters['ab_test_id'];
		}
		if ( ! empty( $filters['variant_id'] ) ) {
			$file_filters['variant_id'] = $filters['variant_id'];
		}

		// Handle date range
		if ( $date_range === 'custom' && ! empty( $filters['start_date'] ) && ! empty( $filters['end_date'] ) ) {
			$file_filters['start_date'] = $filters['start_date'];
			$file_filters['end_date'] = $filters['end_date'];
		}

		// Add batching support
		$batch = isset( $filters['batch'] ) ? (int) $filters['batch'] : 1;
		$batch_size = isset( $filters['batch_size'] ) ? (int) $filters['batch_size'] : 100;
		$file_filters['batch'] = $batch;
		$file_filters['batch_size'] = $batch_size;

		// Read pre-extracted heatmap files (FAST!) - with batching.
		// read_heatmap_files() returns array('files' => ..., 'total_files' => ...) so we get
		// the total count in the same scandir pass, avoiding a redundant get_heatmap_file_count() call.
		$read_result   = $heatmap_storage->read_heatmap_files( $url_hash, $storage_type, $file_filters );
		$files_data    = $read_result['files'];
		$total_files   = $read_result['total_files'];
		$total_batches = ceil( $total_files / $batch_size );

		$this->debug_log( "[Opti-Behavior-Heatmap-Fast] Total files: $total_files, Batch: $batch/$total_batches, Batch size: $batch_size" );

		if ( empty( $files_data ) ) {
			$this->debug_log( "[Opti-Behavior-Heatmap-Fast] No heatmap files found for $url_hash/$storage_type (batch $batch)" );

			// Return empty result with pagination info if this is a subsequent batch
			if ( $batch > 1 ) {
				return array(
					'coordinates'          => array(),
					'viewport'             => array( 'width' => 1920, 'height' => 1080 ),
					'reference_width'      => 1920,
					'reference_height'     => 1080,
					'recordings_processed' => 0,
					'fast_storage'         => true,
					'batch'                => $batch,
					'total_batches'        => $total_batches,
					'total_files'          => $total_files,
					'has_more'             => false,
				);
			}
			return false;
		}

		$this->debug_log( "[Opti-Behavior-Heatmap-Fast] Found " . count( $files_data ) . " heatmap files for batch $batch" );

		// Aggregate data from this batch
		$aggregated = $heatmap_storage->aggregate_heatmap_data( $files_data, $storage_type );

		// For move heatmaps, we also need to generate trajectories
		$trajectories = array();
		if ( $type === 'move' && ! empty( $files_data ) ) {
			$this->debug_log( "[Move Heatmap] Type is move, generating trajectories from " . count($files_data) . " files" );

			// Collect all move data from files
			$all_move_data = array();
			foreach ( $files_data as $file_data ) {
				if ( isset( $file_data['data'] ) && is_array( $file_data['data'] ) ) {
					foreach ( $file_data['data'] as $coord ) {
						$all_move_data[] = $coord;
					}
				}
			}

			$this->debug_log( "[Move Heatmap] Collected " . count($all_move_data) . " move coordinates" );

			// Use parser to generate trajectories from move data
			if ( ! empty( $all_move_data ) ) {
				$move_aggregated = $this->parser->aggregate_move_data( $all_move_data, $aggregated['viewport'] );
				$this->debug_log( "[Move Heatmap] Parser returned " . count($move_aggregated['trajectories']) . " trajectories" );
				if ( isset( $move_aggregated['trajectories'] ) ) {
					$trajectories = $move_aggregated['trajectories'];
				}
			}
		}

		$result = array(
			'coordinates'          => $aggregated['coordinates'],
			'viewport'             => $aggregated['viewport'],
			'reference_width'      => $aggregated['reference_width'],
			'reference_height'     => isset( $aggregated['reference_height'] ) ? $aggregated['reference_height'] : $aggregated['viewport']['height'],
			'window_height'        => isset( $aggregated['window_height'] ) ? $aggregated['window_height'] : 0,
			'recordings_processed' => count( $files_data ),
			'fast_storage'         => true,
			'batch'                => $batch,
			'total_batches'        => $total_batches,
			'total_files'          => $total_files,
			'has_more'             => $batch < $total_batches,
		);

		// Add trajectories for move heatmap
		if ( $type === 'move' ) {
			$result['trajectories'] = $trajectories;
			$this->debug_log( "[Move Heatmap] Returning " . count($trajectories) . " trajectories to frontend" );
		}

		return $result;
	}

	/**
	 * AJAX handler for batched heatmap data loading.
	 *
	 * This endpoint is called repeatedly by JavaScript to load heatmap data
	 * in batches of 100 files at a time, preventing timeouts with large datasets.
	 *
	 * Supports multi-select filters for country and browser:
	 * - country: single value or comma-separated (e.g., "US" or "US,FR,DE")
	 * - browser: single value or comma-separated (e.g., "Chrome" or "Chrome,Firefox")
	 * - visitor_type: 'logged_in' or 'guest' to filter by user login status
	 */
	public function ajax_get_heatmap_batch() {
		// Verify nonce.
		check_ajax_referer( 'opti_heatmap_detail', 'nonce' );

		// Verify permissions.
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'opti-behavior' ) ) );
		}

		// Get parameters.
		$page_id      = isset( $_POST['page_id'] ) ? absint( $_POST['page_id'] ) : 0;
		$device       = isset( $_POST['device'] ) ? sanitize_text_field( wp_unslash( $_POST['device'] ) ) : 'desktop';
		$type         = isset( $_POST['type'] ) ? sanitize_text_field( wp_unslash( $_POST['type'] ) ) : 'click';
		$date_range   = isset( $_POST['date_range'] ) ? sanitize_text_field( wp_unslash( $_POST['date_range'] ) ) : 'all';
		$batch        = isset( $_POST['batch'] ) ? absint( $_POST['batch'] ) : 1;
		$batch_size   = isset( $_POST['batch_size'] ) ? absint( $_POST['batch_size'] ) : 100;

		// Handle multi-select filters (can be single value or comma-separated)
		$country      = isset( $_POST['country'] ) ? sanitize_text_field( wp_unslash( $_POST['country'] ) ) : '';
		$browser      = isset( $_POST['browser'] ) ? sanitize_text_field( wp_unslash( $_POST['browser'] ) ) : '';
		$visitor_type = isset( $_POST['visitor_type'] ) ? sanitize_text_field( wp_unslash( $_POST['visitor_type'] ) ) : '';

		if ( ! $page_id ) {
			wp_send_json_error( array( 'message' => __( 'Invalid page ID', 'opti-behavior' ) ) );
		}

		// PRO-only check: move, scroll and attention heatmaps are PRO features
		$is_pro = function_exists( 'opti_behavior_pro_active' ) && opti_behavior_pro_active();
		if ( ! $is_pro && in_array( $type, array( 'move', 'scroll', 'attention' ), true ) ) {
			wp_send_json_error( array(
				'message'    => __( 'Move, Scroll and Attention heatmaps are available in Pro version only', 'opti-behavior' ),
				'pro_required' => true,
			) );
		}

		// A/B test variant filtering (Pro feature).
		$ab_test_id = isset( $_POST['ab_test_id'] ) ? absint( $_POST['ab_test_id'] ) : 0;
		$variant_id = isset( $_POST['variant_id'] ) ? absint( $_POST['variant_id'] ) : 0;

		// Build filters - country and browser can be comma-separated for multi-select
		// The storage class will parse these into arrays
		$filters = array(
			'country'      => $country,
			'browser'      => $browser,
			'visitor_type' => $visitor_type,
			'batch'        => $batch,
			'batch_size'   => $batch_size,
		);

		// Filter by A/B variant using direct filename metadata matching.
		if ( $ab_test_id && $variant_id ) {
			$filters['ab_test_id'] = $ab_test_id;
			$filters['variant_id'] = $variant_id;
		}

		// Generate batched heatmap data
		$data = $this->generate_heatmap_data_fast( $page_id, $device, $type, $date_range, $filters );

		if ( $data === false ) {
			wp_send_json_error( array(
				'message'   => __( 'No heatmap data found', 'opti-behavior' ),
				'batch'     => $batch,
				'has_more'  => false,
			) );
		}

		wp_send_json_success( $data );
	}

	/**
	 * AJAX handler for fetching filter options (countries, browsers) from filenames.
	 *
	 * This endpoint is designed to be FAST - it only scans filenames, not file contents.
	 * Called immediately on page load to populate filter dropdowns while heatmap data
	 * loads in background (two-thread approach).
	 */
	public function ajax_get_filter_options() {
		// Verify nonce.
		check_ajax_referer( 'opti_heatmap_detail', 'nonce' );

		// Verify permissions.
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'opti-behavior' ) ) );
		}

		// Get parameters.
		$page_id    = isset( $_POST['page_id'] ) ? absint( $_POST['page_id'] ) : 0;
		$device     = isset( $_POST['device'] ) ? sanitize_text_field( wp_unslash( $_POST['device'] ) ) : '';
		$type       = isset( $_POST['type'] ) ? sanitize_text_field( wp_unslash( $_POST['type'] ) ) : 'click';
		$date_range = isset( $_POST['date_range'] ) ? sanitize_text_field( wp_unslash( $_POST['date_range'] ) ) : 'all';

		if ( ! $page_id ) {
			wp_send_json_error( array( 'message' => __( 'Invalid page ID', 'opti-behavior' ) ) );
		}

		// Check if heatmap storage is available.
		if ( ! class_exists( 'Opti_Behavior_Heatmap_Storage' ) ) {
			wp_send_json_error( array( 'message' => __( 'Heatmap storage not available', 'opti-behavior' ) ) );
		}

		$heatmap_storage = Opti_Behavior_Heatmap_Storage::get_instance();

		// Get URL hash for this page.
		$url_hash = $heatmap_storage->get_url_hash_for_page( $page_id );
		if ( ! $url_hash ) {
			// Return empty options if no URL hash found (no heatmap data yet).
			wp_send_json_success( array(
				'countries' => array(),
				'browsers'  => array(),
				'devices'   => array(),
			) );
		}

		// Map type to storage folder name.
		$type_map = array(
			'click'     => 'clicks',
			'move'      => 'moves',
			'scroll'    => 'scrolls',
			'attention' => 'scrolls', // Attention heatmap uses scroll/viewposition data to show dwell time zones
		);
		$storage_type = isset( $type_map[ $type ] ) ? $type_map[ $type ] : 'clicks';

		// Build filters including device.
		$filters = array();
		if ( ! empty( $date_range ) && $date_range !== 'all' ) {
			$filters['date_range'] = $date_range;
		}
		if ( ! empty( $device ) ) {
			$filters['device'] = $device;
		}

		// Scan filenames to get unique filter options filtered by device (FAST!).
		$options = $this->scan_filter_options_by_device( $url_hash, $storage_type, $filters );

		// Map country codes to country names for display.
		$country_names = $this->get_country_names();
		$countries_with_names = array();
		foreach ( $options['countries'] as $code ) {
			$countries_with_names[ $code ] = isset( $country_names[ $code ] ) ? $country_names[ $code ] : $code;
		}

		wp_send_json_success( array(
			'countries' => $countries_with_names,
			'browsers'  => $options['browsers'],
			'devices'   => $options['devices'],
		) );
	}

	/**
	 * Scan heatmap files to get available filter options filtered by device.
	 *
	 * @param string $url_hash     URL hash for the page.
	 * @param string $storage_type Storage type folder (clicks, moves, scrolls).
	 * @param array  $filters      Filters including device.
	 * @return array Available options: countries, browsers, devices.
	 */
	private function scan_filter_options_by_device( $url_hash, $storage_type, $filters ) {
		$upload_dir = wp_upload_dir();
		$base_dir = $upload_dir['basedir'] . '/opti-behavior-data/' . $url_hash . '/' . $storage_type;

		$countries = array();
		$browsers = array();
		$devices = array();

		if ( ! is_dir( $base_dir ) ) {
			return array(
				'countries' => $countries,
				'browsers'  => $browsers,
				'devices'   => $devices,
			);
		}

		$files = glob( $base_dir . '/*.json' );
		if ( empty( $files ) ) {
			return array(
				'countries' => $countries,
				'browsers'  => $browsers,
				'devices'   => $devices,
			);
		}

		$selected_device = ! empty( $filters['device'] ) ? strtolower( $filters['device'] ) : '';
		$date_range = ! empty( $filters['date_range'] ) ? $filters['date_range'] : 'all';

		foreach ( $files as $file ) {
			$filename = basename( $file );
			$parts = explode( '_', str_replace( '.json', '', $filename ) );

			// Parse filename: timestamp_country_browser_count_device_visitortype_duration_session.json
			if ( count( $parts ) < 7 ) {
				continue;
			}

			$timestamp = isset( $parts[0] ) ? $parts[0] : '';
			$file_country = isset( $parts[1] ) ? $parts[1] : '';
			$file_browser = isset( $parts[2] ) ? $parts[2] : '';
			$file_device = isset( $parts[4] ) ? strtolower( $parts[4] ) : 'desktop';

			// Normalize device type.
			if ( $file_device === 'phone' ) {
				$file_device = 'mobile';
			}
			if ( ! in_array( $file_device, array( 'desktop', 'mobile', 'tablet' ), true ) ) {
				$file_device = 'desktop';
			}

			// Apply date range filter.
			if ( ! $this->is_file_in_date_range( $timestamp, $date_range ) ) {
				continue;
			}

			// Track all devices (for device count display).
			$devices[ $file_device ] = true;

			// Apply device filter - only collect countries/browsers for the selected device.
			if ( ! empty( $selected_device ) && $file_device !== $selected_device ) {
				continue;
			}

			// Collect countries and browsers for this device.
			if ( ! empty( $file_country ) ) {
				$countries[ $file_country ] = true;
			}
			if ( ! empty( $file_browser ) ) {
				$browsers[ $file_browser ] = true;
			}
		}

		return array(
			'countries' => array_keys( $countries ),
			'browsers'  => array_keys( $browsers ),
			'devices'   => array_keys( $devices ),
		);
	}

	/**
	 * AJAX handler for fetching dependent filter options based on current filter selections.
	 *
	 * When a user selects a country, this returns only browsers that have data for that country.
	 * When a user selects a browser, this returns only countries that have data for that browser.
	 * This prevents users from selecting filter combinations that result in zero data.
	 */
	public function ajax_get_dependent_filter_options() {
		// Verify nonce.
		check_ajax_referer( 'opti_heatmap_detail', 'nonce' );

		// Verify permissions.
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'opti-behavior' ) ) );
		}

		// Get parameters.
		$page_id      = isset( $_POST['page_id'] ) ? absint( $_POST['page_id'] ) : 0;
		$device       = isset( $_POST['device'] ) ? sanitize_text_field( wp_unslash( $_POST['device'] ) ) : 'desktop';
		$type         = isset( $_POST['type'] ) ? sanitize_text_field( wp_unslash( $_POST['type'] ) ) : 'click';
		$date_range   = isset( $_POST['date_range'] ) ? sanitize_text_field( wp_unslash( $_POST['date_range'] ) ) : 'all';
		$country      = isset( $_POST['country'] ) ? sanitize_text_field( wp_unslash( $_POST['country'] ) ) : '';
		$browser      = isset( $_POST['browser'] ) ? sanitize_text_field( wp_unslash( $_POST['browser'] ) ) : '';
		$visitor_type = isset( $_POST['visitor_type'] ) ? sanitize_text_field( wp_unslash( $_POST['visitor_type'] ) ) : '';

		if ( ! $page_id ) {
			wp_send_json_error( array( 'message' => __( 'Invalid page ID', 'opti-behavior' ) ) );
		}

		// Check if heatmap storage is available.
		if ( ! class_exists( 'Opti_Behavior_Heatmap_Storage' ) ) {
			wp_send_json_error( array( 'message' => __( 'Heatmap storage not available', 'opti-behavior' ) ) );
		}

		$heatmap_storage = Opti_Behavior_Heatmap_Storage::get_instance();

		// Get URL hash for this page.
		$url_hash = $heatmap_storage->get_url_hash_for_page( $page_id );
		if ( ! $url_hash ) {
			wp_send_json_success( array(
				'countries'     => array(),
				'browsers'      => array(),
				'device_counts' => array( 'desktop' => 0, 'mobile' => 0, 'tablet' => 0 ),
			) );
		}

		// Map type to storage folder name.
		$type_map = array(
			'click'     => 'clicks',
			'move'      => 'moves',
			'scroll'    => 'scrolls',
			'attention' => 'scrolls', // Attention heatmap uses scroll/viewposition data to show dwell time zones
		);
		$storage_type = isset( $type_map[ $type ] ) ? $type_map[ $type ] : 'clicks';

		// Build current filters.
		$filters = array(
			'device'       => $device,
			'date_range'   => $date_range,
			'country'      => $country,
			'browser'      => $browser,
			'visitor_type' => $visitor_type,
		);

		// Scan files to get dependent filter options.
		$options = $this->scan_dependent_filter_options( $url_hash, $storage_type, $filters );

		// Map country codes to country names for display.
		$country_names = $this->get_country_names();
		$countries_with_names = array();
		foreach ( $options['countries'] as $code ) {
			$countries_with_names[ $code ] = isset( $country_names[ $code ] ) ? $country_names[ $code ] : $code;
		}

		wp_send_json_success( array(
			'countries'     => $countries_with_names,
			'browsers'      => $options['browsers'],
			'device_counts' => $options['device_counts'],
		) );
	}

	/**
	 * Scan heatmap files to get available filter options based on current selections.
	 *
	 * This method scans filenames to determine which filter values are available
	 * given the current filter selections. For example, if a country is selected,
	 * it returns only browsers that have data for that country.
	 *
	 * @param string $url_hash     URL hash for the page.
	 * @param string $storage_type Storage type folder (clicks, moves, scrolls).
	 * @param array  $filters      Current filter selections.
	 * @return array Available options: countries, browsers, device_counts.
	 */
	private function scan_dependent_filter_options( $url_hash, $storage_type, $filters ) {
		$upload_dir = wp_upload_dir();
		$base_dir = $upload_dir['basedir'] . '/opti-behavior-data/' . $url_hash . '/' . $storage_type;

		$countries = array();
		$browsers = array();
		$device_counts = array(
			'desktop' => 0,
			'mobile'  => 0,
			'tablet'  => 0,
		);
		$device_sessions = array(
			'desktop' => array(),
			'mobile'  => array(),
			'tablet'  => array(),
		);

		if ( ! is_dir( $base_dir ) ) {
			return array(
				'countries'     => $countries,
				'browsers'      => $browsers,
				'device_counts' => $device_counts,
			);
		}

		$files = glob( $base_dir . '/*.json' );
		if ( empty( $files ) ) {
			return array(
				'countries'     => $countries,
				'browsers'      => $browsers,
				'device_counts' => $device_counts,
			);
		}

		// Parse selected filters (can be comma-separated for multi-select).
		$selected_countries = ! empty( $filters['country'] ) ? array_map( 'trim', explode( ',', $filters['country'] ) ) : array();
		$selected_browsers = ! empty( $filters['browser'] ) ? array_map( 'trim', explode( ',', $filters['browser'] ) ) : array();
		$selected_device = ! empty( $filters['device'] ) ? strtolower( $filters['device'] ) : '';
		$selected_visitor_type = ! empty( $filters['visitor_type'] ) ? $filters['visitor_type'] : '';
		$date_range = ! empty( $filters['date_range'] ) ? $filters['date_range'] : 'all';

		foreach ( $files as $file ) {
			$filename = basename( $file );
			$parts = explode( '_', str_replace( '.json', '', $filename ) );

			// Parse filename: timestamp_country_browser_count_device_visitortype_duration_session.json
			if ( count( $parts ) < 7 ) {
				continue;
			}

			// Support both old (7 parts) and new (8 parts) filename formats.
			$timestamp = isset( $parts[0] ) ? $parts[0] : '';
			$file_country = isset( $parts[1] ) ? $parts[1] : '';
			$file_browser = isset( $parts[2] ) ? $parts[2] : '';
			$file_device = isset( $parts[4] ) ? strtolower( $parts[4] ) : 'desktop';
			$file_visitor_type = isset( $parts[5] ) ? $parts[5] : '';
			// 10-part AB format: session is at index 9
			if ( count( $parts ) >= 10 && is_numeric( $parts[7] ) && is_numeric( $parts[8] ) ) {
				$file_session = $parts[9];
			} elseif ( count( $parts ) >= 8 ) {
				$file_session = $parts[7];
			} else {
				$file_session = isset( $parts[6] ) ? $parts[6] : '';
			}

			// Normalize device type.
			if ( $file_device === 'phone' ) {
				$file_device = 'mobile';
			}
			if ( ! in_array( $file_device, array( 'desktop', 'mobile', 'tablet' ), true ) ) {
				$file_device = 'desktop';
			}

			// Apply date range filter.
			if ( ! $this->is_file_in_date_range( $timestamp, $date_range ) ) {
				continue;
			}

			// Apply visitor type filter if selected.
			if ( ! empty( $selected_visitor_type ) ) {
				$filter_type = $selected_visitor_type === 'logged_in' ? 'L' : 'G';
				if ( $file_visitor_type !== $filter_type ) {
					continue;
				}
			}

			// Check if this file matches the currently applied filters.
			$matches_country = empty( $selected_countries ) || in_array( $file_country, $selected_countries, true );
			$matches_browser = empty( $selected_browsers ) || in_array( $file_browser, $selected_browsers, true );
			$matches_device = empty( $selected_device ) || $file_device === $selected_device;

			// For countries: show countries available for selected browsers (and current device).
			if ( $matches_browser && $matches_device ) {
				$countries[ $file_country ] = true;
			}

			// For browsers: show browsers available for selected countries (and current device).
			if ( $matches_country && $matches_device ) {
				$browsers[ $file_browser ] = true;
			}

			// For device counts: count sessions matching both country and browser filters.
			if ( $matches_country && $matches_browser && ! empty( $file_session ) ) {
				if ( ! isset( $device_sessions[ $file_device ][ $file_session ] ) ) {
					$device_sessions[ $file_device ][ $file_session ] = true;
				}
			}
		}

		// Convert to arrays and count.
		$device_counts = array(
			'desktop' => count( $device_sessions['desktop'] ),
			'mobile'  => count( $device_sessions['mobile'] ),
			'tablet'  => count( $device_sessions['tablet'] ),
		);

		return array(
			'countries'     => array_keys( $countries ),
			'browsers'      => array_keys( $browsers ),
			'device_counts' => $device_counts,
		);
	}

	/**
	 * Get country code to name mapping.
	 *
	 * @return array Country code => name mapping.
	 */
	private function get_country_names() {
		return array(
			'AF' => 'Afghanistan',
			'AL' => 'Albania',
			'DZ' => 'Algeria',
			'AR' => 'Argentina',
			'AU' => 'Australia',
			'AT' => 'Austria',
			'BE' => 'Belgium',
			'BR' => 'Brazil',
			'CA' => 'Canada',
			'CL' => 'Chile',
			'CN' => 'China',
			'CO' => 'Colombia',
			'CZ' => 'Czech Republic',
			'DK' => 'Denmark',
			'EG' => 'Egypt',
			'FI' => 'Finland',
			'FR' => 'France',
			'DE' => 'Germany',
			'GR' => 'Greece',
			'HK' => 'Hong Kong',
			'HU' => 'Hungary',
			'IN' => 'India',
			'ID' => 'Indonesia',
			'IE' => 'Ireland',
			'IL' => 'Israel',
			'IT' => 'Italy',
			'JP' => 'Japan',
			'KR' => 'South Korea',
			'MY' => 'Malaysia',
			'MX' => 'Mexico',
			'NL' => 'Netherlands',
			'NZ' => 'New Zealand',
			'NO' => 'Norway',
			'PK' => 'Pakistan',
			'PH' => 'Philippines',
			'PL' => 'Poland',
			'PT' => 'Portugal',
			'RO' => 'Romania',
			'RU' => 'Russia',
			'SA' => 'Saudi Arabia',
			'SG' => 'Singapore',
			'ZA' => 'South Africa',
			'ES' => 'Spain',
			'SE' => 'Sweden',
			'CH' => 'Switzerland',
			'TW' => 'Taiwan',
			'TH' => 'Thailand',
			'TR' => 'Turkey',
			'UA' => 'Ukraine',
			'AE' => 'United Arab Emirates',
			'GB' => 'United Kingdom',
			'US' => 'United States',
			'VN' => 'Vietnam',
			'XX' => 'Unknown',
		);
	}

	/**
	 * Generate statistics from recordings.
	 *
	 * @param int    $page_id Page ID.
	 * @param string $device Device type.
	 * @param string $date_range Date range.
	 * @param string $type Heatmap type ('click', 'move', 'scroll').
	 * @param array  $filters Optional filters (country, browser, visitor_type).
	 * @return array|WP_Error Statistics or error.
	 */
	private function generate_statistics( $page_id, $device, $date_range, $type = 'click', $filters = array() ) {
		// IMPORTANT: Use file-based statistics instead of database
		// This ensures stats work correctly when switching from FREE to PRO
		// and provides more accurate data based on actual file metadata
		return $this->generate_statistics_from_files( $page_id, $device, $date_range, $type, $filters );
	}

	/**
	 * Generate statistics from file metadata (filenames).
	 * This is more reliable than database queries, especially when switching from FREE to PRO.
	 *
	 * @param int    $page_id Page ID.
	 * @param string $device Device type.
	 * @param string $date_range Date range.
	 * @param string $type Heatmap type (click, move, scroll).
	 * @param array  $filters Optional filters (country, browser, visitor_type).
	 * @return array Statistics.
	 */
	private function generate_statistics_from_files( $page_id, $device, $date_range, $type = 'click', $filters = array() ) {
		global $wpdb;

		$empty_result = array(
			'total_views'     => 0,
			'total_clicks'    => 0,
			'total_moves'     => 0,
			'total_scrolls'   => 0,
			'avg_time'        => 0,
			'engagement_rate' => 0,
			'avg_scroll'      => 0,
		);

		// Get URL hash for this page — use heatmap_pages mapping table first (normalised hash),
		// then fall back to raw URL variations for legacy data.
		$upload_dir = wp_upload_dir();
		$base_dir   = null;

		// 1. Try the heatmap_pages mapping table (most reliable — stores normalised hash).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix; analytics queries.
		$table_name = esc_sql( $wpdb->prefix . 'optibehavior_heatmap_pages' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$mapped_hash = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT url_hash FROM {$table_name} WHERE page_id = %d",
				$page_id
			)
		);
		if ( $mapped_hash ) {
			$test_dir = $upload_dir['basedir'] . '/opti-behavior-data/' . $mapped_hash;
			if ( is_dir( $test_dir ) ) {
				$base_dir = $test_dir;
			}
		}

		// 2. Fallback: compute from raw URL with variations (legacy data).
		if ( ! $base_dir ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix; analytics queries.
			$url = $wpdb->get_var( $wpdb->prepare( "SELECT url FROM {$wpdb->prefix}optibehavior_pages WHERE id = %d", $page_id ) );

			if ( ! $url ) {
				return $empty_result;
			}

			$url_variations = array(
				md5( $url ),
				md5( rtrim( $url, '/' ) ),
				md5( $url . '/' ),
			);

			foreach ( $url_variations as $url_hash ) {
				$test_dir = $upload_dir['basedir'] . '/opti-behavior-data/' . $url_hash;
				if ( is_dir( $test_dir ) ) {
					$base_dir = $test_dir;
					break;
				}
			}
		}

		if ( ! $base_dir ) {
			return $empty_result;
		}

		// Map type to folder name
		$type_folders = array(
			'click'  => 'clicks',
			'move'   => 'moves',
			'scroll' => 'scrolls',
		);

		$total_clicks  = 0;
		$total_moves   = 0;
		$total_scrolls = 0;
		$sessions      = array(); // Track unique sessions with duration
		$total_time    = 0;

		// A/B variant direct filtering.
		$opti_ab_stats_test_id    = isset( $filters['ab_test_id'] ) ? (int) $filters['ab_test_id'] : 0;
		$opti_ab_stats_variant_id = isset( $filters['variant_id'] ) ? (int) $filters['variant_id'] : 0;

		// Legacy: Pre-compute short session ID suffixes for A/B variant filtering.
		$short_session_ids = null;
		if ( ! $opti_ab_stats_test_id && ! empty( $filters['session_ids'] ) && is_array( $filters['session_ids'] ) ) {
			$short_session_ids = array();
			foreach ( $filters['session_ids'] as $opti_behavior_sid ) {
				$opti_behavior_sid_clean = preg_replace( '/[^a-zA-Z0-9]/', '', $opti_behavior_sid );
				$short_session_ids[]     = substr( $opti_behavior_sid_clean, -8 );
			}
		}

		// Process all event types to get comprehensive stats
		foreach ( $type_folders as $event_type => $folder_name ) {
			$dir = $base_dir . '/' . $folder_name;

			if ( ! is_dir( $dir ) ) {
				continue;
			}

			$files = glob( $dir . '/*.json' );

			if ( empty( $files ) ) {
				continue;
			}

			foreach ( $files as $file ) {
				$filename = basename( $file );
				$parts = explode( '_', str_replace( '.json', '', $filename ) );

				// Support multiple filename formats:
				// AB format (10 parts): timestamp_country_browser_count_device_visitortype_duration_abtestid_variantid_session.json
				// Newest format (8 parts): timestamp_country_browser_count_device_visitortype_duration_session.json
				// Old format (7 parts): timestamp_country_browser_count_device_visitortype_session.json
				$timestamp = null;
				$country = null;
				$browser = null;
				$count = null;
				$file_device = null;
				$visitor_type = null;
				$duration = 0;
				$session = null;
				$opti_ab_file_test    = 0;
				$opti_ab_file_variant = 0;

				if ( count( $parts ) >= 10 && in_array( $parts[5], array( 'L', 'G' ), true ) && is_numeric( $parts[7] ) && is_numeric( $parts[8] ) ) {
					// AB format with ab_test_id and variant_id
					list( $timestamp, $country, $browser, $count, $file_device, $visitor_type, $duration, $opti_ab_file_test, $opti_ab_file_variant, $session ) = $parts;
					$duration             = intval( $duration );
					$opti_ab_file_test    = intval( $opti_ab_file_test );
					$opti_ab_file_variant = intval( $opti_ab_file_variant );
				} elseif ( count( $parts ) >= 8 ) {
					// Newest format with duration
					list( $timestamp, $country, $browser, $count, $file_device, $visitor_type, $duration, $session ) = $parts;
					$duration = intval( $duration );
				} elseif ( count( $parts ) >= 7 ) {
					// Old format without duration
					list( $timestamp, $country, $browser, $count, $file_device, $visitor_type, $session ) = $parts;
					$duration = 0;
				} else {
					// Invalid format, skip
					continue;
				}

				// Apply device filter (case-insensitive to match get_sessions_by_device_from_files)
				$file_device_lower = strtolower( $file_device );
				$device_lower = strtolower( $device );
				// Normalize 'phone' to 'mobile'
				if ( $file_device_lower === 'phone' ) {
					$file_device_lower = 'mobile';
				}
				// Treat unknown device types as 'desktop' to match get_sessions_by_device_from_files() behavior
				if ( ! in_array( $file_device_lower, array( 'desktop', 'mobile', 'tablet' ), true ) ) {
					$file_device_lower = 'desktop';
				}
				if ( $device_lower !== $file_device_lower ) {
					continue;
				}

				// Apply country filter (supports multi-select)
				if ( ! empty( $filters['country'] ) ) {
					$allowed_countries = explode( ',', $filters['country'] );
					if ( ! in_array( $country, $allowed_countries ) ) {
						continue;
					}
				}

				// Apply browser filter (supports multi-select)
				if ( ! empty( $filters['browser'] ) ) {
					$allowed_browsers = explode( ',', $filters['browser'] );
					if ( ! in_array( $browser, $allowed_browsers ) ) {
						continue;
					}
				}

				// Apply visitor type filter
				if ( ! empty( $filters['visitor_type'] ) ) {
					$filter_type = $filters['visitor_type'] === 'logged_in' ? 'L' : 'G';
					if ( $visitor_type !== $filter_type ) {
						continue;
					}
				}

				// Apply date range filter
				if ( ! $this->is_file_in_date_range( $timestamp, $date_range ) ) {
					continue;
				}

				// A/B variant filter: direct match on ab_test_id and variant_id from filename.
				if ( $opti_ab_stats_test_id > 0 && $opti_ab_stats_variant_id > 0 ) {
					if ( $opti_ab_file_test !== $opti_ab_stats_test_id || $opti_ab_file_variant !== $opti_ab_stats_variant_id ) {
						continue;
					}
				}

				// Legacy A/B variant session filter: match short session suffix.
				if ( null !== $short_session_ids ) {
					if ( ! in_array( $session, $short_session_ids, true ) ) {
						continue;
					}
				}

				// Count events
				$event_count = intval( $count );
				if ( $event_type === 'click' ) {
					$total_clicks += $event_count;
				} elseif ( $event_type === 'move' ) {
					$total_moves += $event_count;
				} elseif ( $event_type === 'scroll' ) {
					$total_scrolls += $event_count;
				}

				// Track unique session with maximum duration (in case same session appears in multiple event types)
				if ( ! isset( $sessions[ $session ] ) || $sessions[ $session ] < $duration ) {
					$sessions[ $session ] = $duration;
				}
			}
		}

		$total_views = count( $sessions );

		// Calculate average time from session durations
		$avg_time = 0;
		if ( $total_views > 0 ) {
			$total_time = array_sum( $sessions );
			$avg_time = (int) round( $total_time / $total_views );
		}

		// Calculate engagement rate based on heatmap type
		if ( $type === 'scroll' ) {
			$engagement_rate = $total_views > 0 ? round( ( $total_scrolls / $total_views ) * 100, 1 ) : 0;
		} elseif ( $type === 'move' ) {
			$engagement_rate = $total_views > 0 ? round( ( $total_moves / $total_views ) * 100, 1 ) : 0;
		} else {
			$engagement_rate = $total_views > 0 ? round( ( $total_clicks / $total_views ) * 100, 1 ) : 0;
		}

		return array(
			'total_views'     => $total_views,
			'total_clicks'    => $total_clicks,
			'total_moves'     => $total_moves,
			'total_scrolls'   => $total_scrolls,
			'avg_time'        => $avg_time,
			'engagement_rate' => $engagement_rate,
			'avg_scroll'      => 0, // Would need file contents for accurate scroll depth
		);
	}

	/**
	 * Check if a file timestamp is within the specified date range.
	 *
	 * @param int    $timestamp File timestamp.
	 * @param string $date_range Date range (all, today, yesterday, last_7_days, last_30_days).
	 * @return bool True if file is in range.
	 */
	private function is_file_in_date_range( $timestamp, $date_range ) {
		if ( $date_range === 'all' ) {
			return true;
		}

		$now = current_time( 'timestamp' );
		$file_time = intval( $timestamp );

		switch ( $date_range ) {
			case 'today':
				$start = strtotime( 'today', $now );
				return $file_time >= $start;

			case 'yesterday':
				$start = strtotime( 'yesterday', $now );
				$end = strtotime( 'today', $now );
				return $file_time >= $start && $file_time < $end;

			case 'last_7_days':
				$start = strtotime( '-7 days', $now );
				return $file_time >= $start;

			case 'last_30_days':
				$start = strtotime( '-30 days', $now );
				return $file_time >= $start;

			default:
				return true;
		}
	}

	/**
	 * Get recordings for page and device.
	 *
	 * @param int    $page_id Page ID.
	 * @param string $device Device type.
	 * @param string $date_range Date range.
	 * @param array  $filters Optional filters (country, browser, visitor_type).
	 * @return array Recordings.
	 */
	private function get_recordings( $page_id, $device, $date_range, $filters = array() ) {
		global $wpdb;

		$table_recordings   = $wpdb->prefix . 'optibehavior_recordings';
		$table_sessions     = $wpdb->prefix . 'optibehavior_sessions';
		$table_visitors     = $wpdb->prefix . 'optibehavior_visitors';
		$table_session_pages = $wpdb->prefix . 'optibehavior_session_pages';

		// Calculate date range.
		$where_date  = $this->get_date_where_clause( $date_range );
		$reset_where = $this->get_heatmap_reset_where_clause( $page_id );

		// Build additional filter conditions.
		$where_filters = '';
		$filter_params = array();

		if ( ! empty( $filters['country'] ) ) {
			$where_filters .= ' AND sp.country = %s';
			$filter_params[] = $filters['country'];
		}

		if ( ! empty( $filters['browser'] ) ) {
			$where_filters .= ' AND sp.browser = %s';
			$filter_params[] = $filters['browser'];
		}

		if ( ! empty( $filters['visitor_type'] ) ) {
			if ( $filters['visitor_type'] === 'logged_in' ) {
				$where_filters .= ' AND s.user_id IS NOT NULL AND s.user_id > 0';
			} elseif ( $filters['visitor_type'] === 'guest' ) {
				$where_filters .= ' AND (s.user_id IS NULL OR s.user_id = 0)';
			}
		}

		// A/B variant session filtering: restrict to sessions belonging to a specific variant.
		if ( ! empty( $filters['session_ids'] ) && is_array( $filters['session_ids'] ) ) {
			$session_placeholders = implode( ',', array_fill( 0, count( $filters['session_ids'] ), '%s' ) );
			$where_filters       .= " AND r.session_id IN ($session_placeholders)";
			$filter_params        = array_merge( $filter_params, $filters['session_ids'] );
		}

		// Build query - join with session_pages to find recordings where the session visited this page
		// This handles multi-page sessions where page_id might not be the entry page
		// Use LOWER() for case-insensitive device comparison (FREE plugin uses mixed casing)
		$query = "
			SELECT DISTINCT r.*, v.device_type
			FROM {$table_recordings} r
			INNER JOIN {$table_sessions} s ON r.session_id = s.id
			INNER JOIN {$table_visitors} v ON s.visitor_id = v.id
			INNER JOIN {$table_session_pages} sp ON sp.session_id = r.session_id
			WHERE sp.page_id = %d
			AND LOWER(v.device_type) = LOWER(%s)
			{$where_date}
			{$reset_where}
			{$where_filters}
			LIMIT 100
		";

		// Build the list of parameters for the prepared query.
		$query_params = array( $page_id, $device );
		$query_params = array_merge( $query_params, $filter_params );

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is built dynamically with proper placeholders
		$prepared_query = $wpdb->prepare( $query, $query_params );

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

		return $results;
	}

	/**
	 * Get device counts for page.
	 *
	 * @param int    $page_id Page ID.
	 * @param string $type    Heatmap type ('click', 'move', 'scroll').
	 * @param array  $filters Optional filters (visitor_type, date_range, country, browser).
	 * @return array Device counts.
	 */
	private function get_recordings_by_device( $page_id, $type, $filters = array() ) {
		global $wpdb;

		// UNIFIED STATS FIX: When Pro is active, use database counts for consistency
		// This ensures heatmap sessions match recording sessions
		$is_pro_active = function_exists( 'opti_behavior_pro_active' ) && opti_behavior_pro_active();

		if ( $is_pro_active ) {
			return $this->get_sessions_by_device_from_database( $page_id, $filters );
		}

		// Fall back to file-based counting for free version
		return $this->get_sessions_by_device_from_files( $page_id, $filters );
	}

	/**
	 * Get session counts by device from database (unified counting for Pro).
	 * This ensures consistent counts between heatmaps and recordings.
	 * IMPORTANT: Uses session_pages joined with recordings table to ensure
	 * heatmap counts EXACTLY match recording counts (100% consistency).
	 *
	 * @param int   $page_id Page ID.
	 * @param array $filters Optional filters (visitor_type, date_range, country, browser).
	 * @return array Device counts.
	 */
	private function get_sessions_by_device_from_database( $page_id, $filters = array() ) {
		global $wpdb;

		$table_sessions      = $wpdb->prefix . 'optibehavior_sessions';
		$table_visitors      = $wpdb->prefix . 'optibehavior_visitors';
		$table_session_pages = $wpdb->prefix . 'optibehavior_session_pages';
		$table_recordings    = $wpdb->prefix . 'optibehavior_recordings';

		// Build dynamic WHERE clauses from filters.
		$extra_where  = '';
		$extra_params = array();

		// Visitor type filter: logged_in → user_id > 0; guest → user_id IS NULL OR = 0.
		if ( ! empty( $filters['visitor_type'] ) ) {
			if ( $filters['visitor_type'] === 'logged_in' ) {
				$extra_where .= ' AND s.user_id IS NOT NULL AND s.user_id > 0';
			} elseif ( $filters['visitor_type'] === 'guest' ) {
				$extra_where .= ' AND (s.user_id IS NULL OR s.user_id = 0)';
			}
		}

		// Date range filter.
		if ( ! empty( $filters['date_range'] ) && $filters['date_range'] !== 'all' ) {
			$now = current_time( 'timestamp' );
			switch ( $filters['date_range'] ) {
				case 'today':
					$extra_where   .= ' AND s.start_time >= %d';
					$extra_params[] = strtotime( 'today', $now );
					break;
				case 'yesterday':
					$extra_where   .= ' AND s.start_time >= %d AND s.start_time < %d';
					$extra_params[] = strtotime( 'yesterday', $now );
					$extra_params[] = strtotime( 'today', $now );
					break;
				case 'last_7_days':
					$extra_where   .= ' AND s.start_time >= %d';
					$extra_params[] = strtotime( '-7 days', $now );
					break;
				case 'last_30_days':
					$extra_where   .= ' AND s.start_time >= %d';
					$extra_params[] = strtotime( '-30 days', $now );
					break;
			}
		}

		// Country filter (stored in session_pages).
		if ( ! empty( $filters['country'] ) ) {
			$allowed_countries = array_filter( array_map( 'trim', explode( ',', $filters['country'] ) ) );
			if ( ! empty( $allowed_countries ) ) {
				$placeholders  = implode( ',', array_fill( 0, count( $allowed_countries ), '%s' ) );
				$extra_where  .= " AND sp.country IN ($placeholders)";
				$extra_params  = array_merge( $extra_params, $allowed_countries );
			}
		}

		// Browser filter (stored in session_pages).
		if ( ! empty( $filters['browser'] ) ) {
			$allowed_browsers = array_filter( array_map( 'trim', explode( ',', $filters['browser'] ) ) );
			if ( ! empty( $allowed_browsers ) ) {
				$placeholders  = implode( ',', array_fill( 0, count( $allowed_browsers ), '%s' ) );
				$extra_where  .= " AND sp.browser IN ($placeholders)";
				$extra_params  = array_merge( $extra_params, $allowed_browsers );
			}
		}

		// UNIFIED COUNTING: Count sessions from session_pages joined with recordings
		// This ensures heatmap counts EXACTLY match recording counts by only counting
		// sessions that have recordings saved (same logic as recordings page).
		// The INNER JOIN to recordings ensures we only count sessions with actual recordings.
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safely prefixed from wpdb
		$query_params = array_merge( array( $page_id ), $extra_params );
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix; extra_where is built from validated values only.
		$query = $wpdb->prepare(
			"SELECT
				LOWER(COALESCE(v.device_type, 'desktop')) as device_type,
				COUNT(DISTINCT sp.session_id) as session_count
			FROM {$table_session_pages} sp
			INNER JOIN {$table_recordings} r ON sp.session_id = r.session_id
			LEFT JOIN {$table_sessions} s ON sp.session_id = s.id
			LEFT JOIN {$table_visitors} v ON s.visitor_id = v.id
			WHERE sp.page_id = %d
			{$extra_where}
			GROUP BY LOWER(COALESCE(v.device_type, 'desktop'))",
			$query_params
		);

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

		$counts = array(
			'desktop' => 0,
			'mobile'  => 0,
			'tablet'  => 0,
		);

		$total = 0;
		foreach ( $results as $row ) {
			$device = strtolower( $row->device_type );
			$count  = intval( $row->session_count );
			$total += $count;

			if ( isset( $counts[ $device ] ) ) {
				$counts[ $device ] = $count;
			} elseif ( $device === 'phone' ) {
				// Handle 'phone' as 'mobile'
				$counts['mobile'] += $count;
			} else {
				// Default unknown devices to desktop
				$counts['desktop'] += $count;
			}
		}

		$counts['total'] = $total;

		return $counts;
	}

	/**
	 * Get session counts by device from files (for free version).
	 *
	 * @param int   $page_id Page ID.
	 * @param array $filters Optional filters (visitor_type, date_range, country, browser).
	 * @return array Device counts.
	 */
	private function get_sessions_by_device_from_files( $page_id, $filters = array() ) {
		global $wpdb;

		// Get URL hash for this page
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix; analytics queries.
		$url = $wpdb->get_var( $wpdb->prepare( "SELECT url FROM {$wpdb->prefix}optibehavior_pages WHERE id = %d", $page_id ) );
		if ( ! $url ) {
			return array(
				'desktop' => 0,
				'mobile'  => 0,
				'tablet'  => 0,
			);
		}

		// Try both URL variations (with and without trailing slash) for backwards compatibility
		$url_variations = array(
			md5( $url ),                     // Try URL as stored in DB
			md5( rtrim( $url, '/' ) ),       // Try without trailing slash
			md5( $url . '/' ),               // Try with trailing slash
		);

		$upload_dir = wp_upload_dir();
		$base_dir = null;

		// Find which URL hash directory actually exists
		foreach ( $url_variations as $url_hash ) {
			$test_dir = $upload_dir['basedir'] . '/opti-behavior-data/' . $url_hash;
			if ( is_dir( $test_dir ) ) {
				$base_dir = $test_dir;
				break;
			}
		}

		if ( ! $base_dir ) {
			return array(
				'desktop' => 0,
				'mobile'  => 0,
				'tablet'  => 0,
			);
		}

		// Count sessions across ALL event types (clicks, moves, scrolls)
		// This ensures consistency with the dashboard which also counts all sessions
		$all_sessions = array();
		$counts_by_device = array(
			'desktop' => array(),
			'mobile'  => array(),
			'tablet'  => array(),
		);

		$type_folders = array( 'clicks', 'moves', 'scrolls' );

		foreach ( $type_folders as $folder_name ) {
			$type_dir = $base_dir . '/' . $folder_name;
			if ( ! is_dir( $type_dir ) ) {
				continue;
			}

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

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

				$file_timestamp    = $parts[0];
				$file_country      = $parts[1];
				$file_browser      = $parts[2];
				$file_device       = $parts[4];
				$file_visitor_type = isset( $parts[5] ) ? $parts[5] : '';

				// 10-part AB format: session at index 9
				if ( count( $parts ) >= 10 && is_numeric( $parts[7] ) && is_numeric( $parts[8] ) ) {
					$session = $parts[9];
				} elseif ( count( $parts ) >= 8 ) {
					$session = $parts[7];
				} elseif ( count( $parts ) >= 7 ) {
					$session = $parts[6];
				} else {
					continue;
				}

				// Apply visitor type filter: 'logged_in' → L files only; 'guest' → G + old files.
				if ( ! empty( $filters['visitor_type'] ) ) {
					$want_logged_in   = ( $filters['visitor_type'] === 'logged_in' );
					$file_is_logged_in = ( $file_visitor_type === 'L' );
					// Old-format files (no L/G marker) default to guest.
					if ( $file_visitor_type === '' ) {
						$file_is_logged_in = false;
					}
					if ( $want_logged_in !== $file_is_logged_in ) {
						continue;
					}
				}

				// Apply date range filter.
				if ( ! empty( $filters['date_range'] ) && $filters['date_range'] !== 'all' ) {
					if ( ! $this->is_file_in_date_range( $file_timestamp, $filters['date_range'] ) ) {
						continue;
					}
				}

				// Apply country filter (multi-select: comma-separated string).
				if ( ! empty( $filters['country'] ) ) {
					$allowed_countries = array_filter( array_map( 'trim', explode( ',', $filters['country'] ) ) );
					if ( ! empty( $allowed_countries ) && ! in_array( strtoupper( $file_country ), array_map( 'strtoupper', $allowed_countries ), true ) ) {
						continue;
					}
				}

				// Apply browser filter (multi-select: comma-separated string).
				if ( ! empty( $filters['browser'] ) ) {
					$allowed_browsers = array_filter( array_map( 'trim', explode( ',', $filters['browser'] ) ) );
					if ( ! empty( $allowed_browsers ) && ! in_array( strtolower( $file_browser ), array_map( 'strtolower', $allowed_browsers ), true ) ) {
						continue;
					}
				}

				// Normalize device type (match existing behaviour in this function).
				$device_normalized = strtolower( $file_device );
				if ( $device_normalized === 'phone' ) {
					$device_normalized = 'mobile';
				}
				if ( ! in_array( $device_normalized, array( 'desktop', 'mobile', 'tablet' ), true ) ) {
					$device_normalized = 'desktop';
				}

				// Track total unique sessions
				$all_sessions[ $session ] = true;

				// Track unique sessions per device across all event types
				if ( isset( $counts_by_device[ $device_normalized ] ) ) {
					$counts_by_device[ $device_normalized ][ $session ] = true;
				}
			}
		}

		// Return session counts per device (counting across all event types)
		return array(
			'desktop' => count( $counts_by_device['desktop'] ),
			'mobile'  => count( $counts_by_device['mobile'] ),
			'tablet'  => count( $counts_by_device['tablet'] ),
			'total'   => count( $all_sessions ),
		);
	}

	/**
	 * Get recording events (decrypted).
	 *
	 * @param object $recording Recording object.
	 * @return array Events array.
	 */
	private function get_recording_events( $recording ) {
		// Check if recording is stored in file.
		if ( ! empty( $recording->file_path ) && $recording->storage_type === 'file' ) {
			// Load from file storage.
			$upload_dir = wp_upload_dir();
			$full_file_path = trailingslashit( $upload_dir['basedir'] ) . 'opti-behavior-data/' . $recording->file_path;

			if ( ! file_exists( $full_file_path ) ) {
				$this->debug_log( '[Opti-Behavior-Heatmap] File not found: ' . $full_file_path );
				return array();
			}

			// Read and decompress the file.
			$compressed_data = file_get_contents( $full_file_path );
			if ( $compressed_data === false ) {
				$this->debug_log( '[Opti-Behavior-Heatmap] Failed to read file: ' . $full_file_path );
				return array();
			}

			// Decompress if file is compressed (has .gz extension).
			$json_data = $compressed_data;
			if ( substr( $recording->file_path, -3 ) === '.gz' ) {
				$json_data = gzuncompress( $compressed_data );
				if ( $json_data === false ) {
					$this->debug_log( '[Opti-Behavior-Heatmap] Failed to decompress file: ' . $full_file_path );
					return array();
				}
			}

			$file_data = json_decode( $json_data, true );
			if ( ! $file_data || ! isset( $file_data['events'] ) ) {
				$this->debug_log( '[Opti-Behavior-Heatmap] Invalid file format: ' . $full_file_path );
				return array();
			}

			$this->debug_log( '[Opti-Behavior-Heatmap] Loaded ' . count( $file_data['events'] ) . ' events from file: ' . $recording->file_path );
			return $file_data['events'];
		}

		// Otherwise, load from database.
		if ( empty( $recording->recording_data ) ) {
			return array();
		}

		// Check if data is encrypted.
		$data = json_decode( $recording->recording_data, true );

		if ( is_array( $data ) && isset( $data['ciphertext'] ) ) {
			// Data is encrypted, decrypt it.
			if ( ! class_exists( 'Opti_Behavior_Session_Encryption' ) ) {
				$this->debug_log( '[Opti-Behavior-Heatmap] Encryption class not found' );
				return array();
			}

			$encryption = Opti_Behavior_Session_Encryption::get_instance();
			$decrypted  = $encryption->decrypt_session( $data );

			if ( $decrypted === false ) {
				$this->debug_log( '[Opti-Behavior-Heatmap] Failed to decrypt recording data for recording ID: ' . ( isset( $recording->id ) ? $recording->id : 'unknown' ) );
				return array();
			}

			$data = json_decode( $decrypted, true );

			if ( ! is_array( $data ) ) {
				$this->debug_log( '[Opti-Behavior-Heatmap] Decrypted data is not valid JSON' );
				return array();
			}
		}

		return is_array( $data ) ? $data : array();
	}

	/**
	 * Filter events to only include those from a specific page URL.
	 *
	 * For multi-page recordings, this finds the navigation event to the target page
	 * and returns all subsequent events until the next navigation.
	 *
	 * @param array  $events   Array of rrweb events.
	 * @param string $page_url Target page URL.
	 * @return array Filtered events.
	 */
	private function filter_events_by_page( $events, $page_url ) {
		if ( empty( $events ) || empty( $page_url ) ) {
			return $events;
		}

		$filtered = array();
		$on_target_page = false;
		$current_url = '';

		foreach ( $events as $event ) {
			// Meta events (type 4) contain the href (current page URL)
			if ( isset( $event['type'] ) && $event['type'] === 4 && isset( $event['data']['href'] ) ) {
				$event_url = $event['data']['href'];

				// Normalize URLs for comparison (remove trailing slashes and query strings)
				$normalized_event_url = rtrim( preg_replace( '/\?.*$/', '', $event_url ), '/' );
				$normalized_page_url = rtrim( preg_replace( '/\?.*$/', '', $page_url ), '/' );

				// Check if we're on the target page
				if ( $normalized_event_url === $normalized_page_url ||
				     strpos( $normalized_event_url, $normalized_page_url ) !== false ) {
					$on_target_page = true;
					$current_url = $event_url;
					$this->debug_log( "[Opti-Behavior-Heatmap] Found target page in meta event: {$event_url}" );
				} else {
					// Navigated away from target page
					if ( $on_target_page ) {
						$this->debug_log( "[Opti-Behavior-Heatmap] Navigated away from target page to: {$event_url}" );
					}
					$on_target_page = false;
					$current_url = $event_url;
				}
			}

			// Custom page_navigation events (type 5) - added to support explicit page navigation tracking
			if ( isset( $event['type'] ) && $event['type'] === 5 &&
			     isset( $event['data']['tag'] ) && $event['data']['tag'] === 'page_navigation' &&
			     isset( $event['data']['payload']['url'] ) ) {
				$event_url = $event['data']['payload']['url'];

				// Normalize URLs for comparison (remove trailing slashes and query strings)
				$normalized_event_url = rtrim( preg_replace( '/\?.*$/', '', $event_url ), '/' );
				$normalized_page_url = rtrim( preg_replace( '/\?.*$/', '', $page_url ), '/' );

				// Check if we're on the target page
				if ( $normalized_event_url === $normalized_page_url ||
				     strpos( $normalized_event_url, $normalized_page_url ) !== false ) {
					$on_target_page = true;
					$current_url = $event_url;
					$this->debug_log( "[Opti-Behavior-Heatmap] Found target page in page_navigation event: {$event_url}" );
				} else {
					// Navigated away from target page
					if ( $on_target_page ) {
						$this->debug_log( "[Opti-Behavior-Heatmap] Navigated away from target page (page_navigation) to: {$event_url}" );
					}
					$on_target_page = false;
					$current_url = $event_url;
				}
			}

			// For the first full snapshot (type 2), check if it contains the target URL in the data
			if ( empty( $current_url ) && isset( $event['type'] ) && $event['type'] === 2 ) {
				// Full snapshot event - might contain the initial URL
				if ( isset( $event['data']['href'] ) ) {
					$event_url = $event['data']['href'];
					$normalized_event_url = rtrim( preg_replace( '/\?.*$/', '', $event_url ), '/' );
					$normalized_page_url = rtrim( preg_replace( '/\?.*$/', '', $page_url ), '/' );

					if ( $normalized_event_url === $normalized_page_url ||
					     strpos( $normalized_event_url, $normalized_page_url ) !== false ) {
						$on_target_page = true;
						$current_url = $event_url;
						$this->debug_log( "[Opti-Behavior-Heatmap] Found target page in full snapshot: {$event_url}" );
					}
				}
			}

			// Include this event if we're on the target page
			if ( $on_target_page ) {
				$filtered[] = $event;
			}
		}

		// If we never detected the page (no meta/snapshot events with href), return all events
		// This handles single-page recordings where the page_id already matches
		if ( empty( $filtered ) && ! empty( $events ) ) {
			$this->debug_log( "[Opti-Behavior-Heatmap] No page detection events found, assuming single-page recording" );
			return $events;
		}

		// Log filtering results
		$click_events = array_filter( $filtered, function( $e ) {
			return isset( $e['type'] ) && $e['type'] === 3 &&
			       isset( $e['data']['source'] ) && $e['data']['source'] === 2;
		} );
		$this->debug_log( "[Opti-Behavior-Heatmap] Filtered " . count( $filtered ) . " events for target page (including " . count( $click_events ) . " click events)" );

		return $filtered;
	}

	/**
	 * Aggregate data from multiple recordings.
	 *
	 * @param array  $all_parsed_data Array of parsed data from multiple recordings.
	 * @param string $type Heatmap type.
	 * @param array  $viewport Viewport dimensions array with 'width' and 'height'.
	 * @return array Aggregated coordinates.
	 */
	private function aggregate_all_data( $all_parsed_data, $type, $viewport = array() ) {
		if ( empty( $all_parsed_data ) ) {
			$this->debug_log( "[Opti-Behavior-Heatmap] No parsed data to aggregate" );
			return array();
		}

		// Default viewport if not provided
		if ( empty( $viewport ) ) {
			$viewport = array( 'width' => 1920, 'height' => 1080 );
		}

		// Combine all coordinates.
		$combined = array(
			'clicks'  => array(),
			'moves'   => array(),
			'scrolls' => array(),
		);

		foreach ( $all_parsed_data as $parsed ) {
			if ( isset( $parsed['clicks'] ) && is_array( $parsed['clicks'] ) ) {
				$combined['clicks'] = array_merge( $combined['clicks'], $parsed['clicks'] );
			}
			if ( isset( $parsed['moves'] ) && is_array( $parsed['moves'] ) ) {
				$combined['moves'] = array_merge( $combined['moves'], $parsed['moves'] );
			}
			if ( isset( $parsed['scrolls'] ) && is_array( $parsed['scrolls'] ) ) {
				$combined['scrolls'] = array_merge( $combined['scrolls'], $parsed['scrolls'] );
			}
		}

		// Log combined data stats
		$this->debug_log( "[Opti-Behavior-Heatmap] Combined data - Clicks: " . count( $combined['clicks'] ) .
				", Moves: " . count( $combined['moves'] ) .
				", Scrolls: " . count( $combined['scrolls'] ) );

		// Aggregate based on type, passing viewport information.
		$result = $this->parser->aggregate_data( $combined, $type, $viewport );

		$this->debug_log( "[Opti-Behavior-Heatmap] Final aggregated points: " . count( $result ) );

		return $result;
	}

	/**
	 * Get a SQL fragment that excludes recordings captured before a page heatmap reset.
	 *
	 * @param int $page_id Page ID from optibehavior_pages.
	 * @return string Prepared SQL fragment or an empty string.
	 */
	private function get_heatmap_reset_where_clause( $page_id ) {
		global $wpdb;

		$resets = get_option( 'opti_behavior_heatmap_page_resets', array() );
		if ( ! is_array( $resets ) || empty( $resets[ $page_id ] ) ) {
			return '';
		}

		$reset_at = sanitize_text_field( $resets[ $page_id ] );
		if ( ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $reset_at ) ) {
			return '';
		}

		return $wpdb->prepare( 'AND r.start_time >= %s', $reset_at );
	}

	/**
	 * Get date where clause for SQL query.
	 *
	 * @param string $date_range Date range.
	 * @return string WHERE clause.
	 */

	/**
	 * Get session IDs for a specific A/B test variant.
	 *
	 * Used to filter heatmap data to only show events from sessions belonging to a variant.
	 *
	 * @param int $ab_test_id A/B test ID.
	 * @param int $variant_id Variant ID.
	 * @return array Array of session ID strings.
	 */
	private function get_variant_session_ids( $ab_test_id, $variant_id ) {
		global $wpdb;
		$table = esc_sql( $wpdb->prefix . 'optibehavior_ab_impressions' );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		return $wpdb->get_col(
			$wpdb->prepare(
				"SELECT DISTINCT session_id FROM " . $table . " WHERE test_id = %d AND variant_id = %d AND session_id IS NOT NULL AND session_id != ''",
				$ab_test_id,
				$variant_id
			)
		);
	}

	private function get_date_where_clause( $date_range ) {
		$now = current_time( 'mysql' );

		switch ( $date_range ) {
			case 'today':
				return "AND DATE(r.start_time) = CURDATE()";

			case 'yesterday':
				return "AND DATE(r.start_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)";

			case 'last7days':
				return "AND r.start_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)";

			case 'last30days':
				return "AND r.start_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)";

			case 'all':
			default:
				// No date filter - return all data
				return "";
		}
	}

	/**
	 * Get recording count for cache validation.
	 *
	 * @param int    $page_id Page ID.
	 * @param string $device Device type.
	 * @param string $date_range Date range.
	 * @return int Number of recordings.
	 */
	private function get_recording_count( $page_id, $device, $date_range ) {
		global $wpdb;

		$date_where  = $this->get_date_where_clause( $date_range );
		$reset_where = $this->get_heatmap_reset_where_clause( $page_id );

		$table_recordings    = $wpdb->prefix . 'optibehavior_recordings';
		$table_sessions      = $wpdb->prefix . 'optibehavior_sessions';
		$table_visitors      = $wpdb->prefix . 'optibehavior_visitors';
		$table_session_pages = $wpdb->prefix . 'optibehavior_session_pages';

		// Use same logic as get_recordings() - join with visitors table for device info
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safely prefixed from wpdb
		$query = $wpdb->prepare(
			"SELECT COUNT(DISTINCT r.id)
			FROM {$table_recordings} r
			INNER JOIN {$table_sessions} s ON r.session_id = s.id
			INNER JOIN {$table_visitors} v ON s.visitor_id = v.id
			INNER JOIN {$table_session_pages} sp ON sp.session_id = r.session_id
			WHERE sp.page_id = %d
			AND LOWER(v.device_type) = LOWER(%s)
			{$date_where}
			{$reset_where}",
			$page_id,
			$device
		);

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

	/**
	 * AJAX handler: proxy a frontend page for heatmap iframe display.
	 *
	 * Some hosting providers (e.g., Hostinger CDN) add X-Frame-Options: DENY
	 * at the infrastructure level, which cannot be overridden from PHP.
	 * This proxy fetches the page HTML server-side via wp_remote_get() and
	 * returns it so the JS can inject it into the iframe via srcdoc.
	 *
	 * SECURITY: Only admins can use this endpoint, and only same-site URLs
	 * are allowed (must match the WordPress site URL).
	 *
	 * @since 1.5.17
	 */
	public function ajax_proxy_page() {
		// Verify nonce.
		if ( ! check_ajax_referer( 'opti_heatmap_detail', 'nonce', false ) ) {
			wp_send_json_error( array( 'message' => 'Invalid nonce' ) );
		}

		// Verify permissions - admin only.
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => 'Insufficient permissions' ) );
		}

		// Get and validate the URL.
		$url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : '';
		if ( empty( $url ) ) {
			wp_send_json_error( array( 'message' => 'No URL provided' ) );
		}

		// SECURITY: Only allow same-site URLs to prevent SSRF attacks.
		$site_url  = wp_parse_url( home_url(), PHP_URL_HOST );
		$requested = wp_parse_url( $url, PHP_URL_HOST );
		if ( ! $site_url || ! $requested || strtolower( $site_url ) !== strtolower( $requested ) ) {
			wp_send_json_error( array( 'message' => 'Only same-site URLs are allowed' ) );
		}

		// Fetch the page HTML server-side (bypasses CDN X-Frame-Options).
		$response = wp_remote_get(
			$url,
			array(
				'timeout'    => 30,
				'sslverify'  => false,
				'user-agent' => 'OptiBehavior-HeatmapProxy/1.0',
				'cookies'    => array(), // Fetch as guest (no cookies).
			)
		);

		if ( is_wp_error( $response ) ) {
			wp_send_json_error( array( 'message' => 'Failed to fetch page: ' . $response->get_error_message() ) );
		}

		$status_code = wp_remote_retrieve_response_code( $response );
		if ( $status_code < 200 || $status_code >= 400 ) {
			wp_send_json_error( array( 'message' => 'Page returned HTTP ' . $status_code ) );
		}

		$html = wp_remote_retrieve_body( $response );
		if ( empty( $html ) ) {
			wp_send_json_error( array( 'message' => 'Empty page response' ) );
		}

		// Strip ALL script tags from the proxy HTML.
		// The proxy injects HTML via iframe.srcdoc which has a null origin.
		// Scripts fail to execute properly in this context (jQuery not defined,
		// Elementor errors, etc.), causing visual rendering differences.
		// For a heatmap background we only need the CSS visual layout,
		// not interactive JavaScript.
		$html = preg_replace( '/<script[^>]*>.*?<\/script>/is', '', $html );
		$html = preg_replace( '/<script[^>]*\/>/i', '', $html );
		// Also strip noscript tags to avoid duplicate or misplaced elements.
		$html = preg_replace( '/<noscript[^>]*>.*?<\/noscript>/is', '', $html );

		// Inject a <base> tag so relative URLs (CSS, images) resolve correctly.
		// Also inject CSS to hide admin bar and disable pointer events/scrolling.
		$base_url    = trailingslashit( home_url() );
		$inject_head = '<base href="' . esc_url( $base_url ) . '">' . "\n"
			. '<style>html{margin-top:0!important}body{margin-top:0!important;overflow:hidden!important}'
			. '#wpadminbar{display:none!important}</style>' . "\n";

		// Insert after <head> tag (case-insensitive).
		$html = preg_replace( '/(<head[^>]*>)/i', '$1' . "\n" . $inject_head, $html, 1 );

		// Return the HTML as a JSON response.
		wp_send_json_success( array( 'html' => $html ) );
	}
}
