<?php

namespace DeliciousBrains\WPMDB\Common\Migration;

use DeliciousBrains\WPMDB\Common\BackupExport;
use DeliciousBrains\WPMDB\Common\Debug;
use DeliciousBrains\WPMDB\Common\Error\ErrorLog;
use DeliciousBrains\WPMDB\Common\Error\HandleRemotePostError;
use DeliciousBrains\WPMDB\Common\Filesystem\Filesystem;
use DeliciousBrains\WPMDB\Common\FormData\FormData;
use DeliciousBrains\WPMDB\Common\FullSite\FullSiteExport;
use DeliciousBrains\WPMDB\Common\Http\Helper;
use DeliciousBrains\WPMDB\Common\Http\Http;
use DeliciousBrains\WPMDB\Common\Http\RemotePost;
use DeliciousBrains\WPMDB\Common\Http\WPMDBRestAPIServer;
use DeliciousBrains\WPMDB\Common\MigrationPersistence\Persistence;
use DeliciousBrains\WPMDB\Common\MigrationState\MigrationState;
use DeliciousBrains\WPMDB\Common\MigrationState\MigrationStateManager;
use DeliciousBrains\WPMDB\Common\Multisite\Multisite;
use DeliciousBrains\WPMDB\Common\Plugin\PluginManagerBase;
use DeliciousBrains\WPMDB\Common\Properties\DynamicProperties;
use DeliciousBrains\WPMDB\Common\Properties\Properties;
use DeliciousBrains\WPMDB\Common\Queue\Jobs\Backup_Table_Job;
use DeliciousBrains\WPMDB\Common\Queue\Jobs\Table_Job;
use DeliciousBrains\WPMDB\Common\Queue\Manager;
use DeliciousBrains\WPMDB\Common\Retry\RetryTrait;
use DeliciousBrains\WPMDB\Common\Sql\Table;
use DeliciousBrains\WPMDB\Common\Sql\TableHelper;
use DeliciousBrains\WPMDB\Common\Util\Util;
use DeliciousBrains\WPMDB\Data\Stage;
use WP_Error;
use WP_Site_Health;

/**
 * @phpstan-import-type StageName from Stage
 */
class MigrationManager {
	use RetryTrait;

	// Constants required by RetryTrait.
	const RETRY_ERROR_COUNT_PROPERTY       = 'last_retry_database_error_count';
	const RETRY_ERROR_COUNT_TOTAL_PROPERTY = 'retry_database_error_count_total';
	const RETRY_IN_PROGRESS_PROPERTY       = 'retry_database_in_progress';
	const RETRY_COUNT_LIMIT                = 5;

	/**
	 * A list of error string partials that we're willing to retry for.
	 *
	 * The key is used for stats.
	 *
	 * @var string[]
	 */
	private $db_retry_errors = [
		'db_connection_refused'           => 'mysqli_real_connect(): (HY000/2002): Connection refused',
		'db_no_such_file'                 => 'mysqli_real_connect(): (HY000/2002): No such file or directory',
		'db_failed_to_retrieve_structure' => 'Failed to retrieve table structure for table',
		'maximum_execution_time'          => 'Maximum execution time',
	];

	/**
	 * @var array
	 */
	public $state_data;

	/**
	 * @var FormData
	 */
	public $form_data;

	/**
	 * @var DynamicProperties
	 */
	public $dynamic_props;

	/**
	 * @var MigrationStateManager
	 */
	protected $migration_state_manager;

	/**
	 * @var Table
	 */
	protected $table;

	/**
	 * @var Http
	 */
	protected $http;

	/**
	 * @var Properties
	 */
	protected $props;

	/**
	 * @var TableHelper
	 */
	protected $table_helper;

	/**
	 * @var Helper
	 */
	protected $http_helper;

	/**
	 * @var Util
	 */
	protected $util;

	/**
	 * @var RemotePost
	 */
	protected $remote_post;

	/**
	 * @var Filesystem
	 */
	protected $filesystem;

	protected $fp;

	/**
	 * @var ErrorLog
	 */
	protected $error_log;

	/**
	 * @var MigrationState
	 */
	protected $migration_state;

	/**
	 * @var BackupExport
	 */
	protected $backup_export;

