<?php
/**
 * Database Class
 *
 * Handles database operations, setup, and migrations.
 *
 * @package opti-behavior
 * @copyright 2025 Opti-User
 * @version 1.0.3
 */

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

/**
 * Database Class
 *
 * Provides database operations and table management.
 *
 * @since 1.0.0
 */
// 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_Database {

	/**
	 * Core instance.
	 *
	 * @since 1.0.0
	 * @var Opti_Behavior_Heatmap_Core
	 */
	private $core;

	/**
	 * Constructor.
	 *
	 * @since 1.0.0
	 * @param Opti_Behavior_Heatmap_Core $core Core instance.
	 */
	public function __construct( $core ) {
		$this->core = $core;
	}

	/**
	 * Plugin activation setup.
	 *
	 * @since 1.0.0
	 */
	public function activation() {
		$debug_manager = $this->core->get_debug_manager();
		$debug_manager->log( 'Plugin activation started', 'info', 'database' );
		$this->setup();
		$debug_manager->log( 'Plugin activation completed', 'info', 'database' );
	}

	/**
	 * Setup database tables and options.
	 *
	 * @since 1.0.0
	 * @global wpdb $wpdb WordPress database abstraction object.
	 */
	public function setup() {
		global $wpdb;
		$debug_manager = $this->core->get_debug_manager();
		$debug_manager->log( 'Database setup started', 'info', 'database' );

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$charset_collate  = $wpdb->get_charset_collate();
		$max_index_length = 191;

		// Capture whether the core tables already existed BEFORE this setup run.
		// This flag is used by handle_migrations() to distinguish a genuine fresh install
		// (tables were just created) from an old incomplete install (tables pre-existed).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time pre-setup check; caching inappropriate here.
		$tables_preexisted = (bool) $wpdb->get_var(
			$wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->prefix . 'optibehavior_events' )
		);

		// Create main events table
		$debug_manager->log( 'Creating/updating events table', 'debug', 'database' );
		$this->create_events_table( $charset_collate );

		// Create pages table
		$debug_manager->log( 'Creating/updating pages table', 'debug', 'database' );
		$this->create_pages_table( $charset_collate, $max_index_length );

		// Create sessions table
		$debug_manager->log( 'Creating/updating sessions table', 'debug', 'database' );
		$this->create_sessions_table( $charset_collate );

		// Create visitors table
		$debug_manager->log( 'Creating/updating visitors table', 'debug', 'database' );
		$this->create_visitors_table( $charset_collate );

		// Create pageviews table
		$debug_manager->log( 'Creating/updating pageviews table', 'debug', 'database' );
		$this->create_pageviews_table( $charset_collate );

		// Create recordings table
		$debug_manager->log( 'Creating/updating recordings table', 'debug', 'database' );
		$this->create_recordings_table( $charset_collate );

		// Create session pages table
		$debug_manager->log( 'Creating/updating session pages table', 'debug', 'database' );
		$this->create_session_pages_table( $charset_collate );

		// Create referrer tracking table
		$debug_manager->log( 'Creating/updating referrers table', 'debug', 'database' );
		$this->create_referrers_table( $charset_collate );

		// Create outbound clicks table
		$debug_manager->log( 'Creating/updating outbound clicks table', 'debug', 'database' );
		$this->create_outbound_clicks_table( $charset_collate );

		// Create bot visits table
		$debug_manager->log( 'Creating/updating bot visits table', 'debug', 'database' );
		$this->create_bot_visits_table( $charset_collate );

		// Create heatmap pages mapping table (for fast heatmap storage)
		$debug_manager->log( 'Creating/updating heatmap pages table', 'debug', 'database' );
		$this->create_heatmap_pages_table( $charset_collate );

		// Create daily stats table (for pre-aggregated dashboard metrics)
		$debug_manager->log( 'Creating/updating daily stats table', 'debug', 'database' );
		$this->create_daily_stats_table( $charset_collate );

		// Create error tracking tables (PRO feature)
		$debug_manager->log( 'Creating/updating errors table', 'debug', 'database' );
		$this->create_errors_table( $charset_collate );

		$debug_manager->log( 'Creating/updating error types table', 'debug', 'database' );
		$this->create_error_types_table( $charset_collate );

		$debug_manager->log( 'Creating/updating friction table', 'debug', 'database' );
		$this->create_friction_table( $charset_collate );

		$debug_manager->log( 'Creating/updating performance table', 'debug', 'database' );
		$this->create_performance_table( $charset_collate );

		$debug_manager->log( 'Creating/updating broken links table', 'debug', 'database' );
		$this->create_broken_links_table( $charset_collate );

		// Create user journey groups table (PRO feature)
		$debug_manager->log( 'Creating/updating journey groups table', 'debug', 'database' );
		$this->create_journey_groups_table( $charset_collate );

		// Create scheduled reports tables
		$debug_manager->log( 'Creating/updating report schedules tables', 'debug', 'database' );
		$this->create_report_schedules_tables( $charset_collate );

		// Create A/B testing tables and set default options
		$debug_manager->log( 'Creating/updating A/B testing tables', 'debug', 'database' );
		if ( ! class_exists( 'Opti_Behavior_AB_Test_Database' ) ) {
			$ab_db_path = OPTI_BEHAVIOR_HEATMAP_INCLUDES_DIR . 'class-opti-behavior-ab-test-database.php';
			if ( file_exists( $ab_db_path ) ) {
				require_once $ab_db_path;
			}
		}
		if ( class_exists( 'Opti_Behavior_AB_Test_Database' ) ) {
			Opti_Behavior_AB_Test_Database::create_tables();
			Opti_Behavior_AB_Test_Database::maybe_set_default_options();
		}

		// Handle migrations from older versions (must run AFTER all tables are created)
		$debug_manager->log( 'Checking for database migrations', 'debug', 'database' );
		$this->handle_migrations( $tables_preexisted );

		// Create default report schedule if none exists
		$debug_manager->log( 'Checking for default report schedule', 'debug', 'database' );
		$this->maybe_create_default_schedule();

		// Update plugin version
		$options = $this->core->get_options();
		$options['activated_ver']  = OPTI_BEHAVIOR_HEATMAP_VERSION;
		$options['activated_plan'] = Opti_Behavior_Heatmap_Core::PLAN;

		// Clean up invalid data
		$debug_manager->log( 'Cleaning up invalid data', 'debug', 'database' );
		$this->cleanup_invalid_data();

		// Setup cron
		$debug_manager->log( 'Setting up daily cron job', 'debug', 'database' );
		$this->setup_daily_cron();

		// Set default auto-cleanup settings on fresh install (do not overwrite existing settings).
		$this->maybe_set_default_auto_cleanup();

		// Update URL2 field
		$debug_manager->log( 'Updating URL2 field', 'debug', 'database' );
		$this->update_url2();

		$debug_manager->log( 'Database setup completed successfully', 'info', 'database' );
	}

	/**
	 * Handle database migrations.
	 *
	 * @param bool $tables_preexisted Whether the core tables existed before this setup run.
	 *                                Pass false (default) on a fresh install so the legacy
	 *                                "drop empty tables" branch is never triggered for tables
	 *                                that setup() itself just created.
	 */
	private function handle_migrations( $tables_preexisted = false ) {
		global $wpdb;
		$options = $this->core->get_options();

		// Legacy migration: older installs (pre-1.0.0) could leave empty tables with no
		// version record.  We drop them so dbDelta can recreate them correctly.
		// IMPORTANT: only run this when the tables existed BEFORE this activation ($tables_preexisted).
		// On a genuine fresh install the tables were just created by setup() and must NOT be dropped.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Migration: checking table existence.
		if ( $tables_preexisted &&
			! isset( $options['activated_ver'] ) &&
			$wpdb->get_row( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->prefix . 'optibehavior_events' ) ) &&
			$wpdb->get_row( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->prefix . 'optibehavior_pages' ) )
		) {
			// Check if there's existing data
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Migration: checking for existing data.
			if ( $wpdb->get_var( 'SELECT COUNT(*) FROM ' . $wpdb->prefix . 'optibehavior_events' ) ||
				$wpdb->get_var( 'SELECT COUNT(*) FROM ' . $wpdb->prefix . 'optibehavior_pages' ) ) {
				$options['activated_ver'] = '1.0.0';
			} else {
				// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange -- Schema changes required for plugin cleanup during migration
				$wpdb->query( 'DROP TABLE ' . $wpdb->prefix . 'optibehavior_events' );
				$wpdb->query( 'DROP TABLE ' . $wpdb->prefix . 'optibehavior_pages' );
				// phpcs:enable WordPress.DB.DirectDatabaseQuery.SchemaChange
			}
		}

		// Specific version migrations
		if ( isset( $options['activated_ver'] ) ) {
			$this->migrate_to_130( $options['activated_ver'] );
			$this->migrate_to_140( $options['activated_ver'] );
			$this->migrate_to_traffic_classification( $options['activated_ver'] );
			$this->migrate_to_taxonomy_tracking( $options['activated_ver'] );
			$this->migrate_session_pages_metadata( $options['activated_ver'] );
		}

		// Always run performance index migration (checks internally if needed)
		$this->migrate_performance_indexes();

		// Always run NULL ip → Anonymous migration (checks internally if needed)
		$this->migrate_null_ip_to_anonymous();
	}

	/**
	 * Migration to version 1.3.0
	 *
	 * @param string $current_version Current version.
	 */
	private function migrate_to_130( $current_version ) {
		global $wpdb;

		if ( version_compare( $current_version, '1.3.0', '<' ) &&
			$wpdb->get_var( "DESCRIBE " . $wpdb->prefix . "optibehavior_events original" )
		) {
			$max_index_length = 191;
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange -- Schema changes required for version 1.3.0 migration
			$wpdb->query( "ALTER TABLE " . $wpdb->prefix . "optibehavior_events CHANGE COLUMN original page_id int(8) UNSIGNED NOT NULL, ADD COLUMN page_id2 int(8) UNSIGNED NOT NULL AFTER page_id, DROP COLUMN keep_query, DROP COLUMN keep_hash, DROP COLUMN united, ADD KEY page_id2 (page_id2)" );
			$wpdb->query( "UPDATE " . $wpdb->prefix . "optibehavior_events SET page_id2 = page_id" );
			$wpdb->query( $wpdb->prepare( "ALTER TABLE " . $wpdb->prefix . "optibehavior_pages ADD COLUMN url2 text NOT NULL AFTER url, ADD KEY url2 (url2(%d))", $max_index_length ) );
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.SchemaChange
		}
	}

	/**
	 * Migration to version 1.4.0
	 *
	 * @param string $current_version Current version.
	 */
	private function migrate_to_140( $current_version ) {
		global $wpdb;

		if ( version_compare( $current_version, '1.4.0', '<' ) ) {
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange -- Schema changes required for version 1.4.0 migration
			if ( $wpdb->get_var( "DESCRIBE " . $wpdb->prefix . "optibehavior_events accuracy" ) ) {
				$wpdb->query( "ALTER TABLE " . $wpdb->prefix . "optibehavior_events DROP COLUMN accuracy" );
			}
			if ( $wpdb->get_var( "SHOW INDEX FROM " . $wpdb->prefix . "optibehavior_events WHERE KEY_NAME = 'event_accuracy'" ) ) {
				$wpdb->query( "DROP INDEX event_accuracy ON " . $wpdb->prefix . "optibehavior_events" );
			}
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.SchemaChange
		}
	}

	/**
	 * Migration to add traffic classification columns
	 *
	 * @param string $current_version Current version.
	 */
	private function migrate_to_traffic_classification( $current_version ) {
		global $wpdb;

		// Check if traffic_type column already exists
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration check, caching not appropriate
		$column_exists = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
				WHERE TABLE_SCHEMA = %s
				AND TABLE_NAME = %s
				AND COLUMN_NAME = %s",
				DB_NAME,
				$wpdb->prefix . 'optibehavior_sessions',
				'traffic_type'
			)
		);

		if ( ! $column_exists ) {
			$debug_manager = $this->core->get_debug_manager();
			$debug_manager->log( 'Adding traffic classification columns to sessions table', 'info', 'database' );

			// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange -- Schema changes required for traffic classification feature
			// Add traffic_type column
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_sessions
				ADD COLUMN traffic_type VARCHAR(20) DEFAULT 'human' AFTER ip"
			);

			// Add bot_type column
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_sessions
				ADD COLUMN bot_type VARCHAR(50) DEFAULT NULL AFTER traffic_type"
			);

			// Add spam_reason column
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_sessions
				ADD COLUMN spam_reason VARCHAR(100) DEFAULT NULL AFTER bot_type"
			);

			// Add index for performance
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_sessions
				ADD KEY idx_traffic_type (traffic_type)"
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.SchemaChange

			$debug_manager->log( 'Traffic classification columns added successfully', 'info', 'database' );
		}
	}

	/**
	 * Migration to add taxonomy tracking columns to pages table
	 *
	 * @param string $current_version Current version.
	 */
	private function migrate_to_taxonomy_tracking( $current_version ) {
		global $wpdb;

		// Check if post_id column already exists
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration check, caching not appropriate
		$column_exists = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
				WHERE TABLE_SCHEMA = %s
				AND TABLE_NAME = %s
				AND COLUMN_NAME = %s",
				DB_NAME,
				$wpdb->prefix . 'optibehavior_pages',
				'post_id'
			)
		);

		if ( ! $column_exists ) {
			$debug_manager = $this->core->get_debug_manager();
			$debug_manager->log( 'Adding taxonomy tracking columns to pages table', 'info', 'database' );

			// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange -- Schema changes required for taxonomy tracking feature
			// Add post_id column
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_pages
				ADD COLUMN post_id BIGINT(20) UNSIGNED DEFAULT NULL AFTER title"
			);

			// Add term_id column
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_pages
				ADD COLUMN term_id BIGINT(20) UNSIGNED DEFAULT NULL AFTER post_id"
			);

			// Add taxonomy column
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_pages
				ADD COLUMN taxonomy VARCHAR(50) DEFAULT NULL AFTER term_id"
			);

			// Add object_type column
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_pages
				ADD COLUMN object_type VARCHAR(20) DEFAULT NULL AFTER taxonomy"
			);

			// Add indexes for performance
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_pages
				ADD KEY post_id (post_id),
				ADD KEY term_id (term_id),
				ADD KEY taxonomy (taxonomy),
				ADD KEY object_type (object_type)"
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.SchemaChange

			$debug_manager->log( 'Taxonomy tracking columns added successfully', 'info', 'database' );
		}
	}

	/**
	 * Migration to add metadata columns to session_pages table
	 * This migration fixes the performance issue with 100M+ session recordings
	 * by storing searchable metadata in the database instead of scanning encrypted files
	 *
	 * @param string $current_version Current version.
	 */
	private function migrate_session_pages_metadata( $current_version ) {
		global $wpdb;

		$debug_manager = $this->core->get_debug_manager();

		// Check if metadata columns already exist by checking one key column
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration check, caching not appropriate
		$column_exists = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
				WHERE TABLE_SCHEMA = %s
				AND TABLE_NAME = %s
				AND COLUMN_NAME = %s",
				DB_NAME,
				$wpdb->prefix . 'optibehavior_session_pages',
				'device_type'
			)
		);

		if ( ! $column_exists ) {
			$debug_manager->log( 'Starting session_pages metadata migration - adding filterable columns', 'info', 'database' );

			// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange -- Schema changes required for session recordings performance optimization

			// Add device and browser columns
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_session_pages
				ADD COLUMN device_type VARCHAR(50) DEFAULT NULL AFTER page_order,
				ADD COLUMN browser VARCHAR(100) DEFAULT NULL AFTER device_type,
				ADD COLUMN os VARCHAR(100) DEFAULT NULL AFTER browser"
			);
			$debug_manager->log( 'Added device_type, browser, os columns', 'debug', 'database' );

			// Add location columns
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_session_pages
				ADD COLUMN country VARCHAR(2) DEFAULT NULL AFTER os,
				ADD COLUMN country_name VARCHAR(100) DEFAULT NULL AFTER country"
			);
			$debug_manager->log( 'Added country, country_name columns', 'debug', 'database' );

			// Add screen resolution columns
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_session_pages
				ADD COLUMN screen_width INT(10) UNSIGNED DEFAULT NULL AFTER country_name,
				ADD COLUMN screen_height INT(10) UNSIGNED DEFAULT NULL AFTER screen_width"
			);
			$debug_manager->log( 'Added screen_width, screen_height columns', 'debug', 'database' );

			// Add referrer and visitor type columns
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_session_pages
				ADD COLUMN referrer TEXT DEFAULT NULL AFTER screen_height,
				ADD COLUMN visitor_type VARCHAR(20) DEFAULT NULL AFTER referrer"
			);
			$debug_manager->log( 'Added referrer, visitor_type columns', 'debug', 'database' );

			// Add UTM tracking columns
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_session_pages
				ADD COLUMN utm_campaign VARCHAR(100) DEFAULT NULL AFTER visitor_type,
				ADD COLUMN utm_source VARCHAR(100) DEFAULT NULL AFTER utm_campaign,
				ADD COLUMN utm_medium VARCHAR(100) DEFAULT NULL AFTER utm_source"
			);
			$debug_manager->log( 'Added utm_campaign, utm_source, utm_medium columns', 'debug', 'database' );

			// Add traffic channel and watched status columns
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_session_pages
				ADD COLUMN traffic_channel VARCHAR(20) DEFAULT NULL AFTER utm_medium,
				ADD COLUMN watched TINYINT(1) UNSIGNED DEFAULT 0 AFTER traffic_channel"
			);
			$debug_manager->log( 'Added traffic_channel, watched columns', 'debug', 'database' );

			// Add performance indexes for fast filtering
			$wpdb->query(
				"ALTER TABLE " . $wpdb->prefix . "optibehavior_session_pages
				ADD KEY device_type (device_type),
				ADD KEY browser (browser),
				ADD KEY country (country),
				ADD KEY visitor_type (visitor_type),
				ADD KEY watched (watched)"
			);
			$debug_manager->log( 'Added performance indexes', 'debug', 'database' );

			// phpcs:enable WordPress.DB.DirectDatabaseQuery.SchemaChange

			// Get table statistics
			$total_rows = $wpdb->get_var( "SELECT COUNT(*) FROM " . $wpdb->prefix . "optibehavior_session_pages" );
			$old_size = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT ROUND((data_length + index_length) / 1024 / 1024, 2) as size_mb
					FROM information_schema.tables
					WHERE table_schema = %s
					AND table_name = %s",
					DB_NAME,
					$wpdb->prefix . 'optibehavior_session_pages'
				)
			);

			$debug_manager->log(
				sprintf(
					'Session pages metadata migration completed successfully. Total rows: %s, Table size: %s MB',
					number_format( $total_rows ),
					$old_size
				),
				'info',
				'database'
			);

			$debug_manager->log(
				'IMPORTANT: New session recordings will automatically save metadata. Old recordings will continue to work but won\'t have metadata for filtering.',
				'info',
				'database'
			);
		} else {
			$debug_manager->log( 'Session pages metadata columns already exist, skipping migration', 'debug', 'database' );
		}
	}

	/**
	 * Migration to add performance indexes for spam filtering optimization
	 *
	 * Adds composite indexes to sessions table for faster spam filter queries.
	 * These indexes significantly improve dashboard load times when spam filtering is enabled.
	 */
	private function migrate_performance_indexes() {
		global $wpdb;

		$debug_manager = $this->core->get_debug_manager();
		$sessions_table = $wpdb->prefix . 'optibehavior_sessions';

		// Guard: skip entirely if the sessions table does not exist yet
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Safety check before any ALTER TABLE
		if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $sessions_table ) ) ) {
			$debug_manager->log( 'Sessions table does not exist yet, skipping performance index migration', 'debug', 'database' );
			return;
		}

		// Check if the composite spam filter index already exists
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration check, caching not appropriate
		$index_exists = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
				WHERE TABLE_SCHEMA = %s
				AND TABLE_NAME = %s
				AND INDEX_NAME = %s",
				DB_NAME,
				$sessions_table,
				'idx_spam_filter'
			)
		);

		if ( ! $index_exists ) {
			$debug_manager->log( 'Adding performance indexes for spam filtering optimization', 'info', 'database' );

			// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange -- Schema changes required for performance optimization
			// Add composite index for spam filter queries (covers date range + traffic type + duration + events)
			// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
			$wpdb->query(
				"ALTER TABLE " . $sessions_table . "
				ADD KEY idx_spam_filter (start_time, traffic_type, duration, events_count)"
			);

			// Add individual indexes for duration and events_count if they don't exist
			$duration_index_exists = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
					WHERE TABLE_SCHEMA = %s
					AND TABLE_NAME = %s
					AND INDEX_NAME = %s",
					DB_NAME,
					$sessions_table,
					'idx_duration'
				)
			);

			if ( ! $duration_index_exists ) {
				// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
				$wpdb->query(
					"ALTER TABLE " . $sessions_table . "
					ADD KEY idx_duration (duration)"
				);
			}

			$events_index_exists = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
					WHERE TABLE_SCHEMA = %s
					AND TABLE_NAME = %s
					AND INDEX_NAME = %s",
					DB_NAME,
					$sessions_table,
					'idx_events_count'
				)
			);

			if ( ! $events_index_exists ) {
				// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
				$wpdb->query(
					"ALTER TABLE " . $sessions_table . "
					ADD KEY idx_events_count (events_count)"
				);
			}
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.SchemaChange

			$debug_manager->log( 'Performance indexes added successfully', 'info', 'database' );
		} else {
			$debug_manager->log( 'Performance indexes already exist, skipping migration', 'debug', 'database' );
		}

		// Add composite index for session-visitor JOINs (critical for top_users query)
		$visitors_table = $wpdb->prefix . 'optibehavior_visitors';
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration check
		$visitor_idx_exists = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
				WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME = %s",
				DB_NAME,
				$sessions_table,
				'idx_visitor_time'
			)
		);

		if ( ! $visitor_idx_exists ) {
			$debug_manager->log( 'Adding visitor-session composite index', 'info', 'database' );
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange
			// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
			$wpdb->query(
				"ALTER TABLE " . $sessions_table . "
				ADD KEY idx_visitor_time (visitor_id, start_time)"
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.SchemaChange
		}

		// Add index on visitors table for faster lookups
		// Guard: skip visitor index if visitors table does not exist
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Safety check before ALTER TABLE
		if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $visitors_table ) ) ) {
			$debug_manager->log( 'Visitors table does not exist yet, skipping visitor index migration', 'debug', 'database' );
		} else {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration check
			$visitor_country_idx = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
					WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME = %s",
					DB_NAME,
					$visitors_table,
					'idx_visitor_country'
				)
			);

			if ( ! $visitor_country_idx ) {
				$debug_manager->log( 'Adding visitor country composite index', 'info', 'database' );
				// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange
				// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
				$wpdb->query(
					"ALTER TABLE " . $visitors_table . "
					ADD KEY idx_visitor_country (id, country, country_name)"
				);
				// phpcs:enable WordPress.DB.DirectDatabaseQuery.SchemaChange
			}
		}

		// Add additional performance indexes for large dataset optimization (1M+ rows)
		$this->add_large_dataset_indexes();
	}

	/**
	 * Migrate sessions that have ip = NULL or ip = '' to ip = 'Anonymous'.
	 *
	 * These rows were caused by a race condition in ajax_save_recording() that could create
	 * a session record before ajax_register_page_visit() had a chance to store the correct
	 * ip value.  The fix stores 'Anonymous' in the fallback INSERT, but this one-time
	 * migration cleans up any existing rows that already have a NULL/empty ip.
	 *
	 * Public so it can also be triggered from admin_init for already-active installs
	 * that haven't gone through a fresh setup() cycle yet.
	 */
	public function migrate_null_ip_to_anonymous() {
		global $wpdb;

		$debug_manager  = $this->core->get_debug_manager();
		$sessions_table = $wpdb->prefix . 'optibehavior_sessions';

		// Guard: skip if table doesn't exist yet
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Safety check before migration
		if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $sessions_table ) ) ) {
			return;
		}

		// Use a one-time option flag so we only run this expensive UPDATE once
		if ( get_option( 'opti_behavior_null_ip_migrated' ) ) {
			return;
		}

		$debug_manager->log( 'Running NULL ip → Anonymous migration for sessions table', 'info', 'database' );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time data migration; no caching needed
		$updated = $wpdb->query(
			$wpdb->prepare( 'UPDATE ' . $wpdb->prefix . 'optibehavior_sessions SET ip = %s WHERE ip IS NULL OR ip = %s', 'Anonymous', '' )
		);

		$debug_manager->log( 'NULL ip migration complete: ' . (int) $updated . ' row(s) updated', 'info', 'database' );

		update_option( 'opti_behavior_null_ip_migrated', '1', false );
	}

	/**
	 * Add performance indexes optimized for large datasets (1M+ rows)
	 *
	 * These indexes are critical for dashboard performance when handling
	 * millions of sessions, pageviews, and events.
	 */
	private function add_large_dataset_indexes() {
		global $wpdb;

		$debug_manager = $this->core->get_debug_manager();

		// Define indexes to add: [table_suffix, index_name, columns]
		$indexes_to_add = array(
			// Sessions table - for date range + traffic type queries
			array( 'optibehavior_sessions', 'idx_start_traffic', 'start_time, traffic_type' ),
			// Sessions table - for bounce rate queries
			array( 'optibehavior_sessions', 'idx_bounce_time', 'is_bounce, start_time' ),
			// Sessions table - standalone start_time index for simple date range queries
			array( 'optibehavior_sessions', 'idx_start_time_only', 'start_time' ),
			// Sessions table - composite for top users aggregation with date filtering
			array( 'optibehavior_sessions', 'idx_visitor_traffic_time', 'visitor_id, traffic_type, start_time' ),

			// Pageviews table - for JOIN with sessions and date range queries
			array( 'optibehavior_pageviews', 'idx_pv_time_session', 'view_time, session_id' ),
			// Pageviews table - for page-specific queries with date filtering
			array( 'optibehavior_pageviews', 'idx_pv_page_time', 'page_id, view_time' ),
			// Pageviews table - for scroll depth aggregation with date filtering
			array( 'optibehavior_pageviews', 'idx_pv_scroll_time', 'view_time, scroll_depth' ),

			// Events table - for heatmap queries with viewport filtering
			array( 'optibehavior_events', 'idx_page_event_width', 'page_id2, event, width' ),
			// Events table - for date-filtered event queries
			array( 'optibehavior_events', 'idx_event_insert', 'event, insert_at' ),

			// Bot visits table - for bot traffic aggregation
			array( 'optibehavior_bot_visits', 'idx_bot_time_type', 'visit_time, bot_type' ),

			// Referrers table - for referrer grouping with date filtering
			array( 'optibehavior_referrers', 'idx_ref_time_type', 'created_at, referrer_type' ),

			// Session pages - for entry/exit page queries (page_order + session_id lookups)
			array( 'optibehavior_session_pages', 'idx_sp_order_session', 'page_order, session_id' ),
			// Session pages - for max page_order per session (exit page detection)
			array( 'optibehavior_session_pages', 'idx_sp_session_order', 'session_id, page_order' ),
		);

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange -- Schema changes required for performance optimization
		foreach ( $indexes_to_add as $index_def ) {
			list( $table_suffix, $index_name, $columns ) = $index_def;
			$table = $wpdb->prefix . $table_suffix;

			// Guard: skip if the table does not exist (e.g. called before table creation)
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Safety check before ALTER TABLE
			if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) {
				continue;
			}

			// Check if index already exists
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration check
			$exists = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
					WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME = %s",
					DB_NAME,
					$table,
					$index_name
				)
			);

			if ( ! $exists ) {
				$debug_manager->log( "Adding performance index " . $index_name . " on " . $table_suffix, 'info', 'database' );
				// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter
				$wpdb->query( "ALTER TABLE " . $table . " ADD KEY " . $index_name . " (" . $columns . ")" );
			}
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.SchemaChange

		$debug_manager->log( 'Large dataset performance indexes migration completed', 'info', 'database' );
	}

	/**
	 * Create events table
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_events_table( $charset_collate ) {
		global $wpdb;
		
		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_events (
			id           bigint(20)   UNSIGNED NOT NULL AUTO_INCREMENT,
			page_id      int(8)       UNSIGNED NOT NULL,
			page_id2     int(8)       UNSIGNED NOT NULL,
			session_id   varchar(64)           DEFAULT NULL,
			event        tinyint(3)   UNSIGNED NOT NULL,
			x            mediumint(5) UNSIGNED NOT NULL,
			y            mediumint(7) UNSIGNED NOT NULL,
			width        mediumint(5) UNSIGNED NOT NULL,
			height       mediumint(7) UNSIGNED NOT NULL,
			x_normalized float(7,6)            DEFAULT NULL,
			y_normalized float(7,6)            DEFAULT NULL,
			viewport_width mediumint(5) UNSIGNED DEFAULT NULL,
			viewport_height mediumint(7) UNSIGNED DEFAULT NULL,
			element_tag  varchar(50)           DEFAULT NULL,
			element_id   varchar(100)          DEFAULT NULL,
			element_class varchar(200)         DEFAULT NULL,
			insert_at    datetime              NOT NULL,
			PRIMARY KEY  (id),
			KEY event (event),
			KEY page_id2 (page_id2),
			KEY session_id (session_id),
			KEY insert_at (insert_at)
			) " . $charset_collate
		);
	}

	/**
	 * Create pages table
	 *
	 * @param string $charset_collate Database charset collation.
	 * @param int    $max_index_length Maximum index length.
	 */
	private function create_pages_table( $charset_collate, $max_index_length ) {
		global $wpdb;

		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_pages (
			id           int(8)   UNSIGNED NOT NULL AUTO_INCREMENT,
			url          text              NOT NULL,
			url2         text              NOT NULL,
			title        text              NOT NULL,
			post_id      bigint(20) UNSIGNED DEFAULT NULL,
			term_id      bigint(20) UNSIGNED DEFAULT NULL,
			taxonomy     varchar(50)       DEFAULT NULL,
			object_type  varchar(20)       DEFAULT NULL,
			insert_at    datetime          NOT NULL,
			update_at    datetime          NOT NULL,
			PRIMARY KEY  (id),
			KEY url (url(" . $max_index_length . ")),
			KEY url2 (url2(" . $max_index_length . ")),
			KEY post_id (post_id),
			KEY term_id (term_id),
			KEY taxonomy (taxonomy),
			KEY object_type (object_type)
			) " . $charset_collate
		);
	}

	/**
	 * Create sessions table
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_sessions_table( $charset_collate ) {
		global $wpdb;

		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_sessions (
			id           varchar(64)          NOT NULL,
			visitor_id   varchar(64)          NOT NULL,
			user_id      bigint(20)  UNSIGNED DEFAULT NULL,
			start_time   datetime             NOT NULL,
			end_time     datetime             DEFAULT NULL,
			duration     int(10)     UNSIGNED DEFAULT 0,
			page_views   int(5)      UNSIGNED DEFAULT 0,
			events_count int(10)     UNSIGNED DEFAULT 0,
			is_bounce    tinyint(1)           DEFAULT 1,
			referrer     text                 DEFAULT NULL,
			utm_source   varchar(100)         DEFAULT NULL,
			utm_medium   varchar(100)         DEFAULT NULL,
			utm_campaign varchar(100)         DEFAULT NULL,
			utm_term     varchar(100)         DEFAULT NULL,
			utm_content  varchar(100)         DEFAULT NULL,
			entry_page   text                 DEFAULT NULL,
			exit_page    text                 DEFAULT NULL,
			ip           varchar(45)          DEFAULT NULL,
			traffic_type varchar(20)          DEFAULT 'human',
			bot_type     varchar(50)          DEFAULT NULL,
			spam_reason  varchar(100)         DEFAULT NULL,
			PRIMARY KEY  (id),
			KEY visitor_id (visitor_id),
			KEY user_id (user_id),
			KEY start_time (start_time),
			KEY is_bounce (is_bounce),
			KEY idx_traffic_type (traffic_type),
			KEY idx_spam_filter (start_time, traffic_type, duration, events_count),
			KEY idx_duration (duration),
			KEY idx_events_count (events_count)
			) " . $charset_collate
		);
	}

	/**
	 * Create visitors table
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_visitors_table( $charset_collate ) {
		global $wpdb;
		
		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_visitors (
			id           varchar(64)          NOT NULL,
			first_visit  datetime             NOT NULL,
			last_visit   datetime             NOT NULL,
			visit_count  int(5)      UNSIGNED DEFAULT 1,
			total_sessions int(5)    UNSIGNED DEFAULT 0,
			total_pageviews int(10)  UNSIGNED DEFAULT 0,
			device_type  varchar(20)          DEFAULT NULL,
			browser      varchar(50)          DEFAULT NULL,
			browser_version varchar(20)       DEFAULT NULL,
			os           varchar(50)          DEFAULT NULL,
			os_version   varchar(20)          DEFAULT NULL,
			screen_width int(5)      UNSIGNED DEFAULT NULL,
			screen_height int(5)     UNSIGNED DEFAULT NULL,
			country      varchar(2)           DEFAULT NULL,
			country_name varchar(100)         DEFAULT NULL,
			region       varchar(100)         DEFAULT NULL,
			city         varchar(100)         DEFAULT NULL,
			timezone     varchar(50)          DEFAULT NULL,
			language     varchar(10)          DEFAULT NULL,
			PRIMARY KEY  (id),
			KEY first_visit (first_visit),
			KEY last_visit (last_visit),
			KEY country (country),
			KEY device_type (device_type)
			) " . $charset_collate
		);
	}

	/**
	 * Create pageviews table
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_pageviews_table( $charset_collate ) {
		global $wpdb;
		
		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_pageviews (
			id           bigint(20)  UNSIGNED NOT NULL AUTO_INCREMENT,
			session_id   varchar(64)          NOT NULL,
			visitor_id   varchar(64)          NOT NULL,
			page_id      int(8)      UNSIGNED NOT NULL,
			url          text                 NOT NULL,
			title        text                 NOT NULL,
			view_time    datetime             NOT NULL,
			time_on_page int(10)     UNSIGNED DEFAULT 0,
			scroll_depth int(3)      UNSIGNED DEFAULT 0,
			exit_page    tinyint(1)           DEFAULT 0,
			PRIMARY KEY  (id),
			KEY session_id (session_id),
			KEY visitor_id (visitor_id),
			KEY page_id (page_id),
			KEY view_time (view_time)
			) " . $charset_collate
		);
	}

	/**
	 * Create recordings table
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_recordings_table( $charset_collate ) {
		global $wpdb;

		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_recordings (
			id           bigint(20)  UNSIGNED NOT NULL AUTO_INCREMENT,
			session_id   varchar(64)          NOT NULL,
			page_id      int(8)      UNSIGNED NOT NULL,
			recording_data longtext            DEFAULT NULL,
			file_path    varchar(255)         DEFAULT NULL,
			storage_type varchar(20)          DEFAULT 'database',
			start_time   datetime             NOT NULL,
			duration     int(10)     UNSIGNED DEFAULT 0,
			file_size    int(10)     UNSIGNED DEFAULT 0,
			watched      tinyint(1)           DEFAULT 0,
			watched_at   datetime             DEFAULT NULL,
			PRIMARY KEY  (id),
			KEY session_id (session_id),
			KEY page_id (page_id),
			KEY start_time (start_time),
			KEY page_start_time (page_id, start_time),
			KEY start_duration (start_time, duration),
			KEY watched (watched),
			KEY file_path (file_path),
			KEY storage_type (storage_type)
			) " . $charset_collate
		);

		// Migrate existing schema if needed
		$this->migrate_recordings_schema();
	}

	/**
	 * Migrate recordings table schema to support file storage
	 */
	private function migrate_recordings_schema() {
		global $wpdb;

		$table_name = $wpdb->prefix . 'optibehavior_recordings';

		// Check if file_path column exists
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Migration: checking column existence via INFORMATION_SCHEMA.
		$column_exists = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
				WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'file_path'",
				$table_name
			)
		);

		if ( empty( $column_exists ) ) {
			// Escape table name for ALTER TABLE statements (DDL does not support placeholders)
			$safe_table_name = esc_sql( $table_name );

			// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange -- Schema changes required for recordings table migration. DDL statements do not support placeholders. Table name is escaped with esc_sql().
			// Add file_path column
			$wpdb->query(
				"ALTER TABLE " . $safe_table_name . "
				ADD COLUMN file_path varchar(255) DEFAULT NULL AFTER recording_data,
				ADD COLUMN storage_type varchar(20) DEFAULT 'database' AFTER file_path,
				ADD INDEX file_path (file_path),
				ADD INDEX storage_type (storage_type)"
			);

			// Make recording_data nullable for file-based storage
			$wpdb->query(
				"ALTER TABLE " . $safe_table_name . "
				MODIFY COLUMN recording_data longtext DEFAULT NULL"
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.SchemaChange
		}

		// Migrate metadata columns for performance optimization
		$this->migrate_recordings_metadata();
	}

	/**
	 * Migrate recordings table to add metadata columns for performance optimization
	 *
	 * This migration adds metadata columns to the recordings table to avoid reading
	 * files every time when displaying the recordings list. Metadata is cached in
	 * the database for fast filtering and sorting.
	 */
	private function migrate_recordings_metadata() {
		global $wpdb;

		$table_name = $wpdb->prefix . 'optibehavior_recordings';

		// Check if metadata columns already exist
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Migration: checking column existence via INFORMATION_SCHEMA.
		$column_exists = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
				WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'device_type'",
				$table_name
			)
		);

		if ( empty( $column_exists ) ) {
			$debug_manager = $this->core->get_debug_manager();
			$debug_manager->log( 'Starting recordings table metadata migration - adding performance optimization columns', 'info', 'database' );

			// Escape table name for ALTER TABLE statements
			$safe_table_name = esc_sql( $table_name );

			// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange -- Schema changes required for recordings table migration. DDL statements do not support placeholders. Table name is escaped with esc_sql().
			// Add metadata columns for fast filtering and display
			$wpdb->query(
				"ALTER TABLE " . $safe_table_name . "
				ADD COLUMN device_type VARCHAR(50) DEFAULT NULL AFTER watched_at,
				ADD COLUMN browser VARCHAR(100) DEFAULT NULL AFTER device_type,
				ADD COLUMN os VARCHAR(100) DEFAULT NULL AFTER browser,
				ADD COLUMN country VARCHAR(2) DEFAULT NULL AFTER os,
				ADD COLUMN country_name VARCHAR(100) DEFAULT NULL AFTER country,
				ADD COLUMN screen_width INT(10) UNSIGNED DEFAULT NULL AFTER country_name,
				ADD COLUMN screen_height INT(10) UNSIGNED DEFAULT NULL AFTER screen_width,
				ADD COLUMN event_count INT(10) UNSIGNED DEFAULT 0 AFTER screen_height,
				ADD COLUMN click_count INT(10) UNSIGNED DEFAULT 0 AFTER event_count,
				ADD COLUMN page_count INT(5) UNSIGNED DEFAULT 0 AFTER click_count,
				ADD COLUMN referrer TEXT DEFAULT NULL AFTER page_count,
				ADD COLUMN visitor_type VARCHAR(20) DEFAULT NULL AFTER referrer,
				ADD COLUMN utm_campaign VARCHAR(100) DEFAULT NULL AFTER visitor_type,
				ADD COLUMN utm_source VARCHAR(100) DEFAULT NULL AFTER utm_campaign,
				ADD COLUMN utm_medium VARCHAR(100) DEFAULT NULL AFTER utm_source"
			);

			// Add indexes for common filters
			$wpdb->query(
				"ALTER TABLE " . $safe_table_name . "
				ADD INDEX device_type (device_type),
				ADD INDEX browser (browser),
				ADD INDEX os (os),
				ADD INDEX country (country),
				ADD INDEX visitor_type (visitor_type),
				ADD INDEX duration_filter (duration),
				ADD INDEX page_count (page_count)"
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.SchemaChange

			$debug_manager->log( 'Recordings metadata migration completed successfully', 'info', 'database' );
		}
	}

	/**
	 * Create session pages table
	 *
	 * Tracks individual page visits within a session for multi-page recording support.
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_session_pages_table( $charset_collate ) {
		global $wpdb;

		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_session_pages (
			id           bigint(20)  UNSIGNED NOT NULL AUTO_INCREMENT,
			recording_id bigint(20)  UNSIGNED NOT NULL,
			session_id   varchar(64)          NOT NULL,
			page_id      int(8)      UNSIGNED NOT NULL,
			url          text                 NOT NULL,
			title        text                 DEFAULT NULL,
			entry_time   datetime             NOT NULL,
			exit_time    datetime             DEFAULT NULL,
			duration     int(10)     UNSIGNED DEFAULT 0,
			events_count int(10)     UNSIGNED DEFAULT 0,
			clicks_count int(10)     UNSIGNED DEFAULT 0,
			scroll_depth int(3)      UNSIGNED DEFAULT 0,
			page_order   int(5)      UNSIGNED DEFAULT 1,
			device_type  varchar(50)          DEFAULT NULL,
			browser      varchar(100)         DEFAULT NULL,
			os           varchar(100)         DEFAULT NULL,
			country      varchar(2)           DEFAULT NULL,
			country_name varchar(100)         DEFAULT NULL,
			screen_width int(10)     UNSIGNED DEFAULT NULL,
			screen_height int(10)    UNSIGNED DEFAULT NULL,
			referrer     text                 DEFAULT NULL,
			visitor_type varchar(20)          DEFAULT NULL,
			utm_campaign varchar(100)         DEFAULT NULL,
			utm_source   varchar(100)         DEFAULT NULL,
			utm_medium   varchar(100)         DEFAULT NULL,
			traffic_channel varchar(20)       DEFAULT NULL,
			watched      tinyint(1)  UNSIGNED DEFAULT 0,
			recording_data longtext            DEFAULT NULL,
			file_path    varchar(255)         DEFAULT NULL,
			storage_type varchar(20)          DEFAULT 'database',
			PRIMARY KEY  (id),
			KEY recording_id (recording_id),
			KEY session_id (session_id),
			KEY page_id (page_id),
			KEY entry_time (entry_time),
			KEY page_order (page_order),
			KEY file_path (file_path),
			KEY device_type (device_type),
			KEY browser (browser),
			KEY country (country),
			KEY duration (duration),
			KEY clicks_count (clicks_count),
			KEY visitor_type (visitor_type),
			KEY watched (watched)
			) " . $charset_collate
		);
	}

	/**
	 * Create referrers table
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_referrers_table( $charset_collate ) {
		global $wpdb;

		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_referrers (
			id           bigint(20)  UNSIGNED NOT NULL AUTO_INCREMENT,
			session_id   varchar(64)          NOT NULL,
			page_id      int(8)      UNSIGNED NOT NULL,
			referrer_url text                 DEFAULT NULL,
			referrer_type varchar(20)         DEFAULT 'external',
			entry_url    text                 NOT NULL,
			utm_source   varchar(100)         DEFAULT NULL,
			utm_medium   varchar(100)         DEFAULT NULL,
			utm_campaign varchar(100)         DEFAULT NULL,
			utm_term     varchar(100)         DEFAULT NULL,
			utm_content  varchar(100)         DEFAULT NULL,
			created_at   datetime             NOT NULL,
			PRIMARY KEY  (id),
			KEY session_id (session_id),
			KEY page_id (page_id),
			KEY referrer_type (referrer_type),
			KEY created_at (created_at)
			) " . $charset_collate
		);
	}

	/**
	 * Create outbound clicks table
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_outbound_clicks_table( $charset_collate ) {
		global $wpdb;

		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_outbound_clicks (
			id           bigint(20)  UNSIGNED NOT NULL AUTO_INCREMENT,
			session_id   varchar(64)          NOT NULL,
			page_id      int(8)      UNSIGNED NOT NULL,
			source_url   text                 NOT NULL,
			target_url   text                 NOT NULL,
			click_type   varchar(20)          DEFAULT 'external',
			element_tag  varchar(50)          DEFAULT NULL,
			element_id   varchar(100)         DEFAULT NULL,
			element_class varchar(200)        DEFAULT NULL,
			element_text varchar(500)         DEFAULT NULL,
			x            mediumint(5) UNSIGNED DEFAULT NULL,
			y            mediumint(7) UNSIGNED DEFAULT NULL,
			created_at   datetime             NOT NULL,
			PRIMARY KEY  (id),
			KEY session_id (session_id),
			KEY page_id (page_id),
			KEY click_type (click_type),
			KEY created_at (created_at)
			) " . $charset_collate
		);
	}

	/**
	 * Create bot visits table
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_bot_visits_table( $charset_collate ) {
		global $wpdb;

		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_bot_visits (
			id           bigint(20)  UNSIGNED NOT NULL AUTO_INCREMENT,
			bot_type     varchar(50)          NOT NULL,
			user_agent   text                 NOT NULL,
			ip           varchar(45)          DEFAULT NULL,
			visited_url  text                 NOT NULL,
			referrer     text                 DEFAULT NULL,
			visit_time   datetime             NOT NULL,
			PRIMARY KEY  (id),
			KEY bot_type (bot_type),
			KEY visit_time (visit_time),
			KEY ip (ip)
			) " . $charset_collate
		);
	}

	/**
	 * Create heatmap pages mapping table
	 *
	 * Maps page_id to url_hash for fast heatmap file lookups.
	 * This table enables the optimized heatmap storage system.
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_heatmap_pages_table( $charset_collate ) {
		global $wpdb;

		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_heatmap_pages (
			id           bigint(20)  UNSIGNED NOT NULL AUTO_INCREMENT,
			page_id      int(8)      UNSIGNED NOT NULL,
			url_hash     varchar(32)          NOT NULL,
			url          text                 NOT NULL,
			created_at   datetime             NOT NULL,
			updated_at   datetime             NOT NULL,
			click_count  int(10)     UNSIGNED DEFAULT 0,
			move_count   int(10)     UNSIGNED DEFAULT 0,
			scroll_count int(10)     UNSIGNED DEFAULT 0,
			last_data_at datetime             DEFAULT NULL,
			PRIMARY KEY  (id),
			UNIQUE KEY url_hash (url_hash),
			KEY page_id (page_id),
			KEY last_data_at (last_data_at)
			) " . $charset_collate
		);
	}

	/**
	 * Create daily stats table for pre-aggregated dashboard metrics
	 *
	 * This table stores pre-computed daily statistics to avoid expensive
	 * GROUP BY DATE() queries on millions of rows. Data is aggregated
	 * nightly via cron job for fast dashboard loading.
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_daily_stats_table( $charset_collate ) {
		global $wpdb;

		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_daily_stats (
			id                  bigint(20)  UNSIGNED NOT NULL AUTO_INCREMENT,
			stat_date           date                 NOT NULL,
			sessions            int(10)     UNSIGNED DEFAULT 0,
			visitors            int(10)     UNSIGNED DEFAULT 0,
			pageviews           int(10)     UNSIGNED DEFAULT 0,
			bounce_sessions     int(10)     UNSIGNED DEFAULT 0,
			total_duration      bigint(20)  UNSIGNED DEFAULT 0,
			total_scroll_depth  bigint(20)  UNSIGNED DEFAULT 0,
			scroll_depth_count  int(10)     UNSIGNED DEFAULT 0,
			human_sessions      int(10)     UNSIGNED DEFAULT 0,
			spam_sessions       int(10)     UNSIGNED DEFAULT 0,
			automated_sessions  int(10)     UNSIGNED DEFAULT 0,
			new_visitors        int(10)     UNSIGNED DEFAULT 0,
			returning_visitors  int(10)     UNSIGNED DEFAULT 0,
			last_aggregated     datetime             NOT NULL,
			is_finalized        tinyint(1)           DEFAULT 0,
			PRIMARY KEY  (id),
			UNIQUE KEY stat_date (stat_date),
			KEY is_finalized (is_finalized),
			KEY last_aggregated (last_aggregated)
			) " . $charset_collate
		);
	}

	/**
	 * Create errors table for tracking JavaScript and other errors
	 *
	 * Stores individual error instances captured from sessions.
	 * Part of the Error Tracking PRO feature.
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_errors_table( $charset_collate ) {
		global $wpdb;

		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_errors (
			id              bigint(20)   UNSIGNED NOT NULL AUTO_INCREMENT,
			session_id      varchar(64)           DEFAULT NULL,
			recording_id    bigint(20)   UNSIGNED DEFAULT NULL,
			page_id         int(8)       UNSIGNED DEFAULT NULL,
			error_type      varchar(50)           NOT NULL,
			error_message   text                  NOT NULL,
			error_stack     text                  DEFAULT NULL,
			error_source    varchar(255)          DEFAULT NULL,
			error_line      int(10)      UNSIGNED DEFAULT NULL,
			error_column    int(10)      UNSIGNED DEFAULT NULL,
			severity        varchar(20)           DEFAULT 'error',
			browser         varchar(100)          DEFAULT NULL,
			os              varchar(100)          DEFAULT NULL,
			device_type     varchar(50)           DEFAULT NULL,
			url             text                  DEFAULT NULL,
			user_id         bigint(20)   UNSIGNED DEFAULT NULL,
			visitor_id      varchar(64)           DEFAULT NULL,
			occurred_at     datetime              NOT NULL,
			resolved        tinyint(1)            DEFAULT 0,
			resolved_at     datetime              DEFAULT NULL,
			resolved_by     bigint(20)   UNSIGNED DEFAULT NULL,
			notes           text                  DEFAULT NULL,
			created_at      datetime              NOT NULL,
			PRIMARY KEY  (id),
			KEY session_id (session_id),
			KEY recording_id (recording_id),
			KEY error_type (error_type),
			KEY severity (severity),
			KEY occurred_at (occurred_at),
			KEY resolved (resolved),
			KEY page_id (page_id),
			KEY browser (browser),
			KEY device_type (device_type),
			KEY visitor_id (visitor_id)
			) " . $charset_collate
		);
	}

	/**
	 * Create error types table for aggregated error statistics
	 *
	 * Groups similar errors together for quick dashboard queries.
	 * Part of the Error Tracking PRO feature.
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_error_types_table( $charset_collate ) {
		global $wpdb;

		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_error_types (
			id                bigint(20)   UNSIGNED NOT NULL AUTO_INCREMENT,
			error_hash        varchar(64)           NOT NULL,
			error_type        varchar(50)           NOT NULL,
			error_message     text                  NOT NULL,
			error_source      varchar(255)          DEFAULT NULL,
			first_seen        datetime              NOT NULL,
			last_seen         datetime              NOT NULL,
			occurrence_count  int(10)      UNSIGNED DEFAULT 1,
			affected_sessions int(10)      UNSIGNED DEFAULT 1,
			affected_users    int(10)      UNSIGNED DEFAULT 1,
			affected_pages    int(10)      UNSIGNED DEFAULT 1,
			severity          varchar(20)           DEFAULT 'error',
			status            varchar(20)           DEFAULT 'open',
			assigned_to       bigint(20)   UNSIGNED DEFAULT NULL,
			priority          int(3)       UNSIGNED DEFAULT 50,
			created_at        datetime              NOT NULL,
			updated_at        datetime              NOT NULL,
			PRIMARY KEY  (id),
			UNIQUE KEY error_hash (error_hash),
			KEY error_type (error_type),
			KEY severity (severity),
			KEY status (status),
			KEY priority (priority),
			KEY occurrence_count (occurrence_count),
			KEY last_seen (last_seen)
			) " . $charset_collate
		);
	}

	/**
	 * Create friction table for tracking user friction events
	 *
	 * Stores rage clicks, dead clicks, form abandonment, etc.
	 * Part of the Error Tracking PRO feature.
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_friction_table( $charset_collate ) {
		global $wpdb;

		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_friction (
			id                bigint(20)   UNSIGNED NOT NULL AUTO_INCREMENT,
			session_id        varchar(64)           DEFAULT NULL,
			recording_id      bigint(20)   UNSIGNED DEFAULT NULL,
			page_id           int(8)       UNSIGNED DEFAULT NULL,
			friction_type     varchar(50)           NOT NULL,
			element_selector  varchar(500)          DEFAULT NULL,
			element_tag       varchar(50)           DEFAULT NULL,
			element_id        varchar(100)          DEFAULT NULL,
			element_class     varchar(200)          DEFAULT NULL,
			element_text      varchar(255)          DEFAULT NULL,
			click_count       int(5)       UNSIGNED DEFAULT NULL,
			time_window       int(10)      UNSIGNED DEFAULT NULL,
			x_position        int(5)       UNSIGNED DEFAULT NULL,
			y_position        int(5)       UNSIGNED DEFAULT NULL,
			viewport_width    int(5)       UNSIGNED DEFAULT NULL,
			viewport_height   int(5)       UNSIGNED DEFAULT NULL,
			url               text                  DEFAULT NULL,
			browser           varchar(100)          DEFAULT NULL,
			device_type       varchar(50)           DEFAULT NULL,
			visitor_id        varchar(64)           DEFAULT NULL,
			user_id           bigint(20)   UNSIGNED DEFAULT NULL,
			occurred_at       datetime              NOT NULL,
			created_at        datetime              NOT NULL,
			PRIMARY KEY  (id),
			KEY session_id (session_id),
			KEY recording_id (recording_id),
			KEY friction_type (friction_type),
			KEY page_id (page_id),
			KEY occurred_at (occurred_at),
			KEY element_selector (element_selector(191)),
			KEY visitor_id (visitor_id)
			) " . $charset_collate
		);
	}

	/**
	 * Create performance table for page performance metrics
	 *
	 * Stores Core Web Vitals and other performance metrics.
	 * Part of the Error Tracking PRO feature.
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_performance_table( $charset_collate ) {
		global $wpdb;

		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_performance (
			id                  bigint(20)   UNSIGNED NOT NULL AUTO_INCREMENT,
			session_id          varchar(64)           DEFAULT NULL,
			page_id             int(8)       UNSIGNED DEFAULT NULL,
			url                 text                  DEFAULT NULL,
			dns_lookup          int(10)      UNSIGNED DEFAULT NULL,
			tcp_connection      int(10)      UNSIGNED DEFAULT NULL,
			ssl_handshake       int(10)      UNSIGNED DEFAULT NULL,
			ttfb                int(10)      UNSIGNED DEFAULT NULL,
			response_time       int(10)      UNSIGNED DEFAULT NULL,
			dom_interactive     int(10)      UNSIGNED DEFAULT NULL,
			dom_complete        int(10)      UNSIGNED DEFAULT NULL,
			load_complete       int(10)      UNSIGNED DEFAULT NULL,
			lcp                 int(10)      UNSIGNED DEFAULT NULL,
			fid                 int(10)      UNSIGNED DEFAULT NULL,
			cls                 float(5,3)            DEFAULT NULL,
			fcp                 int(10)      UNSIGNED DEFAULT NULL,
			inp                 int(10)      UNSIGNED DEFAULT NULL,
			resource_count      int(5)       UNSIGNED DEFAULT NULL,
			total_transfer_size bigint(20)   UNSIGNED DEFAULT NULL,
			performance_score   int(3)       UNSIGNED DEFAULT NULL,
			is_slow             tinyint(1)            DEFAULT 0,
			browser             varchar(100)          DEFAULT NULL,
			device_type         varchar(50)           DEFAULT NULL,
			connection_type     varchar(50)           DEFAULT NULL,
			visitor_id          varchar(64)           DEFAULT NULL,
			measured_at         datetime              NOT NULL,
			created_at          datetime              NOT NULL,
			PRIMARY KEY  (id),
			KEY session_id (session_id),
			KEY page_id (page_id),
			KEY measured_at (measured_at),
			KEY is_slow (is_slow),
			KEY performance_score (performance_score),
			KEY load_complete (load_complete),
			KEY lcp (lcp),
			KEY visitor_id (visitor_id)
			) " . $charset_collate
		);
	}

	/**
	 * Create broken links table for tracking 404s and broken links
	 *
	 * Stores broken links and 404 errors detected during sessions.
	 * Part of the Error Tracking PRO feature.
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_broken_links_table( $charset_collate ) {
		global $wpdb;

		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_broken_links (
			id                bigint(20)   UNSIGNED NOT NULL AUTO_INCREMENT,
			url               text                  NOT NULL,
			url_hash          varchar(64)           NOT NULL,
			source_page_id    int(8)       UNSIGNED DEFAULT NULL,
			source_url        text                  DEFAULT NULL,
			link_text         varchar(255)          DEFAULT NULL,
			http_status       int(3)       UNSIGNED DEFAULT NULL,
			error_type        varchar(50)           DEFAULT '404',
			first_detected    datetime              NOT NULL,
			last_detected     datetime              NOT NULL,
			occurrence_count  int(10)      UNSIGNED DEFAULT 1,
			affected_sessions int(10)      UNSIGNED DEFAULT 1,
			status            varchar(20)           DEFAULT 'open',
			fixed_at          datetime              DEFAULT NULL,
			notes             text                  DEFAULT NULL,
			created_at        datetime              NOT NULL,
			updated_at        datetime              NOT NULL,
			PRIMARY KEY  (id),
			UNIQUE KEY url_hash (url_hash),
			KEY source_page_id (source_page_id),
			KEY http_status (http_status),
			KEY status (status),
			KEY occurrence_count (occurrence_count),
			KEY last_detected (last_detected),
			KEY error_type (error_type)
			) " . $charset_collate
		);
	}

	/**
	 * Create journey groups table
	 *
	 * Creates table for storing user journey page grouping rules (PRO feature).
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_journey_groups_table( $charset_collate ) {
		global $wpdb;

		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_journey_groups (
			id             bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			group_name     varchar(255)        NOT NULL,
			group_color    varchar(7)          DEFAULT '#6366f1',
			match_type     varchar(20)         DEFAULT 'contains',
			match_pattern  varchar(500)        NOT NULL,
			priority       int(11)             DEFAULT 0,
			is_active      tinyint(1)          DEFAULT 1,
			created_at     datetime            DEFAULT CURRENT_TIMESTAMP,
			PRIMARY KEY (id),
			KEY idx_active_priority (is_active, priority)
			) " . $charset_collate
		);
	}

	/**
	 * Create report schedules tables
	 *
	 * Creates tables for storing scheduled report configurations and logs.
	 *
	 * @param string $charset_collate Database charset collation.
	 */
	private function create_report_schedules_tables( $charset_collate ) {
		global $wpdb;

		// Report schedules table
		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_report_schedules (
				id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
				name VARCHAR(255) NOT NULL DEFAULT 'My Report',
				enabled TINYINT(1) NOT NULL DEFAULT 1,
				frequency VARCHAR(20) NOT NULL DEFAULT 'weekly',
				day_of_week TINYINT UNSIGNED DEFAULT 1,
				day_of_month TINYINT UNSIGNED DEFAULT 1,
				send_time TIME NOT NULL DEFAULT '09:00:00',
				timezone VARCHAR(50) DEFAULT '',
				report_period VARCHAR(30) NOT NULL DEFAULT 'last7days',
				include_kpis TINYINT(1) DEFAULT 1,
				include_top_pages TINYINT(1) DEFAULT 1,
				include_top_referrers TINYINT(1) DEFAULT 1,
				include_traffic_breakdown TINYINT(1) DEFAULT 1,
				include_funnels TINYINT(1) DEFAULT 1,
				include_heatmap_summary TINYINT(1) DEFAULT 1,
				include_geographic TINYINT(1) DEFAULT 1,
				include_recordings_stats TINYINT(1) DEFAULT 0,
				include_errors TINYINT(1) DEFAULT 0,
				include_friction TINYINT(1) DEFAULT 0,
				include_performance TINYINT(1) DEFAULT 0,
				include_broken_links TINYINT(1) DEFAULT 0,
				include_user_journeys TINYINT(1) DEFAULT 0,
				include_form_analytics TINYINT(1) DEFAULT 0,
				recipients TEXT NOT NULL,
				created_by BIGINT UNSIGNED DEFAULT NULL,
				created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
				updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
				last_sent_at DATETIME DEFAULT NULL,
				next_send_at DATETIME DEFAULT NULL,
				PRIMARY KEY (id),
				KEY enabled (enabled),
				KEY next_send_at (next_send_at),
				KEY frequency (frequency)
			) " . $charset_collate
		);

		// Report logs table
		dbDelta(
			"CREATE TABLE " . $wpdb->prefix . "optibehavior_report_logs (
				id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
				schedule_id BIGINT UNSIGNED NOT NULL,
				sent_at DATETIME NOT NULL,
				status VARCHAR(20) NOT NULL DEFAULT 'pending',
				recipients_count INT UNSIGNED DEFAULT 0,
				recipients_success INT UNSIGNED DEFAULT 0,
				report_period_start DATE DEFAULT NULL,
				report_period_end DATE DEFAULT NULL,
				error_message TEXT DEFAULT NULL,
				execution_time_ms INT UNSIGNED DEFAULT 0,
				created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
				PRIMARY KEY (id),
				KEY schedule_id (schedule_id),
				KEY sent_at (sent_at),
				KEY status (status)
			) " . $charset_collate
		);
	}

	/**
	 * Create a default report schedule if none exists.
	 *
	 * Creates a weekly report schedule for the admin email on first activation.
	 *
	 * @since 1.1.0
	 */
	private function maybe_create_default_schedule() {
		global $wpdb;

		// Check if any schedules exist
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix; analytics queries.
		$count = $wpdb->get_var( "SELECT COUNT(*) FROM " . $wpdb->prefix . "optibehavior_report_schedules" );

		if ( $count > 0 ) {
			return; // Schedules already exist, don't create default
		}

		// Get admin email
		$admin_email = get_option( 'admin_email' );
		if ( empty( $admin_email ) ) {
			return;
		}

		// Calculate next Monday at 9 AM
		$tz = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( 'UTC' );
		$now = new DateTime( 'now', $tz );
		$next_monday = clone $now;
		$next_monday->modify( 'next monday' );
		$next_monday->setTime( 9, 0, 0 );

		// Pro sections default to enabled when pro is active
		$pro_default = ( function_exists( 'opti_behavior_pro_active' ) && opti_behavior_pro_active() ) ? 1 : 0;

		// Create default weekly report
		$wpdb->insert(
			$wpdb->prefix . 'optibehavior_report_schedules',
			array(
				'name'                      => __( 'Weekly Analytics Report', 'opti-behavior' ),
				'enabled'                   => 1,
				'frequency'                 => 'weekly',
				'day_of_week'               => 1, // Monday
				'day_of_month'              => 1,
				'send_time'                 => '09:00:00',
				'timezone'                  => '',
				'report_period'             => 'last7days',
				'include_kpis'              => 1,
				'include_top_pages'         => 1,
				'include_top_referrers'     => 1,
				'include_traffic_breakdown' => 1,
				'include_funnels'           => 1,
				'include_heatmap_summary'   => 1,
				'include_geographic'        => 1,
				'include_recordings_stats'  => $pro_default,
				'include_errors'            => $pro_default,
				'include_friction'          => $pro_default,
				'include_performance'       => $pro_default,
				'include_broken_links'      => $pro_default,
				'include_user_journeys'     => $pro_default,
				'include_form_analytics'    => $pro_default,
				'recipients'                => wp_json_encode( array( $admin_email ) ),
				'created_by'                => get_current_user_id(),
				'created_at'                => current_time( 'mysql' ),
				'updated_at'                => current_time( 'mysql' ),
				'next_send_at'              => $next_monday->format( 'Y-m-d H:i:s' ),
			),
			array(
				'%s', '%d', '%s', '%d', '%d', '%s', '%s', '%s',
				'%d', '%d', '%d', '%d', '%d', '%d', '%d',
				'%d', '%d', '%d', '%d', '%d',
				'%d', '%d',
				'%s', '%d', '%s', '%s', '%s',
			)
		);

		$debug_manager = $this->core->get_debug_manager();
		$debug_manager->log( 'Created default weekly report schedule for ' . $admin_email, 'info', 'database' );
	}

	/**
	 * Clean up invalid click data
	 */
	private function cleanup_invalid_data() {
		global $wpdb;

		// Guard: table may not exist on a fresh install where the legacy migration dropped it,
		// or in error-recovery scenarios.  Skip silently rather than triggering a DB error.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table existence check; caching not appropriate.
		if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->prefix . 'optibehavior_events' ) ) ) {
			return;
		}

		// 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->query( $wpdb->prepare(
			'DELETE FROM ' . $wpdb->prefix . 'optibehavior_events WHERE event IN (%d, %d) AND ( x < 1 AND y < 1 )',
			Opti_Behavior_Heatmap_Core::CLICK_PC,
			Opti_Behavior_Heatmap_Core::CLICK_MOBILE
		) );
	}

	/**
	 * Setup daily cron jobs
	 */
	private function setup_daily_cron() {
		$tz  = $this->wp_timezone();
		$now = new DateTime( 'now', $tz );

		// Schedule aggregation at 3 AM (runs before cleanup)
		$am3 = new DateTime( 'T0300', $tz );
		if ( $am3 < $now ) {
			$am3->add( new DateInterval( 'P1D' ) );
		}
		$am3_ts = $am3->getTimestamp();

		$next_agg = wp_next_scheduled( 'opti_behavior_aggregate_daily_stats' );
		if ( ! $next_agg ) {
			wp_schedule_event( $am3_ts, 'daily', 'opti_behavior_aggregate_daily_stats' );
		} elseif ( $next_agg < $am3_ts || $am3_ts + 3600 < $next_agg ) {
			wp_clear_scheduled_hook( 'opti_behavior_aggregate_daily_stats' );
			wp_schedule_event( $am3_ts, 'daily', 'opti_behavior_aggregate_daily_stats' );
		}

		// Schedule cleanup at 4 AM (runs after aggregation)
		$am4 = new DateTime( 'T0400', $tz );
		if ( $am4 < $now ) {
			$am4->add( new DateInterval( 'P1D' ) );
		}
		$am4_ts = $am4->getTimestamp();

		$next = wp_next_scheduled( 'opti_behavior_heatmap_cron_daily' );
		if ( ! $next ) {
			wp_schedule_event( $am4_ts, 'daily', 'opti_behavior_heatmap_cron_daily' );
		} elseif ( $next < $am4_ts || $am4_ts + 3600 < $next ) {
			wp_clear_scheduled_hook( 'opti_behavior_heatmap_cron_daily' );
			wp_schedule_event( $am4_ts, 'daily', 'opti_behavior_heatmap_cron_daily' );
		}

		// Schedule report sending (every 15 minutes)
		if ( ! wp_next_scheduled( 'opti_behavior_send_scheduled_reports' ) ) {
			// Add custom interval if not exists
			add_filter( 'cron_schedules', array( $this, 'add_cron_intervals' ) );
			wp_schedule_event( time(), 'every_fifteen_minutes', 'opti_behavior_send_scheduled_reports' );
		}

		// A/B Testing cron: daily aggregation at 3:15 AM.
		if ( ! wp_next_scheduled( 'opti_behavior_ab_aggregate_daily' ) ) {
			$am315 = new DateTime( 'T0315', $tz );
			if ( $am315 < $now ) {
				$am315->add( new DateInterval( 'P1D' ) );
			}
			wp_schedule_event( $am315->getTimestamp(), 'daily', 'opti_behavior_ab_aggregate_daily' );
		}

		// A/B Testing cron: hourly auto-winner check.
		if ( ! wp_next_scheduled( 'opti_behavior_ab_auto_winner_check' ) ) {
			wp_schedule_event( time() + 600, 'hourly', 'opti_behavior_ab_auto_winner_check' );
		}

		// A/B Testing cron: daily cleanup of old raw data.
		if ( ! wp_next_scheduled( 'opti_behavior_ab_cleanup' ) ) {
			$am5 = new DateTime( 'T0500', $tz );
			if ( $am5 < $now ) {
				$am5->add( new DateInterval( 'P1D' ) );
			}
			wp_schedule_event( $am5->getTimestamp(), 'daily', 'opti_behavior_ab_cleanup' );
		}
	}

	/**
	 * Set sensible default auto-cleanup settings on first install.
	 *
	 * Only runs when the option does not yet exist (fresh install).
	 * Existing users who update the plugin are never affected.
	 *
	 * Defaults:
	 *  - Enabled, weekly frequency.
	 *  - Remove sessions older than 90 days.
	 *  - Remove bot/spam sessions (include_bots).
	 *  - Remove bounce sessions (< 2s) older than 30 days.
	 *  - Delete orphaned visitors without sessions.
	 *
	 * @since 1.2.5
	 */
	private function maybe_set_default_auto_cleanup() {
		// Do not overwrite if the user already has settings.
		if ( false !== get_option( 'opti_behavior_auto_cleanup_settings' ) ) {
			return;
		}

		$defaults = array(
			'enabled'                  => true,
			'frequency'                => 'weekly',
			'delete_orphaned_visitors' => true,
			'conditions'               => array(
				'older_than_days'      => 90,   // Remove sessions older than 90 days.
				'include_bots'         => true,  // Remove bot/spam sessions.
				'max_duration'         => 2,     // Remove sessions shorter than 2 seconds …
				'max_duration_age_days' => 30,   // … only if older than 30 days.
			),
			'last_run'                 => null,
		);

		update_option( 'opti_behavior_auto_cleanup_settings', $defaults );

		// Schedule the cron event for weekly cleanup.
		$hook = 'opti_behavior_scheduled_smart_cleanup';
		if ( ! wp_next_scheduled( $hook ) ) {
			wp_schedule_event( time() + HOUR_IN_SECONDS, 'weekly', $hook );
		}

		$debug_manager = $this->core->get_debug_manager();
		$debug_manager->log( 'Default auto-cleanup settings applied (weekly, 90-day retention, bot + bounce cleanup)', 'info', 'database' );
	}

	/**
	 * Add custom cron intervals.
	 *
	 * @param array $schedules Existing schedules.
	 * @return array Modified schedules.
	 */
	public function add_cron_intervals( $schedules ) {
		if ( ! isset( $schedules['every_fifteen_minutes'] ) ) {
			$schedules['every_fifteen_minutes'] = array(
				'interval' => 15 * MINUTE_IN_SECONDS,
				'display'  => __( 'Every 15 Minutes', 'opti-behavior' ),
			);
		}
		return $schedules;
	}

	/**
	 * Get WordPress timezone
	 *
	 * @return DateTimeZone
	 */
	private function wp_timezone() {
		if ( function_exists( 'wp_timezone' ) ) {
			return wp_timezone();
		}

		// Polyfill for wp_timezone() function
		$timezone_string = get_option( 'timezone_string' );

		if ( $timezone_string ) {
			return new DateTimeZone( $timezone_string );
		}

		$offset  = (float) get_option( 'gmt_offset' );
		$hours   = (int) $offset;
		$minutes = ( $offset - $hours );

		$sign      = ( $offset < 0 ) ? '-' : '+';
		$abs_hour  = abs( $hours );
		$abs_mins  = abs( $minutes * 60 );
		$tz_offset = sprintf( '%s%02d:%02d', $sign, $abs_hour, $abs_mins );

		return new DateTimeZone( $tz_offset );
	}

	/**
	 * Update URL2 field for existing pages
	 */
	public function update_url2() {
		global $wpdb;

		// Guard: table may not exist on a fresh install; skip silently.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table existence check; caching not appropriate.
		if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->prefix . 'optibehavior_pages' ) ) ) {
			return;
		}

		$options = $this->core->get_options();
		$query_filter = $this->make_url_filter( true );
		$fragment_filter = $options['keep_url_hash'];

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix; analytics queries.
		$pages = $wpdb->get_results( 'SELECT id, url FROM ' . $wpdb->prefix . 'optibehavior_pages WHERE url2 = \'\'' );

		foreach ( $pages as $page ) {
			$url2 = $this->rebuild_url( $page->url, $query_filter, $fragment_filter );
			$wpdb->update(
				$wpdb->prefix . "optibehavior_pages",
				array( 'url2' => $url2 ),
				array( 'id' => $page->id ),
				array( '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Rebuild ALL URL2 fields (used when settings change)
	 */
	public function rebuild_all_url2() {
		global $wpdb;

		$options = $this->core->get_options();
		$query_filter = $this->make_url_filter( true );
		$fragment_filter = $options['keep_url_hash'];

		// Get ALL pages, not just empty url2
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix; analytics queries.
		$pages = $wpdb->get_results( "SELECT id, url FROM " . $wpdb->prefix . "optibehavior_pages" );

		foreach ( $pages as $page ) {
			$url2 = $this->rebuild_url( $page->url, $query_filter, $fragment_filter );
			$wpdb->update(
				$wpdb->prefix . "optibehavior_pages",
				array( 'url2' => $url2 ),
				array( 'id' => $page->id ),
				array( '%s' ),
				array( '%d' )
			);
		}

		return count( $pages );
	}

	/**
	 * Make URL filter for query parameters
	 *
	 * @param bool $use_options Whether to use current options.
	 * @return array|null
	 */
	public function make_url_filter( $use_options = false ) {
		$options = $this->core->get_options();

		if ( $use_options ) {
			$keep_url_query = isset( $options['keep_url_query'] ) ? $options['keep_url_query'] : false;
			$include = isset( $options['url_query_include'] ) && is_array( $options['url_query_include'] ) ? $options['url_query_include'] : array();
			$exclude = isset( $options['url_query_exclude'] ) && is_array( $options['url_query_exclude'] ) ? $options['url_query_exclude'] : array();
		} else {
			$keep_url_query = false;
			$include = array();
			$exclude = array();
		}

		global $wp;
		$wp_public_query_vars = $wp->public_query_vars;

		// Handle case where $wp->public_query_vars is null (during plugin activation)
		if ( ! is_array( $wp_public_query_vars ) ) {
			$wp_public_query_vars = array();
		}

		// Build initial filter according to "keep_url_query" option
		if ( $keep_url_query ) {
			$filter = array_merge( $wp_public_query_vars, $exclude );
		} else {
			$filter = array_diff( $wp_public_query_vars, $include );
		}

		// Always strip debug/marketing params from canonical URL (url2)
		$always_strip = array(
			'opti_behavior_debug',
			'utm_source','utm_medium','utm_campaign','utm_term','utm_content',
			'gclid','fbclid','msclkid','mc_cid','mc_eid','ref',
			'elementor-preview','ver','preview','preview_id','preview_nonce' // Strip preview/version params
		);
		$filter = array_values( array_unique( array_merge( $filter, $always_strip ) ) );

		return $filter;
	}

	/**
	 * Rebuild URL with filters
	 *
	 * @param string     $url Original URL.
	 * @param array|null $query_filter Query parameter filter.
	 * @param bool       $fragment_filter Fragment filter.
	 * @return string
	 */
	public function rebuild_url( $url, $query_filter = null, $fragment_filter = null ) {
		$parsed = wp_parse_url( $url );

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

		$result = '';

		// Scheme and host
		if ( isset( $parsed['scheme'] ) ) {
			$result .= $parsed['scheme'] . '://';
		}
		if ( isset( $parsed['host'] ) ) {
			$result .= $parsed['host'];
		}
		if ( isset( $parsed['port'] ) ) {
			$result .= ':' . $parsed['port'];
		}
		if ( isset( $parsed['path'] ) ) {
			$result .= $parsed['path'];
		}

		// Query parameters
		if ( isset( $parsed['query'] ) && null !== $query_filter ) {
			parse_str( $parsed['query'], $query_params );
			$filtered_params = array();

			foreach ( $query_params as $key => $value ) {
				if ( ! in_array( $key, $query_filter, true ) ) {
					$filtered_params[ $key ] = $value;
				}
			}

			if ( ! empty( $filtered_params ) ) {
				$result .= '?' . http_build_query( $filtered_params );
			}
		} elseif ( isset( $parsed['query'] ) ) {
			$result .= '?' . $parsed['query'];
		}

		// Fragment
		if ( isset( $parsed['fragment'] ) && $fragment_filter ) {
			$result .= '#' . $parsed['fragment'];
		}

		return $result;
	}

	/**
	 * Delete old data based on retention period
	 *
	 * Uses batched deletion to avoid timeouts and lock contention
	 * on large datasets (1M+ rows).
	 *
	 * @param int $months Number of months to retain.
	 */
	public function delete_old_data( $months = 1 ) {
		global $wpdb;

		$debug_manager = $this->core->get_debug_manager();
		$months = absint( $months );
		if ( $months < 1 ) {
			$months = 1;
		}
		$date = new DateTime();
		$date->sub( new DateInterval( "P" . $months . "M" ) );
		$cutoff = $date->format( 'Y-m-d H:i:s' );

		$debug_manager->log( "Starting data cleanup for records older than " . $cutoff, 'info', 'database' );

		// Tables to clean with their date columns
		$tables = array(
			'optibehavior_events'          => 'insert_at',
			'optibehavior_sessions'        => 'start_time',
			'optibehavior_pageviews'       => 'view_time',
			'optibehavior_recordings'      => 'start_time',
			'optibehavior_session_pages'   => 'entry_time',
			'optibehavior_referrers'       => 'created_at',
			'optibehavior_outbound_clicks' => 'created_at',
			'optibehavior_bot_visits'      => 'visit_time',
			'optibehavior_errors'          => 'occurred_at',
			'optibehavior_friction'        => 'occurred_at',
			'optibehavior_performance'     => 'measured_at',
		);

		$batch_size   = 10000;
		$total_deleted = 0;

		foreach ( $tables as $table_suffix => $date_column ) {
			$table = $wpdb->prefix . $table_suffix;

			// Check if table exists before trying to delete
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Checking table existence.
			$table_exists = $wpdb->get_var(
				$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
			);

			if ( ! $table_exists ) {
				continue;
			}

			$table_deleted = 0;

			do {
				// Delete in batches to avoid lock timeouts
				// 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.
				$deleted = $wpdb->query(
					$wpdb->prepare(
						"DELETE FROM " . $table . " WHERE " . $date_column . " < %s LIMIT %d",
						$cutoff,
						$batch_size
					)
				);

				if ( $deleted > 0 ) {
					$table_deleted += $deleted;
					$total_deleted += $deleted;

					// Yield to other processes between batches
					if ( $deleted >= $batch_size ) {
						usleep( 100000 ); // 100ms pause
					}
				}
			} while ( $deleted >= $batch_size );

			if ( $table_deleted > 0 ) {
				$debug_manager->log(
					"Deleted " . $table_deleted . " old records from " . $table_suffix,
					'debug',
					'database'
				);
			}
		}

		// Don't delete daily_stats - keep for historical reports
		// Daily stats are pre-aggregated and small in size

		$debug_manager->log(
			"Data cleanup completed. Total records deleted: " . $total_deleted,
			'info',
			'database'
		);

		// Optimize tables if significant data was deleted
		if ( $total_deleted > 50000 ) {
			$this->optimize_tables_after_cleanup();
		}

		return $total_deleted;
	}

	/**
	 * Optimize tables after bulk deletion to reclaim disk space
	 *
	 * Only optimizes tables with significant fragmentation (>10MB free space).
	 */
	private function optimize_tables_after_cleanup() {
		global $wpdb;

		$debug_manager = $this->core->get_debug_manager();
		$debug_manager->log( 'Checking tables for optimization after cleanup', 'debug', 'database' );

		$tables_to_check = array(
			$wpdb->prefix . 'optibehavior_events',
			$wpdb->prefix . 'optibehavior_sessions',
			$wpdb->prefix . 'optibehavior_pageviews',
		);

		foreach ( $tables_to_check as $table ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time check during maintenance
			$status = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT Data_free FROM information_schema.tables
					WHERE table_schema = %s AND table_name = %s",
					DB_NAME,
					$table
				)
			);

			// Only optimize if >10MB fragmented space
			if ( $status && $status->Data_free > 10 * 1024 * 1024 ) {
				$debug_manager->log( "Optimizing table " . $table . " (fragmented: " . round( $status->Data_free / 1024 / 1024, 2 ) . 'MB)', 'info', 'database' );
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange -- Table optimization required for maintenance
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$wpdb->query( "OPTIMIZE TABLE " . $table );
			}
		}
	}

	/**
	 * Clean up orphaned pages
	 */
	public function cleanup_pages() {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix; analytics queries.
		$wpdb->query(
			"DELETE p FROM " . $wpdb->prefix . "optibehavior_pages p
			LEFT JOIN " . $wpdb->prefix . "optibehavior_events e ON p.id = e.page_id2
			WHERE e.page_id2 IS NULL"
		);
	}

	/**
	 * Delete all heatmap data
	 */
	public function delete_all() {
		global $wpdb;

		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_events" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_pages" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_sessions" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_visitors" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_pageviews" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_recordings" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_referrers" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_outbound_clicks" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_errors" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_error_types" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_friction" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_performance" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_broken_links" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "opti_behavior_funnels" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "opti_behavior_funnel_tracking" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_journey_groups" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_report_logs" );
		$wpdb->query( "TRUNCATE TABLE " . $wpdb->prefix . "optibehavior_visitor_daily_stats" );
	}

	/**
	 * Delete specific page data
	 *
	 * @param array $page_ids Array of page IDs to delete.
	 */
	public function delete_data( $page_ids ) {
		global $wpdb;

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

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query( $wpdb->prepare(
			"DELETE FROM " . $wpdb->prefix . "optibehavior_events WHERE page_id2 IN (" . implode( ',', array_fill( 0, count( $page_ids ), '%d' ) ) . ")",
			...$page_ids
		) );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query( $wpdb->prepare(
			"DELETE FROM " . $wpdb->prefix . "optibehavior_pages WHERE id IN (" . implode( ',', array_fill( 0, count( $page_ids ), '%d' ) ) . ")",
			...$page_ids
		) );
	}

	/**
	 * Validate options
	 *
	 * @param array $new_options New options.
	 * @param array $old_options Old options.
	 * @return array
	 */
}
