<?php
/**
 * Table Checksums Class.
 *
 * @package automattic/jetpack-sync
 */

namespace Automattic\Jetpack\Sync\Replicastore;

use Automattic\Jetpack\Sync;
use Automattic\Jetpack\Sync\Modules\WooCommerce_HPOS_Orders;
use Exception;
use WP_Error;

// TODO add rest endpoints to work with this, hopefully in the same folder.
/**
 * Class to handle Table Checksums.
 */
class Table_Checksum {

	/**
	 * Table to be checksummed.
	 *
	 * @var string
	 */
	public $table = '';

	/**
	 * Table Checksum Configuration.
	 *
	 * @var array
	 */
	public $table_configuration = array();

	/**
	 * Perform Text Conversion to latin1.
	 *
	 * @var boolean
	 */
	protected $perform_text_conversion = false;

	/**
	 * Field to be used for range queries.
	 *
	 * @var string
	 */
	public $range_field = '';

	/**
	 * ID Field(s) to be used.
	 *
	 * @var array
	 */
	public $key_fields = array();

	/**
	 * Field(s) to be used in generating the checksum value.
	 *
	 * @var array
	 */
	public $checksum_fields = array();

	/**
	 * Field(s) to be used in generating the checksum value that need latin1 conversion.
	 *
	 * @var array
	 */
	public $checksum_text_fields = array();

	/**
	 * Default filter values for the table
	 *
	 * @var array
	 */
	public $filter_values = array();

	/**
	 * SQL Query to be used to filter results (allow/disallow).
	 *
	 * @var string
	 */
	public $additional_filter_sql = '';

	/**
	 * Default Checksum Table Configurations.
	 *
	 * @var array
	 */
	public $default_tables = array();

	/**
	 * Salt to be used when generating checksum.
	 *
	 * @var string
	 */
	public $salt = '';

	/**
	 * Tables which are allowed to be checksummed.
	 *
	 * @var string
	 */
	public $allowed_tables = array();

	/**
	 * If the table has a "parent" table that it's related to.
	 *
	 * @var mixed|null
	 */
	protected $parent_table = null;

	/**
	 * What field to use for the parent table join, if it has a "parent" table.
	 *
	 * @var mixed|null
	 */
	protected $parent_join_field = null;

	/**
	 * What field to use for the table join, if it has a "parent" table.
	 *
	 * @var mixed|null
	 */
	protected $table_join_field = null;

	/**
	 * Some tables might not exist on the remote, and we want to verify they exist, before trying to query them.
	 *
	 * @var callable
	 */
	protected $is_table_enabled_callback = false;

	/**
	 * Table_Checksum constructor.
	 *
	 * @param string  $table                   The table to calculate checksums for.
	 * @param string  $salt                    Optional salt to add to the checksum.
	 * @param boolean $perform_text_conversion If text fields should be latin1 converted.
	 * @param array   $additional_columns      Additional columns to add to the checksum calculation.
	 *
	 * @throws Exception Throws exception from inner functions.
	 */
	public function __construct( $table, $salt = null, $perform_text_conversion = false, $additional_columns = null ) {

		if ( ! Sync\Settings::is_checksum_enabled() ) {
			throw new Exception( 'Checksums are currently disabled.' );
		}

		$this->salt = $salt;

		$this->default_tables = static::get_default_tables();

		$this->perform_text_conversion = $perform_text_conversion;

		// TODO change filters to allow the array format.
		// TODO add get_fields or similar method to get things out of the table.
		// TODO extract this configuration in a better way, still make it work with `$wpdb` names.
		// TODO take over the replicastore functions and move them over to this class.
		// TODO make the API work.

		$this->allowed_tables = apply_filters( 'jetpack_sync_checksum_allowed_tables', $this->default_tables );

		$this->table               = $this->validate_table_name( $table );
		$this->table_configuration = $this->allowed_tables[ $table ];

		$this->prepare_fields( $this->table_configuration );

		$this->prepare_additional_columns( $additional_columns );

		// Run any callbacks to check if a table is enabled or not.
		if (
			is_callable( $this->is_table_enabled_callback )
			&& ! call_user_func( $this->is_table_enabled_callback, $table )
		) {
			throw new Exception( "Unable to use table name: $table" );
		}
	}

