HEX
Server: LiteSpeed
System: Linux premium263.web-hosting.com 4.18.0-553.50.1.lve.el8.x86_64 #1 SMP Thu Apr 17 19:10:24 UTC 2025 x86_64
User: eastcjee (525)
PHP: 8.2.31
Disabled: NONE
Upload Files
File: //proc/self/cwd/wp-content/plugins/burst-statistics/includes/Admin/Statistics/class-query-data.php
<?php
namespace Burst\Admin\Statistics;

use Burst\Traits\Admin_Helper;
use Burst\Traits\Sanitize;

use function Burst\burst_loader;

defined( 'ABSPATH' ) || die();

/**
 * Query_Data class for building complex SQL queries.
 *
 * This class provides a structured way to build SQL queries for the Burst Statistics plugin.
 * It supports filtering, grouping, ordering, custom WHERE clauses, and more.
 */
class Query_Data {
	use Sanitize;
	use Admin_Helper;

	/**
	 * Strict mode - limits available options for frontend/non-privileged users
	 *
	 * @var bool $strict Whether strict mode is enabled.
	 */
	private bool $strict;

	/**
	 * Store allowed metrics for reuse
	 *
	 * @var array<string,string>
	 */
	private array $allowed_metrics;

	/**
	 * Store allowed filter keys for reuse
	 *
	 * @var array<string>
	 */
	private array $allowed_filter_keys;

	/**
	 * Store allowed group_by values
	 *
	 * @var array<string>
	 */
	private array $allowed_group_by;

	/**
	 * Store allowed order_by values
	 *
	 * @var array<string>
	 */
	private array $allowed_order_by;

	/**
	 * Stable identifier for query family/source.
	 *
	 * @var string $id Query id used for deterministic fingerprinting.
	 */
	public string $id;

	/**
	 * Start date for the query (timestamp).
	 *
	 * @var int $date_start Start date for the query (timestamp).
	 */
	public int $date_start = 0;

	/**
	 * End date for the query (timestamp).
	 *
	 * @var int $date_end End date for the query (timestamp).
	 */
	public int $date_end = 0;

	/**
	 * Selected metrics for the query.
	 *
	 * @var array $select Metrics to select in the query.
	 */
	public array $select = [ '*' ];

	/**
	 * Filters for the query.
	 *
	 * @var array $filters Filters to apply in the query.
	 */
	public array $filters = [];

	/**
	 * Per-filter exclusion mode.
	 *
	 * Maps filter key → 'include' | 'exclude'.
	 *
	 * @var array<string, string> $filter_exclusions
	 */
	public array $filter_exclusions = [];

	/**
	 * Group by clause for the query.
	 *
	 * @var string[] $group_by Group by clause for the query.
	 */
	public array $group_by = [];

	/**
	 * Order by clause for the query.
	 *
	 * @var string[] $order_by Order by clause for the query.
	 */
	public array $order_by = [];

	/**
	 * Offset for the query results.
	 *
	 * @var int $offset Offset for the query results.
	 */
	public int $limit = 0;

	/**
	 * Joins for the query.
	 *
	 * @var array $joins Additional JOIN clauses for the query.
	 */
	public array $joins = [];

	/**
	 * Date modifiers for the query.
	 *
	 * @var array $date_modifiers Date modifiers for the query.
	 */
	public array $date_modifiers = [];

	/**
	 * Having clauses for the query.
	 *
	 * @var array $having HAVING clauses for the query.
	 */
	public array $having = [];

	/**
	 * Custom SELECT clause for the query.
	 *
	 * @var string $custom_select Custom SELECT clause for the query.
	 */
	public string $custom_select = '';

	/**
	 * Custom WHERE clause for the query.
	 *
	 * @var string $custom_where Custom WHERE clause for the query.
	 */
	public string $custom_where = '';

	public array $custom_where_parameters  = [];
	public array $custom_select_parameters = [];

	/**
	 * Subquery for the query.
	 *
	 * @var string $subquery Subquery for the query.
	 */
	public string $subquery = '';

	/**
	 * UNION clauses for the query.
	 *
	 * @var array $union UNION clauses for the query.
	 */
	public array $union = [];

	/**
	 * Distinct flag for the query.
	 *
	 * @var bool $distinct Whether to use DISTINCT in the query.
	 */
	public bool $distinct = false;

	/**
	 * Window functions for the query.
	 *
	 * @var array $window Window functions for the query.
	 */
	public array $window = [];

	/**
	 * Exclude bounces flag for the query.
	 *
	 * @var bool $exclude_bounces Whether to exclude bounces from the query.
	 */
	public bool $exclude_bounces = false;
	/**
	 * List of metrics and labels.
	 *
	 * @var array $metrics Array of metrics with labels.
	 */
	public array $metrics = [];
	/**
	 * Metric keys that are allowed in strict mode (frontend).
	 *
	 * @var array $strict_metric_keys Metric keys that are allowed in strict mode (frontend).
	 */
	private array $strict_metric_keys = [
		'pageviews',
		'visitors',
		'sessions',
		'bounce_rate',
		'avg_time_on_page',
		'first_time_visitors',
		'page_url',
		'referrer',
		'device',
	];

