<?php
/**
 * Sync replicastore.
 *
 * @package automattic/jetpack-sync
 */

namespace Automattic\Jetpack\Sync;

/**
 * An implementation of Replicastore Interface which returns data stored in a WordPress.org DB.
 * This is useful to compare values in the local WP DB to values in the synced replica store
 */
class Replicastore implements Replicastore_Interface {
	/**
	 * Empty and reset the replicastore.
	 *
	 * @access public
	 */
	public function reset() {
		global $wpdb;

		$wpdb->query( "DELETE FROM $wpdb->posts" );

		// Delete comments from cache.
		$comment_ids = $wpdb->get_col( "SELECT comment_ID FROM $wpdb->comments" );
		if ( ! empty( $comment_ids ) ) {
			clean_comment_cache( $comment_ids );
		}
		$wpdb->query( "DELETE FROM $wpdb->comments" );

		// Also need to delete terms from cache.
		$term_ids = $wpdb->get_col( "SELECT term_id FROM $wpdb->terms" );
		foreach ( $term_ids as $term_id ) {
			wp_cache_delete( $term_id, 'terms' );
		}

		$wpdb->query( "DELETE FROM $wpdb->terms" );

		$wpdb->query( "DELETE FROM $wpdb->term_taxonomy" );
		$wpdb->query( "DELETE FROM $wpdb->term_relationships" );

		// Callables and constants.
		$wpdb->query( "DELETE FROM $wpdb->options WHERE option_name LIKE 'jetpack_%'" );
		$wpdb->query( "DELETE FROM $wpdb->postmeta WHERE meta_key NOT LIKE '\_%'" );
	}

	/**
	 * Ran when full sync has just started.
	 *
	 * @access public
	 *
	 * @param array $config Full sync configuration for this sync module.
	 */
	public function full_sync_start( $config ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		$this->reset();
	}

	/**
	 * Ran when full sync has just finished.
	 *
	 * @access public
	 *
	 * @param string $checksum Deprecated since 7.3.0.
	 */
	public function full_sync_end( $checksum ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		// Noop right now.
	}

	/**
	 * Retrieve the number of terms.
	 *
	 * @access public
	 *
	 * @return int Number of terms.
	 */
	public function term_count() {
		global $wpdb;
		return $wpdb->get_var( "SELECT COUNT(*) FROM $wpdb->terms" );
	}

	/**
	 * Retrieve the number of rows in the `term_taxonomy` table.
	 *
	 * @access public
	 *
	 * @return int Number of terms.
	 */
	public function term_taxonomy_count() {
		global $wpdb;
		return $wpdb->get_var( "SELECT COUNT(*) FROM $wpdb->term_taxonomy" );
	}

	/**
	 * Retrieve the number of term relationships.
	 *
	 * @access public
	 *
	 * @return int Number of rows in the term relationships table.
	 */
	public function term_relationship_count() {
		global $wpdb;
		return $wpdb->get_var( "SELECT COUNT(*) FROM $wpdb->term_relationships" );
	}