	/**
	 * Get Default Table configurations.
	 *
	 * @return array
	 */
	protected static function get_default_tables() {
		global $wpdb;

		return array(
			'posts'                      => array(
				'table'                     => $wpdb->posts,
				'range_field'               => 'ID',
				'key_fields'                => array( 'ID' ),
				'checksum_fields'           => array( 'post_modified_gmt' ),
				'filter_values'             => Sync\Settings::get_disallowed_post_types_structured(),
				'is_table_enabled_callback' => function () {
					return false !== Sync\Modules::get_module( 'posts' );
				},
			),
			'postmeta'                   => array(
				'table'                     => $wpdb->postmeta,
				'range_field'               => 'post_id',
				'key_fields'                => array( 'post_id', 'meta_key' ),
				'checksum_text_fields'      => array( 'meta_key', 'meta_value' ),
				'filter_values'             => Sync\Settings::get_allowed_post_meta_structured(),
				'parent_table'              => 'posts',
				'parent_join_field'         => 'ID',
				'table_join_field'          => 'post_id',
				'is_table_enabled_callback' => function () {
					return false !== Sync\Modules::get_module( 'posts' );
				},
			),
			'comments'                   => array(
				'table'                     => $wpdb->comments,
				'range_field'               => 'comment_ID',
				'key_fields'                => array( 'comment_ID' ),
				'checksum_fields'           => array( 'comment_date_gmt' ),
				'filter_values'             => array_merge(
					Sync\Settings::get_allowed_comment_types_structured(),
					array(
						'comment_approved' => array(
							'operator' => 'NOT IN',
							'values'   => array( 'spam' ),
						),
					)
				),
				'is_table_enabled_callback' => function () {
					return false !== Sync\Modules::get_module( 'comments' );
				},
			),
			'commentmeta'                => array(
				'table'                     => $wpdb->commentmeta,
				'range_field'               => 'comment_id',
				'key_fields'                => array( 'comment_id', 'meta_key' ),
				'checksum_text_fields'      => array( 'meta_key', 'meta_value' ),
				'filter_values'             => Sync\Settings::get_allowed_comment_meta_structured(),
				'parent_table'              => 'comments',
				'parent_join_field'         => 'comment_ID',
				'table_join_field'          => 'comment_id',
				'is_table_enabled_callback' => function () {
					return false !== Sync\Modules::get_module( 'comments' );
				},
			),
			'terms'                      => array(
				'table'                     => $wpdb->terms,
				'range_field'               => 'term_id',
				'key_fields'                => array( 'term_id' ),
				'checksum_fields'           => array( 'term_id' ),
				'checksum_text_fields'      => array( 'name', 'slug' ),
				'parent_table'              => 'term_taxonomy',
				'is_table_enabled_callback' => function () {
					return false !== Sync\Modules::get_module( 'terms' );
				},
			),
			'termmeta'                   => array(
				'table'                     => $wpdb->termmeta,
				'range_field'               => 'term_id',
				'key_fields'                => array( 'term_id', 'meta_key' ),
				'checksum_text_fields'      => array( 'meta_key', 'meta_value' ),
				'parent_table'              => 'term_taxonomy',
				'is_table_enabled_callback' => function () {
					return false !== Sync\Modules::get_module( 'terms' );
				},
			),
			'term_relationships'         => array(
				'table'                     => $wpdb->term_relationships,
				'range_field'               => 'object_id',
				'key_fields'                => array( 'object_id' ),
				'checksum_fields'           => array( 'object_id', 'term_taxonomy_id' ),
				'parent_table'              => 'term_taxonomy',
				'parent_join_field'         => 'term_taxonomy_id',
				'table_join_field'          => 'term_taxonomy_id',
				'is_table_enabled_callback' => function () {
					return false !== Sync\Modules::get_module( 'terms' );
				},
			),
			'term_taxonomy'              => array(
				'table'                     => $wpdb->term_taxonomy,
				'range_field'               => 'term_taxonomy_id',
				'key_fields'                => array( 'term_taxonomy_id' ),
				'checksum_fields'           => array( 'term_taxonomy_id', 'term_id', 'parent' ),
				'checksum_text_fields'      => array( 'taxonomy', 'description' ),
				'filter_values'             => Sync\Settings::get_allowed_taxonomies_structured(),
				'is_table_enabled_callback' => function () {
					return false !== Sync\Modules::get_module( 'terms' );
				},
			),
			'links'                      => $wpdb->links, // TODO describe in the array format or add exceptions.
			'options'                    => $wpdb->options, // TODO describe in the array format or add exceptions.
			'wc_product_lookup'          => array( // wc_product_lookup is a table in the cache database
				'table'                     => $wpdb->posts,
				'range_field'               => 'ID',
				'key_fields'                => array( 'ID' ),
				'checksum_fields'           => array( 'post_modified_gmt' ),
				'filter_values'             => array(
					'post_type' => array(
						'operator' => 'IN',
						'values'   => array( 'product', 'product_variation' ),
					),
				),
				'is_table_enabled_callback' => function () {
					return false !== Sync\Modules::get_module( 'woocommerce_products' );
				},
			),
			'woocommerce_order_items'    => array(
				'table'                     => "{$wpdb->prefix}woocommerce_order_items",
				'range_field'               => 'order_item_id',
				'key_fields'                => array( 'order_item_id' ),
				'checksum_fields'           => array( 'order_id' ),
				'checksum_text_fields'      => array( 'order_item_name', 'order_item_type' ),
				'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_tables',
			),
			'woocommerce_order_itemmeta' => array(
				'table'                     => "{$wpdb->prefix}woocommerce_order_itemmeta",
				'range_field'               => 'order_item_id',
				'key_fields'                => array( 'order_item_id', 'meta_key' ),
				'checksum_text_fields'      => array( 'meta_key', 'meta_value' ),
				'filter_values'             => Sync\Settings::get_allowed_order_itemmeta_structured(),
				'parent_table'              => 'woocommerce_order_items',
				'parent_join_field'         => 'order_item_id',
				'table_join_field'          => 'order_item_id',
				'is_table_enabled_callback' => function () {
					return false !== Sync\Modules::get_module( 'meta' ) && self::enable_woocommerce_tables();
				},
			),
			'wc_orders'                  => array(
				'table'                     => "{$wpdb->prefix}wc_orders",
				'range_field'               => 'id',
				'key_fields'                => array( 'id' ),
				'checksum_fields'           => array( 'date_updated_gmt', 'total_amount' ),
				'checksum_text_fields'      => array( 'type', 'status' ),
				'filter_values'             => array(
					'type'   => array(
						'operator' => 'IN',
						'values'   => WooCommerce_HPOS_Orders::get_order_types_to_sync( true ),
					),
					'status' => array(
						'operator' => 'IN',
						'values'   => WooCommerce_HPOS_Orders::get_all_possible_order_status_keys(),
					),
				),
				'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_hpos_tables',
			),
			'wc_order_addresses'         => array(
				'table'                     => "{$wpdb->prefix}wc_order_addresses",
				'range_field'               => 'order_id',
				'key_fields'                => array( 'order_id', 'address_type' ),
				'checksum_text_fields'      => array( 'address_type' ),
				'parent_table'              => 'wc_orders',
				'parent_join_field'         => 'id',
				'table_join_field'          => 'order_id',
				'filter_values'             => array(),
				'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_hpos_tables',
			),
			'wc_order_operational_data'  => array(
				'table'                     => "{$wpdb->prefix}wc_order_operational_data",
				'range_field'               => 'order_id',
				'key_fields'                => array( 'order_id' ),
				'checksum_fields'           => array( 'date_paid_gmt', 'date_completed_gmt' ),
				'checksum_text_fields'      => array( 'order_key' ),
				'parent_table'              => 'wc_orders',
				'parent_join_field'         => 'id',
				'table_join_field'          => 'order_id',
				'filter_values'             => array(),
				'is_table_enabled_callback' => 'Automattic\Jetpack\Sync\Replicastore\Table_Checksum::enable_woocommerce_hpos_tables',
			),
			'users'                      => array(
				'table'                     => $wpdb->users,
				'range_field'               => 'ID',
				'key_fields'                => array( 'ID' ),
				'checksum_text_fields'      => array( 'user_login', 'user_nicename', 'user_email', 'user_url', 'user_registered', 'user_status', 'display_name' ),
				'filter_values'             => array(),
				'is_table_enabled_callback' => function () {
					return false !== Sync\Modules::get_module( 'users' );
				},
			),

			/**
			 * Usermeta is a special table, as it needs to use a custom override flow,
			 * as the user roles, capabilities, locale, mime types can be filtered by plugins.
			 * This prevents us from doing a direct comparison in the database.
			 */
			'usermeta'                   => array(
				'table'                     => $wpdb->users,
				/**
				 * Range field points to ID, which in this case is the `WP_User` ID,
				 * since we're querying the whole WP_User objects, instead of meta entries in the DB.
				 */
				'range_field'               => 'ID',
				'key_fields'                => array(),
				'checksum_fields'           => array(),
				'is_table_enabled_callback' => function () {
					return false !== Sync\Modules::get_module( 'users' );
				},
			),
		);
	}