	/**
	 * Constructor to initialize the Query_Data object with sanitizing arguments.
	 *
	 * Preferred usage: new Query_Data( 'my_query_id', [ ...args ] ).
	 * Legacy usage with array-only input is still accepted temporarily.
	 *
	 * @param string              $id   Query id, or legacy args array.
	 * @param array<string,mixed> $args Associative array of query parameters.
	 */
	public function __construct( string $id, array $args = [] ) {
		$args = $this->apply_share_link_restrictions_to_args( $args );

		$this->strict  = ! $this->has_admin_access();
		$this->metrics = [
			'host'                 => 'Domain',
			'page_url'             => 'Page',
			'referrer'             => 'Referrer',
			'pageviews'            => 'Pageviews',
			'sessions'             => 'Sessions',
			'visitors'             => 'Visitors',
			'avg_time_on_page'     => 'Avg. time on page',
			'avg_session_duration' => 'Avg. session duration',
			'conversion_rate'      => 'Goal conv. rate',
			'first_time_visitors'  => 'New visitors',
			'conversions'          => 'Goal completions',
			'bounces'              => 'Bounced visitors',
			'bounce_rate'          => 'Bounce rate',
			'device'               => 'Device',
			'browser'              => 'Browser',
			'platform'             => 'Platform',
			'device_id'            => 'Device',
			'browser_id'           => 'Browser',
			'platform_id'          => 'Platform',
			'count'                => 'Count',
			'period'               => 'Period',
			'active_time'          => 'Active time',
			'time_on_page'         => 'Time on page',
			'time'                 => 'Time',
			'uid'                  => 'UID',
			'page_id'              => 'Page ID',
		];
		$this->initialize_allowlists();

		$this->id = sanitize_key( $id );

		if ( empty( $this->id ) ) {
			self::error_log( 'ID property is required for Query Data class.' );
		}

		// these parameters are used for prepared statements in custom SQL clauses.
		$this->custom_where_parameters  = isset( $args['custom_where_parameters'] ) && is_array( $args['custom_where_parameters'] ) ? $args['custom_where_parameters'] : [];
		$this->custom_select_parameters = isset( $args['custom_select_parameters'] ) && is_array( $args['custom_select_parameters'] ) ? $args['custom_select_parameters'] : [];

		if ( ! empty( $args ) ) {
			foreach ( $args as $key => $value ) {
				if ( ! property_exists( $this, $key ) ) {
					$this::error_log( "QueryData error: Invalid property '$key' in Query_Data class. Please check your arguments." );
					continue;
				}

				$this->assign_property( $key, $value );
			}

			$this->exclude_bounces = $this->exclude_bounces();
		}
	}

	/**
	 * Apply share-link restrictions to Query_Data arguments.
	 *
	 * This centralizes enforcement so any REST endpoint that constructs Query_Data
	 * cannot bypass share link restrictions by skipping routing-level sanitization.
	 *
	 * @param array<string,mixed> $args Query args.
	 * @return array<string,mixed> Modified args.
	 */
	private function apply_share_link_restrictions_to_args( array $args ): array {
		if ( ! self::is_shareable_link_viewer() ) {
			return $args;
		}

		$loader = burst_loader();
		if ( ! isset( $loader->admin, $loader->admin->share, $loader->admin->share->routing ) ) {
			return $args;
		}

		$routing = $loader->admin->share->routing;
		if ( ! is_object( $routing ) || ! method_exists( $routing, 'apply_share_link_restrictions' ) ) {
			return $args;
		}

		// apply_share_link_restrictions() is share-viewer aware and does not restrict admins.
		return $routing->apply_share_link_restrictions( $args );
	}

	/**
	 * Initialize allowlists based on strict mode
	 */
	private function initialize_allowlists(): void {
		$this->initialize_allowed_metrics();
		$this->initialize_allowed_filter_keys();
		$this->initialize_allowed_group_by();
		$this->initialize_allowed_order_by();
	}

	/**
	 * Get allowed metrics based on strict mode
	 */
	private function initialize_allowed_metrics(): void {
		$metrics = $this->metrics;
		if ( $this->is_strict() ) {
			$keys    = apply_filters( 'burst_allowed_metric_keys', $this->strict_metric_keys, $this->is_strict() );
			$metrics = array_intersect_key( $metrics, array_flip( $keys ) );
		}

		$this->allowed_metrics = apply_filters( 'burst_allowed_metrics', $metrics, $this->is_strict() );
	}