	/**
	 * Retrieve the number of posts with a particular post status within a certain range.
	 *
	 * @access public
	 *
	 * @todo Prepare the SQL query before executing it.
	 *
	 * @param string $status Post status.
	 * @param int    $min_id Minimum post ID.
	 * @param int    $max_id Maximum post ID.
	 * @return int Number of posts.
	 */
	public function post_count( $status = null, $min_id = null, $max_id = null ) {
		global $wpdb;

		$where = '';

		if ( $status ) {
			$where = "post_status = '" . esc_sql( $status ) . "'";
		} else {
			$where = '1=1';
		}

		if ( ! empty( $min_id ) ) {
			$where .= ' AND ID >= ' . (int) $min_id;
		}

		if ( ! empty( $max_id ) ) {
			$where .= ' AND ID <= ' . (int) $max_id;
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return $wpdb->get_var( "SELECT COUNT(*) FROM $wpdb->posts WHERE $where" );
	}

	/**
	 * Retrieve the posts with a particular post status.
	 *
	 * @access public
	 *
	 * @todo Implement range and actually use max_id/min_id arguments.
	 *
	 * @param string $status Post status.
	 * @param int    $min_id Minimum post ID.
	 * @param int    $max_id Maximum post ID.
	 * @return array Array of posts.
	 */
	public function get_posts( $status = null, $min_id = null, $max_id = null ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		$args = array(
			'orderby'        => 'ID',
			'posts_per_page' => -1,
		);

		if ( $status ) {
			$args['post_status'] = $status;
		} else {
			$args['post_status'] = 'any';
		}

		return get_posts( $args );
	}

	/**
	 * Retrieve a post object by the post ID.
	 *
	 * @access public
	 *
	 * @param int $id Post ID.
	 * @return \WP_Post Post object.
	 */
	public function get_post( $id ) {
		return get_post( $id );
	}

	/**
	 * Update or insert a post.
	 *
	 * @access public
	 *
	 * @param \WP_Post $post   Post object.
	 * @param bool     $silent Whether to perform a silent action. Not used in this implementation.
	 */
	public function upsert_post( $post, $silent = false ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		global $wpdb;

		// Reject the post if it's not a \WP_Post.
		if ( ! $post instanceof \WP_Post ) {
			return;
		}

		$post = $post->to_array();

		// Reject posts without an ID.
		if ( ! isset( $post['ID'] ) ) {
			return;
		}

		$now     = current_time( 'mysql' );
		$now_gmt = get_gmt_from_date( $now );

		$defaults = array(
			'ID'                    => 0,
			'post_author'           => '0',
			'post_content'          => '',
			'post_content_filtered' => '',
			'post_title'            => '',
			'post_name'             => '',
			'post_excerpt'          => '',
			'post_status'           => 'draft',
			'post_type'             => 'post',
			'comment_status'        => 'closed',
			'comment_count'         => '0',
			'ping_status'           => '',
			'post_password'         => '',
			'to_ping'               => '',
			'pinged'                => '',
			'post_parent'           => 0,
			'menu_order'            => 0,
			'guid'                  => '',
			'post_date'             => $now,
			'post_date_gmt'         => $now_gmt,
			'post_modified'         => $now,
			'post_modified_gmt'     => $now_gmt,
		);

		$post = array_intersect_key( $post, $defaults );

		$post = sanitize_post( $post, 'db' );

		unset( $post['filter'] );

		$exists = $wpdb->get_var( $wpdb->prepare( "SELECT EXISTS( SELECT 1 FROM $wpdb->posts WHERE ID = %d )", $post['ID'] ) );

		if ( $exists ) {
			$wpdb->update( $wpdb->posts, $post, array( 'ID' => $post['ID'] ) );
		} else {
			$wpdb->insert( $wpdb->posts, $post );
		}

		clean_post_cache( $post['ID'] );
	}

	/**
	 * Delete a post by the post ID.
	 *
	 * @access public
	 *
	 * @param int $post_id Post ID.
	 */
	public function delete_post( $post_id ) {
		wp_delete_post( $post_id, true );
	}

	/**
	 * Retrieve the checksum for posts within a range.
	 *
	 * @access public
	 *
	 * @param int $min_id Minimum post ID.
	 * @param int $max_id Maximum post ID.
	 * @return int The checksum.
	 */
	public function posts_checksum( $min_id = null, $max_id = null ) {
		global $wpdb;
		return $this->table_checksum( $wpdb->posts, Defaults::$default_post_checksum_columns, 'ID', Settings::get_blacklisted_post_types_sql(), $min_id, $max_id );
	}

	/**
	 * Retrieve the checksum for post meta within a range.
	 *
	 * @access public
	 *
	 * @param int $min_id Minimum post meta ID.
	 * @param int $max_id Maximum post meta ID.
	 * @return int The checksum.
	 */
	public function post_meta_checksum( $min_id = null, $max_id = null ) {
		global $wpdb;
		return $this->table_checksum( $wpdb->postmeta, Defaults::$default_post_meta_checksum_columns, 'meta_id', Settings::get_whitelisted_post_meta_sql(), $min_id, $max_id );
	}

	/**
	 * Retrieve the number of comments with a particular comment status within a certain range.
	 *
	 * @access public
	 *
	 * @todo Prepare the SQL query before executing it.
	 *
	 * @param string $status Comment status.
	 * @param int    $min_id Minimum comment ID.
	 * @param int    $max_id Maximum comment ID.
	 * @return int Number of comments.
	 */
	public function comment_count( $status = null, $min_id = null, $max_id = null ) {
		global $wpdb;

		$comment_approved = $this->comment_status_to_approval_value( $status );

		if ( false !== $comment_approved ) {
			$where = "comment_approved = '" . esc_sql( $comment_approved ) . "'";
		} else {
			$where = '1=1';
		}

		if ( ! empty( $min_id ) ) {
			$where .= ' AND comment_ID >= ' . (int) $min_id;
		}

		if ( ! empty( $max_id ) ) {
			$where .= ' AND comment_ID <= ' . (int) $max_id;
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return $wpdb->get_var( "SELECT COUNT(*) FROM $wpdb->comments WHERE $where" );
	}

	/**
	 * Translate a comment status to a value of the comment_approved field.
	 *
	 * @access protected
	 *
	 * @param string $status Comment status.
	 * @return string|bool New comment_approved value, false if the status doesn't affect it.
	 */
	protected function comment_status_to_approval_value( $status ) {
		switch ( (string) $status ) {
			case 'approve':
			case '1':
				return '1';
			case 'hold':
			case '0':
				return '0';
			case 'spam':
				return 'spam';
			case 'trash':
				return 'trash';
			case 'post-trashed':
				return 'post-trashed';
			case 'any':
			case 'all':
			default:
				return false;
		}
	}

	/**
	 * Retrieve the comments with a particular comment status.
	 *
	 * @access public
	 *
	 * @todo Implement range and actually use max_id/min_id arguments.
	 *
	 * @param string $status Comment status.
	 * @param int    $min_id Minimum comment ID.
	 * @param int    $max_id Maximum comment ID.
	 * @return array Array of comments.
	 */
	public function get_comments( $status = null, $min_id = null, $max_id = null ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		$args = array(
			'orderby' => 'ID',
			'status'  => 'all',
		);

		if ( $status ) {
			$args['status'] = $status;
		}

		return get_comments( $args );
	}

	/**
	 * Retrieve a comment object by the comment ID.
	 *
	 * @access public
	 *
	 * @param int $id Comment ID.
	 * @return \WP_Comment Comment object.
	 */
	public function get_comment( $id ) {
		return \WP_Comment::get_instance( $id );
	}

	/**
	 * Update or insert a comment.
	 *
	 * @access public
	 *
	 * @param \WP_Comment $comment Comment object.
	 */
	public function upsert_comment( $comment ) {
		global $wpdb;

		$comment = $comment->to_array();

		// Filter by fields on comment table.
		$comment_fields_whitelist = array(
			'comment_ID',
			'comment_post_ID',
			'comment_author',
			'comment_author_email',
			'comment_author_url',
			'comment_author_IP',
			'comment_date',
			'comment_date_gmt',
			'comment_content',
			'comment_karma',
			'comment_approved',
			'comment_agent',
			'comment_type',
			'comment_parent',
			'user_id',
		);

		foreach ( $comment as $key => $value ) {
			if ( ! in_array( $key, $comment_fields_whitelist, true ) ) {
				unset( $comment[ $key ] );
			}
		}

		$exists = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT EXISTS( SELECT 1 FROM $wpdb->comments WHERE comment_ID = %d )",
				$comment['comment_ID']
			)
		);

		if ( $exists ) {
			$wpdb->update( $wpdb->comments, $comment, array( 'comment_ID' => $comment['comment_ID'] ) );
		} else {
			$wpdb->insert( $wpdb->comments, $comment );
		}
		// Remove comment from cache.
		clean_comment_cache( $comment['comment_ID'] );

		wp_update_comment_count( $comment['comment_post_ID'] );
	}