	/**
	 * @var Multisite
	 */
	protected $multisite;

	/**
	 * @var InitiateMigration
	 */
	protected $initiate_migration;

	/**
	 * @var FinalizeMigration
	 */
	protected $finalize_migration;

	/**
	 * @var mixed $form_data_arr
	 */
	private $form_data_arr;

	/**
	 * @var WPMDBRestAPIServer
	 */
	private $rest_API_server;

	/**
	 * @var array|string
	 */
	private $migration_options;

	/**
	 * @var MigrationHelper
	 */
	private $migration_helper;

	/**
	 * @var Flush
	 */
	private $flush;

	/**
	 * @var FullSiteExport
	 */
	private $full_site_export;

	/**
	 * @var Manager
	 */
	private $queue_manager;

	public function __construct(
		MigrationStateManager $migration_state_manager,
		MigrationState $migration_state,
		Table $table,
		Http $http,
		TableHelper $table_helper,
		Helper $http_helper,
		Util $util,
		RemotePost $remote_post,
		FormData $form_data,
		Filesystem $filesystem,
		ErrorLog $error_log,
		BackupExport $backup_export,
		Multisite $multisite,
		InitiateMigration $initiate_migration,
		FinalizeMigration $finalize_migration,
		Properties $properties,
		WPMDBRestAPIServer $rest_API_server,
		MigrationHelper $migration_helper,
		FullSiteExport $full_site_export,
		Manager $queue_manager
	) {
		$this->migration_state_manager = $migration_state_manager;
		$this->table                   = $table;
		$this->http                    = $http;
		$this->props                   = $properties;
		$this->table_helper            = $table_helper;
		$this->http_helper             = $http_helper;
		$this->util                    = $util;
		$this->remote_post             = $remote_post;
		$this->filesystem              = $filesystem;
		$this->error_log               = $error_log;
		$this->migration_state         = $migration_state;
		$this->backup_export           = $backup_export;
		$this->multisite               = $multisite;
		$this->dynamic_props           = DynamicProperties::getInstance();
		$this->form_data               = $form_data;
		$this->form_data_arr           = $form_data->getFormData();
		$this->initiate_migration      = $initiate_migration;
		$this->finalize_migration      = $finalize_migration;
		$this->rest_API_server         = $rest_API_server;
		$this->migration_helper        = $migration_helper;
		$this->full_site_export        = $full_site_export;
		$this->queue_manager           = $queue_manager;
	}