	/**
	 * Get allowed table configurations.
	 *
	 * @return array
	 */
	public static function get_allowed_tables() {
		return apply_filters( 'jetpack_sync_checksum_allowed_tables', static::get_default_tables() );
	}

	/**
	 * Prepare field params based off provided configuration.
	 *
	 * @param array $table_configuration The table configuration array.
	 */
	protected function prepare_fields( $table_configuration ) {
		$this->key_fields                = $table_configuration['key_fields'];
		$this->range_field               = $table_configuration['range_field'];
		$this->checksum_fields           = $table_configuration['checksum_fields'] ?? array();
		$this->checksum_text_fields      = $table_configuration['checksum_text_fields'] ?? array();
		$this->filter_values             = $table_configuration['filter_values'] ?? null;
		$this->additional_filter_sql     = ! empty( $table_configuration['filter_sql'] ) ? $table_configuration['filter_sql'] : '';
		$this->parent_table              = $table_configuration['parent_table'] ?? null;
		$this->parent_join_field         = $table_configuration['parent_join_field'] ?? $table_configuration['range_field'];
		$this->table_join_field          = $table_configuration['table_join_field'] ?? $table_configuration['range_field'];
		$this->is_table_enabled_callback = $table_configuration['is_table_enabled_callback'] ?? false;
	}