	/**
	 * Trash a comment by the comment ID.
	 *
	 * @access public
	 *
	 * @param int $comment_id Comment ID.
	 */
	public function trash_comment( $comment_id ) {
		wp_delete_comment( $comment_id );
	}

	/**
	 * Delete a comment by the comment ID.
	 *
	 * @access public
	 *
	 * @param int $comment_id Comment ID.
	 */
	public function delete_comment( $comment_id ) {
		wp_delete_comment( $comment_id, true );
	}

	/**
	 * Mark a comment by the comment ID as spam.
	 *
	 * @access public
	 *
	 * @param int $comment_id Comment ID.
	 */
	public function spam_comment( $comment_id ) {
		wp_spam_comment( $comment_id );
	}

	/**
	 * Trash the comments of a post.
	 *
	 * @access public
	 *
	 * @param int   $post_id  Post ID.
	 * @param array $statuses Post statuses. Not used in this implementation.
	 */
	public function trashed_post_comments( $post_id, $statuses ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		wp_trash_post_comments( $post_id );
	}

	/**
	 * Untrash the comments of a post.
	 *
	 * @access public
	 *
	 * @param int $post_id Post ID.
	 */
	public function untrashed_post_comments( $post_id ) {
		wp_untrash_post_comments( $post_id );
	}

	/**
	 * Retrieve the checksum for comments within a range.
	 *
	 * @access public
	 *
	 * @param int $min_id Minimum comment ID.
	 * @param int $max_id Maximum comment ID.
	 * @return int The checksum.
	 */
	public function comments_checksum( $min_id = null, $max_id = null ) {
		global $wpdb;
		return $this->table_checksum( $wpdb->comments, Defaults::$default_comment_checksum_columns, 'comment_ID', Settings::get_comments_filter_sql(), $min_id, $max_id );
	}

	/**
	 * Retrieve the checksum for comment meta within a range.
	 *
	 * @access public
	 *
	 * @param int $min_id Minimum comment meta ID.
	 * @param int $max_id Maximum comment meta ID.
	 * @return int The checksum.
	 */
	public function comment_meta_checksum( $min_id = null, $max_id = null ) {
		global $wpdb;
		return $this->table_checksum( $wpdb->commentmeta, Defaults::$default_comment_meta_checksum_columns, 'meta_id', Settings::get_whitelisted_comment_meta_sql(), $min_id, $max_id );
	}

	/**
	 * Retrieve the checksum for all options.
	 *
	 * @access public
	 *
	 * @return int The checksum.
	 */
	public function options_checksum() {
		global $wpdb;
		$options_whitelist = "'" . implode( "', '", Defaults::$default_options_whitelist ) . "'";
		$where_sql         = "option_name IN ( $options_whitelist )";

		return $this->table_checksum( $wpdb->options, Defaults::$default_option_checksum_columns, null, $where_sql, null, null );
	}

	/**
	 * Update the value of an option.
	 *
	 * @access public
	 *
	 * @param string $option Option name.
	 * @param mixed  $value  Option value.
	 * @return bool False if value was not updated and true if value was updated.
	 */
	public function update_option( $option, $value ) {
		return update_option( $option, $value );
	}

	/**
	 * Retrieve an option value based on an option name.
	 *
	 * @access public
	 *
	 * @param string $option  Name of option to retrieve.
	 * @param mixed  $default Optional. Default value to return if the option does not exist.
	 * @return mixed Value set for the option.
	 */
	public function get_option( $option, $default = false ) {
		return get_option( $option, $default );
	}

	/**
	 * Remove an option by name.
	 *
	 * @access public
	 *
	 * @param string $option Name of option to remove.
	 * @return bool True, if option is successfully deleted. False on failure.
	 */
	public function delete_option( $option ) {
		return delete_option( $option );
	}

	/**
	 * Change the info of the current theme.
	 *
	 * @access public
	 *
	 * @param array $theme_info Theme info array.
	 */
	public function set_theme_info( $theme_info ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		// Noop.
	}

	/**
	 * Whether the current theme supports a certain feature.
	 *
	 * @access public
	 *
	 * @param string $feature Name of the feature.
	 */
	public function current_theme_supports( $feature ) {
		return current_theme_supports( $feature );
	}

	/**
	 * Retrieve metadata for the specified object.
	 *
	 * @access public
	 *
	 * @param string $type       Meta type.
	 * @param int    $object_id  ID of the object.
	 * @param string $meta_key   Meta key.
	 * @param bool   $single     If true, return only the first value of the specified meta_key.
	 *
	 * @return mixed Single metadata value, or array of values.
	 */
	public function get_metadata( $type, $object_id, $meta_key = '', $single = false ) {
		return get_metadata( $type, $object_id, $meta_key, $single );
	}