	/**
	 * Get allowed filter keys based on strict mode
	 */
	private function initialize_allowed_filter_keys(): void {
		$keys = [
			'page_type',
			'page_id',
			'page_url',
			'referrer',
			'device',
			'browser',
			'platform',
		];

		if ( ! $this->is_strict() ) {
			$extra = [
				'goal_id',
				'bounces',
				'new_visitor',
				'device_id',
				'browser_id',
				'platform_id',
				'time_per_session',
			];

			$keys = array_merge( $keys, $extra );
		}

		$this->allowed_filter_keys = apply_filters( 'burst_statistics_allowed_filter_keys', $keys, $this->is_strict() );
	}

	/**
	 * Get allowed group_by values based on strict mode
	 */
	private function initialize_allowed_group_by(): void {
		if ( $this->is_strict() ) {
			$group_by = [
				'page_type',
				'page_id',
				'page_url',
				'referrer',
				'device',
				'browser',
				'platform',
				'period',
				'continent_code',
			];
		} else {
			$group_by   = $this->get_allowed_metrics();
			$group_by[] = 'period';
		}

		$this->allowed_group_by = apply_filters( 'burst_statistics_allowed_group_by', $group_by, $this->is_strict() );
	}

	/**
	 * Get allowed order_by values based on strict mode
	 */
	private function initialize_allowed_order_by(): void {

		$metrics  = $this->get_allowed_metrics();
		$order_by = [];
		foreach ( $metrics as $metric ) {
			$order_by[] = $metric . ' DESC';
			$order_by[] = $metric . ' ASC';
			$order_by[] = $metric;
		}

		$this->allowed_order_by = apply_filters( 'burst_statistics_allowed_order_by', $order_by, $this->is_strict() );
	}

	/**
	 * Assign and sanitize a property value.
	 *
	 * @param string $key   Property name.
	 * @param mixed  $value Property value.
	 */
	private function assign_property( string $key, mixed $value ): void {
		if ( $key === 'id' ) {
			$this->id = sanitize_key( (string) $value );
			if ( empty( $this->id ) ) {
				self::error_log( 'ID property is required for Query Data class.' );
			}
			return;
		}

		if ( $key === 'filters' ) {
			$this->filters = $this->normalize_filter_values( is_array( $value ) ? $value : [] );
			$this->filters = $this->sanitize_filters( $this->filters );
			return;
		}

		if ( $key === 'select' ) {
			$this->select = $this->sanitize_metrics( is_array( $value ) ? $value : [ $value ] );
			return;
		}

		if ( $key === 'custom_where' && ! $this->strict ) {
			$this->custom_where = $this->get_prepared_custom_sql( $value, 'where' );
			return;
		}

		if ( $key === 'custom_select' && ! $this->strict ) {
			$this->custom_select = $this->get_prepared_custom_sql( $value, 'select' );
			return;
		}

		if ( $key === 'group_by' ) {
			$array_values   = $this->ensure_array_if_applicable( $value );
			$this->group_by = $this->sanitize_group_by( is_array( $array_values ) ? $array_values : [ $array_values ] );
			return;
		}

		if ( $key === 'order_by' ) {
			$array_values   = $this->ensure_array_if_applicable( $value );
			$this->order_by = is_array( $array_values ) ? $array_values : [ $array_values ];
			$this->order_by = $this->validate_order_by( $this->order_by );
			return;
		}

		if ( $key === 'date_modifiers' ) {
			$this->date_modifiers = $value;
			return;
		}

		if ( is_array( $value ) ) {
			$this->$key = $this->sanitize_array_values( $value );
		} elseif ( is_string( $value ) ) {
			$this->$key = esc_sql( $value );
		} elseif ( is_int( $value ) ) {
			$this->$key = absint( $value );
		} elseif ( is_bool( $value ) || is_float( $value ) ) {
			$this->$key = $value;
		} else {
			static::error_log( 'QueryData error: sanitize_arg arg not found: ' . $key . ' with value: ' . wp_json_encode( $value ) );
		}
	}

