<?php
/**
 * Upgrades API
 *
 * @package     AffiliateWP
 * @subpackage  Utilities
 * @copyright   Copyright (c) 2021, Sandhills Development, LLC
 * @license     http://opensource.org/licenses/gpl-2.0.php GNU Public License
 * @since       1.0.0
 *
 * @see includes/utils/traits/trait-db.php `upgrade_table()` for alternative usage for
 *      upgrading database tables vs. using legacy methods for doing so here.
 */

affwp_require_util_traits( 'db', 'data' );

#[\AllowDynamicProperties]

/**
 * Core class for handling upgrade operations.
 *
 * @since 1.0.0
 */
class Affiliate_WP_Upgrades {

	use \AffiliateWP\Utils\DB;
	use \AffiliateWP\Utils\Data;

	/**
	 * Whether debug mode is enabled.
	 *
	 * @since 1.8.6
	 * @access private
	 * @var bool
	 */
	private $debug;

	/**
	 * Affiliate_WP_Logging instance.
	 *
	 * @since 1.8.6
	 * @access private
	 * @var Affiliate_WP_Logging
	 */
	private $logs;

	/**
	 * Signals whether the upgrade was successful.
	 *
	 * @access public
	 * @var    bool
	 */
	private $upgraded = false;

	/**
	 * AffiliateWP version.
	 *
	 * @access private
	 * @since  2.0
	 * @var    string
	 */
	private $version;

	/**
	 * Utilities class instance.
	 *
	 * @access private
	 * @since  2.0
	 * @var    \Affiliate_WP_Utilities
	 */
	private $utils;

	/**
	 * Upgrade routine registry.
	 *
	 * @access private
	 * @since  2.0.5
	 * @var    \AffWP\Utils\Upgrades\Registry
	 */
	private $registry;

	/**
	 * Sets up the Upgrades class instance.
	 *
	 * @access public
	 *
	 * @param \Affiliate_WP_Utilities $utils Utilities class instance.
	 */
	public function __construct( $utils ) {

		$this->utils    = $utils;
		$this->version  = get_option( 'affwp_version' );
		$this->registry = new \AffWP\Utils\Upgrades\Registry();

		add_action( 'init', [ $this, 'init' ], -9999 );

		add_action( 'init', [ $this, 'schedule_v2270_upgrade' ], 20 );
		add_action( 'affwp_schedule_v2270_upgrade_batches', [ $this, 'schedule_v2270_upgrade_batches' ] );
		add_action( 'affwp_v2270_process_affiliate_batch', [ $this, 'process_v2270_affiliate_batch' ], 10, 3 );

		add_action( 'affwp_v2330_store_credit_backfill_batch', [ $this, 'process_v2330_store_credit_backfill_batch' ], 10, 1 );
		add_action( 'affwp_v2330_stripe_backfill_batch', [ $this, 'process_v2330_stripe_backfill_batch' ], 10, 1 );
		add_action( 'affwp_v2330_payouts_service_backfill_batch', [ $this, 'process_v2330_payouts_service_backfill_batch' ], 10, 1 );

		add_action( 'affwp_v2340_stripe_tax_backfill_batch', [ $this, 'process_v2340_stripe_tax_backfill_batch' ], 10, 1 );

		// Post-upgrade notice prompting verification of the inferred Default Payout Method, via the shared Admin_Notice API.
		add_action( 'affiliatewp_admin_notices', [ $this, 'register_payout_default_inference_notice' ] );

		$settings    = new Affiliate_WP_Settings();
		$this->debug = (bool) $settings->get( 'debug_mode', false );

		$this->register_core_upgrades();
	}

	/**
	 * Initializes upgrade routines for the current version of AffiliateWP.
	 *
	 * @access public
	 */
	public function init() {

		if ( empty( $this->version ) ) {
			$this->version = '1.0.6'; // last version that didn't have the version option set
		}

		if ( version_compare( $this->version, '1.1', '<' ) ) {
			$this->v11_upgrades();
		}

		if ( version_compare( $this->version, '1.2.1', '<' ) ) {
			$this->v121_upgrades();
		}

		if ( version_compare( $this->version, '1.3', '<' ) ) {
			$this->v13_upgrades();
		}

		if ( version_compare( $this->version, '1.6', '<' ) ) {
			$this->v16_upgrades();
		}

		if ( version_compare( $this->version, '1.7', '<' ) ) {
			$this->v17_upgrades();
		}

		if ( version_compare( $this->version, '1.7.3', '<' ) ) {
			$this->v173_upgrades();
		}

		if ( version_compare( $this->version, '1.7.11', '<' ) ) {
			$this->v1711_upgrades();
		}

		if ( version_compare( $this->version, '1.7.14', '<' ) ) {
			$this->v1714_upgrades();
		}

		if ( version_compare( $this->version, '1.9', '<' ) ) {
			$this->v19_upgrade();
		}

		if ( version_compare( $this->version, '1.9.5', '<' ) ) {
			$this->v195_upgrade();
		}

		if ( true === version_compare( AFFILIATEWP_VERSION, '2.0', '<' ) ) {
			$this->v20_upgrade();
		}

		if ( version_compare( $this->version, '2.0.2', '<' ) ) {
			$this->v202_upgrade();
		}

		if ( version_compare( $this->version, '2.0.10', '<' ) ) {
			$this->v210_upgrade();
		}

		if ( version_compare( $this->version, '2.1', '<' ) ) {
			$this->v21_upgrade();
		}

		if ( version_compare( $this->version, '2.1.3.1', '<' ) ) {
			$this->v2131_upgrade();
		}

		if ( version_compare( $this->version, '2.2', '<' ) ) {
			$this->v22_upgrade();
		}

		if ( version_compare( $this->version, '2.2.2', '<' ) ) {
			$this->v222_upgrade();
		}

		if ( version_compare( $this->version, '2.2.8', '<' ) ) {
			$this->v228_upgrade();
		}

		if ( version_compare( $this->version, '2.2.9', '<' ) ) {
			$this->v229_upgrade();
		}

		if ( version_compare( $this->version, '2.3', '<' ) ) {
			$this->v23_upgrade();
		}

		if ( version_compare( $this->version, '2.4', '<' ) ) {
			$this->v24_upgrade();
		}

		if ( version_compare( $this->version, '2.4.2', '<' ) ) {
			$this->v242_upgrade();
		}

		if ( version_compare( $this->version, '2.5', '<' ) ) {
			$this->v25_upgrade();
		}

		if ( version_compare( $this->version, '2.6', '<' ) ) {
			$this->v26_upgrade();
		}

		if ( version_compare( $this->version, '2.7', '<' ) ) {
			$this->v27_upgrade();
		}

		if ( version_compare( $this->version, '2.7.4', '<' ) ) {
			$this->v274_upgrade();
		}

		if ( version_compare( $this->version, '2.8', '<' ) ) {
			$this->v28_upgrade();
		}

		if ( version_compare( $this->version, '2.9', '<' ) ) {
			$this->v29_upgrade();
		}

		if ( version_compare( $this->version, '2.9.5', '<' ) ) {
			$this->v295_upgrade();
		}

		if ( version_compare( $this->version, '2.9.6', '<' ) ) {
			$this->v296_upgrade();
		}

		if ( version_compare( $this->version, '2.9.6.1', '<' ) ) {
			$this->v2961_upgrade();
		}

		if ( version_compare( $this->version, '2.11.0', '<' ) ) {
			$this->v2110_upgrade();
		}

		if ( version_compare( $this->version, '2.14.0', '<' ) ) {
			$this->v2140_upgrade();
		}

		if ( version_compare( $this->version, '2.15.0', '<' ) ) {
			$this->v2150_upgrade();
		}

		if ( version_compare( $this->version, '2.16.0', '<' ) ) {
			$this->v2160_upgrade();
		}

		if ( version_compare( $this->version, '2.16.3', '<' ) ) {
			$this->v2163_upgrade();
		}

		if ( version_compare( $this->version, '2.17.0', '<' ) ) {
			$this->v2170_upgrade();
		}

		if ( version_compare( $this->version, '2.18.0', '<' ) ) {
			$this->v2180_upgrade();
		}

		if ( version_compare( $this->version, '2.27.0', '<' ) ) {
			$this->v2270_upgrade();
		}

		if ( version_compare( $this->version, '2.28.0', '<' ) ) {
			$this->upgrade_v2280_migrate_captcha_settings();
		}

		if ( version_compare( $this->version, '2.30.0', '<' ) ) {
			$this->upgrade_v2300_schedule_log_migration();
		}

		if ( version_compare( $this->version, '2.31.0', '<' ) ) {
			$this->upgrade_v2310_migrate_allow_own_referrals_addon();
		}

		if ( version_compare( $this->version, '2.33.0', '<' ) ) {
			$this->upgrade_v2330_payout_batches_table();
		}

		// NOTE: '2.34.0' must match the version that actually ships the Stripe 1099 capability.
		if ( version_compare( $this->version, '2.34.0', '<' ) ) {
			$this->upgrade_v2340_unfreeze_payout_failure_emails();
			$this->upgrade_v2340_stripe_tax_capability_backfill();
		}

		if ( version_compare( $this->version, '2.34.1', '<' ) ) {
			$this->upgrade_v2341_remove_campaign_unique_urls_options();
		}

		// Inconsistency between current and saved version.
		if ( version_compare( $this->version, AFFILIATEWP_VERSION, '<>' ) ) {
			$this->upgraded = true;
		}

		// If upgrades have occurred.
		if ( $this->upgraded ) {

			update_option( 'affwp_version_upgraded_from', $this->version );
			update_option( 'affwp_version', AFFILIATEWP_VERSION );
		}
	}

