<?php
/**
 * Optimized Heatmap Storage Class
 *
 * Provides fast file-based storage for heatmap data (clicks, moves, scrolls).
 * Uses filename metadata for fast filtering without reading file contents.
 *
 * @package opti-behavior
 * @copyright 2025 Opti-User
 * @version 1.0.0
 */

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

// 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

/**
 * Opti_Behavior_Heatmap_Storage
 *
 * Handles optimized storage and retrieval of heatmap data.
 * Files are organized by URL hash and contain pre-extracted coordinates.
 *
 * @since 1.0.0
 */
class Opti_Behavior_Heatmap_Storage {

	/**
	 * Singleton instance
	 *
	 * @var Opti_Behavior_Heatmap_Storage
	 */
	private static $instance = null;

	/**
	 * Storage base directory
	 *
	 * @var string
	 */
	private $base_dir;

	/**
	 * Debug manager instance
	 *
	 * @var Opti_Behavior_Heatmap_Debug_Manager|null
	 */
	private $debug_manager = null;

	/**
	 * Event buffer for batch saving
	 *
	 * @var array
	 */
	private $event_buffer = array();

	/**
	 * Buffer flush threshold
	 *
	 * @var int
	 */
	private $buffer_threshold = 10;

	/**
	 * Get singleton instance
	 *
	 * @return Opti_Behavior_Heatmap_Storage
	 */
	public static function get_instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Constructor
	 *
	 * @param Opti_Behavior_Heatmap_Debug_Manager|null $debug_manager Optional debug manager instance.
	 */
	public function __construct( $debug_manager = null ) {
		$this->debug_manager = $debug_manager;
		$this->init_storage_directory();

		// Flush buffer on shutdown
		add_action( 'shutdown', array( $this, 'flush_all_buffers' ) );
	}

	/**
	 * Set debug manager
	 *
	 * @param Opti_Behavior_Heatmap_Debug_Manager $debug_manager Debug manager instance.
	 */
	public function set_debug_manager( $debug_manager ) {
		$this->debug_manager = $debug_manager;
	}

	/**
	 * Log a debug message
	 *
	 * @param string $message Message to log.
	 * @param string $level   Log level (error, warning, info, debug).
	 */
	private function log( $message, $level = 'info' ) {
		if ( $this->debug_manager ) {
			$this->debug_manager->log( $message, $level, 'heatmap-storage' );
		}
	}

	/**
	 * Initialize storage directory
	 */
	private function init_storage_directory() {
		$upload_dir = wp_upload_dir();
		$this->base_dir = trailingslashit( $upload_dir['basedir'] ) . 'opti-behavior-data/';

		// Create base directory if it doesn't exist
		if ( ! file_exists( $this->base_dir ) ) {
			wp_mkdir_p( $this->base_dir );

			// Add .htaccess to protect data files
			$this->create_htaccess( $this->base_dir );

			// Add index.php to prevent directory listing
			$this->create_index_file( $this->base_dir );
		}
	}

	/**
	 * Create .htaccess file to protect data
	 *
	 * @param string $dir Directory path.
	 */
	private function create_htaccess( $dir ) {
		$htaccess_file = trailingslashit( $dir ) . '.htaccess';
		if ( ! file_exists( $htaccess_file ) ) {
			$content = "# Opti-Behavior Heatmap Data Protection\n";
			$content .= "Order deny,allow\n";
			$content .= "Deny from all\n";
			$content .= "<FilesMatch '\.(json)$'>\n";
			$content .= "  Deny from all\n";
			$content .= "</FilesMatch>\n";

			file_put_contents( $htaccess_file, $content );
		}
	}

	/**
	 * Create index.php to prevent directory listing
	 *
	 * @param string $dir Directory path.
	 */
	private function create_index_file( $dir ) {
		$index_file = trailingslashit( $dir ) . 'index.php';
		if ( ! file_exists( $index_file ) ) {
			file_put_contents( $index_file, "<?php\n// Silence is golden.\n" );
		}
	}

	/**
	 * Get URL hash for organizing files
	 *
	 * @param string $url The URL to hash.
	 * @return string MD5 hash of the normalized URL.
	 */
	public function get_url_hash( $url ) {
		// Normalize URL: remove query params and fragment
		$parsed = wp_parse_url( $url );

		$normalized = '';
		if ( isset( $parsed['scheme'] ) ) {
			$normalized .= $parsed['scheme'] . '://';
		}
		if ( isset( $parsed['host'] ) ) {
			$normalized .= strtolower( $parsed['host'] );
		}
		if ( isset( $parsed['path'] ) ) {
			$normalized .= rtrim( $parsed['path'], '/' );
		}

		return md5( $normalized );
	}

	/**
	 * Get directory path for heatmap data
	 *
	 * Structure: opti-behavior-data/{url_hash}/clicks/
	 *            opti-behavior-data/{url_hash}/moves/
	 *            opti-behavior-data/{url_hash}/scrolls/
	 *
	 * @param string $url_hash URL hash (unique identifier for each visited URL).
	 * @param string $type     Data type (clicks, moves, scrolls).
	 * @return string Directory path.
	 */
	public function get_heatmap_dir( $url_hash, $type = 'clicks' ) {
		return $this->base_dir . $url_hash . '/' . $type . '/';
	}

	/**
	 * Ensure directory exists
	 *
	 * @param string $path Directory path.
	 * @return bool Success.
	 */
	public function ensure_directory_exists( $path ) {
		if ( ! file_exists( $path ) ) {
			$result = wp_mkdir_p( $path );
			if ( $result ) {
				$this->create_index_file( $path );
			}
			return $result;
		}
		return true;
	}

	/**
	 * Normalize browser name
	 *
	 * @param string $browser Raw browser string.
	 * @return string Normalized browser name.
	 */
	private function normalize_browser( $browser ) {
		$browser = strtolower( $browser );

		if ( strpos( $browser, 'chrome' ) !== false && strpos( $browser, 'edg' ) === false ) {
			return 'Chrome';
		}
		if ( strpos( $browser, 'firefox' ) !== false ) {
			return 'Firefox';
		}
		if ( strpos( $browser, 'safari' ) !== false && strpos( $browser, 'chrome' ) === false ) {
			return 'Safari';
		}
		if ( strpos( $browser, 'edg' ) !== false ) {
			return 'Edge';
		}
		if ( strpos( $browser, 'opera' ) !== false || strpos( $browser, 'opr' ) !== false ) {
			return 'Opera';
		}

		return 'Other';
	}

	/**
	 * Normalize device type
	 *
	 * @param string $device Raw device type.
	 * @return string Normalized device type.
	 */
	private function normalize_device( $device ) {
		$device = strtolower( $device );

		if ( in_array( $device, array( 'desktop', 'pc' ), true ) ) {
			return 'desktop';
		}
		if ( in_array( $device, array( 'mobile', 'phone' ), true ) ) {
			return 'mobile';
		}
		if ( $device === 'tablet' ) {
			return 'tablet';
		}

		return 'desktop';
	}