	/**
	 * Flexibly sanitize filters for statistics queries.
	 *
	 * @param array $filters Array of filters to sanitize.
	 * @return array<string, mixed> Sanitized filters.
	 */
	public function sanitize_filters_flexibly( array $filters ): array {
		// Filter out false or empty values, except zeros.
		$filters = array_filter(
			$filters,
			static function ( $item ) {
				// Keep values that are not false and not empty string, OR are exactly zero (int or string).
				if ( $item === 0 || $item === '0' ) {
					return true;
				}
				return $item !== false && $item !== '';
			}
		);

		$filter_config = $this->filter_validation_config();
		// Sanitize keys and values.
		$output = [];
		foreach ( $filters as $key => $value ) {
			$key = sanitize_text_field( $key );
			// Handle array values.
			if ( is_array( $value ) ) {
				$output[ $key ] = $this->sanitize_filters( $value );
				continue;
			}

			// Handle special filter cases with specific sanitization rules.
			if ( isset( $filter_config[ $key ] ) && isset( $filter_config[ $key ]['sanitize'] ) ) {
				$sanitize_function = $filter_config[ $key ]['sanitize'];
				// Handle callable sanitization functions (including class methods).
				if ( is_callable( $sanitize_function ) ) {
					try {
						$output[ $key ] = call_user_func( $sanitize_function, $value );
					} catch ( \Exception $e ) {
						static::error_log( 'QueryData error: Error sanitizing filter ' . $key . ': ' . $e->getMessage() );
						$output[ $key ] = sanitize_text_field( $value );
					}
				} elseif ( is_callable( [ $this, $sanitize_function ] ) ) {
					try {
						$output[ $key ] = call_user_func( [ $this, $sanitize_function ], $value );
					} catch ( \Exception $e ) {
						static::error_log( 'QueryData error: Error sanitizing filter ' . $key . ': ' . $e->getMessage() );
						$output[ $key ] = sanitize_text_field( $value );
					}
				} else {
					// Fallback to default sanitization.
					static::error_log( 'QueryData error: Sanitization function not found for filter: ' . $key );
					$output[ $key ] = is_numeric( $value ) ? $value : sanitize_text_field( $value );
				}
			} else {
				// Default sanitization for values that don't have specific rules.
				$output[ $key ] = is_numeric( $value ) ? (int) $value : sanitize_text_field( $value );
			}
		}

		return $output;
	}

	/**
	 * Strictly sanitize filters for statistics queries.
	 *
	 * @param array $filters Array of filters to sanitize.
	 * @return array<string, mixed> Sanitized filters.
	 */
	private function sanitize_filters_strictly( array $filters ): array {
		// Filter out false or empty values.
		$filters = array_filter(
			$filters,
			function ( $item ) {
				return $item !== false && $item !== '';
			}
		);

		// Sanitize keys and values and limit to allowed keys.
		$sanitized = [];
		foreach ( $filters as $key => $value ) {
			// Only allow filters with whitelisted keys.
			if ( in_array( $key, $this->get_allowed_filter_keys(), true ) ) {
				$sanitized_key = $key;

				// Use appropriate sanitization based on filter type.
				switch ( $key ) {
					case 'page_url':
						// For URLs, use wp_parse_url to extract path component.
						$parsed_url      = wp_parse_url( $value, PHP_URL_PATH );
						$sanitized_value = ( $parsed_url !== false && $parsed_url !== null ) ? $parsed_url : sanitize_text_field( $value );
						break;
					case 'referrer':
						// For referrers, sanitize as URL.
						$sanitized_value = esc_url_raw( $value );
						break;
					case 'page_id':
						$sanitized_value = absint( $value );
						break;
					case 'page_type':
						$allowed_page_types = apply_filters( 'burst_allowed_post_types', get_post_types( [ 'public' => true ] ) );
						$sanitized_value    = in_array( $value, $allowed_page_types, true ) ? $value : 'post';
						break;
					case 'device':
					case 'browser':
					case 'platform':
						// For device/browser/platform, use sanitize_key for consistency.
						$sanitized_value = sanitize_key( $value );
						break;
					default:
						// Default to text field sanitization.
						$sanitized_value = sanitize_text_field( $value );
						break;
				}

				if ( ! empty( $sanitized_value ) ) {
					$sanitized[ $sanitized_key ] = $sanitized_value;
				}
			}
		}
		return $sanitized;
	}

	/**
	 * Sanitize filters for statistics queries.
	 *
	 * Normalizes the new { value, exclusion } filter shape from the frontend
	 * into flat string values (backward compatible) while storing per-filter
	 * exclusion modes in $this->filter_exclusions.
	 *
	 * @param array $filters Array of filters to sanitize.
	 * @return array<string, mixed> Sanitized filters.
	 */
	public function sanitize_filters( array $filters ): array {
		if ( $this->is_strict() ) {
			return $this->sanitize_filters_strictly( $filters );
		} else {
			return $this->sanitize_filters_flexibly( $filters );
		}
	}

	/**
	 * Normalize filter values: detect '!' prefix indicating exclusion,
	 * strip it, and record the exclusion mode. All filters are passed through.
	 *
	 * @param array $filters Raw filters array.
	 * @return array<string, mixed> Filters with '!' prefix stripped where applicable.
	 */
	private function normalize_filter_values( array $filters ): array {
		$normalized = [];

		foreach ( $filters as $key => $value ) {
			if ( is_string( $value ) && str_starts_with( $value, '!' ) ) {
				$normalized[ $key ]              = substr( $value, 1 );
				$this->filter_exclusions[ $key ] = 'exclude';
			} else {
				$normalized[ $key ]              = $value;
				$this->filter_exclusions[ $key ] = 'include';
			}
		}

		return $normalized;
	}