	/**
	 * Verify provided table name is valid for checksum processing.
	 *
	 * @param string $table Table name to validate.
	 *
	 * @return mixed|string
	 * @throws Exception Throw an exception on validation failure.
	 */
	protected function validate_table_name( $table ) {
		if ( empty( $table ) ) {
			throw new Exception( 'Invalid table name: empty' );
		}

		if ( ! array_key_exists( $table, $this->allowed_tables ) ) {
			throw new Exception( "Invalid table name: $table not allowed" );
		}

		return $this->allowed_tables[ $table ]['table'];
	}

	/**
	 * Verify provided fields are proper names.
	 *
	 * @param array $fields Array of field names to validate.
	 *
	 * @throws Exception Throw an exception on failure to validate.
	 */
	protected function validate_fields( $fields ) {
		foreach ( $fields as $field ) {
			if ( ! preg_match( '/^[0-9,a-z,A-Z$_]+$/i', $field ) ) {
				throw new Exception( "Invalid field name: $field is not allowed" );
			}

			// TODO other verifications of the field names.
		}
	}

	/**
	 * Verify the fields exist in the table.
	 *
	 * @param array $fields Array of fields to validate.
	 *
	 * @return bool
	 * @throws Exception Throw an exception on failure to validate.
	 */
	protected function validate_fields_against_table( $fields ) {
		global $wpdb;

		$valid_fields = array();

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$result = $wpdb->get_results( "SHOW COLUMNS FROM {$this->table}", ARRAY_A );

		foreach ( $result as $result_row ) {
			$valid_fields[] = $result_row['Field'];
		}

		// Check if the fields are actually contained in the table.
		foreach ( $fields as $field_to_check ) {
			if ( ! in_array( $field_to_check, $valid_fields, true ) ) {
				throw new Exception( "Invalid field name: field '{$field_to_check}' doesn't exist in table {$this->table}" );
			}
		}

		return true;
	}

	/**
	 * Verify the configured fields.
	 *
	 * @throws Exception Throw an exception on failure to validate in the internal functions.
	 */
	protected function validate_input() {
		$fields = array_merge( array( $this->range_field ), $this->key_fields, $this->checksum_fields, $this->checksum_text_fields );

		$this->validate_fields( $fields );
		$this->validate_fields_against_table( $fields );
	}

	/**
	 * Prepare filter values as SQL statements to be added to the other filters.
	 *
	 * @param array  $filter_values The filter values array.
	 * @param string $table_prefix  If the values are going to be used in a sub-query, add a prefix with the table alias.
	 *
	 * @return array|null
	 */
	protected function prepare_filter_values_as_sql( $filter_values = array(), $table_prefix = '' ) {
		global $wpdb;

		if ( ! is_array( $filter_values ) ) {
			return null;
		}

		$result = array();

		foreach ( $filter_values as $field => $filter ) {
			$key = ( ! empty( $table_prefix ) ? $table_prefix : $this->table ) . '.' . $field;

			switch ( $filter['operator'] ) {
				case 'IN':
				case 'NOT IN':
					$filter_values_count = is_countable( $filter['values'] ) ? count( $filter['values'] ) : 0;
					$values_placeholders = implode( ',', array_fill( 0, $filter_values_count, '%s' ) );
					$statement           = "{$key} {$filter['operator']} ( $values_placeholders )";

					// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
					$prepared_statement = $wpdb->prepare( $statement, $filter['values'] );

					$result[] = $prepared_statement;
					break;
			}
		}

		return $result;
	}