	public function register() {
		// Register Queue manager actions
		$this->queue_manager->register();

		// REST endpoints
		add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] );

		// This class does double duty and handles processing table related stages.
		add_filter( 'wpmdb_process_stage', [ $this, 'filter_process_stage' ], 10, 3 );
		add_filter( 'wpmdb_data', [ $this, 'filter_source_basic_auth_status' ] );
		add_filter( 'wpmdb_data', [ $this, 'filter_loopback_requests_failing' ] );
	}

	public function register_rest_routes() {
		$this->rest_API_server->registerRestRoute(
			'/initiate-migration',
			[
				'methods'  => 'POST',
				'callback' => [ $this->initiate_migration, 'ajax_initiate_migration' ],
			]
		);

		$this->rest_API_server->registerRestRoute(
			'/cancel-migration',
			[
				'methods'  => 'POST',
				'callback' => [ $this, 'ajax_cancel_migration' ],
			]
		);
	}

	/**
	 * Migrate a table.
	 *
	 * @param array $state_data
	 *
	 * @return array|WP_Error
	 */
	public function migrate_table( $state_data = [] ) {
		global $wpdb;

		// This *might* be set to a file pointer below
		// @TODO using a global file pointer is extremely error prone and not a great idea
		$fp = null;

		$key_rules = array(
			'action'              => 'key',
			'migration_state_id'  => 'key',
			'table'               => 'string',
			'stage'               => 'key',
			'current_row'         => 'numeric',
			'form_data'           => 'json',
			'last_table'          => 'positive_int',
			'primary_keys'        => 'json',
			'table_schema_done'   => 'positive_int',
			'retry'               => 'positive_int',
			'gzip'                => 'int',
			'nonce'               => 'key',
			'bottleneck'          => 'positive_int',
			'prefix'              => 'string',
			'path_current_site'   => 'string',
			'domain_current_site' => 'text',
			'import_info'         => 'array',
		);

		$state_data = Persistence::setPostData( $key_rules, __METHOD__, WPMDB_MIGRATION_STATE_OPTION, $state_data );

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

		if ( 'import' === $state_data['intent'] && ! $this->table->table_exists( $state_data['table'] ) ) {
			return array( 'current_row' => -1 );
		}

		// Checks if we're performing a backup, if so, continue with the backup and exit immediately after.
		if ( $state_data['stage'] === Stage::BACKUP && $state_data['intent'] !== 'savefile' ) {
			// If performing a push we need to backup the REMOTE machine's DB.
			if ( 'push' === $state_data['intent'] ) {
				$return = $this->handle_remote_backup( $state_data );
			} else {
				$return = $this->handle_table_backup();
			}

			if ( Util::is_json( $return ) ) {
				$return = json_decode( $return, true );
			}

			return $return;
		}

		// Pull and push need to be handled differently for obvious reasons,
		// and trigger different code depending on the migration intent (push or pull).
		if ( in_array( $state_data['intent'], array( 'push', 'savefile', 'find_replace', 'import' ) ) ) {
			$this->dynamic_props->maximum_chunk_size = $this->util->get_bottleneck();

			if ( isset( $state_data['bottleneck'] ) ) {
				$this->dynamic_props->maximum_chunk_size = (int) $state_data['bottleneck'];
			}
			$is_full_site_export = isset( $state_data['full_site_export'] ) ? $state_data['full_site_export'] : false;
			if ( 'savefile' === $state_data['intent'] ) {
				$sql_dump_file_name = Filesystem::get_upload_info( 'path' ) . DIRECTORY_SEPARATOR;
				// TODO: Handle possible WP_Error from format_dump_name.
				$sql_dump_file_name .= $this->table_helper->format_dump_name( $state_data['dump_filename'] );
				$fp                 = $this->filesystem->open( $sql_dump_file_name, 'a', $is_full_site_export );
			}

			if ( ! empty( $state_data['db_version'] ) ) {
				$this->dynamic_props->target_db_version = $state_data['db_version'];
				if ( 'push' == $state_data['intent'] ) {
					// $this->dynamic_props->target_db_version has been set to remote database's version.
					add_filter(
						'wpmdb_create_table_query',
						array( $this->table_helper, 'mysql_compat_filter' ),
						10,
						5
					);
				} elseif ( 'savefile' == $state_data['intent'] && ! empty( $this->form_data_arr['compatibility_older_mysql'] ) ) {
					// compatibility_older_mysql is currently a checkbox meaning pre-5.5 compatibility (we play safe and target 5.1),
					// this may change in the future to be a dropdown or radiobox returning the version to be compatible with.
					$this->dynamic_props->target_db_version = '5.1';
					add_filter(
						'wpmdb_create_table_query',
						array( $this->table_helper, 'mysql_compat_filter' ),
						10,
						5
					);
				}
			}

			if ( ! empty( $state_data['find_replace_pairs'] ) ) {
				$this->dynamic_props->find_replace_pairs = $state_data['find_replace_pairs'];
			}

			ob_start();
			$result = $this->table->process_table( $state_data['table'], $fp, $state_data );

			if ( \is_resource( $fp ) && $state_data['intent'] === 'savefile' ) {
				$this->filesystem->close( $fp, $is_full_site_export );
			}

			return $result;
		} else { // PULLS
			$data = $this->http_helper->filter_post_elements(
				$state_data,
				array(
					'remote_state_id',
					'intent',
					'url',
					'table',
					'form_data',
					'stage',
					'bottleneck',
					'current_row',
					'last_table',
					'gzip',
					'primary_keys',
					'table_schema_done',
					'retry',
					'site_url',
					'find_replace_pairs',
					'source_prefix',
					'destination_prefix',
				)
			);

			$data['action']       = 'wpmdb_process_pull_request';
			$data['migration_id'] = MigrationHelper::get_current_migration_id();
			$data['pull_limit']   = $this->http_helper->get_sensible_pull_limit();
			$data['db_version']   = $wpdb->db_version();

			if ( is_multisite() ) {
				$data['path_current_site']   = $this->util->get_path_current_site();
				$data['domain_current_site'] = $this->multisite->get_domain_current_site();
			}

			$data['prefix'] = $wpdb->base_prefix;

			if ( isset( $data['sig'] ) ) {
				unset( $data['sig'] );
			}

			$sig_data = $data;
			unset( $sig_data['find_replace_pairs'], $sig_data['form_data'], $sig_data['source_prefix'], $sig_data['destination_prefix'] );
			$data['find_replace_pairs'] = base64_encode( json_encode( $data['find_replace_pairs'] ) );
			$data['form_data']          = base64_encode( $data['form_data'] );
			$data['primary_keys']       = base64_encode( $data['primary_keys'] );
			$data['source_prefix']      = base64_encode( $data['source_prefix'] );
			$data['destination_prefix'] = base64_encode( $data['destination_prefix'] );

			$data['sig'] = $this->http_helper->create_signature( $sig_data, $state_data['key'] );

			// Don't add to computed signature
			$data['site_details'] = base64_encode( json_encode( $state_data['site_details'] ) );
			$ajax_url             = $this->util->ajax_url();
			$response             = $this->remote_post->post( $ajax_url, $data, __FUNCTION__ );

			ob_start();
			$this->util->display_errors();
			$maybe_errors = trim( ob_get_clean() );

			// WP_Error is thrown manually by remote_post() to tell us something went wrong
			if ( is_wp_error( $response ) ) {
				return $response;
			}

			// returned data is just a big string like this query;query;query;33
			// need to split this up into a chunk and row_tracker
			// only strip the last new line if it exists
			$row_information = false !== strpos( $response, "\n" ) ?
				trim( substr( strrchr( $response, "\n" ), 1 ) ) :
				trim( $response );
			$row_information = explode( MigrationHelper::DATA_DELIMITER, $row_information );
			$sql_end         = strrpos( $response, ";\n" );
			$chunk           = $sql_end ? substr( $response, 0, $sql_end + 1 ) : '';

			if ( ! empty( $chunk ) ) {
				$process_chunk_result = $this->table->process_chunk( $chunk );
				if ( true !== $process_chunk_result ) {
					return $process_chunk_result;
				}
			}

			$result = array(
				'current_row'       => $row_information[0],
				'primary_keys'      => $row_information[1],
				'table_schema_done' => $row_information[2],
			);

			return $result;
		}
	}

	/**
	 * Appends an export of a table to a backup file as per params defined in $this->state_data.
	 *
	 * @return mixed|null
	 */
	public function handle_table_backup( $key = WPMDB_MIGRATION_STATE_OPTION ) {
		$state_data = Persistence::getStateData( $key );

		if ( empty( $state_data['dumpfile_created'] ) ) {
			$state_data['dumpfile_created'] = true;

			Persistence::saveStateData( $state_data, $key );
		}

		$this->dynamic_props->maximum_chunk_size = $this->util->get_bottleneck();
		$sql_dump_file_name                      = Filesystem::get_upload_info( 'path' ) . DIRECTORY_SEPARATOR;
		// TODO: Handle possible WP_Error from format_dump_name.
		$sql_dump_file_name .= $this->table_helper->format_dump_name( $state_data['dump_filename'] );
		$file_created       = file_exists( $sql_dump_file_name );
		$fp                 = $this->filesystem->open( $sql_dump_file_name );

		if ( ! $file_created ) {
			$this->table->db_backup_header( $fp );
		}

		$result = $this->table->process_table( $state_data['table'], $fp, $state_data );

		if ( isset( $fp ) && is_resource( $fp ) ) {
			$this->filesystem->close( $fp );
		}

		return $result;
	}

	/**
	 * Called to cancel an in-progress migration.
	 *
	 * @return void
	 */
	public function ajax_cancel_migration() {
		$_POST = $this->http_helper->convert_json_body_to_post();

		$result = $this->cancel_migration( $_POST );

		if ( is_wp_error( $result ) ) {
			$this->http->end_ajax( $result );
		}

		$this->http->end_ajax( 'success' );
	}

	/**
	 * Called to cancel an in-progress migration.
	 *
	 * @param array $state_data
	 *
	 * @return bool|WP_Error
	 */
	public function cancel_migration( $state_data = false ) {
		$key_rules = array(
			'action' => 'key',
		);

		$state_data = Persistence::setPostData( $key_rules, __METHOD__, WPMDB_MIGRATION_STATE_OPTION, $state_data );

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

		if ( empty( $state_data['stage'] ) ) {
			Debug::log( __FUNCTION__ . ': Invalid state_data:' );
			Debug::log( $state_data );

			return new WP_Error(
				'missing-stage-for-cancel',
				__( 'Could not cleanly cancel due to state data missing stage.', 'wp-migrate-db' )
			);
		}

		switch ( $state_data['intent'] ) {
			case 'savefile':
				if ( $state_data['full_site_export'] !== true || $state_data['stage'] === Stage::MIGRATE ) {
					$deleted = $this->backup_export->delete_export_file( $state_data['dump_filename'], false );

					if ( is_wp_error( $deleted ) ) {
						return $deleted;
					}
				}
				if ( $state_data['full_site_export'] === true ) {
					return $this->full_site_export->delete_export_zip( $state_data['export_path'] );
				}
				break;
			case 'push':
				$data = $this->http_helper->filter_post_elements(
					$state_data,
					array(
						'remote_state_id',
						'intent',
						'url',
						'form_data',
						'temp_prefix',
						'stage',
					)
				);

				$data['form_data']    = base64_encode( $state_data['form_data'] );
				$data['action']       = 'wpmdb_process_push_migration_cancellation';
				$data['migration_id'] = MigrationHelper::get_current_migration_id();
				$data['sig']          = $this->http_helper->create_signature( $data, $state_data['key'] );
				$ajax_url             = $this->util->ajax_url();

				$response          = $this->remote_post->post( $ajax_url, $data, __FUNCTION__ );
				$filtered_response = HandleRemotePostError::handle( 'wpmdb_remote_cancellation_failed', $response );
				do_action( 'wpmdb_cancellation' );

				return $filtered_response;
			case 'pull':
				if ( $state_data['stage'] == Stage::BACKUP ) {
					if ( ! empty( $state_data['dumpfile_created'] ) ) {
						$deleted = $this->backup_export->delete_export_file( $state_data['dump_filename'], true );

						if ( is_wp_error( $deleted ) ) {
							return $deleted;
						}
					}
				} else {
					$deleted = $this->table->delete_temporary_tables( $state_data['temp_prefix'] );

					if ( is_wp_error( $deleted ) ) {
						return $deleted;
					}
				}
				break;
			case 'find_replace':
				if ( Stage::BACKUP === $state_data['stage'] && ! empty( $state_data['dumpfile_created'] ) ) {
					$deleted = $this->backup_export->delete_export_file( $state_data['dump_filename'], true );
				} else {
					$deleted = $this->table->delete_temporary_tables( $this->props->temp_prefix );
				}

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

				break;
			case 'import':
				if ( Stage::BACKUP === $state_data['stage'] && ! empty( $state_data['dumpfile_created'] ) ) {
					$deleted = $this->backup_export->delete_export_file( $state_data['dump_filename'], true );
				} else {
					// Import might have been deleted already
					if ( $this->filesystem->file_exists( $state_data['import_path'] ) ) {
						$sanitized_import_filename = sanitize_file_name( $state_data['import_filename'] );
						if ( $state_data['import_info']['import_gzipped'] ) {
							$is_backup = $this->filesystem->file_exists( substr( $state_data['import_path'], 0, -3 ) );
							$deleted   = $this->backup_export->delete_export_file(
								$sanitized_import_filename,
								$is_backup
							);
						} else {
							$deleted = $this->backup_export->delete_export_file( $sanitized_import_filename, true );
						}

						if ( is_wp_error( $deleted ) ) {
							return $deleted;
						}
					}
					$deleted = $this->table->delete_temporary_tables( $this->props->temp_prefix );
				}

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

				break;
			default:
				break;
		}

		do_action( 'wpmdb_cancellation' );

		return true;
	}

	/**
	 * Tell the remote to backup a table.
	 *
	 * @param array|WP_Error $state_data
	 *
	 * @return mixed|WP_Error
	 */
	protected function handle_remote_backup( $state_data ) {
		$data = $this->http_helper->filter_post_elements(
			$state_data,
			array(
				'action',
				'url',
				'table',
				'form_data',
				'stage',
				'current_row',
				'last_table',
				'gzip',
				'primary_keys',
				'table_schema_done',
				'path_current_site',
				'domain_current_site',
			)
		);

		$data['action']       = 'wpmdb_backup_remote_table';
		$data['intent']       = 'push';
		$data['migration_id'] = MigrationHelper::get_current_migration_id();

		$data['form_data'] = base64_encode( $data['form_data'] );

		$data['sig'] = $this->http_helper->create_signature( $data, $state_data['key'] );

		$data['primary_keys'] = base64_encode( $data['primary_keys'] );
		$ajax_url             = $this->util->ajax_url();
		$response             = $this->remote_post->post( $ajax_url, $data, __FUNCTION__ );

		return HandleRemotePostError::handle( 'wpmdb-handle-remote-backup-failed', $response );
	}

	/**
	 * Process jobs for a stage and return their total processed bytes and stage status indicator.
	 *
	 * @param string $stage
	 * @param string $migration_id
	 *
	 * @return array|WP_Error Array with processed_bytes and complete bool, or error.
	 */
	public function process_stage( $stage, $migration_id ) {
		/**
		 * Filter to process jobs for a stage and return their total processed bytes and stage status indicator.
		 *
		 * @param array|WP_Error $progress Array with processed_bytes and complete bool, or error.
		 * @param string         $stage
		 * @param string         $migration_id
		 *
		 * @return array|WP_Error Array with processed_bytes and complete bool, or error.
		 */
		$progress = apply_filters(
			'wpmdb_process_stage',
			[ 'processed_bytes' => 0, 'complete' => false ],
			$stage,
			$migration_id
		);

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

		// If unexpected data is returned, return an error to stop processing.
		if ( empty( $progress ) || ! is_array( $progress ) || ! isset( $progress['processed_bytes'], $progress['complete'] ) ) {
			return new WP_Error(
				'unexpected-processing-result',
				sprintf( __( 'Unexpected processing result returned for %s stage.', 'wp-migrate-db' ), $stage )
			);
		}

		return $progress;
	}

	/**
	 * Handle process_stage filter to process tables from queue and return their processed bytes and whether stage is complete.
	 *
	 * @param array     $progress Current progress data.
	 * @param StageName $stage
	 * @param string    $migration_id
	 *
	 * @return array|WP_Error
	 * @handles wpmdb_process_stage
	 */
	public function filter_process_stage( $progress, $stage, $migration_id ) {
		if ( ! in_array( $stage, [ Stage::TABLES, Stage::BACKUP ] ) ) {
			return $progress;
		}

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

		// Do we have an appropriate job at the top of the queue?
		// For table jobs we grab the first 2 because we need to determine whether the first in the queue
		// is the last for the stage and pass that info into the handler to close off backup files etc.
		$jobs = $this->queue_manager->list_jobs( 2 );

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

		// Nothing in queue, we're done.
		if ( empty( $jobs ) ) {
			$progress['complete'] = true;

			return $progress;
		}

		$job      = reset( $jobs );
		$next_job = next( $jobs );

		// Assume that stage is complete if top of queue is not for current stage.
		if (
			( Stage::TABLES === $stage && ! is_a( $job, Table_Job::class ) ) ||
			( Stage::BACKUP === $stage && ! is_a( $job, Backup_Table_Job::class ) )
		) {
			$progress['complete'] = true;

			return $progress;
		}

		// Get previous state data and adjust for current job.
		$state_data = Persistence::getStateData();

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

		// If we're processing the first, or a different table,
		// reset the control state data ready to process the table schema
		// in the first slice, and then the table data in following slices.
		if (
			empty( $state_data['table'] ) ||
			$job->table !== $state_data['table'] ||
			! isset(
				$state_data['current_row'],
				$state_data['primary_keys'],
				$state_data['table_schema_done'],
				$state_data['retry']
			)
		) {
			$state_data['table']             = $job->table;
			$state_data['current_row']       = -1;
			$state_data['primary_keys']      = '';
			$state_data['table_schema_done'] = '0';
			$state_data['retry']             = '0';
		}

		// Last table to be processed for stage?
		if (
			! $next_job ||
			( Stage::TABLES === $stage && ! is_a( $next_job, Table_Job::class ) ) ||
			( Stage::BACKUP === $stage && ! is_a( $next_job, Backup_Table_Job::class ) )
		) {
			$state_data['last_table'] = '1';
		} else {
			$state_data['last_table'] = '0';
		}

		// If doing a backup, ensure state data is correct.
		if ( Stage::BACKUP === $stage && Stage::BACKUP !== $state_data['stage'] ) {
			$state_data['stage'] = Stage::BACKUP;
		}

		// When past the backup phase, we should update the state to migrate phase.
		if ( Stage::TABLES === $stage && Stage::MIGRATE !== $state_data['stage'] ) {
			$state_data['stage'] = Stage::MIGRATE;
		}

		// However, when handling a find/replace or import migration and past the backup stage,
		// the state data's stage should be switched from "migrate" to "find_replace".
		if ( Stage::TABLES === $stage &&
		     in_array( $state_data['intent'], [ 'find_replace', 'import' ] ) &&
		     Stage::MIGRATE === $state_data['stage']
		) {
			$state_data['stage'] = Stage::FIND_REPLACE;
		}

		// Make a note of some state data to determine success afterwards.
		$table_schema_done = Table::table_schema_done( $state_data );
		$current_row       = ! empty( $state_data['current_row'] ) && 0 < $state_data['current_row'] ? $state_data['current_row'] : 0;

		self::maybe_wait_before_retry();

		$result = $this->migrate_table( $state_data );

		if ( is_wp_error( $result ) ) {
			// If the error qualifies for retry, and we're able to retry, return original progress for go-around.
			if ( $this->handle_error( $result, $stage ) && self::should_retry( $result, $stage ) ) {
				$state_data['retry'] = '1';
				Persistence::saveStateData( $state_data );
				self::set_retry_in_progress( true );

				return $progress;
			}

			// End the migration with error.
			return $result;
		} else {
			$state_data['retry'] = '0';
			self::reset_error_count();
			self::set_retry_in_progress( false );
		}

		// Merge result into state data and save ready for another go-around if needed.
		if ( ! empty( $result ) && is_array( $result ) ) {
			$state_data = array_merge( $state_data, $result );
			Persistence::saveStateData( $state_data );
		}

		// If we expected the table schema to be processed, but it wasn't, throw an error.
		if ( ! $table_schema_done && empty( $result['table_schema_done'] ) ) {
			return new WP_Error(
				'wpmdb-table-schema-error',
				sprintf(
					__( 'wp-migrate-db', 'Schema for table "%s" failed to be processed as expected.' ),
					$job->table
				),
				$result
			);
		}

		// If the table schema was processed, just return the current progress untouched
		// for another go-around to then start processing its data.
		if ( ! $table_schema_done ) {
			return $progress;
		}

		if ( 0 > $result['current_row'] && 0 === $current_row ) {
			// One-shot processed the table data.
			$progress['processed_bytes'] = $job->bytes;
		} elseif ( 0 > $result['current_row'] ) {
			// Finished processing the table data.
			$progress['processed_bytes'] = min(
				$job->bytes,
				floor( $job->bytes / $job->rows ) * ( $job->rows - $current_row )
			);
		} else {
			// Part way through processing the table data.
			$progress['processed_bytes'] = min(
				$job->bytes,
				floor( $job->bytes / $job->rows ) * ( $result['current_row'] - $current_row )
			);
		}

		// If table is finished with, pop it off the queue and handle any progress updates.
		if ( 0 > $result['current_row'] ) {
			$this->queue_manager->delete_data_from_queue( 1 );

			// This was the last table to be processed, stage is complete.
			if ( '1' === $state_data['last_table'] ) {
				$progress['complete'] = true;
			}
		}

		return $progress;
	}

	/**
	 * Adds the source site basic auth status info to wpmdb_data array
	 *
	 * @param array $data
	 *
	 * @handles wpmdb_data
	 * @return array|mixed
	 */
	public function filter_source_basic_auth_status( $data ) {
		if ( ! is_array( $data ) ) {
			return $data;
		}

		if ( ! isset( $data['source_basic_auth'] ) ) {
			$data['source_basic_auth']      = false;
			$data['auto_source_basic_auth'] = false;
		}

		if ( Util::basic_auth_enabled() ) {
			$data['source_basic_auth'] = true;
		}

		if ( ! empty( $_SERVER['PHP_AUTH_USER'] ) && ! empty( $_SERVER['PHP_AUTH_PW'] ) ) {
			$data['auto_source_basic_auth'] = true;
		}

		return $data;
	}

	/**
	 * Adds the loopback requests status info to wpmdb_data array
	 *
	 * @param array $data
	 *
	 * @return array
	 * @handles wpmdb_data
	 */
	public function filter_loopback_requests_failing( $data ) {
		if ( ! is_array( $data ) ) {
			return $data;
		}

		if ( isset( $data['loopback_requests_failing'] ) ) {
			return $data;
		}

		$site_health_path = trailingslashit( ABSPATH ) . 'wp-admin/includes/class-wp-site-health.php';

		if (
			file_exists( $site_health_path ) &&
			true !== get_site_transient( WPMDB_OPTION_PREFIX . 'loopback_test' )
		) {
			set_site_transient( WPMDB_OPTION_PREFIX . 'loopback_test', true, 30 );

			require_once $site_health_path;
			$site_health = new WP_Site_Health();

			$loopback = $site_health->get_test_loopback_requests();

			if (
				! empty( $loopback['status'] ) &&
				'good' !== $loopback['status'] &&
				! empty( $loopback['label'] ) &&
				! empty( $loopback['description'] )
			) {
				$data['loopback_requests_failing']                = true;
				$data['loopback_requests_failing_panel']['title'] = __( 'Loopback request failed', 'wp-migrate-db' );
				$data['loopback_requests_failing_panel']['intro'] = __(
					'A loopback is when your own site tries to connect to itself. This plugin relies on loopback requests to perform migrations. Therefore a new migration cannot be started while loopback requests are failing.',
					'wp-migrate-db'
				);

				$site_health_url = get_dashboard_url(
					get_current_user_id(),
					'site-health.php'
				);

				$failed_loopback_request_doc_link = Util::external_link(
					PluginManagerBase::wpe_url(
						'/support/wp-engine-site-migration/',
						[ 'utm_content' => 'wpesm_plugin_failed_loopback_request' ],
						'Failed_Loopback_Request'
					),
					__( 'how to troubleshoot failed loopback requests', 'wp-migrate-db' ),
					true
				);

				/* translators: 2nd placeholder is external how to troubleshoot failed loopback requests link. */
				$how_to_fix = __(
					'Visit the <a class="underline" href="%1$s">Site Health</a> page to check whether your site can perform loopback requests, and learn %2$s.',
					'wp-migrate-db'
				);

				$data['loopback_requests_failing_panel']['how_to_fix'] = sprintf(
					$how_to_fix,
					$site_health_url,
					$failed_loopback_request_doc_link
				);
			}
		}

		return $data;
	}

	/**
	 * Handles the returned errors and updates the current_migration state with
	 * the error count for the current stage.
	 *
	 * @param WP_Error $error
	 * @param string   $stage
	 *
	 * @return bool whether the error qualifies for retry.
	 */
	protected function handle_error( $error, $stage ) {
		$error_message = $error->get_error_message();

		if ( empty( $error_message ) || ! is_string( $error_message ) ) {
			return false;
		}

		$retry_errors = array_filter(
			array_merge( $this->get_retry_errors(), $this->db_retry_errors ),
			function ( $item ) use ( $error_message ) {
				return strpos( trim( strtolower( $error_message ) ), trim( strtolower( $item ) ) ) !== false;
			}
		);

		// Does the error qualify for retry?
		if ( count( $retry_errors ) > 0 ) {
			// Record the found errors in the migration stats.
			self::update_error_stats( $stage, $retry_errors );

			// Increment the error count.
			self::increment_error_count();

			return true;
		}

		// We're not able to retry.
		return false;
	}
}