	/**
	 * Sanitize array of metrics.
	 *
	 * @param array $metrics Array of metrics to sanitize.
	 * @return array<string> Sanitized metrics array.
	 */
	public function sanitize_metrics( array $metrics ): array {
		$sanitized_metrics = [];
		foreach ( $metrics as $metric ) {
			$sanitized_metrics[] = $this->sanitize_metric( $metric );
		}
		return $sanitized_metrics;
	}

	/**
	 * Sanitize a metric against list of allowed metrics.
	 *
	 * @param string $metric The metric to sanitize.
	 * @return string Sanitized metric.
	 */
	public function sanitize_metric( string $metric ): string {
		$metric = sanitize_text_field( $metric );

		$allowed_metrics = $this->get_allowed_metrics();
		$default_metric  = $this->default_metric();

		if ( in_array( $metric, $allowed_metrics, true ) ) {
			return $metric;
		} else {
			self::error_log( "QueryData error: Metric '$metric' is not allowed. Returning default metric '$default_metric'." );
		}

		return $default_metric;
	}

	/**
	 * Recursively sanitize array values.
	 *
	 * @param array $values Array to sanitize.
	 * @return array Sanitized array.
	 */
	private function sanitize_array_values( array $values ): array {
		foreach ( $values as $array_key => $array_value ) {
			if ( is_array( $array_value ) ) {
				$values[ $array_key ] = $this->sanitize_array_values( $array_value );
				continue;
			}

			if ( is_string( $array_value ) ) {
				$values[ $array_key ] = esc_sql( $array_value );
			}
		}

		return $values;
	}

	/**
	 * Sanitize device filter value
	 *
	 * @param string $device Device value to sanitize.
	 * @return string Sanitized device value
	 */
	public function sanitize_device_filter( string $device ): string {
		$allowed_devices = [ 'desktop', 'tablet', 'mobile', 'other' ];

		if ( in_array( $device, $allowed_devices, true ) ) {
			return $device;
		} else {
			self::error_log( "QueryData error: Device filter value '$device' is not allowed." );
		}

		return '';
	}

	/**
	 * Sanitize group_by parameters.
	 *
	 * @param array $group_by Group by parameters to sanitize.
	 * @return array<string> Sanitized group_by array.
	 */
	public function sanitize_group_by( array $group_by ): array {
		$allowed_group_by   = $this->get_allowed_group_by();
		$sanitized_group_by = [];

		foreach ( $group_by as $field ) {
			$field = sanitize_text_field( trim( $field ) );
			if ( empty( $field ) ) {
				continue;
			}
			// Only allow valid metric fields for group_by.
			if ( in_array( $field, $allowed_group_by, true ) ) {
				$sanitized_group_by[] = $field;
			} else {
				self::error_log( "QueryData error: Group by field '$field' is not allowed." );
			}
		}

		// Remove duplicates and return.
		return array_unique( $sanitized_group_by );
	}

	/**
	 * Validate group_by against allowlist.
	 *
	 * @param string|string[] $group_by Group by clause(s) to validate.
	 * @return string[] Validated group_by or empty array.
	 */
	public function validate_group_by( array|string $group_by ): array {
		// Normalize to array.
		$group_by = is_array( $group_by ) ? $group_by : [ $group_by ];

		$allowed   = $this->get_allowed_group_by();
		$validated = [];

		foreach ( $group_by as $item ) {
			$item = trim( (string) $item );
			if ( in_array( $item, $allowed, true ) ) {
				$validated[] = $item;
			}
		}

		return $validated;
	}

	/**
	 * Validate order_by against allowlist.
	 *
	 * @param string|string[] $order_by Order by clause(s) to validate.
	 * @return string[] Validated order_by or empty array.
	 */
	public function validate_order_by( array|string $order_by ): array {
		// Normalize to array.
		$order_by = is_array( $order_by ) ? $order_by : [ $order_by ];

		$allowed   = $this->get_allowed_order_by();
		$validated = [];

		foreach ( $order_by as $item ) {
			$item = trim( (string) $item );
			if ( empty( $item ) ) {
				continue;
			}
			if ( in_array( $item, $allowed, true ) ) {
				$validated[] = $item;
			} else {
				self::error_log( "QueryData error: Order by clause '$item' is not allowed." );
			}
		}

		return $validated;
	}

	/**
	 * Get allowed metrics (public accessor)
	 *
	 * @return array<string> List of allowed metrics.
	 */
	public function get_allowed_metrics(): array {
		return array_keys( $this->allowed_metrics );
	}

	/**
	 * Get allowed filter keys (public accessor)
	 *
	 * @return array<string> List of allowed filter keys.
	 */
	public function get_allowed_filter_keys(): array {
		return $this->allowed_filter_keys;
	}