	/**
	 * Build the filter query baased off range fields and values and the additional sql.
	 *
	 * @param int|null   $range_from    Start of the range.
	 * @param int|null   $range_to      End of the range.
	 * @param array|null $filter_values Additional filter values. Not used at the moment.
	 * @param string     $table_prefix  Table name to be prefixed to the columns. Used in sub-queries where columns can clash.
	 *
	 * @return string
	 */
	public function build_filter_statement( $range_from = null, $range_to = null, $filter_values = null, $table_prefix = '' ) {
		global $wpdb;

		// If there is a field prefix that we want to use with table aliases.
		$parent_prefix = ( ! empty( $table_prefix ) ? $table_prefix : $this->table ) . '.';

		/**
		 * Prepare the ranges.
		 */

		$filter_array = array( '1 = 1' );
		if ( null !== $range_from ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$filter_array[] = $wpdb->prepare( "{$parent_prefix}{$this->range_field} >= %d", array( intval( $range_from ) ) );
		}
		if ( null !== $range_to ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$filter_array[] = $wpdb->prepare( "{$parent_prefix}{$this->range_field} <= %d", array( intval( $range_to ) ) );
		}

		/**
		 * End prepare the ranges.
		 */

		/**
		 * Prepare data filters.
		 */

		// Default filters.
		if ( $this->filter_values ) {
			$prepared_values_statements = $this->prepare_filter_values_as_sql( $this->filter_values, $table_prefix );
			if ( $prepared_values_statements ) {
				$filter_array = array_merge( $filter_array, $prepared_values_statements );
			}
		}

		// Additional filters.
		if ( ! empty( $filter_values ) ) {
			// Prepare filtering.
			$prepared_values_statements = $this->prepare_filter_values_as_sql( $filter_values, $table_prefix );
			if ( $prepared_values_statements ) {
				$filter_array = array_merge( $filter_array, $prepared_values_statements );
			}
		}

		// Add any additional filters via direct SQL statement.
		// Currently used only because we haven't converted all filtering to happen via `filter_values`.
		// This SQL is NOT prefixed and column clashes can occur when used in sub-queries.
		if ( $this->additional_filter_sql ) {
			$filter_array[] = $this->additional_filter_sql;
		}

		/**
		 * End prepare data filters.
		 */
		return implode( ' AND ', $filter_array );
	}

	/**
	 * Returns the checksum query. All validation of fields and configurations are expected to occur prior to usage.
	 *
	 * @param int|null   $range_from      The start of the range.
	 * @param int|null   $range_to        The end of the range.
	 * @param array|null $filter_values   Additional filter values. Not used at the moment.
	 * @param bool       $granular_result If the function should return a granular result.
	 *
	 * @return string
	 *
	 * @throws Exception Throws an exception if validation fails in the internal function calls.
	 */
	protected function build_checksum_query( $range_from = null, $range_to = null, $filter_values = null, $granular_result = false ) {
		global $wpdb;

		// Escape the salt.
		$salt = $wpdb->prepare( '%s', $this->salt );

		// Prepare the compound key.
		$key_fields = array();

		// Prefix the fields with the table name, to avoid clashes in queries with sub-queries (e.g. meta tables).
		foreach ( $this->key_fields as $field ) {
			$key_fields[] = $this->table . '.' . $field;
		}

		$key_fields = implode( ',', $key_fields );

		// Prepare the checksum fields.
		$checksum_fields = array();
		// Prefix the fields with the table name, to avoid clashes in queries with sub-queries (e.g. meta tables).
		foreach ( $this->checksum_fields as $field ) {
			$checksum_fields[] = $this->table . '.' . $field;
		}
		// Apply latin1 conversion if enabled.
		if ( $this->perform_text_conversion ) {
			// Convert text fields to allow for encoding discrepancies as WP.com is latin1.
			foreach ( $this->checksum_text_fields as $field ) {
				$checksum_fields[] = 'CONVERT(' . $this->table . '.' . $field . ' using latin1 )';
			}
		} else {
			// Conversion disabled, default to table prefixing.
			foreach ( $this->checksum_text_fields as $field ) {
				$checksum_fields[] = $this->table . '.' . $field;
			}
		}

		$checksum_fields_string = implode( ',', array_merge( $checksum_fields, array( $salt ) ) );

		$additional_fields = '';
		if ( $granular_result ) {
			// TODO uniq the fields as sometimes(most) range_index is the key and there's no need to select the same field twice.
			$additional_fields = "
				{$this->table}.{$this->range_field} as range_index,
			    {$key_fields},
			";
		}

		$filter_stamenet = $this->build_filter_statement( $range_from, $range_to, $filter_values );

		$join_statement = '';
		// On WPCOM the checksum comparison does not use the parent table INNER JOIN.
		// WPCOM sets parent_table in its config solely for the count optimization in
		// get_range_edges(), so we skip the JOIN to avoid query differences.
		if ( $this->parent_table && ! ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) {
			$parent_table_obj    = new Table_Checksum( $this->parent_table );
			$parent_filter_query = $parent_table_obj->build_filter_statement( null, null, null, 'parent_table' );

			// It is possible to have the GROUP By cause multiple rows to be returned for the same row for term_taxonomy.
			// To get distinct entries we use a correlatd subquery back on the parent table using the primary key.
			$additional_unique_clause = '';
			if ( 'term_taxonomy' === $this->parent_table ) {
				$additional_unique_clause = "
				AND parent_table.{$parent_table_obj->range_field} = (
				SELECT min( parent_table_cs.{$parent_table_obj->range_field} )
			            FROM {$parent_table_obj->table} as parent_table_cs
			            WHERE parent_table_cs.{$this->parent_join_field} = {$this->table}.{$this->table_join_field}
			        )
				";
			}

			$join_statement = "
			    INNER JOIN {$parent_table_obj->table} as parent_table
			    ON (
			        {$this->table}.{$this->table_join_field} = parent_table.{$this->parent_join_field}
			        AND {$parent_filter_query}
			        $additional_unique_clause
			    )
			";
		}

		$query = "
			SELECT
				{$additional_fields}
				SUM(
					CRC32(
						CONCAT_WS( '#', {$salt}, {$checksum_fields_string} )
					)
				)  AS checksum
			 FROM
			    {$this->table}
				{$join_statement}
			 WHERE
				{$filter_stamenet}
		";

		/**
		 * We need the GROUP BY only for compound keys.
		 */
		if ( $granular_result ) {
			$query .= "
				GROUP BY {$key_fields}
				LIMIT 9999999
			";
		}

		return $query;
	}