	/**
	 * Stores remote meta key/values alongside an ID mapping key.
	 *
	 * @access public
	 *
	 * @todo Refactor to not use interpolated values when preparing the SQL query.
	 *
	 * @param string $type       Meta type.
	 * @param int    $object_id  ID of the object.
	 * @param string $meta_key   Meta key.
	 * @param mixed  $meta_value Meta value.
	 * @param int    $meta_id    ID of the meta.
	 *
	 * @return bool False if meta table does not exist, true otherwise.
	 */
	public function upsert_metadata( $type, $object_id, $meta_key, $meta_value, $meta_id ) {
		$table = _get_meta_table( $type );
		if ( ! $table ) {
			return false;
		}

		global $wpdb;

		$exists = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT EXISTS( SELECT 1 FROM $table WHERE meta_id = %d )",
				$meta_id
			)
		);

		if ( $exists ) {
			$wpdb->update(
				$table,
				array(
					'meta_key'   => $meta_key,
					'meta_value' => maybe_serialize( $meta_value ),
				),
				array( 'meta_id' => $meta_id )
			);
		} else {
			$object_id_field = $type . '_id';
			$wpdb->insert(
				$table,
				array(
					'meta_id'        => $meta_id,
					$object_id_field => $object_id,
					'meta_key'       => $meta_key,
					'meta_value'     => maybe_serialize( $meta_value ),
				)
			);
		}

		wp_cache_delete( $object_id, $type . '_meta' );

		return true;
	}

	/**
	 * Delete metadata for the specified object.
	 *
	 * @access public
	 *
	 * @todo Refactor to not use interpolated values when preparing the SQL query.
	 *
	 * @param string $type      Meta type.
	 * @param int    $object_id ID of the object.
	 * @param array  $meta_ids  IDs of the meta objects to delete.
	 */
	public function delete_metadata( $type, $object_id, $meta_ids ) {
		global $wpdb;

		$table = _get_meta_table( $type );
		if ( ! $table ) {
			return false;
		}

		foreach ( $meta_ids as $meta_id ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$wpdb->query( $wpdb->prepare( "DELETE FROM $table WHERE meta_id = %d", $meta_id ) );
		}

		// If we don't have an object ID what do we do - invalidate ALL meta?
		if ( $object_id ) {
			wp_cache_delete( $object_id, $type . '_meta' );
		}
	}

	/**
	 * Delete metadata with a certain key for the specified objects.
	 *
	 * @access public
	 *
	 * @todo Test this out to make sure it works as expected.
	 * @todo Refactor to not use interpolated values when preparing the SQL query.
	 *
	 * @param string $type       Meta type.
	 * @param array  $object_ids IDs of the objects.
	 * @param string $meta_key   Meta key.
	 */
	public function delete_batch_metadata( $type, $object_ids, $meta_key ) {
		global $wpdb;

		$table = _get_meta_table( $type );
		if ( ! $table ) {
			return false;
		}
		$column = sanitize_key( $type . '_id' );
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$wpdb->query( $wpdb->prepare( "DELETE FROM $table WHERE $column IN (%s) && meta_key = %s", implode( ',', $object_ids ), $meta_key ) );

		// If we don't have an object ID what do we do - invalidate ALL meta?
		foreach ( $object_ids as $object_id ) {
			wp_cache_delete( $object_id, $type . '_meta' );
		}
	}

	/**
	 * Retrieve value of a constant based on the constant name.
	 *
	 * We explicitly return null instead of false if the constant doesn't exist.
	 *
	 * @access public
	 *
	 * @param string $constant Name of constant to retrieve.
	 * @return mixed Value set for the constant.
	 */
	public function get_constant( $constant ) {
		$value = get_option( 'jetpack_constant_' . $constant );

		if ( $value ) {
			return $value;
		}

		return null;
	}

	/**
	 * Set the value of a constant.
	 *
	 * @access public
	 *
	 * @param string $constant Name of constant to retrieve.
	 * @param mixed  $value    Value set for the constant.
	 */
	public function set_constant( $constant, $value ) {
		update_option( 'jetpack_constant_' . $constant, $value );
	}

	/**
	 * Retrieve the number of the available updates of a certain type.
	 * Type is one of: `plugins`, `themes`, `wordpress`, `translations`, `total`, `wp_update_version`.
	 *
	 * @access public
	 *
	 * @param string $type Type of updates to retrieve.
	 * @return int|null Number of updates available, `null` if type is invalid or missing.
	 */
	public function get_updates( $type ) {
		$all_updates = get_option( 'jetpack_updates', array() );

		if ( isset( $all_updates[ $type ] ) ) {
			return $all_updates[ $type ];
		} else {
			return null;
		}
	}

	/**
	 * Set the available updates of a certain type.
	 * Type is one of: `plugins`, `themes`, `wordpress`, `translations`, `total`, `wp_update_version`.
	 *
	 * @access public
	 *
	 * @param string $type    Type of updates to set.
	 * @param int    $updates Total number of updates.
	 */
	public function set_updates( $type, $updates ) {
		$all_updates          = get_option( 'jetpack_updates', array() );
		$all_updates[ $type ] = $updates;
		update_option( 'jetpack_updates', $all_updates );
	}

	/**
	 * Retrieve a callable value based on its name.
	 *
	 * @access public
	 *
	 * @param string $name Name of the callable to retrieve.
	 * @return mixed Value of the callable.
	 */
	public function get_callable( $name ) {
		$value = get_option( 'jetpack_' . $name );

		if ( $value ) {
			return $value;
		}

		return null;
	}

	/**
	 * Update the value of a callable.
	 *
	 * @access public
	 *
	 * @param string $name  Callable name.
	 * @param mixed  $value Callable value.
	 */
	public function set_callable( $name, $value ) {
		update_option( 'jetpack_' . $name, $value );
	}

	/**
	 * Retrieve a network option value based on a network option name.
	 *
	 * @access public
	 *
	 * @param string $option Name of network option to retrieve.
	 * @return mixed Value set for the network option.
	 */
	public function get_site_option( $option ) {
		return get_option( 'jetpack_network_' . $option );
	}

	/**
	 * Update the value of a network option.
	 *
	 * @access public
	 *
	 * @param string $option Network option name.
	 * @param mixed  $value  Network option value.
	 * @return bool False if value was not updated and true if value was updated.
	 */
	public function update_site_option( $option, $value ) {
		return update_option( 'jetpack_network_' . $option, $value );
	}

	/**
	 * Remove a network option by name.
	 *
	 * @access public
	 *
	 * @param string $option Name of option to remove.
	 * @return bool True, if option is successfully deleted. False on failure.
	 */
	public function delete_site_option( $option ) {
		return delete_option( 'jetpack_network_' . $option );
	}

	/**
	 * Retrieve the terms from a particular taxonomy.
	 *
	 * @access public
	 *
	 * @param string $taxonomy Taxonomy slug.
	 * @return array|\WP_Error Array of terms or WP_Error object on failure.
	 */
	public function get_terms( $taxonomy ) {
		$t = $this->ensure_taxonomy( $taxonomy );
		if ( ! $t || is_wp_error( $t ) ) {
			return $t;
		}
		return get_terms( $taxonomy );
	}

	/**
	 * Retrieve a particular term.
	 *
	 * @access public
	 *
	 * @param string $taxonomy   Taxonomy slug.
	 * @param int    $term_id    ID of the term.
	 * @param string $term_key   ID Field `term_id` or `term_taxonomy_id`.
	 * @return \WP_Term|\WP_Error Term object on success, \WP_Error object on failure.
	 */
	public function get_term( $taxonomy, $term_id, $term_key = 'term_id' ) {

		// Full Sync will pass false for the $taxonomy so a check for term_taxonomy_id is needed before ensure_taxonomy.
		if ( 'term_taxonomy_id' === $term_key ) {
			return get_term_by( 'term_taxonomy_id', $term_id );
		}

		$t = $this->ensure_taxonomy( $taxonomy );
		if ( ! $t || is_wp_error( $t ) ) {
			return $t;
		}

		return get_term( $term_id, $taxonomy );
	}

	/**
	 * Verify a taxonomy is legitimate and register it if necessary.
	 *
	 * @access private
	 *
	 * @param string $taxonomy Taxonomy slug.
	 * @return bool|void|\WP_Error True if already exists; void if it was registered; \WP_Error on error.
	 */
	private function ensure_taxonomy( $taxonomy ) {
		if ( ! taxonomy_exists( $taxonomy ) ) {
			// Try re-registering synced taxonomies.
			$taxonomies = $this->get_callable( 'taxonomies' );
			if ( ! isset( $taxonomies[ $taxonomy ] ) ) {
				// Doesn't exist, or somehow hasn't been synced.
				return new \WP_Error( 'invalid_taxonomy', "The taxonomy '$taxonomy' doesn't exist" );
			}
			$t = $taxonomies[ $taxonomy ];

			return register_taxonomy(
				$taxonomy,
				$t->object_type,
				(array) $t
			);
		}

		return true;
	}

	/**
	 * Retrieve all terms from a taxonomy that are related to an object with a particular ID.
	 *
	 * @access public
	 *
	 * @param int    $object_id Object ID.
	 * @param string $taxonomy  Taxonomy slug.
	 * @return array|bool|\WP_Error Array of terms on success, `false` if no terms or post doesn't exist, \WP_Error on failure.
	 */
	public function get_the_terms( $object_id, $taxonomy ) {
		return get_the_terms( $object_id, $taxonomy );
	}

	/**
	 * Insert or update a term.
	 *
	 * @access public
	 *
	 * @param \WP_Term $term_object Term object.
	 * @return array|bool|\WP_Error Array of term_id and term_taxonomy_id if updated, true if inserted, \WP_Error on failure.
	 */
	public function update_term( $term_object ) {
		$taxonomy = $term_object->taxonomy;
		global $wpdb;
		$exists = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT EXISTS( SELECT 1 FROM $wpdb->terms WHERE term_id = %d )",
				$term_object->term_id
			)
		);
		if ( ! $exists ) {
			$term_object   = sanitize_term( clone $term_object, $taxonomy, 'db' );
			$term          = array(
				'term_id'    => $term_object->term_id,
				'name'       => $term_object->name,
				'slug'       => $term_object->slug,
				'term_group' => $term_object->term_group,
			);
			$term_taxonomy = array(
				'term_taxonomy_id' => $term_object->term_taxonomy_id,
				'term_id'          => $term_object->term_id,
				'taxonomy'         => $term_object->taxonomy,
				'description'      => $term_object->description,
				'parent'           => (int) $term_object->parent,
				'count'            => (int) $term_object->count,
			);
			$wpdb->insert( $wpdb->terms, $term );
			$wpdb->insert( $wpdb->term_taxonomy, $term_taxonomy );

			return true;
		}

		return wp_update_term( $term_object->term_id, $taxonomy, (array) $term_object );
	}

	/**
	 * Delete a term by the term ID and its corresponding taxonomy.
	 *
	 * @access public
	 *
	 * @param int    $term_id  Term ID.
	 * @param string $taxonomy Taxonomy slug.
	 * @return bool|int|\WP_Error True on success, false if term doesn't exist. Zero if trying with default category. \WP_Error on invalid taxonomy.
	 */
	public function delete_term( $term_id, $taxonomy ) {
		$this->ensure_taxonomy( $taxonomy );
		return wp_delete_term( $term_id, $taxonomy );
	}

	/**
	 * Add/update terms of a particular taxonomy of an object with the specified ID.
	 *
	 * @access public
	 *
	 * @param int              $object_id The object to relate to.
	 * @param string           $taxonomy  The context in which to relate the term to the object.
	 * @param string|int|array $terms     A single term slug, single term id, or array of either term slugs or ids.
	 * @param bool             $append    Optional. If false will delete difference of terms. Default false.
	 */
	public function update_object_terms( $object_id, $taxonomy, $terms, $append ) {
		$this->ensure_taxonomy( $taxonomy );
		wp_set_object_terms( $object_id, $terms, $taxonomy, $append );
	}

	/**
	 * Remove certain term relationships from the specified object.
	 *
	 * @access public
	 *
	 * @todo Refactor to not use interpolated values when preparing the SQL query.
	 *
	 * @param int   $object_id ID of the object.
	 * @param array $tt_ids    Term taxonomy IDs.
	 * @return bool True on success, false on failure.
	 */
	public function delete_object_terms( $object_id, $tt_ids ) {
		global $wpdb;

		if ( is_array( $tt_ids ) && ! empty( $tt_ids ) ) {
			// Escape.
			$tt_ids_sanitized = array_map( 'intval', $tt_ids );

			$taxonomies = array();
			foreach ( $tt_ids_sanitized as $tt_id ) {
				$term                            = get_term_by( 'term_taxonomy_id', $tt_id );
				$taxonomies[ $term->taxonomy ][] = $tt_id;
			}
			$in_tt_ids = implode( ', ', $tt_ids_sanitized );

			/**
			 * Fires immediately before an object-term relationship is deleted.
			 *
			 * @since 2.9.0
			 *
			 * @param int   $object_id Object ID.
			 * @param array $tt_ids    An array of term taxonomy IDs.
			 */
			do_action( 'delete_term_relationships', $object_id, $tt_ids_sanitized );
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->term_relationships WHERE object_id = %d AND term_taxonomy_id IN ($in_tt_ids)", $object_id ) );
			foreach ( $taxonomies as $taxonomy => $taxonomy_tt_ids ) {
				$this->ensure_taxonomy( $taxonomy );
				wp_cache_delete( $object_id, $taxonomy . '_relationships' );
				/**
				 * Fires immediately after an object-term relationship is deleted.
				 *
				 * @since 2.9.0
				 *
				 * @param int   $object_id Object ID.
				 * @param array $tt_ids    An array of term taxonomy IDs.
				 */
				do_action( 'deleted_term_relationships', $object_id, $taxonomy_tt_ids );
				wp_update_term_count( $taxonomy_tt_ids, $taxonomy );
			}

			return (bool) $deleted;
		}

		return false;
	}

	/**
	 * Retrieve the number of users.
	 * Not supported in this replicastore.
	 *
	 * @access public
	 */
	public function user_count() {
		// Noop.
	}

	/**
	 * Retrieve a user object by the user ID.
	 *
	 * @access public
	 *
	 * @param int $user_id User ID.
	 * @return \WP_User User object.
	 */
	public function get_user( $user_id ) {
		return \WP_User::get_instance( $user_id );
	}

	/**
	 * Insert or update a user.
	 * Not supported in this replicastore.
	 *
	 * @access public
	 * @throws \Exception If this method is invoked.
	 *
	 * @param \WP_User $user User object.
	 */
	public function upsert_user( $user ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		$this->invalid_call();
	}

	/**
	 * Delete a user.
	 * Not supported in this replicastore.
	 *
	 * @access public
	 * @throws \Exception If this method is invoked.
	 *
	 * @param int $user_id User ID.
	 */
	public function delete_user( $user_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		$this->invalid_call();
	}

	/**
	 * Update/insert user locale.
	 * Not supported in this replicastore.
	 *
	 * @access public
	 * @throws \Exception If this method is invoked.
	 *
	 * @param int    $user_id User ID.
	 * @param string $local   The user locale.
	 */
	public function upsert_user_locale( $user_id, $local ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		$this->invalid_call();
	}

	/**
	 * Delete user locale.
	 * Not supported in this replicastore.
	 *
	 * @access public
	 * @throws \Exception If this method is invoked.
	 *
	 * @param int $user_id User ID.
	 */
	public function delete_user_locale( $user_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		$this->invalid_call();
	}

	/**
	 * Retrieve the user locale.
	 *
	 * @access public
	 *
	 * @param int $user_id User ID.
	 * @return string The user locale.
	 */
	public function get_user_locale( $user_id ) {
		return get_user_locale( $user_id );
	}

	/**
	 * Retrieve the allowed mime types for the user.
	 * Not supported in this replicastore.
	 *
	 * @access public
	 *
	 * @param int $user_id User ID.
	 */
	public function get_allowed_mime_types( $user_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		// Noop.
	}

	/**
	 * Retrieve all the checksums we are interested in.
	 * Currently that is posts, comments, post meta and comment meta.
	 *
	 * @access public
	 *
	 * @return array Checksums.
	 */
	public function checksum_all() {
		$post_meta_checksum    = $this->checksum_histogram( 'post_meta', 1 );
		$comment_meta_checksum = $this->checksum_histogram( 'comment_meta', 1 );

		return array(
			'posts'        => $this->posts_checksum(),
			'comments'     => $this->comments_checksum(),
			'post_meta'    => reset( $post_meta_checksum ),
			'comment_meta' => reset( $comment_meta_checksum ),
		);
	}

	/**
	 * Retrieve the columns that are needed to calculate a checksum for an object type.
	 *
	 * @access public
	 *
	 * @todo Refactor to not use interpolated values and prepare the SQL query.
	 *
	 * @param string $object_type Object type.
	 * @return array|bool Columns, or false if invalid object type is specified.
	 */
	public function get_checksum_columns_for_object_type( $object_type ) {
		switch ( $object_type ) {
			case 'posts':
				return Defaults::$default_post_checksum_columns;
			case 'post_meta':
				return Defaults::$default_post_meta_checksum_columns;
			case 'comments':
				return Defaults::$default_comment_checksum_columns;
			case 'comment_meta':
				return Defaults::$default_post_meta_checksum_columns;
			case 'terms':
				return Defaults::$default_term_checksum_columns;
			case 'term_taxonomy':
				return Defaults::$default_term_taxonomy_checksum_columns;
			case 'term_relationships':
				return Defaults::$default_term_relationships_checksum_columns;
			default:
				return false;
		}
	}

	/**
	 * Grabs the minimum and maximum object ids for the given parameters.
	 *
	 * @access public
	 *
	 * @param string $id_field     The id column in the table to query.
	 * @param string $object_table The table to query.
	 * @param string $where        A sql where clause without 'WHERE'.
	 * @param int    $bucket_size  The maximum amount of objects to include in the query.
	 *                             For `term_relationships` table, the bucket size will refer to the amount
	 *                             of distinct object ids. This will likely include more database rows than
	 *                             the bucket size implies.
	 *
	 * @return object An object with min_id and max_id properties.
	 */
	public function get_min_max_object_id( $id_field, $object_table, $where, $bucket_size ) {
		global $wpdb;

		// The term relationship table's unique key is a combination of 2 columns. `DISTINCT` helps us get a more acurate query.
		$distinct_sql = ( $wpdb->term_relationships === $object_table ) ? 'DISTINCT' : '';
		$where_sql    = $where ? "WHERE $where" : '';

		// Since MIN() and MAX() do not work with LIMIT, we'll need to adjust the dataset we query if a limit is present.
		// With a limit present, we'll look at a dataset consisting of object_ids that meet the constructs of the $where clause.
		// Without a limit, we can use the actual table as a dataset.
		$from = $bucket_size ?
			"( SELECT $distinct_sql $id_field FROM $object_table $where_sql ORDER BY $id_field ASC LIMIT $bucket_size ) as ids" :
			"$object_table $where_sql ORDER BY $id_field ASC";

		return $wpdb->get_row(
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			"SELECT MIN($id_field) as min, MAX($id_field) as max FROM $from"
		);
	}

	/**
	 * Retrieve the checksum histogram for a specific object type.
	 *
	 * @access public
	 *
	 * @todo Refactor to not use interpolated values and properly prepare the SQL query.
	 *
	 * @param string $object_type     Object type.
	 * @param int    $buckets         Number of buckets to split the objects to.
	 * @param int    $start_id        Minimum object ID.
	 * @param int    $end_id          Maximum object ID.
	 * @param array  $columns         Table columns to calculate the checksum from.
	 * @param bool   $strip_non_ascii Whether to strip non-ASCII characters.
	 * @param string $salt            Salt, used for $wpdb->prepare()'s args.
	 * @return array The checksum histogram.
	 */
	public function checksum_histogram( $object_type, $buckets, $start_id = null, $end_id = null, $columns = null, $strip_non_ascii = true, $salt = '' ) {
		global $wpdb;

		$wpdb->queries = array();

		if ( empty( $columns ) ) {
			$columns = $this->get_checksum_columns_for_object_type( $object_type );
		}

		switch ( $object_type ) {
			case 'posts':
				$object_count = $this->post_count( null, $start_id, $end_id );
				$object_table = $wpdb->posts;
				$id_field     = 'ID';
				$where_sql    = Settings::get_blacklisted_post_types_sql();
				break;
			case 'post_meta':
				$object_table = $wpdb->postmeta;
				$where_sql    = Settings::get_whitelisted_post_meta_sql();
				$object_count = $this->meta_count( $object_table, $where_sql, $start_id, $end_id );
				$id_field     = 'meta_id';
				break;
			case 'comments':
				$object_count = $this->comment_count( null, $start_id, $end_id );
				$object_table = $wpdb->comments;
				$id_field     = 'comment_ID';
				$where_sql    = Settings::get_comments_filter_sql();
				break;
			case 'comment_meta':
				$object_table = $wpdb->commentmeta;
				$where_sql    = Settings::get_whitelisted_comment_meta_sql();
				$object_count = $this->meta_count( $object_table, $where_sql, $start_id, $end_id );
				$id_field     = 'meta_id';
				break;
			case 'terms':
				$object_table = $wpdb->terms;
				$object_count = $this->term_count();
				$id_field     = 'term_id';
				$where_sql    = '1=1';
				break;
			case 'term_taxonomy':
				$object_table = $wpdb->term_taxonomy;
				$object_count = $this->term_taxonomy_count();
				$id_field     = 'term_taxonomy_id';
				$where_sql    = '1=1';
				break;
			case 'term_relationships':
				$object_table = $wpdb->term_relationships;
				$object_count = $this->term_relationship_count();
				$id_field     = 'object_id';
				$where_sql    = '1=1';
				break;
			default:
				return false;
		}

		$bucket_size     = (int) ceil( $object_count / $buckets );
		$previous_max_id = 0;
		$histogram       = array();

		// This is used for the min / max query, while $where_sql is used for the checksum query.
		$where = $where_sql;

		if ( $start_id ) {
			$where .= " AND $id_field >= " . (int) $start_id;
		}

		if ( $end_id ) {
			$where .= " AND $id_field <= " . (int) $end_id;
		}

		do {
			$result = $this->get_min_max_object_id(
				$id_field,
				$object_table,
				$where . " AND $id_field > $previous_max_id",
				$bucket_size
			);

			if ( null === $result->min || null === $result->max ) {
				// Nothing to checksum here...
				break;
			}

			// Get the checksum value.
			$value = $this->table_checksum( $object_table, $columns, $id_field, $where_sql, $result->min, $result->max, $strip_non_ascii, $salt );

			if ( is_wp_error( $value ) ) {
				return $value;
			}

			if ( null === $result->min || null === $result->max ) {
				break;
			} elseif ( $result->min === $result->max ) {
				$histogram[ $result->min ] = $value;
			} else {
				$histogram[ "{$result->min}-{$result->max}" ] = $value;
			}

			$previous_max_id = $result->max;
		} while ( true );

		return $histogram;
	}

	/**
	 * Retrieve the checksum for a specific database table.
	 *
	 * @access private
	 *
	 * @todo Refactor to properly prepare the SQL query.
	 *
	 * @param string $table           Table name.
	 * @param array  $columns         Table columns to calculate the checksum from.
	 * @param int    $id_column       Name of the unique ID column.
	 * @param string $where_sql       Additional WHERE clause SQL.
	 * @param int    $min_id          Minimum object ID.
	 * @param int    $max_id          Maximum object ID.
	 * @param bool   $strip_non_ascii Whether to strip non-ASCII characters.
	 * @param string $salt            Salt, used for $wpdb->prepare()'s args.
	 * @return int|\WP_Error The table histogram, or \WP_Error on failure.
	 */
	private function table_checksum( $table, $columns, $id_column, $where_sql = '1=1', $min_id = null, $max_id = null, $strip_non_ascii = true, $salt = '' ) {
		global $wpdb;

		// Sanitize to just valid MySQL column names.
		$sanitized_columns = preg_grep( '/^[0-9,a-z,A-Z$_]+$/i', $columns );

		if ( $strip_non_ascii ) {
			$columns_sql = implode( ',', array_map( array( $this, 'strip_non_ascii_sql' ), $sanitized_columns ) );
		} else {
			$columns_sql = implode( ',', $sanitized_columns );
		}

		if ( null !== $min_id && null !== $max_id ) {
			if ( $min_id === $max_id ) {
				$min_id     = (int) $min_id;
				$where_sql .= " AND $id_column = $min_id LIMIT 1";
			} else {
				$min_id     = (int) $min_id;
				$max_id     = (int) $max_id;
				$size       = $max_id - $min_id;
				$where_sql .= " AND $id_column >= $min_id AND $id_column <= $max_id LIMIT $size";
			}
		} else {
			if ( null !== $min_id ) {
				$min_id     = (int) $min_id;
				$where_sql .= " AND $id_column >= $min_id";
			}

			if ( null !== $max_id ) {
				$max_id     = (int) $max_id;
				$where_sql .= " AND $id_column <= $max_id";
			}
		}

		$query = <<<ENDSQL
			SELECT CAST( SUM( CRC32( CONCAT_WS( '#', '%s', {$columns_sql} ) ) ) AS UNSIGNED INT )
			FROM $table
			WHERE $where_sql;
ENDSQL;
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$result = $wpdb->get_var( $wpdb->prepare( $query, $salt ) );
		if ( $wpdb->last_error ) {
			return new \WP_Error( 'database_error', $wpdb->last_error );
		}

		return $result;
	}

	/**
	 * Retrieve the type of the checksum.
	 *
	 * @access public
	 *
	 * @return string Type of the checksum.
	 */
	public function get_checksum_type() {
		return 'sum';
	}

	/**
	 * Count the meta values in a table, within a specified range.
	 *
	 * @access private
	 *
	 * @todo Refactor to not use interpolated values when preparing the SQL query.
	 *
	 * @param string $table     Table name.
	 * @param string $where_sql Additional WHERE SQL.
	 * @param int    $min_id    Minimum meta ID.
	 * @param int    $max_id    Maximum meta ID.
	 * @return int Number of meta values.
	 */
	private function meta_count( $table, $where_sql, $min_id, $max_id ) {
		global $wpdb;

		if ( ! empty( $min_id ) ) {
			$where_sql .= ' AND meta_id >= ' . (int) $min_id;
		}

		if ( ! empty( $max_id ) ) {
			$where_sql .= ' AND meta_id <= ' . (int) $max_id;
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return $wpdb->get_var( "SELECT COUNT(*) FROM $table WHERE $where_sql" );
	}

	/**
	 * Wraps a column name in SQL which strips non-ASCII chars.
	 * This helps normalize data to avoid checksum differences caused by
	 * badly encoded data in the DB.
	 *
	 * @param string $column_name Name of the column.
	 * @return string Column name, without the non-ASCII chars.
	 */
	public function strip_non_ascii_sql( $column_name ) {
		return "REPLACE( CONVERT( $column_name USING ascii ), '?', '' )";
	}

	/**
	 * Used in methods that are not implemented and shouldn't be invoked.
	 *
	 * @access private
	 * @throws \Exception If this method is invoked.
	 */
	private function invalid_call() {
		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
		$backtrace = debug_backtrace();
		$caller    = $backtrace[1]['function'];
		throw new \Exception( "This function $caller is not supported on the WP Replicastore" );
	}
}