	/**
	 * Registers core upgrade routines.
	 *
	 * @access private
	 * @since  2.0.5
	 *
	 * @see \Affiliate_WP_Upgrades::add_routine()
	 */
	private function register_core_upgrades() {
		$this->add_routine(
			'upgrade_v20_recount_unpaid_earnings',
			[
				'version'       => '2.0',
				'compare'       => '<',
				'batch_process' => [
					'id'    => 'recount-affiliate-stats-upgrade',
					'class' => 'AffWP\Utils\Batch_Process\Upgrade_Recount_Stats',
					'file'  => AFFILIATEWP_PLUGIN_DIR . 'includes/admin/tools/upgrades/class-batch-upgrade-recount-affiliate-stats.php',
				],
			]
		);

		$this->add_routine(
			'upgrade_v22_create_customer_records',
			[
				'version'       => '2.2',
				'compare'       => '<',
				'batch_process' => [
					'id'    => 'create-customers-upgrade',
					'class' => 'AffWP\Utils\Batch_Process\Upgrade_Create_Customers',
					'file'  => AFFILIATEWP_PLUGIN_DIR . 'includes/admin/tools/upgrades/class-batch-upgrade-create-customers.php',
				],
			]
		);

		$this->add_routine(
			'upgrade_v245_create_customer_affiliate_relationship_records',
			[
				'version'       => '2.4.5',
				'compare'       => '<',
				'batch_process' => [
					'id'    => 'create-customer-affiliate-relationship-upgrade',
					'class' => 'AffWP\Utils\Batch_Process\Upgrade_Create_Customer_Affiliate_Relationship',
					'file'  => AFFILIATEWP_PLUGIN_DIR . 'includes/admin/tools/upgrades/class-batch-upgrade-create-customer-affiliate-relationship.php',
				],
			]
		);

		$this->add_routine(
			'upgrade_v26_create_dynamic_coupons',
			[
				'version'       => '2.6',
				'compare'       => '<',
				'batch_process' => [
					'id'    => 'create-dynamic-coupons-upgrade',
					'class' => 'AffWP\Utils\Batch_Process\Upgrade_Create_Dynamic_Coupons',
					'file'  => AFFILIATEWP_PLUGIN_DIR . 'includes/admin/tools/upgrades/class-batch-upgrade-create-dynamic-coupons.php',
				],
			]
		);

		$this->add_routine(
			'upgrade_v261_utf8mb4_compat',
			[
				'version'       => '2.6.1',
				'compare'       => '<',
				'batch_process' => [
					'id'    => 'upgrade-db-utf8mb4',
					'class' => 'AffWP\Utils\Batch_Process\Upgrade_Database_ut8mb4_Compat',
					'file'  => AFFILIATEWP_PLUGIN_DIR . 'includes/admin/tools/upgrades/class-batch-upgrade-db-utf8mb4.php',
				],
			]
		);

		$this->add_routine(
			'upgrade_v27_calculate_campaigns',
			[
				'version' => '2.7',
				'compare' => '<',
			]
		);

		$this->add_routine(
			'upgrade_v274_calculate_campaigns',
			[
				'version' => '2.7.4',
				'compare' => '<',
			]
		);

		$this->add_routine(
			'migrate_affiliate_user_meta',
			[
				'version'       => '2.8',
				'compare'       => '<',
				'batch_process' => [
					'id'    => 'migrate-affiliate-user-meta',
					'class' => 'AffWP\Utils\Batch_Process\Batch_Migrate_Affiliate_User_Meta',
					'file'  => AFFILIATEWP_PLUGIN_DIR . 'includes/admin/tools/class-batch-migrate-affwp-user-meta.php',
				],
			]
		);

		$this->add_routine(
			'upgrade_v281_convert_failed_referrals',
			[
				'version'       => '2.8.1',
				'compare'       => '<',
				'batch_process' => [
					'id'    => 'upgrade-convert-failed-referrals',
					'class' => 'AffWP\Utils\Batch_Process\Batch_Upgrade_Convert_Failed_Referrals',
					'file'  => AFFILIATEWP_PLUGIN_DIR . 'includes/admin/tools/upgrades/class-batch-upgrade-convert-failed-referrals.php',
				],
			]
		);

		$this->add_routine(
			'upgrade_v2140_set_creative_type',
			[
				'version'       => '2.14.0',
				'compare'       => '<',
				'batch_process' => [
					'id'    => 'set-creative-type',
					'class' => 'AffWP\Utils\Batch_Process\Batch_Set_Creative_Type',
					'file'  => AFFILIATEWP_PLUGIN_DIR . 'includes/admin/tools/class-batch-set-creative-type.php',
				],
			]
		);

		$this->add_routine(
			'upgrade_v2160_update_creative_names',
			[
				'version'       => '2.16.0',
				'compare'       => '<',
				'batch_process' => [
					'id'    => 'update-creative-names',
					'class' => 'AffWP\Utils\Batch_Process\Batch_Update_Creative_Names',
					'file'  => AFFILIATEWP_PLUGIN_DIR . 'includes/admin/tools/class-batch-update-creative-names.php',
				],
			]
		);

		$this->add_routine(
			'upgrade_v2276_clean_empty_registration_form_meta',
			[
				'version'       => '2.27.6',
				'compare'       => '<',
				'batch_process' => [
					'id'    => 'clean-empty-registration-form-meta',
					'class' => 'AffWP\Utils\Batch_Process\Batch_Clean_Empty_Registration_Form_Meta',
					'file'  => AFFILIATEWP_PLUGIN_DIR . 'includes/admin/tools/class-batch-clean-empty-registration-form-meta.php',
				],
			]
		);

		$this->add_routine(
			'upgrade_v2250_create_login_registration_pages',
			[
				'version'       => '2.25.0',
				'compare'       => '<',
				'batch_process' => [
					'id'    => 'create-login-registration-pages',
					'class' => 'AffWP\Utils\Batch_Process\Batch_Create_Login_Registration_Pages',
					'file'  => AFFILIATEWP_PLUGIN_DIR . 'includes/admin/tools/class-batch-create-login-registration-pages.php',
				],
			]
		);
	}

	/**
	 * Registers a new upgrade routine.
	 *
	 * @access public
	 * @since  2.0.5
	 *
	 * @param string $upgrade_id Upgrade ID.
	 * @param array  $args {
	 *     Arguments for registering a new upgrade routine.
	 *
	 *     @type string $version       Version the upgrade routine should be run against.
	 *     @type string $compare       Comparison operator to use when determining if the routine
	 *                                 should be executed.
	 *     @type array  $batch_process {
	 *         Optional. Arguments for registering a batch process.
	 *
	 *         @type string $id    Batch process ID.
	 *         @type string $class Batch processor class to use.
	 *         @type string $file  File containing the batch processor class.
	 *     }
	 * }
	 * @return bool True if the upgrade routine was added, otherwise false.
	 */
	public function add_routine( $upgrade_id, $args ) {
		// Register the batch process if one has been defined.
		if ( ! empty( $args['batch_process'] ) ) {

			$utils = $this->utils;
			$batch = $args['batch_process'];

			// Log an error if it's too late to register the batch process.
			if ( did_action( 'affwp_batch_process_init' ) ) {

				$utils->log(
					sprintf(
						'The %s batch process was registered too late. Registrations must occur while/before <code>affwp_batch_process_init</code> fires.',
						esc_html( $args['batch_process']['id'] )
					)
				);

				return false;

			} else {

				add_action(
					'affwp_batch_process_init',
					function () use ( $utils, $batch ) {
						$utils->batch->register_process(
							$batch['id'],
							[
								'class' => $batch['class'],
								'file'  => $batch['file'],
							]
						);
					}
				);

			}

			unset( $args['batch_process'] );
		}

		// Add the routine to the registry.
		return $this->registry->add_upgrade( $upgrade_id, $args );
	}

	/**
	 * Retrieves an upgrade routine from the registry.
	 *
	 * @access public
	 * @since  2.0.5
	 *
	 * @param string $upgrade_id Upgrade ID.
	 * @return array|false Upgrade entry from the registry, otherwise false.
	 */
	public function get_routine( $upgrade_id ) {
		return $this->registry->get( $upgrade_id );
	}

	/**
	 * Writes a log message.
	 *
	 * @access private
	 * @since 1.8.6
	 *
	 * @param string $message Optional. Message to log.
	 */
	private function log( $message = '' ) {
		$this->utils->log( $message );
	}