	/**
	 * Obtain the min-max values (edges) of the range.
	 *
	 * @param int|null $range_from The start of the range.
	 * @param int|null $range_to   The end of the range.
	 * @param int|null $limit      How many values to return.
	 *
	 * @return array|object|void
	 * @throws Exception Throws an exception if validation fails on the internal function calls.
	 */
	public function get_range_edges( $range_from = null, $range_to = null, $limit = null ) {
		global $wpdb;

		$this->validate_fields( array( $this->range_field ) );

		// Performance :: For meta tables (postmeta, commentmeta, termmeta, woocommerce_order_itemmeta)
		// we strip the filter_values (e.g. meta_key whitelist) when building the range edges query.
		// These filters cause non-performant queries that can timeout on large tables.
		// The actual data filtering happens during checksum calculation — via the filter_values
		// WHERE clause and, when enabled, the parent table INNER JOIN.
		$is_meta_table = in_array(
			$this->table,
			array( $wpdb->postmeta, $wpdb->commentmeta, $wpdb->termmeta, "{$wpdb->prefix}woocommerce_order_itemmeta" ),
			true
		);
		$filter_values = $this->filter_values;
		if ( $is_meta_table ) {
			$this->filter_values = null;
		}

		// `trim()` to make sure we don't add the statement if it's empty.
		$filters = trim( $this->build_filter_statement( $range_from, $range_to ) );

		// Restore filter values.
		if ( $is_meta_table ) {
			$this->filter_values = $filter_values;
		}

		$filter_statement = '';
		if ( ! empty( $filters ) ) {
			$filter_statement = "
				WHERE
					{$filters}
			";
		}

		// Only make the distinct count when we know there can be multiple entries for the range column.
		$distinct_count = '';
		if ( count( $this->key_fields ) > 1 || $wpdb->terms === $this->table || $wpdb->term_relationships === $this->table ) {
			$distinct_count = 'DISTINCT';
		}

		$query = "
			SELECT
			       MIN({$this->range_field}) as min_range,
			       MAX({$this->range_field}) as max_range,
			       COUNT( {$distinct_count} {$this->range_field}) as item_count
			FROM
		";

		/**
		 * If `$limit` is not specified, we can directly use the table.
		 */
		if ( ! $limit ) {
			// For tables that would use COUNT(DISTINCT), avoid the expensive full table scan
			// by using the parent table's count instead. Only for full-table calls — sub-range
			// calls need the actual COUNT(DISTINCT) scoped to the range, and those are cheap
			// because the WHERE clause limits the scan.
			if ( $distinct_count && null === $range_from && null === $range_to ) {
				$parent_count = $this->get_parent_table_count();
				if ( (int) $parent_count > 0 ) {
					$min_max_query = "
						SELECT
							MIN({$this->range_field}) as min_range,
							MAX({$this->range_field}) as max_range
						FROM
							{$this->table}
							{$filter_statement}
					";

					// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
					$result = $wpdb->get_row( $min_max_query, ARRAY_A );

					if ( $result && is_array( $result ) ) {
						$result['item_count']                    = $parent_count;
						self::$range_edges_cache[ $this->table ] = $result;
						return $result;
					}
				}
			}

			$query .= "
				{$this->table}
	            {$filter_statement}
			";
		} else {
			/**
			 * If there is `$limit` specified, we can't directly use `MIN/MAX()` as they don't work with `LIMIT`.
			 * That's why we will alter the query for this case.
			 */
			$limit = intval( $limit );

			$query .= "
				(
					SELECT
						{$distinct_count} {$this->range_field}
					FROM
						{$this->table}
						{$filter_statement}
					ORDER BY
						{$this->range_field} ASC
					LIMIT {$limit}
				) as ids_query
			";
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$result = $wpdb->get_row( $query, ARRAY_A );

		if ( ! $result || ! is_array( $result ) ) {
			throw new Exception( 'Unable to get range edges' );
		}

		// Cache full-range results so child meta tables can reuse the parent's count.
		// Only cache when no range constraints — sub-range counts would pollute the cache.
		if ( ! $limit && null === $range_from && null === $range_to ) {
			self::$range_edges_cache[ $this->table ] = $result;
		}

		return $result;
	}

	/**
	 * Static cache for range edge results, keyed by table name.
	 *
	 * When checksum_all() processes tables sequentially, the parent table's
	 * get_range_edges() result is cached so child tables can reuse the
	 * item_count without re-querying.
	 *
	 * @var array
	 */
	private static $range_edges_cache = array();

	/**
	 * Reset the static range edges cache.
	 *
	 * Should be called when the underlying data changes and cached
	 * counts may be stale (e.g. between test runs).
	 */
	public static function reset_range_edges_cache() {
		self::$range_edges_cache = array();
	}

	/**
	 * Get the row count from the parent table as an approximate item count.
	 *
	 * For tables with compound keys or non-unique range fields, COUNT(DISTINCT range_field)
	 * causes expensive full table scans. Since item_count is only used for bucket sizing
	 * in checksum_histogram(), the parent table's row count is an acceptable approximation.
	 * In typical cases the parent count >= the distinct child count, producing slightly
	 * more (smaller) buckets. The caller guards against a zero parent count (e.g. orphaned
	 * child rows) by falling back to the original COUNT(DISTINCT) query.
	 *
	 * Returns false when the parent table's count is not a reliable proxy (e.g.
	 * term_taxonomy, whose count does not correlate with distinct range_field values
	 * in terms, termmeta, or term_relationships).
	 *
	 * Uses a static cache so that if the parent table was already processed
	 * (e.g. posts before postmeta in checksum_all), no additional query is needed.
	 *
	 * @return int|false The parent table row count, or false if not applicable.
	 */
	private function get_parent_table_count() {
		if ( ! $this->parent_table ) {
			return false;
		}

		// term_taxonomy's count is not a reliable proxy for the distinct range_field
		// values in terms, termmeta, or term_relationships.
		if ( 'term_taxonomy' === $this->parent_table ) {
			return false;
		}

		try {
			$parent_table_obj = new Table_Checksum( $this->parent_table );
		} catch ( Exception $e ) {
			return false;
		}

		// Check static cache first — the parent may have been queried already
		// (e.g. posts processed before postmeta in checksum_all).
		if ( isset( self::$range_edges_cache[ $parent_table_obj->table ] ) ) {
			return (int) self::$range_edges_cache[ $parent_table_obj->table ]['item_count'];
		}

		// Query the parent table's range edges. For single-key parent tables this is
		// a simple COUNT (no DISTINCT), so it's fast.
		try {
			$parent_range = $parent_table_obj->get_range_edges();

			if ( is_array( $parent_range ) && isset( $parent_range['item_count'] ) ) {
				return (int) $parent_range['item_count'];
			}

			return false;
		} catch ( Exception $e ) {
			return false;
		}
	}

	/**
	 * Update the results to have key/checksum format.
	 *
	 * @param array $results Prepare the results for output of granular results.
	 */
	protected function prepare_results_for_output( &$results ) {
		// get the compound key.
		// only return range and compound key for granular results.

		$return_value = array();

		foreach ( $results as &$result ) {
			// Working on reference to save memory here.

			$key = array();
			foreach ( $this->key_fields as $field ) {
				$key[] = $result[ $field ];
			}

			$return_value[ implode( '-', $key ) ] = $result['checksum'];
		}

		return $return_value;
	}

	/**
	 * Calculate the checksum based on provided range and filters.
	 *
	 * @param int|null   $range_from          The start of the range.
	 * @param int|null   $range_to            The end of the range.
	 * @param array|null $filter_values       Additional filter values. Not used at the moment.
	 * @param bool       $granular_result     If the returned result should be granular or only the checksum.
	 * @param bool       $simple_return_value If we want to use a simple return value for non-granular results (return only the checksum, without wrappers).
	 *
	 * @return array|mixed|object|WP_Error|null
	 */
	public function calculate_checksum( $range_from = null, $range_to = null, $filter_values = null, $granular_result = false, $simple_return_value = true ) {

		if ( ! Sync\Settings::is_checksum_enabled() ) {
			return new WP_Error( 'checksum_disabled', 'Checksums are currently disabled.' );
		}

		try {
			$this->validate_input();
		} catch ( Exception $ex ) {
			return new WP_Error( 'invalid_input', $ex->getMessage() );
		}

		$query = $this->build_checksum_query( $range_from, $range_to, $filter_values, $granular_result );

		global $wpdb;

		if ( ! $granular_result ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$result = $wpdb->get_row( $query, ARRAY_A );

			if ( ! is_array( $result ) ) {
				return new WP_Error( 'invalid_query', "Result wasn't an array" );
			}

			if ( $simple_return_value ) {
				return $result['checksum'];
			}

			return array(
				'range'    => $range_from . '-' . $range_to,
				'checksum' => $result['checksum'],
			);
		} else {
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$result = $wpdb->get_results( $query, ARRAY_A );
			return $this->prepare_results_for_output( $result );
		}
	}

	/**
	 * Make sure the WooCommerce tables should be enabled for Checksum/Fix.
	 *
	 * @return bool
	 */
	public static function enable_woocommerce_tables() {
		/**
		 * On WordPress.com, we can't directly check if the site has support for WooCommerce.
		 * Having the option to override the functionality here helps with syncing WooCommerce tables.
		 *
		 * @since 10.1
		 *
		 * @param bool If we should we force-enable WooCommerce tables support.
		 */
		$force_woocommerce_support = apply_filters( 'jetpack_table_checksum_force_enable_woocommerce', false );

		// If we're forcing WooCommerce tables support, there's no need to check further.
		// This is used on WordPress.com.
		if ( $force_woocommerce_support ) {
			return true;
		}

		// If the 'woocommerce' module is enabled, this means that WooCommerce class exists.
		return false !== Sync\Modules::get_module( 'woocommerce' );
	}

	/**
	 * Make sure the WooCommerce HPOS tables should be enabled for Checksum/Fix.
	 *
	 * @see Automattic\Jetpack\SyncActions::initialize_woocommerce
	 *
	 * @since 3.3.0
	 *
	 * @return bool
	 */
	public static function enable_woocommerce_hpos_tables() {
		/**
		 * On WordPress.com, we can't directly check if the site has support for WooCommerce HPOS tables.
		 * Having the option to override the functionality here helps with syncing WooCommerce HPOS tables.
		 *
		 * @since 3.3.0
		 *
		 * @param bool If we should we force-enable WooCommerce HPOS tables support.
		 */
		$force_woocommerce_hpos_support = apply_filters( 'jetpack_table_checksum_force_enable_woocommerce_hpos', false );

		// If we're forcing WooCommerce HPOS tables support, there's no need to check further.
		// This is used on WordPress.com.
		if ( $force_woocommerce_hpos_support ) {
			return true;
		}

		// If the 'woocommerce_hpos_orders' module is enabled, this means that WooCommerce class exists
		// and HPOS is enabled too.
		return false !== Sync\Modules::get_module( 'woocommerce_hpos_orders' );
	}

	/**
	 * Prepare and append custom columns to the list of columns that we run the checksum on.
	 *
	 * @param string|array $additional_columns List of additional columns.
	 *
	 * @return void
	 * @throws Exception When field validation fails.
	 */
	protected function prepare_additional_columns( $additional_columns ) {
		/**
		 * No need to do anything if the parameter is not provided or empty.
		 */
		if ( empty( $additional_columns ) ) {
			return;
		}

		if ( ! is_array( $additional_columns ) ) {
			if ( ! is_string( $additional_columns ) ) {
				throw new Exception( 'Invalid value for additional fields' );
			}

			$additional_columns = explode( ',', $additional_columns );
		}

		/**
		 * Validate the fields. If any don't conform to the required norms, we will throw an exception and
		 * halt code here.
		 */
		$this->validate_fields( $additional_columns );

		/**
		 * Assign the fields to the checksum_fields to be used in the checksum later.
		 *
		 * We're adding the fields to the rest of the `checksum_fields`, so we don't need
		 * to implement extra logic just for the additional fields.
		 */
		$this->checksum_fields = array_unique(
			array_merge(
				$this->checksum_fields,
				$additional_columns
			)
		);
	}
}