	/**
	 * Get allowed group_by values (public accessor)
	 *
	 * @return array<string> List of allowed group_by values.
	 */
	public function get_allowed_group_by(): array {
		return $this->allowed_group_by;
	}

	/**
	 * Get allowed order_by values (public accessor)
	 *
	 * @return array<string> List of allowed order_by values.
	 */
	public function get_allowed_order_by(): array {
		return $this->allowed_order_by;
	}

	/**
	 * Get allowed metrics labels based on strict mode
	 *
	 * @return array<string, string> Associative array of metric keys and their labels.
	 */
	public function get_allowed_metrics_labels(): array {
		$labels = apply_filters(
			'burst_allowed_metrics_labels',
			[
				'host'                  => __( 'Domain', 'burst-statistics' ),
				'page_url'              => __( 'Page', 'burst-statistics' ),
				'referrer'              => __( 'Referrer', 'burst-statistics' ),
				'pageviews'             => __( 'Pageviews', 'burst-statistics' ),
				'sessions'              => __( 'Sessions', 'burst-statistics' ),
				'visitors'              => __( 'Visitors', 'burst-statistics' ),
				'avg_time_on_page'      => __( 'Avg. time on page', 'burst-statistics' ),
				'avg_session_duration'  => __( 'Avg. session duration', 'burst-statistics' ),
				'conversion_rate'       => __( 'Goal conv. rate', 'burst-statistics' ),
				'first_time_visitors'   => __( 'New visitors', 'burst-statistics' ),
				'conversions'           => __( 'Goal completions', 'burst-statistics' ),
				'bounces'               => __( 'Bounced visitors', 'burst-statistics' ),
				'bounce_rate'           => __( 'Bounce rate', 'burst-statistics' ),
				'device'                => __( 'Device', 'burst-statistics' ),
				'browser'               => __( 'Browser', 'burst-statistics' ),
				'platform'              => __( 'Platform', 'burst-statistics' ),
				'device_id'             => __( 'Device', 'burst-statistics' ),
				'browser_id'            => __( 'Browser', 'burst-statistics' ),
				'platform_id'           => __( 'Platform', 'burst-statistics' ),
				'country_code'          => __( 'Country', 'burst-statistics' ),
				'city'                  => __( 'City', 'burst-statistics' ),
				'state'                 => __( 'State', 'burst-statistics' ),
				'continent'             => __( 'Continent', 'burst-statistics' ),
				'continent_code'        => __( 'Continent', 'burst-statistics' ),
				'source'                => __( 'Source', 'burst-statistics' ),
				'medium'                => __( 'Medium', 'burst-statistics' ),
				'campaign'              => __( 'Campaign', 'burst-statistics' ),
				'term'                  => __( 'Term', 'burst-statistics' ),
				'content'               => __( 'Content', 'burst-statistics' ),
				'parameter'             => __( 'Parameter', 'burst-statistics' ),
				'parameters'            => __( 'Parameters', 'burst-statistics' ),
				'product'               => __( 'Product', 'burst-statistics' ),
				'sales'                 => __( 'Sales', 'burst-statistics' ),
				'revenue'               => __( 'Revenue', 'burst-statistics' ),
				'page_value'            => __( 'Page value', 'burst-statistics' ),
				'sales_conversion_rate' => __( 'Sales conv. rate', 'burst-statistics' ),
				'entrances'             => __( 'Entrances', 'burst-statistics' ),
				'exit_rate'             => __( 'Exit rate', 'burst-statistics' ),
				'avg_order_value'       => __( 'Avg. order value', 'burst-statistics' ),
				'adds_to_cart'          => __( 'Added to cart', 'burst-statistics' ),
			]
		);

		// Filter labels to only include allowed metrics.
		$allowed_keys = array_keys( $this->allowed_metrics );
		return array_intersect_key( $labels, array_flip( $allowed_keys ) );
	}

	/**
	 * Get the start date timestamp for the query.
	 *
	 * @return int Start date (timestamp).
	 */
	public function get_date_start(): int {
		return $this->date_start;
	}

	/**
	 * Get the end date timestamp for the query.
	 *
	 * @return int End date (timestamp).
	 */
	public function get_date_end(): int {
		return $this->date_end;
	}

	/**
	 * Get the query id used by stats fingerprinting.
	 */
	public function get_id(): string {
		$id = sanitize_key( $this->id );

		return $id !== '' ? $id : 'unknown_query';
	}