	/**
	 * Perform database upgrades for version 1.1
	 *
	 * @access  private
	 * @since   1.1
	 */
	private function v11_upgrades() {

		@affiliate_wp()->affiliates->create_table();

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 1.2.1
	 *
	 * @access  private
	 * @since   1.2.1
	 */
	private function v121_upgrades() {

		@affiliate_wp()->creatives->create_table();

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 1.3
	 *
	 * @access  private
	 * @since   1.3
	 */
	private function v13_upgrades() {

		@affiliate_wp()->creatives->create_table();

		// Clear rewrite rules
		flush_rewrite_rules();

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 1.6
	 *
	 * @access  private
	 * @since   1.6
	 */
	private function v16_upgrades() {

		@affiliate_wp()->affiliate_meta->create_table();
		@affiliate_wp()->referrals->create_table();

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 1.7
	 *
	 * @access  private
	 * @since   1.7
	 */
	private function v17_upgrades() {

		@affiliate_wp()->referrals->create_table();
		@affiliate_wp()->visits->create_table();
		@affiliate_wp()->campaigns->create_view();

		$this->v17_upgrade_referral_rates();

		$this->v17_upgrade_gforms();

		$this->v17_upgrade_nforms();

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 1.7.3
	 *
	 * @access  private
	 * @since   1.7.3
	 */
	private function v173_upgrades() {

		$this->v17_upgrade_referral_rates();

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for referral rates in version 1.7
	 *
	 * @access  private
	 * @since   1.7
	 */
	private function v17_upgrade_referral_rates() {

		global $wpdb;

		$prefix  = ( defined( 'AFFILIATE_WP_NETWORK_WIDE' ) && AFFILIATE_WP_NETWORK_WIDE ) ? null : $wpdb->prefix;
		$results = $wpdb->get_results( "SELECT affiliate_id, rate FROM {$prefix}affiliate_wp_affiliates WHERE rate_type = 'percentage' AND rate > 0 AND rate <= 1;" );

		if ( $results ) {
			foreach ( $results as $result ) {
				$wpdb->update(
					"{$prefix}affiliate_wp_affiliates",
					[ 'rate' => floatval( $result->rate ) * 100 ],
					[ 'affiliate_id' => $result->affiliate_id ],
					[ '%d' ],
					[ '%d' ]
				);
			}
		}

		$settings  = get_option( 'affwp_settings' );
		$rate_type = ! empty( $settings['referral_rate_type'] ) ? $settings['referral_rate_type'] : null;
		$rate      = isset( $settings['referral_rate'] ) ? $settings['referral_rate'] : 20;

		if ( 'percentage' !== $rate_type ) {
			return;
		}

		if ( $rate > 0 && $rate <= 1 ) {
			$settings['referral_rate'] = floatval( $rate ) * 100;
		} elseif ( '' === $rate || '0' === $rate || '0.00' === $rate ) {
			$settings['referral_rate'] = 0;
		} else {
			$settings['referral_rate'] = floatval( $rate );
		}

		// Update settings.
		affiliate_wp()->settings->set( $settings, $save = true );
	}

	/**
	 * Perform database upgrades for Gravity Forms in version 1.7
	 *
	 * @access  private
	 * @since   1.7
	 */
	private function v17_upgrade_gforms() {

		$settings = get_option( 'affwp_settings' );

		if ( empty( $settings['integrations'] ) || ! array_key_exists( 'gravityforms', $settings['integrations'] ) ) {
			return;
		}

		global $wpdb;

		$tables = $wpdb->get_var( "SHOW TABLES LIKE '{$wpdb->prefix}rg_form%';" );

		if ( ! $tables ) {
			return;
		}

		$forms = $wpdb->get_results( "SELECT id FROM {$wpdb->prefix}rg_form;" );

		if ( ! $forms ) {
			return;
		}

		foreach ( $forms as $form ) {

			$meta = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT display_meta FROM {$wpdb->prefix}rg_form_meta WHERE form_id = %d;",
					$form->id
				)
			);

			$meta = json_decode( $meta );

			if ( isset( $meta->gform_allow_referrals ) ) {
				continue;
			}

			$meta->gform_allow_referrals = 1;

			$meta = json_encode( $meta );

			$wpdb->query(
				$wpdb->prepare(
					"UPDATE {$wpdb->prefix}rg_form_meta SET display_meta = %s WHERE form_id = %d;",
					$meta,
					$form->id
				)
			);

		}
	}

	/**
	 * Perform database upgrades for Ninja Forms in version 1.7
	 *
	 * @access  private
	 * @since   1.7
	 */
	private function v17_upgrade_nforms() {

		$settings = get_option( 'affwp_settings' );

		if ( empty( $settings['integrations'] ) || ! array_key_exists( 'ninja-forms', $settings['integrations'] ) ) {
			return;
		}

		global $wpdb;

		$tables = $wpdb->get_var( "SHOW TABLES LIKE '{$wpdb->prefix}nf_object%';" );

		if ( ! $tables ) {
			return;
		}

		$forms = $wpdb->get_results( "SELECT id FROM {$wpdb->prefix}nf_objects WHERE type = 'form';" );

		if ( ! $forms ) {
			return;
		}

		// There could be forms that already have this meta saved in the DB, we will ignore those
		$_forms = $wpdb->get_results( "SELECT object_id FROM {$wpdb->prefix}nf_objectmeta WHERE meta_key = 'affwp_allow_referrals';" );

		$forms  = wp_list_pluck( $forms, 'id' );
		$_forms = wp_list_pluck( $_forms, 'object_id' );
		$forms  = array_diff( $forms, $_forms );

		if ( ! $forms ) {
			return;
		}

		foreach ( $forms as $form_id ) {

			$wpdb->query(
				$wpdb->prepare(
					"INSERT INTO {$wpdb->prefix}nf_objectmeta (object_id,meta_key,meta_value) VALUES (%d,'affwp_allow_referrals','1');",
					$form_id
				)
			);

		}
	}

	/**
	 * Perform database upgrades for version 1.7.11
	 *
	 * @access  private
	 * @since   1.7.11
	 */
	private function v1711_upgrades() {

		$settings = affiliate_wp()->settings->get_all();

		// Ensures settings are not lost if the duplicate email/subject fields were used before they were removed
		if ( ! empty( $settings['rejected_email'] ) && empty( $settings['rejection_email'] ) ) {
			$settings['rejection_email'] = $settings['rejected_email'];
			unset( $settings['rejected_email'] );
		}

		if ( ! empty( $settings['rejected_subject'] ) && empty( $settings['rejection_subject'] ) ) {
			$settings['rejection_subject'] = $settings['rejected_subject'];
			unset( $settings['rejected_subject'] );
		}

		// Update settings.
		affiliate_wp()->settings->set( $settings, $save = true );

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 1.7.14
	 *
	 * @access  private
	 * @since   1.7.14
	 */
	private function v1714_upgrades() {

		@affiliate_wp()->visits->create_table();

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 1.9.
	 *
	 * @since 1.9
	 * @access private
	 */
	private function v19_upgrade() {
		@affiliate_wp()->referrals->create_table();
		@affiliate_wp()->utils->log( 'Upgrade: The Referrals table upgrade for 1.9 has completed.' );

		@affiliate_wp()->affiliates->payouts->create_table();
		@affiliate_wp()->utils->log( 'Upgrade: The Payouts table creation process for 1.9 has completed.' );

		@affiliate_wp()->REST->consumers->create_table();
		@affiliate_wp()->utils->log( 'Upgrade: The API consumers table creation process for 1.9 has completed' );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 1.9.5.
	 *
	 * @since 1.9.5
	 * @access private
	 */
	private function v195_upgrade() {
		@affiliate_wp()->affiliates->payouts->create_table();
		@affiliate_wp()->utils->log( 'Upgrade: The Payouts table upgrade for 1.9.5 has completed.' );

		wp_cache_set( 'last_changed', microtime(), 'payouts' );
		@affiliate_wp()->utils->log( 'Upgrade: The Payouts cache has been invalidated following the 1.9.5 upgrade routine.' );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 2.0.
	 *
	 * @since 2.0
	 * @access private
	 */
	private function v20_upgrade() {
		// New primitive and meta capabilities.
		@affiliate_wp()->capabilities->add_caps();
		@affiliate_wp()->utils->log( 'Upgrade: Core capabilities have been upgraded.' );

		// Update settings
		@affiliate_wp()->settings->set(
			[
				'required_registration_fields' => [
					'your_name'   => __( 'Your Name', 'affiliate-wp' ),
					'website_url' => __( 'Website URL', 'affiliate-wp' ),
				],
			],
			$save = true
		);
		@affiliate_wp()->utils->log( 'Upgrade: The default required registration field settings have been configured.' );

		// Affiliate schema update.
		@affiliate_wp()->affiliates->create_table();
		@affiliate_wp()->utils->log( 'Upgrade: The unpaid_earnings column has been added to the affiliates table.' );

		wp_cache_set( 'last_changed', microtime(), 'affiliates' );
		@affiliate_wp()->utils->log( 'Upgrade: The Affiliates cache has been invalidated following the 2.0 upgrade.' );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 2.0.2.
	 *
	 * @since 2.0.2
	 * @access private
	 */
	private function v202_upgrade() {
		// New 'context' column for visits.
		@affiliate_wp()->visits->create_table();
		$this->log( 'Upgrade: The context column has been added to the Visits table.' );

		wp_cache_set( 'last_changed', microtime(), 'visits' );
		$this->log( 'Upgrade: The Visits cache has been invalidated following the 2.0.2 upgrade.' );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 2.0.10.
	 *
	 * @since 2.0.10
	 * @access private
	 */
	private function v210_upgrade() {
		update_option( 'affwp_flush_rewrites', '1' );
		@affiliate_wp()->utils->log( 'Upgrade: AffiliateWP rewrite rules have been flushed following the 2.0.10 upgrade.' );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 2.1.
	 *
	 * @access private
	 * @since  2.1
	 */
	private function v21_upgrade() {
		// Schedule a rewrites flush.
		flush_rewrite_rules();
		$this->log( 'Upgrade: Rewrite rules flushed following the 2.1 upgrade.' );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 2.1.3.1.
	 *
	 * @access private
	 * @since  2.1.3.1
	 */
	private function v2131_upgrade() {
		// Refresh capabilities missed in 2.1 update (export_visit_data).
		@affiliate_wp()->capabilities->add_caps();
		@affiliate_wp()->utils->log( 'Upgrade: Core capabilities have been upgraded for 2.1.3.1.' );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 2.2.
	 *
	 * @access private
	 * @since  2.2
	 */
	private function v22_upgrade() {

		global $wpdb;

		// Add type column to referrals database.
		@affiliate_wp()->referrals->create_table();
		$table = affiliate_wp()->referrals->table_name;
		$wpdb->query( "UPDATE $table SET type = 'sale' where type IS NULL;" );
		@affiliate_wp()->utils->log( 'Upgrade: Referrals table has been upgraded for 2.2.' );

		// New 'customer_id' column for referrals.
		@affiliate_wp()->referrals->create_table();
		@affiliate_wp()->capabilities->add_caps();
		@affiliate_wp()->customers->create_table();
		affiliate_wp()->utils->log( 'Upgrade: The customers table has been created.' );
		@affiliate_wp()->customer_meta->create_table();
		affiliate_wp()->utils->log( 'Upgrade: The customer meta table has been created.' );

		// Update email settings
		$registration_notifications   = 'registration_notifications';
		$admin_referral_notifications = 'admin_referral_notifications';
		$disable_all_emails           = 'disable_all_emails';

		/**
		 * Enable all email notifications by default.
		 * Fresh installations of AffiliateWP and upgrades should enable all notifications.
		 */
		$email_notifications = affiliate_wp()->settings->email_notifications( true );

		/**
		 * If "Disable All Emails" checkbox option was previously enabled,
		 * clear out the email notification array, essentially disabling all notifications.
		 */
		if ( affiliate_wp()->settings->get( $disable_all_emails ) ) {
			$email_notifications = [];
		}

		// Enable the new admin affiliate registration email if it was previously enabled.
		if ( affiliate_wp()->settings->get( $registration_notifications ) ) {
			$email_notifications['admin_affiliate_registration_email'] = __( 'Notify site admin when a new affiliate has registered', 'affiliate-wp' );
		} else {
			// Uncheck the new admin affiliate registration email if it was previously unchecked.
			unset( $email_notifications['admin_affiliate_registration_email'] );
		}

		// Enable the new admin referral notification email if it was previously enabled.
		if ( affiliate_wp()->settings->get( $admin_referral_notifications ) ) {
			$email_notifications['admin_new_referral_email'] = __( 'Notify site admin when new referrals are earned', 'affiliate-wp' );
		} else {
			// Uncheck the new admin referral notification email if it was previously unchecked.
			unset( $email_notifications['admin_new_referral_email'] );
		}

		// Make the required changes to the Email Notifications.
		@affiliate_wp()->settings->set(
			[
				'email_notifications' => $email_notifications,
			],
			$save = true
		);

		// Get all settings.
		$settings = affiliate_wp()->settings->get_all();

		// Remove old "Disable All Emails" setting.
		if ( isset( $settings[ $disable_all_emails ] ) ) {
			unset( $settings[ $disable_all_emails ] );
		}

		// Remove old "Notify Admin" setting.
		if ( isset( $settings[ $registration_notifications ] ) ) {
			unset( $settings[ $registration_notifications ] );
		}

		// Remove old "Notify Admin of Referrals" setting.
		if ( isset( $settings[ $admin_referral_notifications ] ) ) {
			unset( $settings[ $admin_referral_notifications ] );
		}

		// Update affwp_settings option.
		update_option( 'affwp_settings', $settings );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 2.2.2.
	 *
	 * @since 2.2.2
	 */
	private function v222_upgrade() {
		foreach ( $this->get_sites_for_upgrade() as $site_id ) {

			if ( is_multisite() ) {
				switch_to_blog( $site_id );
			}

			affiliate_wp()->affiliates->create_table();
			@affiliate_wp()->utils->log( sprintf( 'Upgrade: The rest_id column has been added to the Affiliates table for site #%1$s.', $site_id ) );

			affiliate_wp()->referrals->create_table();
			@affiliate_wp()->utils->log( sprintf( 'Upgrade: The rest_id column has been added to the Referrals table for site #%1$s.', $site_id ) );

			affiliate_wp()->REST->consumers->create_table();
			@affiliate_wp()->utils->log( sprintf( 'Upgrade: The status and date columns have been added to the REST Consumers table for site #%1$s.', $site_id ) );

			affiliate_wp()->visits->create_table();
			@affiliate_wp()->utils->log( sprintf( 'Upgrade: The rest_id column has been added to the Visits table for site #%1$s.', $site_id ) );

			// Populate the date and status columns for existing consumers.
			$consumers = affiliate_wp()->REST->consumers->get_consumers(
				[
					'number' => -1,
				]
			);

			if ( ! empty( $consumers ) ) {
				$date = get_post_field( 'post_date', affwp_get_affiliate_area_page_id() );

				if ( empty( $date ) ) {
					$date = gmdate( 'Y-m-d H:i:s' );
				} else {
					$date = gmdate( 'Y-m-d H:i:s', strtotime( $date ) );
				}

				foreach ( $consumers as $consumer ) {

					affiliate_wp()->REST->consumers->update(
						$consumer->ID,
						[
							'date'   => $date,
							'status' => 'active',
						]
					);
				}
			}

			if ( is_multisite() ) {
				restore_current_blog();
			}
		}

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 2.2.8.
	 *
	 * @since 2.2.8
	 */
	private function v228_upgrade() {
		affiliate_wp()->referrals->create_table();
		@affiliate_wp()->utils->log( 'Upgrade: The length of the campaign column in the Referrals table has been changed to 50 characters.' );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 2.2.9.
	 *
	 * @since 2.2.9
	 */
	private function v229_upgrade() {
		affiliate_wp()->referrals->create_table();
		@affiliate_wp()->utils->log( 'Upgrade: The parent_id column has been added to the Referrals table.' );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 2.3.
	 *
	 * @since 2.3
	 */
	private function v23_upgrade() {
		// Adds the flat rate basis column.
		affiliate_wp()->affiliates->create_table();
		@affiliate_wp()->utils->log( 'Upgrade: the flat_rate_basis column has been added to the Affiliates table.' );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 2.4.
	 *
	 * @since 2.4
	 */
	private function v24_upgrade() {
		// New 'service_account, service_id, service_invoice_link and description' columns for payouts.
		affiliate_wp()->affiliates->payouts->create_table();
		@affiliate_wp()->utils->log( 'Upgrade: The service_account, service_id, service_invoice_link and description columns have been added to the Payouts table.' );

		wp_cache_set( 'last_changed', microtime(), 'payouts' );
		@affiliate_wp()->utils->log( 'Upgrade: The Payouts cache has been invalidated following the 2.4 upgrade.' );

		// Adds the referral meta table.
		affiliate_wp()->referral_meta->create_table();
		@affiliate_wp()->utils->log( 'Upgrade: The referral meta table has been created.' );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 2.4.2.
	 *
	 * @since 2.4.2
	 */
	private function v242_upgrade() {
		// Flush rewrites for the benefit of the EDD integration.
		flush_rewrite_rules();
		@affiliate_wp()->utils->log( 'Upgrade: Rewrite rules flushed.' );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 2.5.
	 *
	 * @since 2.5
	 */
	private function v25_upgrade() {
		affiliate_wp()->referrals->sales->create_table();
		@affiliate_wp()->utils->log( 'Upgrade: The sales table has been created.' );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 2.6.
	 *
	 * @since 2.6
	 */
	private function v26_upgrade() {
		affiliate_wp()->affiliates->coupons->create_table();
		@affiliate_wp()->utils->log( 'Upgrade: The coupons table has been created.' );

		// Enable the affiliate coupons setting (will not cause unexpected behavior).
		@affiliate_wp()->settings->set(
			[
				'affiliate_coupons' => true,
			],
			$save = true
		);

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for 2.7
	 *
	 * @since 2.7
	 */
	private function v27_upgrade() {
		global $wpdb;

		$dropped = $wpdb->query( "DROP VIEW IF EXISTS {$wpdb->prefix}affiliate_wp_campaigns" );

		if ( true === $dropped ) {
			@affiliate_wp()->utils->log( 'Upgrade: The campaigns view has been dropped.' );
		} else {
			@affiliate_wp()->utils->log( 'Upgrade: The campaigns view was not dropped.', $wpdb->last_error );
		}

		@affiliate_wp()->campaigns->create_table();
		@affiliate_wp()->utils->log( 'Upgrade: The campaigns table has been created.' );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for 2.7.4
	 *
	 * @since 2.7.4
	 */
	private function v274_upgrade() {
		$upload_dir = wp_upload_dir( null, false );
		$base_dir   = isset( $upload_dir['basedir'] ) ? $upload_dir['basedir'] : ABSPATH;

		$old_file = trailingslashit( $base_dir ) . 'affwp-debug.log';

		if ( file_exists( $old_file ) && is_writeable( $old_file ) && is_writeable( $base_dir ) ) {
			$hash     = affwp_get_hash( $upload_dir, affiliatewp_get_salt() );
			$new_file = trailingslashit( $base_dir ) . sprintf( 'affwp-debug-log__%s.log', $hash );
			@rename( $old_file, $new_file );
		}

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for 2.8.
	 *
	 * @since 2.8
	 */
	private function v28_upgrade() {
		global $wpdb;

		$table_name = affiliate_wp()->affiliates->coupons->table_name;

		// Update the length of the coupon_code column to 191 characters.
		affiliate_wp()->affiliates->coupons->create_table();

		affiliate_wp()->utils->log( 'Upgrade: The coupons table has been updated to support lengthier coupon codes and types.' );

		// Set default coupon format and hyphen delimeter.
		$coupons_settings = [
			'coupon_format'           => '{coupon_code}',
			'coupon_hyphen_delimiter' => 1,
		];

		affiliate_wp()->settings->set( $coupons_settings, $save = true );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for 2.9.
	 *
	 * @since 2.9
	 */
	private function v29_upgrade() {
		global $wpdb;

		$table_name = affiliate_wp()->affiliates->coupons->table_name;

		// Add the 'locked' column.
		affiliate_wp()->affiliates->coupons->create_table();

		affiliate_wp()->utils->log( 'Upgrade: The locked column has been added to the coupons table.' );

		// Update type field of existing coupons.
		$old_type = '';
		$new_type = 'dynamic';

		$wpdb->query(
			$wpdb->prepare(
				"UPDATE $table_name SET type = %s where type = %s;",
				$new_type,
				$old_type
			)
		);

		affiliate_wp()->utils->log( 'Upgrade: All dynamic coupons now have a "dynamic" type in the coupons table.' );

		wp_cache_set( 'last_changed', microtime(), 'coupons' );

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 2.9.5
	 *
	 * @access  private
	 * @since   2.9.5
	 */
	private function v295_upgrade() {
		affiliate_wp()->notifications->create_table();
		affiliate_wp()->utils->log( 'Upgrade: The in-plugin notifications table has been created.' );
		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 2.9.6
	 *
	 * @access  private
	 * @since   2.9.6
	 */
	private function v296_upgrade() {

		affiliate_wp()->referrals->create_table();
		@affiliate_wp()->utils->log( 'Upgrade: The flag column has been added to the referrals table.' );

		affiliate_wp()->visits->create_table();
		@affiliate_wp()->utils->log( 'Upgrade: The flag column has been added to the visits table.' );

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 2.9.6.1
	 *
	 * @access  private
	 * @since   2.9.6.1
	 */
	private function v2961_upgrade() {

		$this->fix_296_action_scheduler_issue();

		$this->upgraded = true;
	}

	/**
	 * Fix scheduler issues in 2.9.6
	 *
	 * Ensure that for the Action Scheduler actions
	 * affwp_daily_scheduled_events, and affwp_monthly_email_summaries
	 * that we make sure there are only one of each of these.
	 *
	 * In 2.9.6 we had an issue where many of these were created, when we only need one
	 * pending action for each of these.
	 *
	 * @since  2.9.6.1
	 * @access private
	 *
	 * @return void Early bail if there's just one scheduled (no duplicates).
	 */
	private function fix_296_action_scheduler_issue() {

		if ( ! function_exists( 'as_get_scheduled_actions' ) ) {
			return; // We can't fix it.
		}

		foreach ( [
			'affwp_monthly_email_summaries',
			'affwp_daily_scheduled_events',
		] as $action ) {

			if ( count(
				// Get all schedule actions (there may be many) for $action.
				as_get_scheduled_actions(
					[
						'hook'     => $action,
						'group'    => 'affiliatewp',
						'status'   => ActionScheduler_Store::STATUS_PENDING,
						'per_page' => -1,
					]
				)
			) <= 1 ) {

				// We only have one scheduled hook for $action, that's correct.
				continue;
			}

			// Remove them all, there should only be one.
			as_unschedule_all_actions( $action, [], 'affiliatewp' );

			// Tell the scheduler not to schedule an email summary for now.
			if ( 'affwp_monthly_email_summaries' === $action ) {
				update_option( 'affwp_email_summary_now', 'no' );
			}
		}
	}

	/**
	 * Perform database upgrades for version 2.11.0
	 *
	 * @access  private
	 * @since   2.11.0
	 */
	private function v2110_upgrade() {

		affiliate_wp()->creatives->create_table();

		@affiliate_wp()->utils->log( 'Upgrade: The attachment_id column has been added to the creatives table.' );

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 2.14.0.
	 *
	 * @access  private
	 * @since   2.14.0
	 */
	private function v2140_upgrade() {

		affiliate_wp()->creatives->create_table();

		@affiliate_wp()->utils->log( 'Upgrade: The type and date_updated columns has been added to the creatives table.' );

		affiliate_wp()->custom_links->create_table();

		@affiliate_wp()->utils->log( 'Upgrade: The custom_links table was created.' );

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 2.15.0.
	 *
	 * @access  private
	 * @since   2.15.0
	 */
	private function v2150_upgrade() {

		affiliate_wp()->creatives->create_table();

		@affiliate_wp()->utils->log( 'Upgrade: The start_date and end_date columns have been added to the creatives table.' );

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 2.16.0.
	 *
	 * @access  private
	 * @since   2.16.0
	 */
	private function v2160_upgrade() {

		affiliate_wp()->creatives->create_table();

		@affiliate_wp()->utils->log( 'Upgrade: The notes column has been added to the creatives table.' );

		// Ensure this will never be overridden.
		if ( ! in_array( get_option( 'affwp_creative_name_privacy', '' ), [ 'pending', 'private', 'public' ], true ) ) {

			update_option( 'affwp_creative_name_upgrade_date', gmdate( 'Y-m-d H:i:s' ) );

			$creatives = affiliate_wp()->creatives->count();

			update_option(
				'affwp_creative_name_privacy',
				empty( $creatives )
					? 'public'
					: 'pending'
			);

		}

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 2.16.3.
	 *
	 * @access  private
	 * @since   2.16.3
	 */
	private function v2163_upgrade() {
		require_once AFFILIATEWP_PLUGIN_DIR . 'includes/admin/class-usage.php';

		$usage_tracking = new Affiliate_WP_Usage_Tracking();

		// Track first registered affiliate.
		$usage_tracking->track_first_affiliate();

		// Track first referral.
		$usage_tracking->track_first_referral();

		// Track first payout.
		$usage_tracking->track_first_payout();

		// Track first creative.
		$usage_tracking->track_first_creative( 0, [] );

		/**
		 * Installs before v2.10.0 won't have the affwp_first_installed option row.
		 * If it doesn't exist, create it based on the post date of the current Affiliate Area page.
		 */
		if ( ! get_option( 'affwp_first_installed' ) ) {
			add_option( 'affwp_first_installed', strtotime( get_post_field( 'post_date', affwp_get_affiliate_area_page_id() ) ), '', 'no' );
		}

		// Remove older affwp_last_checkin option row.
		if ( get_option( 'affwp_last_checkin' ) ) {
			delete_option( 'affwp_last_checkin' );
		}

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 2.17.0.
	 *
	 * @since 2.17.0
	 */
	private function v2170_upgrade() {

		affiliate_wp()->creative_meta->create_table();

		@affiliate_wp()->utils->log( 'Upgrade: The creativemeta table was created.' );

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 2.18.0.
	 *
	 * @since 2.18.0
	 */
	private function v2180_upgrade() {
		// Get all settings.
		$settings = affiliate_wp()->settings->get_all();

		// User has Auto Register New Users option enabled.
		if ( ! empty( $settings['auto_register'] ) ) {

			// Enable our new setting under "Additional Registration Modes".
			$settings['additional_registration_modes'] = 'auto_register_new_users';

			// Unset the old setting.
			unset( $settings['auto_register'] );

			// Update the affwp_settings option.
			update_option( 'affwp_settings', $settings );
		}

		$this->upgraded = true;
	}

	/*
	Perform database upgrades for version 2.27.0.
	*
	* @since 2.27.0
	*/
	private function v2270_upgrade() {
		// Set the Commission Delay Period to 0 to for existing users.
		affiliate_wp()->settings->set( [ 'commission_holding_period' => 0 ], $save = true );

		$this->upgraded = true;
	}

	/**
	 * Perform database upgrades for version 2.28.0
	 *
	 * Migrate old reCAPTCHA settings to new CAPTCHA provider structure.
	 *
	 * Scenarios handled:
	 * - recaptcha_type = 'v2' → captcha_type = 'recaptcha' (keep recaptcha_type = 'v2')
	 * - recaptcha_type = 'v3' → captcha_type = 'recaptcha' (keep recaptcha_type = 'v3')
	 * - recaptcha_type missing/other → captcha_type = 'none'
	 * - captcha_type = 'none' + recaptcha_type = 'v2'/'v3' → captcha_type = 'recaptcha' (fixes inconsistent state)
	 *
	 * @access private
	 * @since  2.28.0
	 */
	private function upgrade_v2280_migrate_captcha_settings() {
		affiliate_wp()->utils->log( 'Upgrade v2.28.0: Starting CAPTCHA settings migration.' );

		// Get all settings.
		$settings = affiliate_wp()->settings->get_all();

		// Debug: Log what we're seeing.
		$captcha_type_status   = isset( $settings['captcha_type'] ) ? "'{$settings['captcha_type']}'" : 'NOT SET';
		$recaptcha_type_status = isset( $settings['recaptcha_type'] ) ? "'{$settings['recaptcha_type']}'" : 'NOT SET';
		affiliate_wp()->utils->log( "Upgrade v2.28.0: Current settings - captcha_type: $captcha_type_status, recaptcha_type: $recaptcha_type_status" );

		// Migrate if captcha_type doesn't exist, or if it's 'none' but recaptcha_type indicates reCAPTCHA is configured
		$should_migrate = ! isset( $settings['captcha_type'] )
			|| ( 'none' === $settings['captcha_type'] && isset( $settings['recaptcha_type'] ) && in_array( $settings['recaptcha_type'], [ 'v2', 'v3' ] ) );

		if ( $should_migrate ) {
			affiliate_wp()->utils->log( 'Upgrade v2.28.0: Performing CAPTCHA settings migration.' );

			// Check if recaptcha_type is set to v2 or v3 values
			if ( isset( $settings['recaptcha_type'] ) && in_array( $settings['recaptcha_type'], [ 'v2', 'v3' ] ) ) {
				// Set captcha_type to 'recaptcha' when reCAPTCHA is being used
				$settings['captcha_type'] = 'recaptcha';
				affiliate_wp()->utils->log( "Upgrade: Set captcha_type='recaptcha' for existing recaptcha_type='{$settings['recaptcha_type']}'." );
			} else {
				// No reCAPTCHA configuration found - set to none.
				$settings['captcha_type'] = 'none';
				$recaptcha_debug          = isset( $settings['recaptcha_type'] ) ? $settings['recaptcha_type'] : 'NOT SET';
				affiliate_wp()->utils->log( "Upgrade: Set captcha_type='none' - recaptcha_type was '$recaptcha_debug'." );
			}

			// Update the affwp_settings option.
			update_option( 'affwp_settings', $settings );
			affiliate_wp()->utils->log( 'Upgrade v2.28.0: Settings updated successfully.' );
		} else {
			affiliate_wp()->utils->log( "Upgrade v2.28.0: captcha_type setting already exists ('{$settings['captcha_type']}') and is consistent, skipping migration." );
		}

		affiliate_wp()->utils->log( 'Upgrade v2.28.0: CAPTCHA settings migration completed.' );
		$this->upgraded = true;
	}

	/**
	 * Schedules the log file migration for v2.30.0.
	 *
	 * @since 2.30.0
	 */
	private function upgrade_v2300_schedule_log_migration() {
		affiliate_wp()->utils->log( 'Upgrade v2.30.0: Scheduling log file migration.' );

		// Schedule the log migration cron event.
		Affiliate_WP_Logging::schedule_log_migration();

		affiliate_wp()->utils->log( 'Upgrade v2.30.0: Log file migration scheduled successfully.' );
		$this->upgraded = true;
	}

	/**
	 * Migrates the Allow Own Referrals addon settings for v2.31.0.
	 *
	 * If the Allow Own Referrals addon was previously active, migrates the setting
	 * to set fraud_prevention_self_referrals to 'allow'.
	 *
	 * @since 2.31.0
	 */
	private function upgrade_v2310_migrate_allow_own_referrals_addon() {
		// Check if we've already run this migration.
		if ( get_option( 'affwp_allow_own_referrals_migrated' ) ) {
			affiliate_wp()->utils->log( 'Upgrade v2.31.0: Allow Own Referrals migration already completed. Skipping.' );
			return;
		}

		affiliate_wp()->utils->log( 'Upgrade v2.31.0: Starting Allow Own Referrals addon migration.' );

		// Check if the Allow Own Referrals addon was previously active.
		$addon_path         = 'affiliatewp-allow-own-referrals/affiliatewp-allow-own-referrals.php';
		$was_addon_active   = false;
		$active_plugins     = get_option( 'active_plugins', [] );
		$network_plugins    = is_multisite() ? get_site_option( 'active_sitewide_plugins', [] ) : [];

		// Check regular active plugins.
		if ( in_array( $addon_path, $active_plugins, true ) ) {
			$was_addon_active = true;
		}

		// Check network-activated plugins.
		if ( ! $was_addon_active && isset( $network_plugins[ $addon_path ] ) ) {
			$was_addon_active = true;
		}

		// If the addon was active, set the self-referral setting to 'allow'.
		if ( $was_addon_active ) {
			affiliate_wp()->utils->log( 'Upgrade v2.31.0: Allow Own Referrals addon was previously active.' );

			$settings = affiliate_wp()->settings->get_all();

			// Set self-referral protection to 'allow'.
			$settings['fraud_prevention_self_referrals'] = 'allow';

			update_option( 'affwp_settings', $settings );
			affiliate_wp()->utils->log( 'Upgrade v2.31.0: Set fraud_prevention_self_referrals to "allow".' );
		} else {
			affiliate_wp()->utils->log( 'Upgrade v2.31.0: Allow Own Referrals addon was not previously active. No migration needed.' );
		}

		// Mark the migration as complete.
		update_option( 'affwp_allow_own_referrals_migrated', true );
		affiliate_wp()->utils->log( 'Upgrade v2.31.0: Allow Own Referrals migration completed successfully.' );

		$this->upgraded = true;
	}

	/**
	 * Performs database upgrades for version 2.33.0.
	 *
	 * - Adds the `batch_id` and `failure_class` columns to the payouts table.
	 * - Creates the new `affiliate_wp_payout_batches` table.
	 * - Migrates existing `store_credit_enabled=true` affiliates to `payout_method=store_credit`.
	 * - Inserts the one-time in-plugin notification announcing the new capability.
	 *
	 * Each sub-step has its own idempotency marker, so the individual pieces retry independently
	 * if one of them couldn't complete on the first pass (e.g. the notifications module not being
	 * loaded at the moment the upgrade fired).
	 *
	 * @since 2.33.0
	 */
	private function upgrade_v2330_payout_batches_table() {
		/*
		 * Re-run create_table on the payouts table to let dbDelta add the new
		 * columns: batch_id (links payouts to their parent batch) and
		 * failure_class (the triage-layer machine-readable failure category).
		 * dbDelta diffs the live schema against the desired one and ALTERs
		 * accordingly, so a single create_table() call handles both new
		 * columns plus the indexes on them.
		 */
		@affiliate_wp()->affiliates->payouts->create_table();
		@affiliate_wp()->utils->log( 'Upgrade v2.33.0: batch_id + failure_class columns added to the Payouts table.' );

		// Create the payout batches container table.
		@affiliate_wp()->affiliates->payout_batches->create_table();
		@affiliate_wp()->utils->log( 'Upgrade v2.33.0: Payout Batches table created.' );

		wp_cache_set( 'last_changed', microtime(), 'payouts' );
		wp_cache_set( 'last_changed', microtime(), 'payout_batches' );

		$this->upgrade_v2330_migrate_store_credit_to_payout_method();
		$this->upgrade_v2330_migrate_store_credit_all_affiliates_setting();
		$this->upgrade_v2330_migrate_store_credit_opt_in_setting();
		$this->upgrade_v2330_migrate_stripe_connection_to_payout_method();
		$this->upgrade_v2330_migrate_payouts_service_to_payout_method();
		$this->upgrade_v2330_infer_default_payout_method();
		$this->upgrade_v2330_init_auto_failure_handling();

		$this->upgraded = true;
	}

	/**
	 * Initializes the 2.33.0 failure-handling automations for an upgrading site.
	 *
	 * Auto-retry is left ON (the setting default) and made safe with a
	 * go-forward watermark: the sweep never retries a payout created before the
	 * upgrade, so the site's historical failed-payout backlog can't trigger a
	 * retroactive blast against payment providers. Only failures that occur
	 * after the upgrade are ever auto-retried.
	 *
	 * Both auto-email and auto-retry are left ON (their setting defaults) for
	 * upgrading sites, because both are safe to enable without consent:
	 *
	 *   - Auto-email is inherently go-forward — it only fires when a NEW payout
	 *     fails, never against the historical backlog — and is capped at one
	 *     email per affiliate per 7 days.
	 *   - Auto-retry is made go-forward by the watermark stamped here, so the
	 *     historical failed-payout backlog can never be retried.
	 *
	 * The capability is communicated via the 2.33.0 announcement and is visible
	 * in Settings → Payouts. Fresh installs also default both ON.
	 *
	 * Idempotent — gated on a one-shot option so it won't re-apply over choices
	 * the admin has since made.
	 *
	 * @since 2.33.0
	 */
	private function upgrade_v2330_init_auto_failure_handling() {
		if ( get_option( 'affwp_v2330_auto_handling_opt_out_applied' ) ) {
			@affiliate_wp()->utils->log( 'Upgrade v2.33.0: Auto-failure-handling init already applied. Skipping.' );
			return;
		}

		// Stamp the go-forward watermark at upgrade time so nothing older is ever auto-retried.
		if ( '' === (string) get_option( 'affwp_auto_retry_watermark', '' ) ) {
			update_option( 'affwp_auto_retry_watermark', gmdate( 'Y-m-d H:i:s' ) );
		}

		$settings = get_option( 'affwp_settings', array() );
		if ( is_array( $settings ) ) {
			// Clear any stale stored values so both settings fall back to their ON
			// default. (Pre-2.33.0 sites won't have these keys; this only matters
			// for builds that previously forced them off.)
			unset( $settings['auto_email_payout_failures'], $settings['auto_retry_failed_payouts'] );

			/*
			 * Carry across a customized Stripe "Transfer Failed" affiliate body.
			 * That email is superseded by the cross-method "Payout Failed —
			 * Action Required" email (Stripe body variant). If the admin had
			 * edited the old body, move their wording into the new Stripe variant
			 * so it isn't lost; an untouched default just adopts the new copy.
			 */
			$old_stripe_default = function_exists( 'affwp_get_stripe_email_content' )
				? (string) affwp_get_stripe_email_content( 'transfer_failed_affiliate', 'body' )
				: '';
			$old_stripe_saved = isset( $settings['stripe_transfer_failed_affiliate_body'] ) ? (string) $settings['stripe_transfer_failed_affiliate_body'] : '';

			if ( '' !== $old_stripe_saved
				&& $old_stripe_saved !== $old_stripe_default
				&& empty( $settings['payout_failure_stripe_body'] )
			) {
				$settings['payout_failure_stripe_body'] = $old_stripe_saved;
				@affiliate_wp()->utils->log( 'Upgrade v2.33.0: migrated customized Stripe transfer-failed affiliate body to the cross-method failure email.' );
			}

			update_option( 'affwp_settings', $settings );
		}

		update_option( 'affwp_v2330_auto_handling_opt_out_applied', 1 );

		@affiliate_wp()->utils->log( 'Upgrade v2.33.0: auto-email + auto-retry ON (auto-retry guarded by go-forward watermark).' );
	}

	/**
	 * Releases "Payout Failed — Action Required" email bodies that were frozen
	 * at the original 2.33.0 defaults back to the live code default.
	 *
	 * Those per-method body fields are default-backed settings: saving the
	 * Emails tab persists each field even when untouched, freezing the
	 * then-current default into the DB. Sites that saved that tab on 2.33.0
	 * captured the original PayPal/generic copy, so the corrected 2.34.0 wording
	 * can never reach them — a stored value always wins over the code default.
	 *
	 * This DELETES any stored body that still byte-for-byte matches an original
	 * 2.33.0 default (line endings normalized, because the form stores CRLF while
	 * the code default is LF). Matching the verbatim shipped default is proof the
	 * admin never edited the copy — they just saved the tab — so it's safe to
	 * release. A genuinely customized body differs from the default and is left
	 * untouched; Stripe bodies match their own (unchanged) default and so never
	 * match these strings.
	 *
	 * Deleting rather than overwriting with the 2.34.0 copy means the field falls
	 * back to the live default and keeps tracking future copy changes, instead of
	 * re-freezing at the new text.
	 *
	 * Idempotent — gated on a one-shot option.
	 *
	 * @since 2.34.0
	 */
	private function upgrade_v2340_unfreeze_payout_failure_emails() {
		if ( get_option( 'affwp_v2340_payout_email_unfreeze_applied' ) ) {
			return;
		}

		$settings = get_option( 'affwp_settings', array() );

		if ( is_array( $settings ) ) {

			/*
			 * The exact bodies that shipped as defaults in 2.33.0 (LF line
			 * endings). A stored value equal to one of these means the admin
			 * saved the Emails tab without editing the copy.
			 */
			$shipped_2330_defaults = array(
				// PayPal variant.
				"Hi {name},\n\nWe couldn't send your {amount} commission from {site_name} via PayPal.\n\nPlease open your settings and make sure the PayPal email on your account is correct and verified with PayPal:\n{affiliate_payout_settings_url}\n\nOnce that's done, we'll send it again automatically. Questions? Just reply to this email.\n\nThanks,\n{site_name}",
				// Generic cross-method variant.
				"Hi {name},\n\nWe tried to send your {amount} commission from {site_name}, but it couldn't be completed. Your payment details need attention.\n\nReview your settings and make sure everything is correct and verified:\n{affiliate_payout_settings_url}\n\nOnce that's done, we'll send it again automatically. Questions? Just reply to this email.\n\nThanks,\n{site_name}",
			);

			/*
			 * Normalize so a CRLF form-save compares equal to the LF code
			 * default, and incidental slashing doesn't defeat the match.
			 */
			$normalize = static function ( $value ) {
				return stripslashes( str_replace( array( "\r\n", "\r" ), "\n", (string) $value ) );
			};

			$normalized_defaults = array_map( $normalize, $shipped_2330_defaults );
			$released            = 0;

			foreach ( $settings as $key => $value ) {

				// Only the per-method failure-email body fields are in scope.
				if ( ! is_string( $key ) || 1 !== preg_match( '/^payout_failure_.+_body$/', $key ) ) {
					continue;
				}

				if ( in_array( $normalize( $value ), $normalized_defaults, true ) ) {
					unset( $settings[ $key ] );
					$released++;
				}
			}

			if ( $released > 0 ) {
				update_option( 'affwp_settings', $settings );
				@affiliate_wp()->utils->log( sprintf( 'Upgrade v2.34.0: released %d payout-failure email body field(s) frozen at the 2.33.0 default back to the code default.', $released ) );
			}
		}

		update_option( 'affwp_v2340_payout_email_unfreeze_applied', 1 );
	}

	/**
	 * Removes the legacy campaign unique-URLs cache rows from wp_options.
	 *
	 * Since 2.12.0, Affiliate_WP_Campaigns_DB::get_unique_visit_count() cached the
	 * array of unique visit URLs per affiliate/campaign in options named
	 * `affwp_campaign_unique_urls_<crc32>`, with no expiry or invalidation, bloating
	 * the options table. As of 2.34.1 that cache lives in the object cache instead,
	 * so the leftover rows are dead data and can be deleted.
	 *
	 * Idempotent — gated on a one-shot option.
	 *
	 * @since 2.34.1
	 */
	private function upgrade_v2341_remove_campaign_unique_urls_options() {
		if ( get_option( 'affwp_v2341_campaign_urls_options_removed' ) ) {
			return;
		}

		global $wpdb;

		// Underscores are escaped so they aren't treated as single-char wildcards.
		$deleted = $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE 'affwp\\_campaign\\_unique\\_urls\\_%'" );

		if ( $deleted > 0 ) {
			@affiliate_wp()->utils->log( sprintf( 'Upgrade v2.34.1: removed %d legacy campaign unique-URLs cache option(s).', $deleted ) );
		}

		update_option( 'affwp_v2341_campaign_urls_options_removed', 1, false );

		$this->upgraded = true;
	}

	/**
	 * Migrates affiliates with the legacy `store_credit_enabled=true` meta flag to the new
	 * `payout_method=store_credit` meta value.
	 *
	 * Idempotent — safe to run more than once; skips affiliates that already have a
	 * `payout_method` set so any earlier explicit choice isn't clobbered.
	 *
	 * The legacy `store_credit_enabled` meta is left in place for rollback safety and will be
	 * cleaned up in a later release.
	 *
	 * @since 2.33.0
	 */
	private function upgrade_v2330_migrate_store_credit_to_payout_method() {
		global $wpdb;

		if ( get_option( 'affwp_payout_method_migrated' ) ) {
			@affiliate_wp()->utils->log( 'Upgrade v2.33.0: Store Credit → payout_method migration already completed. Skipping.' );
			return;
		}

		$meta_table = affiliate_wp()->affiliate_meta->table_name;

		// Affiliates that opted into Store Credit under the old model.
		$affiliate_ids = $wpdb->get_col(
			"SELECT DISTINCT affiliate_id FROM {$meta_table}
			 WHERE meta_key = 'store_credit_enabled' AND meta_value = '1'"
		);

		$scheduled = $this->schedule_v2330_backfill_batches( 'affwp_v2330_store_credit_backfill_batch', $affiliate_ids );

		if ( 0 === $scheduled ) {
			update_option( 'affwp_payout_method_migrated', true );
		}

		@affiliate_wp()->utils->log( sprintf( 'Upgrade v2.33.0: Store Credit → payout_method backfill scheduled in %d batches.', $scheduled ) );
	}

	/**
	 * Worker — backfills `payout_method = store_credit` for a chunk of affiliate IDs.
	 *
	 * @since 2.33.0
	 *
	 * @param int[] $affiliate_ids Affiliate IDs to process in this chunk.
	 */
	public function process_v2330_store_credit_backfill_batch( $affiliate_ids ) {

		$migrated = 0;

		foreach ( (array) $affiliate_ids as $affiliate_id ) {
			$affiliate_id = absint( $affiliate_id );
			if ( empty( $affiliate_id ) ) {
				continue;
			}

			$existing = affwp_get_affiliate_meta( $affiliate_id, 'payout_method', true );
			if ( ! empty( $existing ) ) {
				continue;
			}

			affwp_update_affiliate_meta( $affiliate_id, 'payout_method', 'store_credit' );
			$migrated++;
		}

		$this->mark_v2330_backfill_batch_complete( 'affwp_v2330_store_credit_backfill_batch', 'affwp_payout_method_migrated', 'Store Credit', $migrated );
	}

	/**
	 * Backfills payout_method = 'stripe' for affiliates who have an existing Stripe Connect
	 * connection but no explicit payout_method assignment.
	 *
	 * Pre-2.33.0, the Stripe Connect OAuth flow only wrote `affwp_stripe_payouts_account_id`
	 * to user meta and didn't touch the affiliate's payout_method. Under the new model,
	 * completing Stripe Connect counts as the affiliate's explicit method selection, so
	 * existing connected affiliates need their payout_method backfilled to match.
	 *
	 * Idempotent — the option marker short-circuits re-runs. Doesn't clobber affiliates who
	 * already have a payout_method set (earlier migrations, store credit migration, etc.).
	 *
	 * @since 2.33.0
	 */
	private function upgrade_v2330_migrate_stripe_connection_to_payout_method() {
		global $wpdb;

		if ( get_option( 'affwp_stripe_payout_method_migrated' ) ) {
			@affiliate_wp()->utils->log( 'Upgrade v2.33.0: Stripe connection → payout_method migration already completed. Skipping.' );
			return;
		}

		// User IDs with a stored Stripe Connect account ID.
		$user_ids = $wpdb->get_col(
			"SELECT DISTINCT user_id FROM {$wpdb->usermeta}
			 WHERE meta_key = 'affwp_stripe_payouts_account_id' AND meta_value != ''"
		);

		$scheduled = $this->schedule_v2330_backfill_batches( 'affwp_v2330_stripe_backfill_batch', $user_ids );

		if ( 0 === $scheduled ) {
			update_option( 'affwp_stripe_payout_method_migrated', true );
		}

		@affiliate_wp()->utils->log( sprintf( 'Upgrade v2.33.0: Stripe connection → payout_method backfill scheduled in %d batches.', $scheduled ) );
	}

	/**
	 * Worker — backfills `payout_method = stripe` for a chunk of user IDs that have a
	 * Stripe Connect account on file.
	 *
	 * @since 2.33.0
	 *
	 * @param int[] $user_ids User IDs to process in this chunk.
	 */
	public function process_v2330_stripe_backfill_batch( $user_ids ) {

		$migrated = 0;

		foreach ( (array) $user_ids as $user_id ) {
			$user_id = absint( $user_id );
			if ( empty( $user_id ) ) {
				continue;
			}

			$affiliate_id = affwp_get_affiliate_id( $user_id );
			if ( empty( $affiliate_id ) ) {
				continue;
			}

			$existing = affwp_get_affiliate_meta( $affiliate_id, 'payout_method', true );
			if ( ! empty( $existing ) ) {
				continue;
			}

			affwp_update_affiliate_meta( $affiliate_id, 'payout_method', 'stripe' );
			$migrated++;
		}

		$this->mark_v2330_backfill_batch_complete( 'affwp_v2330_stripe_backfill_batch', 'affwp_stripe_payout_method_migrated', 'Stripe', $migrated );
	}

	/**
	 * One-time 2.34.0 backfill over existing affiliate Stripe Express accounts. It does two things:
	 * stores each affiliate's `affwp_stripe_country` (on every platform), and on a US platform
	 * requests the `tax_reporting_us_1099_misc` capability on US accounts that lack it.
	 *
	 * Accounts created before 2.34.0 have no 1099 capability, so US affiliates already connected
	 * can't have a 1099 filed until it's requested. The worker requests it (US platform and US
	 * account only, since Stripe rejects it elsewhere) and stamps `metadata.affiliate_id` so the
	 * verification email and future `account.updated` webhooks can resolve the affiliate. Stripe
	 * then surfaces the tax-info requirement through the existing account.updated to verification
	 * email flow. The country backfill runs regardless of platform so non-US (e.g. UK) stores still
	 * get their affiliates' country for routing, reporting, and payout-method visibility.
	 *
	 * @since 2.34.0
	 */
	private function upgrade_v2340_stripe_tax_capability_backfill() {
		global $wpdb;

		if ( get_option( 'affwp_v2340_stripe_tax_capability_migrated' ) ) {
			@affiliate_wp()->utils->log( 'Upgrade v2.34.0: Stripe backfill already completed. Skipping.' );
			return;
		}

		// Runs on every platform: the worker stores each affiliate's country regardless of platform and
		// gates only the US-1099 capability request on the platform being US.

		// User IDs with a stored (completed-onboarding) Stripe Connect account ID.
		$user_ids = $wpdb->get_col(
			"SELECT DISTINCT user_id FROM {$wpdb->usermeta}
			 WHERE meta_key = 'affwp_stripe_payouts_account_id' AND meta_value != ''"
		);

		// Reuse the shared batch scheduler (hook-parameterized; batches of 50 via Action Scheduler).
		$scheduled = $this->schedule_v2330_backfill_batches( 'affwp_v2340_stripe_tax_backfill_batch', $user_ids );

		if ( 0 === $scheduled ) {
			update_option( 'affwp_v2340_stripe_tax_capability_migrated', true );
		}

		@affiliate_wp()->utils->log( sprintf( 'Upgrade v2.34.0: Stripe 1099 capability backfill scheduled in %d batches.', $scheduled ) );
	}

	/**
	 * Worker for one chunk. Per account: retrieve it and store the affiliate's `affwp_stripe_country`
	 * from Stripe (every platform). On a US platform, also request `tax_reporting_us_1099_misc` on US
	 * accounts that lack it and stamp `metadata.affiliate_id`.
	 *
	 * The capability is US-only and a non-US platform has no US filing role, so the request is gated
	 * on the platform being US (an unknown platform country is treated as non-US, never guessed). Each
	 * Stripe call is guarded so one API failure doesn't abort the chunk. Unlike the 2.33.0 workers
	 * this does network I/O, hence the per-account try/catch.
	 *
	 * @since 2.34.0
	 *
	 * @param int[] $user_ids User IDs to process in this chunk.
	 */
	public function process_v2340_stripe_tax_backfill_batch( $user_ids ) {

		$migrated = 0;

		// Nothing to do without Stripe credentials; mark complete so the batch doesn't hang.
		if ( function_exists( 'affwp_stripe_payouts_is_configured' ) && ! affwp_stripe_payouts_is_configured() ) {
			$this->mark_v2330_backfill_batch_complete( 'affwp_v2340_stripe_tax_backfill_batch', 'affwp_v2340_stripe_tax_capability_migrated', 'Stripe Tax', 0 );
			return;
		}

		affwp_stripe_payouts_init_api();

		// The 1099 capability is a US platform's concern only: a non-US store (e.g. UK) has no US
		// filing role, so Stripe leaves it unfulfillable. Resolve the platform once and gate only the
		// capability request on it; the per-affiliate country backfill below runs on every platform.
		// An unknown platform country (WP_Error) is treated as non-US, never guessed, matching the
		// gate at account creation.
		$platform_country = affwp_stripe_payouts_get_platform_country();
		$platform_is_us   = ! is_wp_error( $platform_country ) && 'US' === $platform_country;

		foreach ( (array) $user_ids as $user_id ) {
			$user_id = absint( $user_id );
			if ( empty( $user_id ) ) {
				continue;
			}

			$affiliate_id = affwp_get_affiliate_id( $user_id );
			if ( empty( $affiliate_id ) ) {
				continue;
			}

			$account_id = get_user_meta( $user_id, 'affwp_stripe_payouts_account_id', true );
			if ( empty( $account_id ) ) {
				continue;
			}

			try {
				// Smooth the call rate so large batches stay well under Stripe's API limit.
				usleep( 100000 );

				$account = $this->stripe_call_with_backoff(
					static function () use ( $account_id ) {
						return \Stripe\Account::retrieve( $account_id );
					}
				);

				// Backfill the affiliate's country from Stripe (the source of truth) for routing,
				// reporting, and payout-method visibility.
				if ( isset( $account->country ) ) {
					affwp_update_affiliate_meta( $affiliate_id, 'affwp_stripe_country', strtoupper( (string) $account->country ) );
				}

				// Request the 1099 capability only for US accounts on a US platform that don't already
				// have it. Everything else keeps the country backfill above but no capability request.
				if ( ! $platform_is_us
					|| 'US' !== strtoupper( (string) $account->country )
					|| isset( $account->capabilities->tax_reporting_us_1099_misc )
				) {
					continue;
				}

				$this->stripe_call_with_backoff(
					static function () use ( $account_id, $affiliate_id ) {
						return \Stripe\Account::update(
							$account_id,
							[
								'capabilities' => [
									'tax_reporting_us_1099_misc' => [ 'requested' => true ],
								],
								// Stamp the affiliate so the verification email + future webhooks can resolve it.
								'metadata'     => [
									'affiliate_id' => $affiliate_id,
								],
							]
						);
					}
				);

				$migrated++;
			} catch ( \Exception $e ) {
				@affiliate_wp()->utils->log( sprintf( 'Upgrade v2.34.0: Stripe 1099 backfill failed for account %s (affiliate #%d): %s', $account_id, $affiliate_id, $e->getMessage() ) );
				continue;
			}
		}

		$this->mark_v2330_backfill_batch_complete( 'affwp_v2340_stripe_tax_backfill_batch', 'affwp_v2340_stripe_tax_capability_migrated', 'Stripe Tax', $migrated );
	}

	/**
	 * Runs a Stripe API closure, retrying on rate limits with a short backoff.
	 *
	 * The v2.34.0 backfill does network I/O per account, so a large batch can brush Stripe's API
	 * limit. A short retry-with-backoff absorbs transient rate limits so most accounts succeed on a
	 * retry rather than failing on the first 429. This keeps the per-batch accounting intact (no
	 * re-scheduling); it runs inside an Action Scheduler worker, so a brief sleep is acceptable.
	 * Non-rate-limit errors propagate immediately. An account still rate-limited after the final
	 * attempt is logged and skipped for this run (the batch is not re-queued); it isn't stranded —
	 * the 1099 capability is re-requested when the affiliate's account.updated webhook fires or they
	 * re-onboard, and every affiliate connecting after the upgrade gets it at account creation.
	 *
	 * @since 2.34.0
	 *
	 * @param callable $fn The Stripe API call to run.
	 * @return mixed The closure's return value.
	 * @throws \Exception Re-throws the last rate-limit error after the final attempt (the caller catches it).
	 */
	private function stripe_call_with_backoff( callable $fn ) {
		$attempt = 0;

		while ( true ) {
			try {
				return $fn();
			} catch ( \Stripe\Exception\RateLimitException $e ) {
				if ( ++$attempt >= 3 ) {
					throw $e;
				}
				// Back off 1s then 2s before retrying.
				sleep( $attempt );
			}
		}
	}

	/**
	 * Backfills `payout_method = payouts-service` for affiliates who completed Payouts Service
	 * onboarding before 2.33.0 (status = 'payout_method_added'). Mirrors the Stripe Connect
	 * backfill — pre-2.33.0 the active payout method was implicit in the per-method batch flow,
	 * so existing PS affiliates need their effective method written explicitly so the new
	 * unified batch processor routes them correctly.
	 *
	 * Only writes when the affiliate has no existing payout_method on file (earlier
	 * migrations and any explicit choice are preserved).
	 *
	 * Skips partially-onboarded affiliates (status = 'account_created'): they haven't picked
	 * a payout method yet, so they aren't actually being paid via PS — falling back to the
	 * site default is correct for them.
	 *
	 * Idempotent — the option marker short-circuits re-runs.
	 *
	 * @since 2.33.0
	 */
	private function upgrade_v2330_migrate_payouts_service_to_payout_method() {
		global $wpdb;

		if ( get_option( 'affwp_payouts_service_payout_method_migrated' ) ) {
			@affiliate_wp()->utils->log( 'Upgrade v2.33.0: Payouts Service → payout_method migration already completed. Skipping.' );
			return;
		}

		$meta_table = affiliate_wp()->affiliate_meta->table_name;

		$affiliate_ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT DISTINCT affiliate_id FROM {$meta_table} WHERE meta_key = %s AND meta_value != ''",
				'payouts_service_account'
			)
		);

		$scheduled = $this->schedule_v2330_backfill_batches( 'affwp_v2330_payouts_service_backfill_batch', $affiliate_ids );

		if ( 0 === $scheduled ) {
			update_option( 'affwp_payouts_service_payout_method_migrated', true );
		}

		@affiliate_wp()->utils->log( sprintf( 'Upgrade v2.33.0: Payouts Service → payout_method backfill scheduled in %d batches.', $scheduled ) );
	}

	/**
	 * Worker — backfills `payout_method = payouts-service` for a chunk of affiliate IDs.
	 *
	 * @since 2.33.0
	 *
	 * @param int[] $affiliate_ids Affiliate IDs to process in this chunk.
	 */
	public function process_v2330_payouts_service_backfill_batch( $affiliate_ids ) {

		$migrated = 0;

		foreach ( (array) $affiliate_ids as $affiliate_id ) {
			$affiliate_id = absint( $affiliate_id );
			if ( empty( $affiliate_id ) ) {
				continue;
			}

			$ps_meta = affwp_get_affiliate_meta( $affiliate_id, 'payouts_service_account', true );
			if ( ! is_array( $ps_meta ) || empty( $ps_meta['status'] ) ) {
				continue;
			}

			if ( 'payout_method_added' !== $ps_meta['status'] ) {
				continue;
			}

			$existing = affwp_get_affiliate_meta( $affiliate_id, 'payout_method', true );
			if ( ! empty( $existing ) ) {
				continue;
			}

			affwp_update_affiliate_meta( $affiliate_id, 'payout_method', 'payouts-service' );
			$migrated++;
		}

		$this->mark_v2330_backfill_batch_complete( 'affwp_v2330_payouts_service_backfill_batch', 'affwp_payouts_service_payout_method_migrated', 'Payouts Service', $migrated );
	}

	/**
	 * Chunks a list of IDs into 50-row batches and schedules an Action Scheduler worker
	 * for each chunk against the given hook. Returns the batch count so the caller can
	 * mark the migration complete inline when there is nothing to do.
	 *
	 * The completion marker for the migration is written by the LAST batch's worker via
	 * `mark_v2330_backfill_batch_complete()`, which compares processed-vs-total counts
	 * stored against the hook name.
	 *
	 * @since 2.33.0
	 *
	 * @param string $hook Action Scheduler hook to fire for each chunk.
	 * @param array  $ids  IDs to chunk.
	 * @return int Number of batches scheduled.
	 */
	private function schedule_v2330_backfill_batches( $hook, $ids ) {

		$ids = array_values( array_filter( array_map( 'absint', (array) $ids ) ) );

		if ( empty( $ids ) ) {
			return 0;
		}

		$batch_size = 50;
		$chunks     = array_chunk( $ids, $batch_size );
		$total      = count( $chunks );

		update_option( "{$hook}_total", $total );
		update_option( "{$hook}_completed", 0 );

		/*
		 * This upgrade is dispatched from init()'s `init` priority -9999 hook, but
		 * Action Scheduler does not initialize its data store until `init` priority 1.
		 * Calling as_schedule_single_action() before that point is a silent no-op: it
		 * returns 0 without scheduling anything and emits a "called before the Action
		 * Scheduler data store was initialized" notice. That would drop the backfill
		 * entirely while the upgrade still marks itself complete and bumps affwp_version.
		 *
		 * Defer the actual scheduling until Action Scheduler signals it is ready. The
		 * `action_scheduler_init` action fires at `init` priority 1 once the store is
		 * initialized, and is the documented-safe point to use the procedural API.
		 */
		$schedule_batches = static function () use ( $chunks, $hook ) {
			foreach ( $chunks as $i => $chunk_ids ) {
				as_schedule_single_action(
					time() + ( $i * 30 ),
					$hook,
					[ $chunk_ids ],
					'affiliatewp'
				);
			}
		};

		if ( did_action( 'action_scheduler_init' ) ) {
			$schedule_batches();
		} else {
			add_action( 'action_scheduler_init', $schedule_batches );
		}

		return $total;
	}

	/**
	 * Records the completion of one v2.33.0 backfill batch; writes the migration's
	 * "done" option marker once the final batch lands.
	 *
	 * @since 2.33.0
	 *
	 * @param string $hook        Hook name used when scheduling (keys the progress options).
	 * @param string $done_option Option name to set when all batches finish.
	 * @param string $label       Human-readable label for logging.
	 * @param int    $migrated    Number of rows migrated in this batch.
	 */
	private function mark_v2330_backfill_batch_complete( $hook, $done_option, $label, $migrated ) {

		$completed = (int) get_option( "{$hook}_completed", 0 ) + 1;
		$total     = (int) get_option( "{$hook}_total", 0 );

		update_option( "{$hook}_completed", $completed );

		@affiliate_wp()->utils->log( sprintf( 'Upgrade v2.33.0: %s backfill batch %d/%d migrated %d rows.', $label, $completed, $total, $migrated ) );

		if ( $total > 0 && $completed >= $total ) {
			update_option( $done_option, true );
			@affiliate_wp()->utils->log( sprintf( 'Upgrade v2.33.0: %s backfill complete (%d batches).', $label, $total ) );
		}
	}

	/**
	 * Translates the legacy Store Credit "Enable For All Affiliates" setting into the new
	 * unified control model: default_payout_method = store_credit AND
	 * allow_affiliate_payout_method_selection = false.
	 *
	 * The legacy `store-credit-all-affiliates` flag is left in the DB for rollback safety
	 * but is no longer read by core — the new unified settings are authoritative.
	 * See decision-store-credit-legacy-deprecation.md.
	 *
	 * Idempotent — the option marker short-circuits re-runs.
	 *
	 * @since 2.33.0
	 */
	private function upgrade_v2330_migrate_store_credit_all_affiliates_setting() {

		if ( get_option( 'affwp_sc_all_affiliates_migrated' ) ) {
			return;
		}

		$sc_all_on = (bool) affiliate_wp()->settings->get( 'store-credit-all-affiliates' );

		if ( $sc_all_on ) {
			affiliate_wp()->settings->set(
				array(
					'default_payout_method'                   => 'store_credit',
					'allow_affiliate_payout_method_selection' => 0,
				),
				true
			);

			@affiliate_wp()->utils->log( 'Upgrade v2.33.0: migrated legacy store-credit-all-affiliates to default_payout_method=store_credit + selector off.' );
		}

		update_option( 'affwp_sc_all_affiliates_migrated', true );
	}

	/**
	 * Translates the legacy Store Credit "Allow Affiliate Opt-In" setting into the new
	 * `affwp_hidden_affiliate_payout_methods` option. When the legacy flag was explicitly
	 * OFF, Store Credit was admin-assigned only and didn't appear in the affiliate-area
	 * dropdown; the new mechanism adds `store_credit` to the admin-only list so
	 * `affwp_get_payout_methods_for_display( 'affiliate' )` keeps hiding it.
	 *
	 * Important distinction: we only migrate when the legacy setting key existed and was
	 * explicitly false. An ABSENT key means the admin never opened the legacy setting
	 * UI — there is no preserved intent to honour, and 2.33.0's natural default
	 * ("enabled methods are selectable when the master toggle is on") should apply.
	 * Treating null and explicit-false the same would spuriously hide Store Credit on
	 * sites that had simply never touched the legacy control.
	 *
	 * Idempotent — the option marker short-circuits re-runs.
	 *
	 * @since 2.33.0
	 */
	private function upgrade_v2330_migrate_store_credit_opt_in_setting() {

		if ( get_option( 'affwp_sc_opt_in_migrated' ) ) {
			return;
		}

		$settings          = get_option( 'affwp_settings', array() );
		$legacy_key_exists = is_array( $settings ) && array_key_exists( 'store-credit-change-payment-method', $settings );

		if ( ! $legacy_key_exists ) {
			// Never explicitly set — no preserved intent. Skip migration entirely.
			update_option( 'affwp_sc_opt_in_migrated', true );
			return;
		}

		$opt_in_on = (bool) $settings['store-credit-change-payment-method'];

		if ( ! $opt_in_on ) {

			$hidden = get_option( 'affwp_hidden_affiliate_payout_methods', array() );

			if ( ! is_array( $hidden ) ) {
				$hidden = array();
			}

			if ( ! in_array( 'store_credit', $hidden, true ) ) {
				$hidden[] = 'store_credit';
				update_option( 'affwp_hidden_affiliate_payout_methods', array_values( $hidden ) );
				@affiliate_wp()->utils->log( 'Upgrade v2.33.0: migrated explicit legacy store-credit-change-payment-method=OFF to affwp_hidden_affiliate_payout_methods += store_credit.' );
			}
		}

		update_option( 'affwp_sc_opt_in_migrated', true );
	}

	/**
	 * Infers a sensible Default Payout Method on upgrade based on which payout methods are
	 * actually enabled at the site level. Avoids the "PayPal-only site upgrades, lands on
	 * Manual, affiliate area suddenly reads 'Your site admin pays you directly'" regression.
	 *
	 * Logic:
	 *  - Only runs if Default Payout Method is currently unset OR equals 'manual'. We never
	 *    overwrite an admin's deliberate choice (or a value set by an earlier migration in
	 *    this same upgrade pass, such as the Store Credit "All Affiliates" migration).
	 *  - Picks the highest-priority enabled method from Stripe > PayPal. Stripe
	 *    wins ties because Stripe Connect Express is the modern recommended path AffiliateWP
	 *    has been steering customers toward since 2.29; sites that enabled it on top of
	 *    legacy PayPal are signaling it's their target state.
	 *  - Store Credit is deliberately NOT a candidate. It's a first-class registered payout
	 *    method (on affwp_payout_methods since 2.33.0), but unlike Stripe/PayPal/Payouts
	 *    Service it registers no affwp_is_payout_method_enabled refining filter, so its
	 *    "enabled" state is satisfied by the `store-credit` master toggle alone. That toggle
	 *    is on for a large share of sites and does not signal intent to make Store Credit the
	 *    site-wide default, so inferring from it would switch every no-method affiliate to
	 *    Store Credit. Genuine store-credit-as-default intent is preserved elsewhere:
	 *    per-affiliate by upgrade_v2330_migrate_store_credit_to_payout_method(), and
	 *    all-affiliates by upgrade_v2330_migrate_store_credit_all_affiliates_setting().
	 *  - Never enables affiliate self-selection. That stays opt-in on every install (a payout
	 *    picker that lets affiliates redirect their own money shouldn't appear as a side effect
	 *    of an upgrade). The Payouts settings tab nudges the admin to turn it on once two or
	 *    more methods are available.
	 *  - When the inference actually changes the Default (i.e. wasn't a no-op), sets the
	 *    `affwp_default_payout_method_auto_selected` marker that triggers a dismissible
	 *    notice prompting the admin to verify the inferred setting. See
	 *    Affiliate_WP_Upgrades::register_payout_default_inference_notice().
	 *
	 * Idempotent — the option marker short-circuits re-runs.
	 *
	 * @since 2.33.0
	 */
	private function upgrade_v2330_infer_default_payout_method() {

		if ( get_option( 'affwp_default_payout_method_inferred' ) ) {
			return;
		}

		$current_default = affiliate_wp()->settings->get( 'default_payout_method' );

		// Respect an explicit non-manual choice, set either by the admin or by an earlier migration in this pass.
		if ( ! empty( $current_default ) && 'manual' !== $current_default ) {
			update_option( 'affwp_default_payout_method_inferred', true );
			return;
		}

		// Priority order for inference. Stripe wins ties for the reasons in the docblock.
		// Store Credit is intentionally excluded (see docblock): its "enabled" state is just
		// the master toggle, so promoting it to the default would silently switch every
		// no-method affiliate to Store Credit.
		$priority = array( 'stripe', 'paypal' );

		$enabled = array();
		foreach ( $priority as $method ) {
			if ( affwp_is_payout_method_enabled( $method ) ) {
				$enabled[] = $method;
			}
		}

		// Nothing automated is enabled — leave Default as Manual.
		if ( empty( $enabled ) ) {
			update_option( 'affwp_default_payout_method_inferred', true );
			@affiliate_wp()->utils->log( 'Upgrade v2.33.0: no automated payout methods enabled at upgrade; Default stays Manual.' );
			return;
		}

		$inferred_default = $enabled[0];

		// Only the default is set. Self-selection is left untouched (opt-in); the settings-tab
		// nudge surfaces it once two or more methods are available.
		affiliate_wp()->settings->set(
			array( 'default_payout_method' => $inferred_default ),
			true
		);

		update_option( 'affwp_default_payout_method_inferred', true );

		/*
		 * Record the auto-picked method so the verification notice can name it. Storing the
		 * scalar (not the live default) keeps the notice reporting what the update chose even
		 * if the admin later edits the setting. Not autoloaded: only read on admin pages.
		 */
		update_option( 'affwp_default_payout_method_auto_selected', $inferred_default, false );

		@affiliate_wp()->utils->log(
			sprintf(
				'Upgrade v2.33.0: inferred Default Payout Method = %s from %d enabled automated method(s).',
				$inferred_default,
				count( $enabled )
			)
		);
	}

	/**
	 * Registers the post-upgrade notice prompting the admin to verify the inferred Default
	 * Payout Method. Fires on the shared `affiliatewp_admin_notices` hook; Admin_Notice owns
	 * rendering, capability, page scoping, and dismissal (one autoload-off dismissal row).
	 *
	 * Shown only when inference auto-picked the default. The marker
	 * (`affwp_default_payout_method_auto_selected`) holds the method we chose, which the
	 * message names.
	 *
	 * @since 2.33.0
	 */
	public function register_payout_default_inference_notice() {

		$method = get_option( 'affwp_default_payout_method_auto_selected' );

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

		// Never in the payout wizard: it wraps content in .affwp-ui, which strips WP notice
		// styling, and the commit flow shouldn't carry announcements.
		if (
			'affiliate-wp-payouts' === filter_input( INPUT_GET, 'page', FILTER_UNSAFE_RAW )
			&& in_array( filter_input( INPUT_GET, 'action', FILTER_UNSAFE_RAW ), [ 'preview_payout', 'new_payout' ], true )
		) {
			return;
		}

		if ( ! class_exists( '\AffiliateWP\Notices\Admin_Notice' ) ) {
			return;
		}

		$method_label = function_exists( 'affwp_get_payout_method_display_label' )
			? affwp_get_payout_method_display_label( $method )
			: ucfirst( str_replace( array( '_', '-' ), ' ', $method ) );

		// Deep link straight to the Default Payout Method control on the Payouts tab.
		$settings_url = admin_url( 'admin.php?page=affiliate-wp-settings&tab=payouts#affwp-default-payout-method' );

		$message = sprintf(
			/* translators: 1: payout method label (e.g. Stripe), 2: settings URL */
			__( 'The latest AffiliateWP update added a <strong>Default Payout Method</strong> and set yours to <strong>%1$s</strong>, based on the payout methods you have enabled. Affiliates who already have their own payout method keep it; everyone else is paid via %1$s. If that\'s correct, you\'re all set.</p><p><a href="%2$s">Review or change it in Settings &rarr; Payouts</a>', 'affiliate-wp' ),
			esc_html( $method_label ),
			esc_url( $settings_url )
		);

		/*
		 * Scoped to AffiliateWP screens: the dismiss handler ships in the affwp-admin bundle,
		 * which only loads on our pages, so an 'any' notice would have an inert dismiss icon
		 * elsewhere. The CTA target (Settings > Payouts) is an AffiliateWP page.
		 */
		new \AffiliateWP\Notices\Admin_Notice(
			'payout_default_inference',
			$message,
			'dismissible',
			'notice-info',
			'manage_affiliates',
			'affiliatewp'
		);
	}

	/**
	 * Retrieves the site IDs array.
	 *
	 * Most commonly used for db schema changes in networks (but also works for single site).
	 *
	 * @return array Site IDs in the network (single or multisite).
	 */
	private function get_sites_for_upgrade() {
		if ( is_multisite() ) {

			if ( true === version_compare( $GLOBALS['wp_version'], '4.6', '<' ) ) {

				$sites = wp_list_pluck( 'blog_id', wp_get_sites() );

			} else {

				$sites = get_sites( [ 'fields' => 'ids' ] );

			}
		} else {

			$sites = [ get_current_blog_id() ];

		}

		$plugin = AFFILIATEWP_PLUGIN_DIR_NAME . '/affiliate-wp.php';

		// Only return sites AffWP is active on.
		foreach ( $sites as $index => $site_id ) {

			if ( is_multisite() ) {

				switch_to_blog( $site_id );

			}

			if ( ! in_array( $plugin, get_option( 'active_plugins', [] ) ) ) {
				unset( $sites[ $index ] );
			}

			if ( is_multisite() ) {

				restore_current_blog();

			}
		}
		return $sites;
	}

	/**
	 * Schedules the upgrade process.
	 *
	 * This method checks if the current version is less than 2.27.0 and
	 * schedules an asynchronous action to initiate the upgrade process.
	 *
	 * @since 2.27.1
	 * @access public
	 */
	public function schedule_v2270_upgrade() {
		if ( version_compare( $this->version, '2.27.0', '<' ) ) {
			as_enqueue_async_action( 'affwp_schedule_v2270_upgrade_batches', [], 'affiliatewp' );
		}
	}

	/**
	 * Schedules upgrade batches for version 2.27.0.
	 *
	 * This method schedules batches for processing affiliate data upgrades
	 * required for version 2.27.0. It retrieves affiliate IDs that need updating,
	 * calculates the number of batches needed, and schedules individual batch
	 * processing actions.
	 *
	 * @since 2.27.1
	 * @access public
	 */
	public function schedule_v2270_upgrade_batches() {
		global $wpdb;

		$batch_size = 10; // Process 10 affiliates at a time.
		$table_name = affiliate_wp()->affiliate_meta->table_name;

		// Get all affiliate IDs that need to be processed.
		$affiliate_ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT DISTINCT a1.affiliate_id
				FROM {$table_name} a1
				JOIN {$table_name} a2 ON a1.affiliate_id = a2.affiliate_id
				WHERE a1.meta_key = %s
				AND a2.meta_key = %s
				AND a2.meta_value = %s",
				'wpforms_form_id',
				'registration_method',
				'affiliate_registration_form'
			)
		);

		$total_affiliates = count( $affiliate_ids );
		$batches          = ceil( $total_affiliates / $batch_size );

		affiliate_wp()->utils->log( "v2270_upgrade: Total affiliates to process: {$total_affiliates}." );

		// Store the total number of affiliates to process.
		update_option( 'affwp_v2270_total_affiliates', $total_affiliates );

		// Schedule an action for each batch.
		for ( $i = 1; $i <= $batches; $i++ ) {
			$batch_affiliate_ids = array_slice( $affiliate_ids, ( $i - 1 ) * $batch_size, $batch_size );
			$batch_count         = count( $batch_affiliate_ids );

			as_schedule_single_action(
				time() + ( ( $i - 1 ) * 30 ), // Add a 30-second delay between batches.
				'affwp_v2270_process_affiliate_batch',
				[
					'batch'            => $i,
					'affiliate_ids'    => $batch_affiliate_ids,
					'total_affiliates' => $total_affiliates,
				],
				'affiliatewp'
			);

			affiliate_wp()->utils->log( "v2270_upgrade: Scheduled batch {$i} with {$batch_count} affiliates." );
		}

		affiliate_wp()->utils->log( "v2270_upgrade: Scheduled {$batches} batches for WPForms registration method update. Total affiliates: {$total_affiliates}." );
	}

	/**
	 * Processes a batch of affiliates for the v2.2.70 upgrade.
	 *
	 * This method updates the registration method for affiliates who used the WPForms
	 * registration form, changing it from 'affiliate_registration_form' to
	 * 'affiliate_registration_form_wpforms'.
	 *
	 * @since 2.27.1
	 *
	 * @param int   $batch             The batch number being processed.
	 * @param array $affiliate_ids     The affiliate IDs to process in this batch.
	 * @param int   $total_affiliates  The total number of affiliates to process across all batches.
	 */
	public function process_v2270_affiliate_batch( $batch, $affiliate_ids, $total_affiliates ) {
		global $wpdb;

		$affiliate_meta_table = affiliate_wp()->affiliate_meta->table_name;
		$updated_count        = 0;
		$total_in_batch       = count( $affiliate_ids );

		affiliate_wp()->utils->log( "v2270_upgrade: Processing batch {$batch} with {$total_in_batch} affiliates" );

		if ( ! empty( $affiliate_ids ) ) {
			foreach ( $affiliate_ids as $affiliate_id ) {
				$result = $wpdb->update(
					$affiliate_meta_table,
					[ 'meta_value' => 'affiliate_registration_form_wpforms' ],
					[
						'affiliate_id' => $affiliate_id,
						'meta_key'     => 'registration_method',
						'meta_value'   => 'affiliate_registration_form',
					],
					[ '%s' ],
					[ '%d', '%s', '%s' ]
				);
				if ( false !== $result ) {
					++$updated_count;
				}
			}

			affiliate_wp()->utils->log( "v2270_upgrade: Updated registration_method for {$updated_count} out of {$total_in_batch} affiliates using WPForms (Batch {$batch})." );

			// Update the count of processed affiliates.
			$processed_affiliates = get_option( 'affwp_v2270_processed_affiliates', 0 ) + $updated_count;
			update_option( 'affwp_v2270_processed_affiliates', $processed_affiliates );

			// Check if all affiliates have been processed.
			if ( $processed_affiliates >= $total_affiliates ) {
				$this->upgraded = true;
				affiliate_wp()->utils->log( 'v2270_upgrade: All batches processed. Upgrade completed.' );

				// Clean up options.
				delete_option( 'affwp_v2270_total_affiliates' );
				delete_option( 'affwp_v2270_processed_affiliates' );
			}
		} else {
			affiliate_wp()->utils->log( "v2270_upgrade: No affiliates to process for WPForms registration method update (Batch {$batch})." );
		}
	}
}