	/**
	 * Build filename with metadata
	 *
	 * @param int    $timestamp    Unix timestamp.
	 * @param string $country      Country code.
	 * @param string $browser      Browser name.
	 * @param int    $count        Event count.
	 * @param string $device       Device type.
	 * @param string $session_id   Session ID.
	 * @param bool   $is_logged_in Whether visitor is logged in.
	 * @param int    $duration     Session duration in seconds (default 0).
	 * @param int    $ab_test_id   A/B test ID (0 = no test).
	 * @param int    $variant_id   A/B variant ID (0 = no variant).
	 * @return string Filename.
	 */
	private function build_filename( $timestamp, $country, $browser, $count, $device, $session_id, $is_logged_in = false, $duration = 0, $ab_test_id = 0, $variant_id = 0 ) {
		$country = strtoupper( substr( preg_replace( '/[^a-zA-Z]/', '', $country ?: 'XX' ), 0, 2 ) );
		$browser = $this->normalize_browser( $browser );
		$device = $this->normalize_device( $device );
		// Use LAST 8 chars of session ID to get unique random suffix
		// session_{timestamp}_{random} -> last 8 chars include the random part
		$session_clean = preg_replace( '/[^a-zA-Z0-9]/', '', $session_id );
		$session_short = substr( $session_clean, -8 ); // Last 8 chars instead of first 8
		$logged_in_marker = $is_logged_in ? 'L' : 'G'; // L=Logged in, G=Guest
		$duration = max( 0, (int) $duration ); // Ensure non-negative integer
		$ab_test_id = max( 0, (int) $ab_test_id );
		$variant_id = max( 0, (int) $variant_id );

		return sprintf(
			'%d_%s_%s_%d_%s_%s_%d_%d_%d_%s.json',
			$timestamp,
			$country,
			$browser,
			$count,
			$device,
			$logged_in_marker,
			$duration,
			$ab_test_id,
			$variant_id,
			$session_short
		);
	}

	/**
	 * Parse metadata from filename
	 *
	 * Supports multiple formats for backward compatibility:
	 * - AB format (10 parts): {timestamp}_{country}_{browser}_{count}_{device}_{L|G}_{duration}_{ab_test_id}_{variant_id}_{session}.json
	 * - Newest format (8 parts): {timestamp}_{country}_{browser}_{count}_{device}_{L|G}_{duration}_{session}.json
	 * - New format (7 parts): {timestamp}_{country}_{browser}_{count}_{device}_{L|G}_{session}.json
	 * - Old format (6 parts): {timestamp}_{country}_{browser}_{count}_{device}_{session}.json
	 *
	 * @param string $filename Filename to parse.
	 * @return array|false Parsed metadata or false on failure.
	 */
	public function parse_filename_metadata( $filename ) {
		// Remove .json extension
		$name = str_replace( '.json', '', $filename );
		$parts = explode( '_', $name );

		if ( count( $parts ) < 6 ) {
			return false;
		}

		// Check if this is AB format (10 parts with ab_test_id and variant_id)
		// Format: {ts}_{country}_{browser}_{count}_{device}_{L|G}_{duration}_{ab_test_id}_{variant_id}_{session}
		if ( count( $parts ) >= 10 && in_array( $parts[5], array( 'L', 'G' ), true ) && is_numeric( $parts[7] ) && is_numeric( $parts[8] ) ) {
			return array(
				'timestamp'    => (int) $parts[0],
				'country'      => $parts[1],
				'browser'      => $parts[2],
				'count'        => (int) $parts[3],
				'device'       => $parts[4],
				'is_logged_in' => $parts[5] === 'L',
				'duration'     => (int) $parts[6],
				'ab_test_id'   => (int) $parts[7],
				'variant_id'   => (int) $parts[8],
				'session_id'   => $parts[9],
			);
		}

		// Check if this is newest format (8 parts with duration)
		if ( count( $parts ) >= 8 && in_array( $parts[5], array( 'L', 'G' ), true ) ) {
			// Newest format with logged_in marker and duration
			return array(
				'timestamp'    => (int) $parts[0],
				'country'      => $parts[1],
				'browser'      => $parts[2],
				'count'        => (int) $parts[3],
				'device'       => $parts[4],
				'is_logged_in' => $parts[5] === 'L',
				'duration'     => (int) $parts[6],
				'ab_test_id'   => 0,
				'variant_id'   => 0,
				'session_id'   => $parts[7],
			);
		}

		// Check if this is new format (7 parts with logged_in marker, no duration)
		if ( count( $parts ) >= 7 && in_array( $parts[5], array( 'L', 'G' ), true ) ) {
			// New format with logged_in marker but no duration
			return array(
				'timestamp'    => (int) $parts[0],
				'country'      => $parts[1],
				'browser'      => $parts[2],
				'count'        => (int) $parts[3],
				'device'       => $parts[4],
				'is_logged_in' => $parts[5] === 'L',
				'duration'     => 0, // No duration in old format
				'ab_test_id'   => 0,
				'variant_id'   => 0,
				'session_id'   => $parts[6],
			);
		}

		// Old format without logged_in marker (assume guest for backward compatibility)
		return array(
			'timestamp'    => (int) $parts[0],
			'country'      => $parts[1],
			'browser'      => $parts[2],
			'count'        => (int) $parts[3],
			'device'       => $parts[4],
			'is_logged_in' => false, // Default to guest for old files
			'duration'     => 0, // No duration in old format
			'ab_test_id'   => 0,
			'variant_id'   => 0,
			'session_id'   => $parts[5],
		);
	}

	/**
	 * Calculate session duration from event timestamps
	 *
	 * @param array $data Array of events with 't' (timestamp) field.
	 * @return int Duration in seconds.
	 */
	private function calculate_session_duration( $data ) {
		if ( empty( $data ) ) {
			return 0;
		}

		$timestamps = array();
		foreach ( $data as $event ) {
			if ( isset( $event['t'] ) ) {
				$timestamps[] = $event['t'];
			}
		}

		if ( count( $timestamps ) < 2 ) {
			return 0;
		}

		$min_ts = min( $timestamps );
		$max_ts = max( $timestamps );

		// Convert milliseconds to seconds and round
		return (int) round( ( $max_ts - $min_ts ) / 1000 );
	}