	/**
	 * Build canonical payload for deterministic query fingerprinting.
	 *
	 * Absolute timestamps are intentionally excluded to avoid hash drift.
	 * We use date_range_days to keep range granularity in the key.
	 *
	 * @return array<string,mixed>
	 */
	public function get_fingerprint_payload(): array {
		$date_range_days = 0;
		if ( $this->date_start > 0 && $this->date_end > 0 && $this->date_end >= $this->date_start ) {
			$date_range_days = (int) ceil( ( $this->date_end - $this->date_start ) / DAY_IN_SECONDS );
		}

		$payload = [
			'id'                => $this->get_id(),
			'select'            => $this->normalize_list_for_fingerprint( $this->select ),
			'filters'           => $this->normalize_for_fingerprint( $this->filters ),
			'filter_exclusions' => $this->normalize_for_fingerprint( $this->filter_exclusions ),
			'group_by'          => $this->normalize_list_for_fingerprint( $this->group_by ),
			'order_by'          => $this->normalize_list_for_fingerprint( $this->order_by ),
			'limit'             => $this->limit,
			'distinct'          => $this->distinct,
			'exclude_bounces'   => $this->exclude_bounces,
			'date_range_days'   => $date_range_days,
		];

		ksort( $payload );

		return $payload;
	}

	/**
	 * Get deterministic hash for this query payload.
	 */
	public function get_fingerprint_hash(): string {
		$payload_json = wp_json_encode( $this->get_fingerprint_payload(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );

		if ( ! is_string( $payload_json ) || $payload_json === '' ) {
			$payload_json = $this->get_id();
		}

		// Use 64-bit hash to stay compatible with existing sql_hash column length.
		return hash( 'fnv1a64', $payload_json );
	}

	/**
	 * Normalize a list where element order is not semantically relevant.
	 *
	 * @param array<mixed> $values List values.
	 * @return array<mixed>
	 */
	private function normalize_list_for_fingerprint( array $values ): array {
		$values = array_map( [ $this, 'normalize_for_fingerprint' ], $values );
		usort( $values, [ $this, 'compare_normalized_values' ] );

		return $values;
	}

	/**
	 * Recursively normalize values for deterministic hashing.
	 */
	private function normalize_for_fingerprint( mixed $value ): mixed {
		if ( is_array( $value ) ) {
			if ( $this->is_list_array( $value ) ) {
				$normalized = array_map( [ $this, 'normalize_for_fingerprint' ], $value );
				usort( $normalized, [ $this, 'compare_normalized_values' ] );

				return $normalized;
			}

			ksort( $value );
			foreach ( $value as $key => $item ) {
				$value[ $key ] = $this->normalize_for_fingerprint( $item );
			}

			return $value;
		}

		if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) || $value === null ) {
			return $value;
		}

		if ( is_numeric( $value ) && ! str_contains( $value, '.' ) ) {
			return (int) $value;
		}

		if ( is_numeric( $value ) ) {
			return (float) $value;
		}