	/**
	 * Save heatmap data to file
	 *
	 * If a file already exists for this session, it will be updated by merging the new data.
	 * This ensures we have only ONE file per session per type (clicks/moves/scrolls).
	 *
	 * @param string $url_hash   URL hash.
	 * @param string $type       Data type (clicks, moves, scrolls).
	 * @param string $session_id Session ID.
	 * @param array  $data       Heatmap coordinates data.
	 * @param array  $metadata   Session metadata (country, browser, device, viewport, etc.).
	 * @return array|false Result with file_path or false on failure.
	 */
	public function save_heatmap_data( $url_hash, $type, $session_id, $data, $metadata ) {
		if ( empty( $data ) ) {
			return false;
		}

		$dir = $this->get_heatmap_dir( $url_hash, $type );
		if ( ! $this->ensure_directory_exists( $dir ) ) {
			$this->log( 'Failed to create directory: ' . $dir, 'error' );
			return false;
		}

		$timestamp = isset( $metadata['timestamp'] ) ? $metadata['timestamp'] : time();
		$country = isset( $metadata['country'] ) ? $metadata['country'] : 'XX';
		$browser = isset( $metadata['browser'] ) ? $metadata['browser'] : 'Other';
		$device = isset( $metadata['device_type'] ) ? $metadata['device_type'] : 'desktop';
		$is_logged_in = ! empty( $metadata['is_logged_in'] );
		$user_id = isset( $metadata['user_id'] ) ? (int) $metadata['user_id'] : 0;
		$ab_test_id = isset( $metadata['ab_test_id'] ) ? (int) $metadata['ab_test_id'] : 0;
		$variant_id = isset( $metadata['variant_id'] ) ? (int) $metadata['variant_id'] : 0;

		// Check if a file already exists for this session
		$existing_file = $this->find_existing_session_file( $dir, $session_id );

		if ( $existing_file ) {
			// File exists - UPDATE it by merging new data
			$this->log( sprintf( 'Found existing file for session %s: %s - merging data', substr( $session_id, 0, 16 ), $existing_file ), 'info' );

			// Race-safety: a concurrent merge writer may have deleted the file
			// between find_existing_session_file() (scandir) and this read.
			// Guard with file_exists + clearstatcache to dodge the PHP warning
			// emitted by file_get_contents on a missing path. See investigation
			// 2026-04-26 (Bug B).
			$existing_path = $dir . $existing_file;
			clearstatcache( true, $existing_path );
			if ( ! file_exists( $existing_path ) ) {
				$this->log( 'Existing file disappeared (likely concurrent merge): ' . $existing_file, 'info' );
				return false;
			}

			// Read existing file
			$existing_content = file_get_contents( $existing_path );
			if ( false === $existing_content ) {
				$this->log( 'Failed to read existing file: ' . $existing_file, 'error' );
				return false;
			}

			$existing_data = json_decode( $existing_content, true );
			if ( ! $existing_data || ! isset( $existing_data['data'] ) ) {
				$this->log( 'Invalid existing file format: ' . $existing_file, 'error' );
				return false;
			}

			// Merge data (append new events to existing)
			$merged_data = array_merge( $existing_data['data'], $data );
			$total_count = count( $merged_data );

			// Calculate duration from merged data
			$duration = $this->calculate_session_duration( $merged_data );

			// Delete old file
			wp_delete_file( $dir . $existing_file );

			// Preserve A/B info from existing file if current metadata lacks it
			$opti_ab_merge_test_id = $ab_test_id > 0 ? $ab_test_id : ( isset( $existing_data['ab_test_id'] ) ? (int) $existing_data['ab_test_id'] : 0 );
			$opti_ab_merge_variant = $variant_id > 0 ? $variant_id : ( isset( $existing_data['variant_id'] ) ? (int) $existing_data['variant_id'] : 0 );

			// Prepare merged file content (keep metadata from first file, update data, count, and duration)
			$file_content = array(
				'page_id'      => $existing_data['page_id'],
				'url'          => isset( $metadata['url'] ) ? $metadata['url'] : ( isset( $existing_data['url'] ) ? $existing_data['url'] : '' ),
				'session_id'   => $session_id,
				'visitor_id'   => $existing_data['visitor_id'],
				'device_type'  => $device,
				'browser'      => $browser,
				'country'      => $country,
				'is_logged_in' => $is_logged_in,
				'user_id'      => $user_id,
				'viewport'     => $existing_data['viewport'],
				'timestamp'    => $existing_data['timestamp'], // Keep original timestamp
				'duration'     => $duration,
				'ab_test_id'   => $opti_ab_merge_test_id,
				'variant_id'   => $opti_ab_merge_variant,
				'data'         => $merged_data,
			);

			// Build new filename with updated count and duration
			$filename = $this->build_filename( $existing_data['timestamp'], $country, $browser, $total_count, $device, $session_id, $is_logged_in, $duration, $opti_ab_merge_test_id, $opti_ab_merge_variant );
			$filepath = $dir . $filename;

			$json = wp_json_encode( $file_content );
			$written = file_put_contents( $filepath, $json );

			if ( false === $written ) {
				$this->log( 'Failed to write merged heatmap file: ' . $filepath, 'error' );
				return false;
			}

			$this->log( sprintf( 'Merged %d new events into existing file (total: %d %s) - %s', count( $data ), $total_count, $type, $filename ), 'info' );

			// Update URL hash mapping in database
			$this->update_url_hash_mapping( $url_hash, $metadata, $type, count( $data ) );

			return array(
				'success'   => true,
				'file_path' => str_replace( $this->base_dir, '', $filepath ),
				'file_size' => filesize( $filepath ),
				'count'     => $total_count,
				'merged'    => true,
			);
		} else {
			// No existing file - CREATE new one
			$count = count( $data );

			// Calculate duration from data
			$duration = $this->calculate_session_duration( $data );

			$filename = $this->build_filename( $timestamp, $country, $browser, $count, $device, $session_id, $is_logged_in, $duration, $ab_test_id, $variant_id );
			$filepath = $dir . $filename;

			// Prepare file content
			$file_content = array(
				'page_id'      => isset( $metadata['page_id'] ) ? $metadata['page_id'] : 0,
				'url'          => isset( $metadata['url'] ) ? $metadata['url'] : '',
				'session_id'   => $session_id,
				'visitor_id'   => isset( $metadata['visitor_id'] ) ? $metadata['visitor_id'] : '',
				'device_type'  => $device,
				'browser'      => $browser,
				'country'      => $country,
				'is_logged_in' => $is_logged_in,
				'user_id'      => $user_id,
				'viewport'     => isset( $metadata['viewport'] ) ? $metadata['viewport'] : array( 'width' => 1920, 'height' => 1080 ),
				'timestamp'    => $timestamp,
				'duration'     => $duration,
				'ab_test_id'   => $ab_test_id,
				'variant_id'   => $variant_id,
				'data'         => $data,
			);

			$json = wp_json_encode( $file_content );
			$written = file_put_contents( $filepath, $json );

			if ( false === $written ) {
				$this->log( 'Failed to write heatmap file: ' . $filepath, 'error' );
				return false;
			}

			$this->log( sprintf( 'Created new file with %d %s events: %s', $count, $type, $filename ), 'info' );

			// Update URL hash mapping in database
			$this->update_url_hash_mapping( $url_hash, $metadata, $type, $count );

			// Race-safety: filesize() emits a PHP Warning if the file vanished
			// between fwrite and stat (concurrent merge writer can wp_delete_file
			// the freshly-created file). Guard via file_exists. See investigation
			// 2026-04-26 (Bug B).
			clearstatcache( true, $filepath );
			$file_size = file_exists( $filepath ) ? (int) filesize( $filepath ) : 0;

			return array(
				'success'   => true,
				'file_path' => str_replace( $this->base_dir, '', $filepath ),
				'file_size' => $file_size,
				'count'     => $count,
				'merged'    => false,
			);
		}
	}

	/**
	 * Find existing heatmap file for a session
	 *
	 * Scans the directory for any file matching this session ID.
	 *
	 * @param string $dir        Directory to search.
	 * @param string $session_id Session ID to find.
	 * @return string|false Filename if found, false otherwise.
	 */
	private function find_existing_session_file( $dir, $session_id ) {
		if ( ! is_dir( $dir ) ) {
			return false;
		}

		$files = scandir( $dir );
		if ( ! $files ) {
			return false;
		}

		// Extract session ID short form - use LAST 8 chars to get unique random part
		// session_{timestamp}_{random} -> take last 8 chars which include the random suffix
		$session_clean = preg_replace( '/[^a-zA-Z0-9]/', '', $session_id );
		$session_short = substr( $session_clean, -8 ); // Last 8 chars instead of first 8

		foreach ( $files as $file ) {
			if ( $file === '.' || $file === '..' || $file === 'index.php' ) {
				continue;
			}

			// Check if filename contains this session ID
			// Format: {timestamp}_{country}_{browser}_{count}_{device}_{L|G}_{session}.json
			if ( strpos( $file, '_' . $session_short . '.json' ) !== false ) {
				return $file;
			}
		}

		return false;
	}

	/**
	 * Update URL hash mapping in database
	 *
	 * @param string $url_hash URL hash.
	 * @param array  $metadata Session metadata.
	 * @param string $type     Event type.
	 * @param int    $count    Event count.
	 */
	private function update_url_hash_mapping( $url_hash, $metadata, $type, $count ) {
		global $wpdb;
		$table_name = $wpdb->prefix . 'optibehavior_heatmap_pages';

		// Check if table exists
		// 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_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );

		if ( ! $table_exists ) {
			return;
		}

		$page_id = isset( $metadata['page_id'] ) ? (int) $metadata['page_id'] : 0;
		$url = isset( $metadata['url'] ) ? $metadata['url'] : '';

		// Check if record exists
		// 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.
		$existing = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT id, click_count, move_count, scroll_count FROM {$table_name} WHERE url_hash = %s",
				$url_hash
			)
		);

		$now = current_time( 'mysql' );

		if ( $existing ) {
			// Update existing record
			$update_data = array(
				'updated_at'   => $now,
				'last_data_at' => $now,
			);

			if ( $type === 'clicks' ) {
				$update_data['click_count'] = $existing->click_count + $count;
			} elseif ( $type === 'moves' ) {
				$update_data['move_count'] = $existing->move_count + $count;
			} elseif ( $type === 'scrolls' ) {
				$update_data['scroll_count'] = $existing->scroll_count + $count;
			}

			// 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.
			$wpdb->update(
				$table_name,
				$update_data,
				array( 'id' => $existing->id ),
				array( '%s', '%s', '%d' ),
				array( '%d' )
			);
		} else {
			// Insert new record
			$insert_data = array(
				'page_id'      => $page_id,
				'url_hash'     => $url_hash,
				'url'          => $url,
				'created_at'   => $now,
				'updated_at'   => $now,
				'click_count'  => $type === 'clicks' ? $count : 0,
				'move_count'   => $type === 'moves' ? $count : 0,
				'scroll_count' => $type === 'scrolls' ? $count : 0,
				'last_data_at' => $now,
			);

			// 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.
			$wpdb->insert(
				$table_name,
				$insert_data,
				array( '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s' )
			);
		}
	}

	/**
	 * Add event to buffer for batch saving
	 *
	 * @param string $url_hash   URL hash.
	 * @param string $type       Data type.
	 * @param string $session_id Session ID.
	 * @param array  $event      Single event data.
	 * @param array  $metadata   Session metadata.
	 */
	public function buffer_event( $url_hash, $type, $session_id, $event, $metadata ) {
		$buffer_key = $url_hash . '_' . $type . '_' . $session_id;

		if ( ! isset( $this->event_buffer[ $buffer_key ] ) ) {
			$this->event_buffer[ $buffer_key ] = array(
				'url_hash'   => $url_hash,
				'type'       => $type,
				'session_id' => $session_id,
				'metadata'   => $metadata,
				'events'     => array(),
			);
		}

		$this->event_buffer[ $buffer_key ]['events'][] = $event;

		// Flush if threshold reached
		if ( count( $this->event_buffer[ $buffer_key ]['events'] ) >= $this->buffer_threshold ) {
			$this->flush_buffer( $buffer_key );
		}
	}

	/**
	 * Flush a specific buffer
	 *
	 * @param string $buffer_key Buffer key.
	 */
	public function flush_buffer( $buffer_key ) {
		if ( ! isset( $this->event_buffer[ $buffer_key ] ) ) {
			return;
		}

		$buffer = $this->event_buffer[ $buffer_key ];

		if ( ! empty( $buffer['events'] ) ) {
			$this->save_heatmap_data(
				$buffer['url_hash'],
				$buffer['type'],
				$buffer['session_id'],
				$buffer['events'],
				$buffer['metadata']
			);
		}

		unset( $this->event_buffer[ $buffer_key ] );
	}

	/**
	 * Flush all buffers (called on shutdown)
	 */
	public function flush_all_buffers() {
		foreach ( array_keys( $this->event_buffer ) as $buffer_key ) {
			$this->flush_buffer( $buffer_key );
		}
	}

	/**
	 * Check if timestamp is within date range
	 *
	 * @param int    $timestamp  Unix timestamp.
	 * @param string $date_range Date range string (today, 7days, 30days, 90days, custom).
	 * @param string $start_date Optional start date for custom range.
	 * @param string $end_date   Optional end date for custom range.
	 * @return bool True if in range.
	 */
	private function is_in_date_range( $timestamp, $date_range, $start_date = '', $end_date = '' ) {
		$now = time();

		switch ( $date_range ) {
			case 'today':
				$start = strtotime( 'today midnight' );
				$end = $now;
				break;
			case '7days':
				$start = strtotime( '-7 days midnight' );
				$end = $now;
				break;
			case '30days':
				$start = strtotime( '-30 days midnight' );
				$end = $now;
				break;
			case '90days':
				$start = strtotime( '-90 days midnight' );
				$end = $now;
				break;
			case 'custom':
				$start = $start_date ? strtotime( $start_date ) : 0;
				$end = $end_date ? strtotime( $end_date . ' 23:59:59' ) : $now;
				break;
			default:
				// No filter - include all
				return true;
		}

		return $timestamp >= $start && $timestamp <= $end;
	}

	/**
	 * Read heatmap files with filtering
	 *
	 * Supports both single values and arrays for country and browser filters.
	 * When arrays are provided, files matching ANY of the values are included.
	 *
	 * @param string $url_hash URL hash.
	 * @param string $type     Data type (clicks, moves, scrolls).
	 * @param array  $filters  Optional filters (device, country, browser, date_range, visitor_type, batch, batch_size).
	 *                         - country: string or array of country codes (e.g., 'US' or ['US', 'FR', 'DE'])
	 *                         - browser: string or array of browser names (e.g., 'Chrome' or ['Chrome', 'Firefox'])
	 *                         - visitor_type: 'logged_in' or 'guest' to filter by login status
	 * @return array Associative array with 'files' (array of file data) and 'total_files' (int total matching count).
	 */
	public function read_heatmap_files( $url_hash, $type, $filters = array() ) {
		$dir = $this->get_heatmap_dir( $url_hash, $type );

		$empty_result = array(
			'files'       => array(),
			'total_files' => 0,
		);

		if ( ! is_dir( $dir ) ) {
			return $empty_result;
		}

		// Build a cache key from filters excluding batch/batch_size (those just slice the same list)
		$filter_for_cache = $filters;
		unset( $filter_for_cache['batch'], $filter_for_cache['batch_size'] );
		$list_cache_key = 'opti_hm_list_' . md5( $url_hash . $type . wp_json_encode( $filter_for_cache ) );

		// Try to get the sorted file path list from transient cache (avoids re-scanning directory for every batch)
		$sorted_paths = get_transient( $list_cache_key );

		if ( false === $sorted_paths ) {
			// Cache miss — scan directory, filter, and sort
			$files = scandir( $dir );
			$matching_files = array();

			// Normalize multi-select filters to arrays
			$country_filter = $this->normalize_filter_to_array( isset( $filters['country'] ) ? $filters['country'] : '' );
			$browser_filter = $this->normalize_filter_to_array( isset( $filters['browser'] ) ? $filters['browser'] : '' );

			// A/B variant filtering: direct match on ab_test_id and variant_id from filename metadata.
			$opti_ab_filter_test_id    = isset( $filters['ab_test_id'] ) ? (int) $filters['ab_test_id'] : 0;
			$opti_ab_filter_variant_id = isset( $filters['variant_id'] ) ? (int) $filters['variant_id'] : 0;

			// Legacy fallback: Pre-compute short session ID suffixes for A/B variant filtering.
			// Used only when ab_test_id/variant_id filters are not set but session_ids are provided.
			$short_session_ids = null;
			if ( ! $opti_ab_filter_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 );
				}
			}

			foreach ( $files as $file ) {
				if ( $file === '.' || $file === '..' || $file === 'index.php' ) {
					continue;
				}

				// Parse metadata from filename (NO file read needed!)
				$meta = $this->parse_filename_metadata( $file );
				if ( ! $meta ) {
					continue;
				}

				// Apply filters based on filename metadata
				// Normalize device type before comparison (unknown devices count as desktop)
				if ( ! empty( $filters['device'] ) && $this->normalize_device( $meta['device'] ) !== strtolower( $filters['device'] ) ) {
					continue;
				}

				// Country filter: match if ANY selected country matches (multi-select support)
				if ( ! empty( $country_filter ) ) {
					$file_country = strtoupper( $meta['country'] );
					$country_match = false;
					foreach ( $country_filter as $selected_country ) {
						if ( strtoupper( $selected_country ) === $file_country ) {
							$country_match = true;
							break;
						}
					}
					if ( ! $country_match ) {
						continue;
					}
				}

				// Browser filter: match if ANY selected browser matches (multi-select support)
				if ( ! empty( $browser_filter ) ) {
					$file_browser = $meta['browser'];
					$browser_match = false;
					foreach ( $browser_filter as $selected_browser ) {
						if ( $this->normalize_browser( $selected_browser ) === $file_browser ) {
							$browser_match = true;
							break;
						}
					}
					if ( ! $browser_match ) {
						continue;
					}
				}

				// Visitor type filter: 'logged_in' or 'guest'
				if ( ! empty( $filters['visitor_type'] ) ) {
					$want_logged_in = ( $filters['visitor_type'] === 'logged_in' );
					$file_logged_in = isset( $meta['is_logged_in'] ) ? $meta['is_logged_in'] : false;
					if ( $want_logged_in !== $file_logged_in ) {
						continue;
					}
				}

				// A/B variant filter: direct match on ab_test_id and variant_id from filename.
				// Files tagged with variant info (new format) are matched directly.
				// Old files without variant tags (ab_test_id=0) are excluded when filtering by variant.
				if ( $opti_ab_filter_test_id > 0 && $opti_ab_filter_variant_id > 0 ) {
					$opti_ab_file_test_id    = isset( $meta['ab_test_id'] ) ? (int) $meta['ab_test_id'] : 0;
					$opti_ab_file_variant_id = isset( $meta['variant_id'] ) ? (int) $meta['variant_id'] : 0;
					if ( $opti_ab_file_test_id !== $opti_ab_filter_test_id || $opti_ab_file_variant_id !== $opti_ab_filter_variant_id ) {
						continue;
					}
				}

				// Legacy A/B variant session filter: only include files whose session_id suffix
				// matches one of the allowed session IDs (pre-computed as short suffixes).
				// Used only for backward compatibility when direct ab_test_id/variant_id is not available.
				if ( null !== $short_session_ids ) {
					$file_session_id = isset( $meta['session_id'] ) ? $meta['session_id'] : '';
					if ( ! in_array( $file_session_id, $short_session_ids, true ) ) {
						continue;
					}
				}

				if ( ! empty( $filters['date_range'] ) ) {
					$start_date = isset( $filters['start_date'] ) ? $filters['start_date'] : '';
					$end_date = isset( $filters['end_date'] ) ? $filters['end_date'] : '';
					if ( ! $this->is_in_date_range( $meta['timestamp'], $filters['date_range'], $start_date, $end_date ) ) {
						continue;
					}
				}

				// Store file path with timestamp for sorting
				$matching_files[] = array(
					'path'      => $dir . $file,
					'timestamp' => $meta['timestamp'],
				);
			}

			// Sort by timestamp descending (newest files first)
			// This ensures most recent data is loaded in the first batches
			usort( $matching_files, function( $a, $b ) {
				return $b['timestamp'] - $a['timestamp']; // Descending order
			});

			// Extract just the file paths after sorting
			$sorted_paths = array_column( $matching_files, 'path' );

			// Cache the sorted list for 60 seconds — all subsequent batch requests reuse it
			set_transient( $list_cache_key, $sorted_paths, 60 );
		}

		$total_files = count( $sorted_paths );

		// Apply batching if requested
		$batch = isset( $filters['batch'] ) ? (int) $filters['batch'] : 0;
		$batch_size = isset( $filters['batch_size'] ) ? (int) $filters['batch_size'] : 100;

		// Read matching files (with optional batching)
		$files_data = $this->read_files_batch( $sorted_paths, $batch, $batch_size );

		return array(
			'files'       => $files_data,
			'total_files' => $total_files,
		);
	}

	/**
	 * Normalize a filter value to an array
	 *
	 * Handles string, array, or comma-separated values.
	 *
	 * @param mixed $value Filter value (string, array, or comma-separated string).
	 * @return array Normalized array of filter values (empty array if no filter).
	 */
	private function normalize_filter_to_array( $value ) {
		if ( empty( $value ) ) {
			return array();
		}

		// Already an array
		if ( is_array( $value ) ) {
			return array_filter( array_map( 'trim', $value ) );
		}

		// Comma-separated string
		if ( strpos( $value, ',' ) !== false ) {
			return array_filter( array_map( 'trim', explode( ',', $value ) ) );
		}

		// Single value
		return array( trim( $value ) );
	}

	/**
	 * Get total count of matching files (for pagination)
	 *
	 * Supports both single values and arrays for country and browser filters.
	 *
	 * @param string $url_hash URL hash.
	 * @param string $type     Data type (clicks, moves, scrolls).
	 * @param array  $filters  Optional filters (device, country, browser, date_range).
	 *                         - country: string or array of country codes
	 *                         - browser: string or array of browser names
	 * @return int Total number of matching files.
	 */
	public function get_heatmap_file_count( $url_hash, $type, $filters = array() ) {
		$dir = $this->get_heatmap_dir( $url_hash, $type );

		if ( ! is_dir( $dir ) ) {
			return 0;
		}

		$files = scandir( $dir );
		$count = 0;

		// Normalize multi-select filters to arrays
		$country_filter = $this->normalize_filter_to_array( isset( $filters['country'] ) ? $filters['country'] : '' );
		$browser_filter = $this->normalize_filter_to_array( isset( $filters['browser'] ) ? $filters['browser'] : '' );

		foreach ( $files as $file ) {
			if ( $file === '.' || $file === '..' || $file === 'index.php' ) {
				continue;
			}

			// Parse metadata from filename (NO file read needed!)
			$meta = $this->parse_filename_metadata( $file );
			if ( ! $meta ) {
				continue;
			}

			// Apply filters based on filename metadata
			// Normalize device type before comparison (unknown devices count as desktop)
			if ( ! empty( $filters['device'] ) && $this->normalize_device( $meta['device'] ) !== strtolower( $filters['device'] ) ) {
				continue;
			}

			// Country filter: match if ANY selected country matches (multi-select support)
			if ( ! empty( $country_filter ) ) {
				$file_country = strtoupper( $meta['country'] );
				$country_match = false;
				foreach ( $country_filter as $selected_country ) {
					if ( strtoupper( $selected_country ) === $file_country ) {
						$country_match = true;
						break;
					}
				}
				if ( ! $country_match ) {
					continue;
				}
			}

			// Browser filter: match if ANY selected browser matches (multi-select support)
			if ( ! empty( $browser_filter ) ) {
				$file_browser = $meta['browser'];
				$browser_match = false;
				foreach ( $browser_filter as $selected_browser ) {
					if ( $this->normalize_browser( $selected_browser ) === $file_browser ) {
						$browser_match = true;
						break;
					}
				}
				if ( ! $browser_match ) {
					continue;
				}
			}

			// Visitor type filter: 'logged_in' or 'guest'
			if ( ! empty( $filters['visitor_type'] ) ) {
				$want_logged_in = ( $filters['visitor_type'] === 'logged_in' );
				$file_logged_in = isset( $meta['is_logged_in'] ) ? $meta['is_logged_in'] : false;
				if ( $want_logged_in !== $file_logged_in ) {
					continue;
				}
			}

			if ( ! empty( $filters['date_range'] ) ) {
				$start_date = isset( $filters['start_date'] ) ? $filters['start_date'] : '';
				$end_date = isset( $filters['end_date'] ) ? $filters['end_date'] : '';
				if ( ! $this->is_in_date_range( $meta['timestamp'], $filters['date_range'], $start_date, $end_date ) ) {
					continue;
				}
			}

			$count++;
		}

		return $count;
	}

	/**
	 * Scan filenames to extract unique filter options
	 *
	 * This method is optimized for speed - it only reads filenames, not file contents.
	 * Used to quickly populate filter dropdowns while heatmap data loads in background.
	 * Supports interdependent filtering: when country_filter is set, only show browsers used by visitors
	 * from those countries. When browser_filter is set, only show countries where those browsers were used.
	 *
	 * @param string $url_hash URL hash.
	 * @param string $type     Data type (clicks, moves, scrolls).
	 * @param array  $filters  Optional filters: date_range, device, country_filter (for filtering browsers), browser_filter (for filtering countries).
	 * @return array Array with 'countries' and 'browsers' arrays.
	 */
	public function scan_filter_options( $url_hash, $type, $filters = array() ) {
		$dir = $this->get_heatmap_dir( $url_hash, $type );

		$result = array(
			'countries' => array(),
			'browsers'  => array(),
			'devices'   => array(),
		);

		if ( ! is_dir( $dir ) ) {
			return $result;
		}

		$files = scandir( $dir );
		$countries = array();
		$browsers = array();
		$devices = array();

		// Parse interdependent filter values (comma-separated strings to arrays)
		// country_filter: when set, only include browsers from files that match these countries
		// browser_filter: when set, only include countries from files that match these browsers
		// device: when set, only include countries/browsers from files that match this device
		$country_filter = array();
		$browser_filter = array();
		$device_filter = ! empty( $filters['device'] ) ? strtolower( $filters['device'] ) : '';
		if ( ! empty( $filters['country_filter'] ) ) {
			$country_filter = array_map( 'strtoupper', array_map( 'trim', explode( ',', $filters['country_filter'] ) ) );
			$country_filter = array_filter( $country_filter );
		}
		if ( ! empty( $filters['browser_filter'] ) ) {
			$browser_filter = array_map( 'strtolower', array_map( 'trim', explode( ',', $filters['browser_filter'] ) ) );
			$browser_filter = array_filter( $browser_filter );
		}

		foreach ( $files as $file ) {
			if ( $file === '.' || $file === '..' || $file === 'index.php' ) {
				continue;
			}

			// Parse metadata from filename (NO file read needed!)
			$meta = $this->parse_filename_metadata( $file );
			if ( ! $meta ) {
				continue;
			}

			// Apply date filter if specified
			if ( ! empty( $filters['date_range'] ) ) {
				$start_date = isset( $filters['start_date'] ) ? $filters['start_date'] : '';
				$end_date = isset( $filters['end_date'] ) ? $filters['end_date'] : '';
				if ( ! $this->is_in_date_range( $meta['timestamp'], $filters['date_range'], $start_date, $end_date ) ) {
					continue;
				}
			}

			$country_code = strtoupper( $meta['country'] );
			$browser_name = $meta['browser'];
			$browser_lower = strtolower( $browser_name );
			$device_type = strtolower( $meta['device'] );

			// Normalize device type (phone -> mobile)
			if ( $device_type === 'phone' ) {
				$device_type = 'mobile';
			}

			// Collect devices: always collect (no filtering for device counts)
			if ( ! empty( $device_type ) ) {
				$devices[ $device_type ] = true;
			}

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

			// Interdependent filtering for dropdown options:
			// - Countries are filtered by browser_filter (show only countries where selected browsers were used)
			// - Browsers are filtered by country_filter (show only browsers used in selected countries)
			$matches_browser_filter = empty( $browser_filter ) || in_array( $browser_lower, $browser_filter, true );
			$matches_country_filter = empty( $country_filter ) || in_array( $country_code, $country_filter, true );

			// Collect countries: only from files that match the browser filter
			if ( $matches_browser_filter && ! empty( $country_code ) && $country_code !== 'XX' ) {
				$countries[ $country_code ] = true;
			}

			// Collect browsers: only from files that match the country filter
			if ( $matches_country_filter && ! empty( $browser_name ) ) {
				$browsers[ $browser_name ] = true;
			}
		}

		// Convert to sorted arrays
		$result['countries'] = array_keys( $countries );
		$result['browsers'] = array_keys( $browsers );
		$result['devices'] = array_keys( $devices );

		sort( $result['countries'] );
		sort( $result['browsers'] );
		sort( $result['devices'] );

		return $result;
	}

	/**
	 * Read multiple files in batch
	 *
	 * @param array $file_paths Array of file paths.
	 * @param int   $batch      Batch number (0-indexed). 0 = all files (no batching).
	 * @param int   $batch_size Number of files per batch.
	 * @return array Array of file data with optional pagination metadata.
	 */
	private function read_files_batch( $file_paths, $batch = 0, $batch_size = 100 ) {
		$total_files = count( $file_paths );

		// If batch is specified (> 0), apply pagination
		if ( $batch > 0 && $batch_size > 0 ) {
			$offset = ( $batch - 1 ) * $batch_size;
			$file_paths = array_slice( $file_paths, $offset, $batch_size );
		} elseif ( $batch === 0 && $total_files > $batch_size ) {
			// First request with many files - return first batch
			$file_paths = array_slice( $file_paths, 0, $batch_size );
		}

		$results = array();

		foreach ( $file_paths as $filepath ) {
			$content = file_get_contents( $filepath );
			if ( false === $content ) {
				continue;
			}

			$data = json_decode( $content, true );
			if ( $data && isset( $data['data'] ) ) {
				$results[] = $data;
			}
		}

		return $results;
	}

	/**
	 * Aggregate heatmap data from multiple files
	 *
	 * @param array  $files_data Array of file data.
	 * @param string $type       Data type (clicks, moves, scrolls).
	 * @return array Aggregated data.
	 */
	public function aggregate_heatmap_data( $files_data, $type = 'clicks' ) {
		if ( empty( $files_data ) ) {
			return array(
				'coordinates'     => array(),
				'viewport'        => array( 'width' => 1920, 'height' => 1080 ),
				'reference_width' => 1920,
				'reference_height' => 1080,
				'window_height'   => 0,
				'total_events'    => 0,
			);
		}

		$all_coordinates = array();
		$viewport_widths = array();
		$viewport_heights = array();
		$page_heights = array();
		$window_heights = array();
		$total_events = 0;
		$max_y_coord = 0;

		// Normal aggregation for click, move, scroll heatmaps
		foreach ( $files_data as $file_data ) {
			if ( ! isset( $file_data['data'] ) || ! is_array( $file_data['data'] ) ) {
				continue;
			}

			// Collect viewport dimensions (viewport.width/height from metadata)
			if ( isset( $file_data['viewport']['width'] ) ) {
				$viewport_widths[] = $file_data['viewport']['width'];
			}
			if ( isset( $file_data['viewport']['height'] ) ) {
				$viewport_heights[] = $file_data['viewport']['height'];
			}

			// Add coordinates and track max Y and page heights from individual events
			foreach ( $file_data['data'] as $coord ) {
				$all_coordinates[] = $coord;
				$total_events++;

				// Track maximum Y coordinate to determine actual page height
				if ( isset( $coord['y'] ) && $coord['y'] > $max_y_coord ) {
					$max_y_coord = (int) $coord['y'];
				}

				// The 'vh' field in click data actually contains page height (scrollHeight),
				// not viewport height. Collect these for accurate reference_height calculation.
				if ( isset( $coord['vh'] ) && $coord['vh'] > 0 ) {
					$page_heights[] = (int) $coord['vh'];
				}

				// Collect actual browser window heights (wh field) for attention heatmap
				if ( isset( $coord['wh'] ) && $coord['wh'] > 0 ) {
					$window_heights[] = (int) $coord['wh'];
				}
			}
		}

		// Calculate reference width (average of viewport widths)
		$reference_width = ! empty( $viewport_widths ) ? round( array_sum( $viewport_widths ) / count( $viewport_widths ) ) : 1920;

		// Calculate reference height for coordinate scaling:
		// Priority 1: Use page heights from event data (vh field contains scrollHeight)
		// Priority 2: Use max Y coordinate + buffer (ensures all clicks are within bounds)
		// Priority 3: Fall back to viewport heights average
		// Priority 4: Default to 5000 (typical long page)
		if ( ! empty( $page_heights ) ) {
			// Use the maximum page height from collected data
			$reference_height = max( $page_heights );
		} elseif ( $max_y_coord > 0 ) {
			// Use max Y coordinate + 20% buffer to ensure full coverage
			$reference_height = (int) ( $max_y_coord * 1.2 );
		} elseif ( ! empty( $viewport_heights ) ) {
			// Fall back to max viewport height (legacy data)
			$reference_height = max( $viewport_heights );
		} else {
			// Default for typical long pages
			$reference_height = 5000;
		}

		// Ensure minimum height for proper scaling
		$reference_height = max( $reference_height, 1080 );

		// Normalize coordinates to reference viewport if needed
		$all_coordinates = $this->normalize_coordinates( $all_coordinates, $reference_width );

		// Grid sampling: merge nearby coordinates into grid cells to reduce data volume.
		// With thousands of sessions, raw coordinate count can exceed 100,000+ points.
		// Sampling reduces this to a few thousand while preserving heatmap accuracy.
		if ( count( $all_coordinates ) > 500 ) {
			$all_coordinates = $this->sample_coordinates( $all_coordinates, 5 );
		}

		// Calculate average browser window height for attention heatmap rendering
		$avg_window_height = ! empty( $window_heights )
			? (int) round( array_sum( $window_heights ) / count( $window_heights ) )
			: 0;

		return array(
			'coordinates'      => $all_coordinates,
			'viewport'         => array(
				'width'  => $reference_width,
				'height' => $reference_height,
			),
			'reference_width'  => $reference_width,
			'reference_height' => $reference_height,
			'window_height'    => $avg_window_height,
			'total_events'     => $total_events,
		);
	}

	/**
	 * Normalize coordinates to reference width
	 *
	 * @param array $coordinates     Array of coordinates.
	 * @param int   $reference_width Reference viewport width.
	 * @return array Normalized coordinates.
	 */
	private function normalize_coordinates( $coordinates, $reference_width ) {
		$normalized = array();

		foreach ( $coordinates as $coord ) {
			// If coordinate has viewport info and differs from reference, normalize
			if ( isset( $coord['vw'] ) && $coord['vw'] > 0 && $coord['vw'] !== $reference_width ) {
				$scale = $reference_width / $coord['vw'];
				$coord['x'] = round( $coord['x'] * $scale );
			}

			$norm = array(
				'x'     => isset( $coord['x'] ) ? (int) $coord['x'] : 0,
				'y'     => isset( $coord['y'] ) ? (int) $coord['y'] : 0,
				'value' => isset( $coord['value'] ) ? (int) $coord['value'] : 1,
			);

			// Preserve browser window height for attention heatmap rendering
			if ( isset( $coord['wh'] ) && $coord['wh'] > 0 ) {
				$norm['wh'] = (int) $coord['wh'];
			}

			$normalized[] = $norm;
		}

		return $normalized;
	}

	/**
	 * Merge nearby coordinates into a grid to reduce data volume.
	 *
	 * Points within the same grid cell are combined into a single point
	 * at the cell center with accumulated values. This dramatically reduces
	 * coordinate count (e.g., 100k → 3k) while preserving heatmap accuracy,
	 * since heatmap.js uses a radius that easily covers a 5px grid.
	 *
	 * @param array $coordinates Array of coordinate arrays with x, y, value keys.
	 * @param int   $grid_size   Grid cell size in pixels (default 5).
	 * @return array Sampled coordinates with merged values.
	 */
	private function sample_coordinates( $coordinates, $grid_size = 5 ) {
		$grid = array();

		foreach ( $coordinates as $coord ) {
			$x = isset( $coord['x'] ) ? (int) $coord['x'] : 0;
			$y = isset( $coord['y'] ) ? (int) $coord['y'] : 0;

			$cell_x = (int) ( $x / $grid_size );
			$cell_y = (int) ( $y / $grid_size );
			$key    = $cell_x . '_' . $cell_y;

			if ( ! isset( $grid[ $key ] ) ) {
				$grid[ $key ] = array(
					'x'     => $cell_x * $grid_size + (int) ( $grid_size / 2 ),
					'y'     => $cell_y * $grid_size + (int) ( $grid_size / 2 ),
					'value' => 0,
				);
				// Preserve wh field from first coordinate in cell (for attention heatmap)
				if ( isset( $coord['wh'] ) && $coord['wh'] > 0 ) {
					$grid[ $key ]['wh'] = (int) $coord['wh'];
				}
			}

			$grid[ $key ]['value'] += isset( $coord['value'] ) ? (int) $coord['value'] : 1;
		}

		return array_values( $grid );
	}

	/**
	 * Delete old heatmap files
	 *
	 * @param int $days Number of days to keep.
	 * @return int Number of files deleted.
	 */
	public function delete_old_heatmap_files( $days ) {
		$cutoff = time() - ( $days * 24 * 60 * 60 );
		$deleted = 0;

		// Iterate through all URL hash directories
		$url_dirs = glob( $this->base_dir . '*', GLOB_ONLYDIR );

		foreach ( $url_dirs as $url_dir ) {
			$type_dirs = glob( $url_dir . '/*', GLOB_ONLYDIR );

			foreach ( $type_dirs as $type_dir ) {
				$files = glob( $type_dir . '/*.json' );

				foreach ( $files as $file ) {
					$filename = basename( $file );
					$meta = $this->parse_filename_metadata( $filename );

					if ( $meta && $meta['timestamp'] < $cutoff ) {
						wp_delete_file( $file );
						if ( ! file_exists( $file ) ) {
							$deleted++;
						}
					}
				}

				// Remove empty directory
				if ( count( glob( $type_dir . '/*' ) ) === 1 ) { // Only index.php remains
					// Clean up
				}
			}
		}

		$this->log( sprintf( 'Deleted %d old heatmap files (older than %d days)', $deleted, $days ), 'info' );

		return $deleted;
	}

	/**
	 * Delete all heatmap files for a session
	 *
	 * @param string $session_id Session ID.
	 * @return int Number of files deleted.
	 */
	public function delete_session_heatmaps( $session_id ) {
		$session_short = substr( preg_replace( '/[^a-zA-Z0-9]/', '', $session_id ), 0, 8 );
		$deleted = 0;

		// Search all directories for files matching this session
		$url_dirs = glob( $this->base_dir . '*', GLOB_ONLYDIR );

		foreach ( $url_dirs as $url_dir ) {
			$type_dirs = glob( $url_dir . '/*', GLOB_ONLYDIR );

			foreach ( $type_dirs as $type_dir ) {
				$pattern = $type_dir . '/*_' . $session_short . '.json';
				$files = glob( $pattern );

				foreach ( $files as $file ) {
					wp_delete_file( $file );
					if ( ! file_exists( $file ) ) {
						$deleted++;
					}
				}
			}
		}

		return $deleted;
	}

	/**
	 * Get URL hash for a page ID
	 *
	 * @param int $page_id Page ID from optibehavior_pages table.
	 * @return string|false URL hash or false if not found.
	 */
	public function get_url_hash_for_page( $page_id ) {
		global $wpdb;

		// Check in-process object cache first (avoids repeated DB queries within the same request
		// and across batch requests when a persistent object cache like Redis/Memcached is available)
		$cache_key = 'opti_url_hash_' . (int) $page_id;
		$cached    = wp_cache_get( $cache_key, 'opti-behavior' );
		if ( false !== $cached ) {
			return $cached;
		}

		// First try the heatmap_pages mapping table
		$table_name = $wpdb->prefix . 'optibehavior_heatmap_pages';
		// 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_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );

		if ( $table_exists ) {
			// 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_hash = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT url_hash FROM {$table_name} WHERE page_id = %d",
					$page_id
				)
			);

			if ( $url_hash ) {
				wp_cache_set( $cache_key, $url_hash, 'opti-behavior', 3600 );
				return $url_hash;
			}
		}

		// Fallback: get URL from pages table and calculate 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.
		$url = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT url FROM {$wpdb->prefix}optibehavior_pages WHERE id = %d",
				$page_id
			)
		);

		if ( $url ) {
			$url_hash = $this->get_url_hash( $url );
			wp_cache_set( $cache_key, $url_hash, 'opti-behavior', 3600 );
			return $url_hash;
		}

		return false;
	}

	/**
	 * Get storage statistics
	 *
	 * @return array Storage statistics.
	 */
	public function get_storage_stats() {
		$stats = array(
			'total_size'    => 0,
			'total_files'   => 0,
			'click_files'   => 0,
			'move_files'    => 0,
			'scroll_files'  => 0,
			'url_count'     => 0,
		);

		$url_dirs = glob( $this->base_dir . '*', GLOB_ONLYDIR );
		$stats['url_count'] = count( $url_dirs );

		foreach ( $url_dirs as $url_dir ) {
			foreach ( array( 'clicks', 'moves', 'scrolls' ) as $type ) {
				$type_dir = $url_dir . '/' . $type;
				if ( is_dir( $type_dir ) ) {
					$files = glob( $type_dir . '/*.json' );
					$count = count( $files );

					if ( $type === 'clicks' ) {
						$stats['click_files'] += $count;
					} elseif ( $type === 'moves' ) {
						$stats['move_files'] += $count;
					} elseif ( $type === 'scrolls' ) {
						$stats['scroll_files'] += $count;
					}

					$stats['total_files'] += $count;

					foreach ( $files as $file ) {
						$stats['total_size'] += filesize( $file );
					}
				}
			}
		}

		return $stats;
	}

	/**
	 * Get base directory path
	 *
	 * @return string Base directory path.
	 */
	public function get_base_dir() {
		return $this->base_dir;
	}
}