		return (string) $value;
	}

	/**
	 * Polyfill for list array detection (for PHP < 8.1 compatibility).
	 *
	 * @param array<mixed> $value Array to inspect.
	 */
	private function is_list_array( array $value ): bool {
		$expected_key = 0;
		foreach ( array_keys( $value ) as $key ) {
			if ( $key !== $expected_key ) {
				return false;
			}
			++$expected_key;
		}

		return true;
	}

	/**
	 * Compare normalized values for deterministic list sorting.
	 */
	private function compare_normalized_values( mixed $a, mixed $b ): int {
		$encoded_a = wp_json_encode( $a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
		$encoded_b = wp_json_encode( $b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );

		if ( ! is_string( $encoded_a ) ) {
			$encoded_a = (string) $a;
		}
		if ( ! is_string( $encoded_b ) ) {
			$encoded_b = (string) $b;
		}

		return strcmp( $encoded_a, $encoded_b );
	}

	/**
	 * Get the selected metrics for the query.
	 *
	 * @return array Selected metrics.
	 */
	public function get_select(): array {
		return $this->select;
	}

	/**
	 * Get the filters applied to the query.
	 *
	 * @return array Filters array.
	 */
	public function get_filters(): array {
		return $this->filters;
	}

	/**
	 * Get the order_by value for the query.
	 *
	 * @return string[] Order by clause.
	 */
	public function get_order_by_value(): array {
		return $this->order_by;
	}

	/**
	 * Get the date modifiers applied to the query.
	 *
	 * @return array Date modifiers.
	 */
	public function get_date_modifiers(): array {
		return $this->date_modifiers;
	}

	/**
	 * Get the prepared custom WHERE clause
	 *
	 * @return string Prepared custom WHERE clause
	 */
	public function get_prepared_custom_sql( string $custom_sql, string $context ): string {
		global $wpdb;

		if ( empty( $custom_sql ) ) {
			return '';
		}

		$custom_parameters = $context === 'select' ? $this->custom_select_parameters : $this->custom_where_parameters;

		// If no params, return empty (required).
		if ( empty( $custom_parameters ) ) {
			self::error_log( 'QueryData error: Custom SQL clause has no parameters, these are required. Use empty string if not needed. Returning empty custom_' . $context );
			return '';
		}

		if ( ! $this->validate_custom_sql_safety( $custom_sql, $context ) ) {
			self::error_log( "QueryData error: Custom $context clause failed safety validation. Returning empty custom_" . $context );
			return '';
		}

		if ( count( $custom_parameters ) === 1 && $custom_parameters[0] === '' && str_contains( $custom_sql, '%s' ) ) {
			// remove the placeholder %s and return sql as is.
			return str_replace( '%s', '', $custom_sql );
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The custom sql does not contain dynamic properties, any dynamic data is prepared below.
		return $wpdb->prepare( $custom_sql, ...$custom_parameters );
	}

	/**
	 * Check if bounces should be excluded from statistics.
	 *
	 * @return bool True if bounces should be excluded, false otherwise.
	 */
	private function exclude_bounces(): bool {
		if ( ! isset( $this->filters['bounces'] ) ) {
			return false;
		}

		return $this->filters['bounces'] === 'exclude';
	}

	/**
	 * Check if strict mode is enabled
	 *
	 * @return bool True if strict mode is enabled.
	 */
	public function is_strict(): bool {
		return $this->strict;
	}


	/**
	 * Validate custom SELECT/WHERE clauses for safe SQL patterns
	 *
	 * @param string $sql SQL clause to validate.
	 * @param string $context 'select' or 'where' for better error messages.
	 * @return bool True if valid, false if suspicious.
	 */
	private function validate_custom_sql_safety( string $sql, string $context = 'select' ): bool {
		// Remove all whitespace for easier pattern matching.
		$normalized = preg_replace( '/\s+/', ' ', trim( $sql ) );

		// 1. Check for dangerous SQL keywords.
		$dangerous_keywords = [
			'DROP',
			'DELETE',
			'TRUNCATE',
			'ALTER',
			'CREATE',
			'REPLACE',
			'INSERT',
			'UPDATE',
			'EXEC',
			'EXECUTE',
			'UNION',
			'LOAD_FILE',
			'OUTFILE',
			'DUMPFILE',
			'INTO\s+(?:OUT|DUMP)FILE',
			'BENCHMARK',
			'SLEEP',
			'WAITFOR',
			'DELAY',
			'INFORMATION_SCHEMA',
			'LOAD\s+DATA',
			'SHOW\s+TABLES',
			'SHOW\s+DATABASES',
		];

		foreach ( $dangerous_keywords as $keyword ) {
			if ( preg_match( '/\b' . $keyword . '\b/i', $normalized ) ) {
				self::error_log(
					"QueryData error: prohibited keyword '$keyword' detected in custom_$context. Query blocked. " .
					'SQL: ' . substr( $sql, 0, 100 )
				);
				return false;
			}
		}

		// 2. Check for suspicious patterns.
		$suspicious_patterns = [
			// Multiple statements (semicolon followed by more SQL).
			'/;.*\w/',
			// SQL comments with content after them.
			'/--\s*[^\s]/',
			// MySQL comments with content.
			'/#.*[^\s]/',
			// Block comments (can hide malicious code).
			'/\/\*.*\*\//',
			// Hex literals (often used in injection).
			'/0x[0-9a-f]+/i',
			// CHAR() function (can encode malicious strings).
			'/char\s*\(/i',
			// CONCAT often used in injection.
			'/concat\s*\(/i',
			// File reading.
			'/load_file\s*\(/i',
			// File writing.
			'/into\s+(outfile|dumpfile)/i',
		];

		foreach ( $suspicious_patterns as $pattern ) {
			if ( preg_match( $pattern, $normalized ) ) {
				self::error_log(
					"QueryData error: Suspicious pattern detected in custom_$context. Query blocked. " .
					"Pattern: $pattern | SQL: " . substr( $sql, 0, 100 )
				);
				return false;
			}
		}

		// 4. Check for balanced parentheses (prevents injection via unclosed brackets).
		$open  = substr_count( $sql, '(' );
		$close = substr_count( $sql, ')' );
		if ( $open !== $close ) {
			self::error_log(
				"QueryData error: Unbalanced parentheses in custom_$context. Query blocked. " .
				'SQL: ' . substr( $sql, 0, 100 )
			);
			return false;
		}

		// 5. Check for balanced quotes (prevents injection via unclosed strings).
		$single_quotes = substr_count( $sql, "'" ) - substr_count( $sql, "\\'" );
		$double_quotes = substr_count( $sql, '"' ) - substr_count( $sql, '\\"' );

		if ( $single_quotes % 2 !== 0 || $double_quotes % 2 !== 0 ) {
			self::error_log(
				"QueryData error: Unbalanced quotes in custom_$context. Query blocked. " .
				'SQL: ' . substr( $sql, 0, 100 )
			);
			return false;
		}

		return true;
	}
}