<?php

/**
 * WC Subscriptions non-destructive metadata importer.
 *
 * @package PeachPay
 */

if (! defined('PEACHPAY_ABSPATH')) {
	exit;
}

/**
 * Migrates old ConvesioPay metadata to PeachPay ConvesioPay metadata.
 */
final class PeachPay_ConvesioPay_Subscription_Migrator
{

	/**
	 * Action Scheduler hook for importer batches.
	 */
	const IMPORT_ACTION_HOOK = 'peachpay_convesiopay_subscription_import_batch';

	/**
	 * Action Scheduler hook for rollback batches.
	 */
	const ROLLBACK_ACTION_HOOK = 'peachpay_convesiopay_subscription_rollback_batch';

	/**
	 * Active run option key.
	 */
	const ACTIVE_RUN_OPTION = 'pp_subscription_migration_active_run';

	/**
	 * Run history option key.
	 */
	const RUNS_OPTION = 'pp_subscription_migration_runs';

	/**
	 * Source payment method (old plugin).
	 */
	const OLD_PAYMENT_METHOD = 'woosa_adyen_credit_card';

	/**
	 * Target subscription payment method (new plugin).
	 */
	const NEW_SUBSCRIPTION_PAYMENT_METHOD = 'peachpay_convesiopay_unified';

	/**
	 * Target order payment method (new plugin).
	 */
	const NEW_ORDER_PAYMENT_METHOD = 'peachpay_convesiopay_card';

	/**
	 * Migration source marker key.
	 */
	const MARKER_SOURCE_KEY = '_pp_migration_source';

	/**
	 * Migration run id marker key.
	 */
	const MARKER_RUN_ID_KEY = '_pp_migration_run_id';

	/**
	 * Migration timestamp marker key.
	 */
	const MARKER_TIMESTAMP_KEY = '_pp_migration_timestamp';

	/**
	 * Parent order original payment method key (captured at import time).
	 */
	const ORDER_ORIGINAL_PAYMENT_METHOD_KEY = '_pp_migration_original_order_payment_method';

	/**
	 * Per-run execution lock transient key.
	 */
	const RUN_LOCK_KEY = 'pp_subscription_migration_run_lock';

	/**
	 * Batch size.
	 */
	const DEFAULT_BATCH_SIZE = 25;

	/**
	 * Common subscription statuses for overview widgets.
	 */
	const OVERVIEW_STATUSES = array('active', 'pending', 'on-hold');

	/**
	 * Emit structured migration logs.
	 *
	 * @param string $level   Log level.
	 * @param string $event   Event name.
	 * @param array  $context Additional context.
	 * @return void
	 */
	private static function log_event($level, $event, $context = array())
	{
		$payload = array_merge(
			array(
				'component' => 'convesiopay_subscription_migrator',
				'event'     => $event,
			),
			is_array($context) ? $context : array()
		);
		$encoded_payload = json_encode($payload);
		peachpay_log($level, false !== $encoded_payload ? $encoded_payload : (string) $event);
	}

	/**
	 * Init hooks.
	 *
	 * Registers all WordPress hooks required for the migration system.
	 *
	 * @return void
	 */
	public static function init_hooks()
	{

		// Register Action Scheduler hook that processes an import batch in the background.
		// This hook is scheduled when the admin starts an import run.
		add_action(self::IMPORT_ACTION_HOOK, array(__CLASS__, 'process_batch'), 10, 1);

		// Register Action Scheduler hook that processes a rollback batch.
		// Uses the same processor but runs the rollback logic instead.
		add_action(self::ROLLBACK_ACTION_HOOK, array(__CLASS__, 'process_batch'), 10, 1);

		// AJAX endpoint triggered when the admin clicks "Run Import" in the migration UI.
		// Starts a new subscription metadata migration run.
		add_action('wp_ajax_peachpay_convesiopay_start_subscription_import', array(__CLASS__, 'ajax_start_import'));

		// AJAX endpoint triggered when the admin clicks "Run Rollback".
		// Starts a rollback run to revert previously migrated subscriptions.
		add_action('wp_ajax_peachpay_convesiopay_start_subscription_rollback', array(__CLASS__, 'ajax_start_rollback'));

		// AJAX endpoint used by the admin UI to fetch the current migration status.
		// Returns progress, counters, logs, and run information.
		add_action('wp_ajax_peachpay_convesiopay_get_subscription_import_status', array(__CLASS__, 'ajax_get_import_status'));

		// AJAX endpoint triggered when the admin clicks "Pause".
		// Cancels future scheduled batches and marks the run as paused.
		add_action('wp_ajax_peachpay_convesiopay_cancel_subscription_migration', array(__CLASS__, 'ajax_cancel_run'));

		// AJAX endpoint triggered when the admin clicks "Resume".
		// Resumes a paused migration by scheduling the next batch.
		add_action('wp_ajax_peachpay_convesiopay_resume_subscription_migration', array(__CLASS__, 'ajax_resume_run'));

		// AJAX endpoint triggered when the admin clicks "Clear Logs".
		// Clears migration run history and per-subscription logs.
		add_action('wp_ajax_peachpay_convesiopay_clear_subscription_migration_logs', array(__CLASS__, 'ajax_clear_logs'));

		// Ensure the migration logs table exists when the PeachPay plugin is first activated.
		// This table stores per-subscription migration results.
		add_action('peachpay_plugin_activated', array(__CLASS__, 'ensure_logs_table'));

		// Ensure the logs table is also created/updated during plugin upgrades.
		// Allows schema updates if the table structure changes in future versions.
		add_action('peachpay_plugin_upgraded', array(__CLASS__, 'ensure_logs_table'));
	}

	/**
	 * Logs table name.
	 *
	 * @return string
	 */
	public static function logs_table_name()
	{
		global $wpdb;
		return $wpdb->prefix . 'pp_subscription_migration_log';
	}

	/**
	 * Ensure logs table exists.
	 *
	 * @return void
	 */
	public static function ensure_logs_table()
	{
		global $wpdb;
		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$table_name      = self::logs_table_name();
		$charset_collate = $wpdb->get_charset_collate();
		$sql             = "CREATE TABLE {$table_name} (
			id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
			run_id VARCHAR(64) NOT NULL,
			subscription_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
			parent_order_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
			status VARCHAR(20) NOT NULL,
			reason VARCHAR(100) NOT NULL,
			description LONGTEXT NULL,
			created_at DATETIME NOT NULL,
			PRIMARY KEY (id),
			KEY run_id_idx (run_id),
			KEY subscription_idx (subscription_id),
			KEY status_idx (status)
		) {$charset_collate};";

		dbDelta($sql);
		self::log_event('debug', 'ensure_logs_table');
	}

	/**
	 * AJAX: start migration run.
	 *
	 * @return void
	 */
	public static function ajax_start_import()
	{
		if (! current_user_can('manage_woocommerce')) {
			wp_send_json_error(array('message' => __('Insufficient permissions.', 'peachpay-for-woocommerce')));
			return;
		}

		$nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
		if (! wp_verify_nonce($nonce, 'peachpay_convesiopay_subscription_importer')) {
			wp_send_json_error(array('message' => __('Security check failed.', 'peachpay-for-woocommerce')));
			return;
		}

		$dry_run     = ! empty($_POST['dry_run']) && '1' === sanitize_text_field(wp_unslash($_POST['dry_run']));
		$limit_count = isset($_POST['limit_count']) ? absint(wp_unslash($_POST['limit_count'])) : 0;
		$result      = self::start_run(
			'import',
			$dry_run,
			array(
				'limit_count' => $limit_count,
			)
		);

		if (is_wp_error($result)) {
			wp_send_json_error(
				array(
					'message' => $result->get_error_message(),
					'code'    => $result->get_error_code(),
				)
			);
			return;
		}

		wp_send_json_success($result);
	}

	/**
	 * AJAX: start rollback run.
	 *
	 * @return void
	 */
	public static function ajax_start_rollback()
	{
		if (! current_user_can('manage_woocommerce')) {
			wp_send_json_error(array('message' => __('Insufficient permissions.', 'peachpay-for-woocommerce')));
			return;
		}

		$nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
		if (! wp_verify_nonce($nonce, 'peachpay_convesiopay_subscription_importer')) {
			wp_send_json_error(array('message' => __('Security check failed.', 'peachpay-for-woocommerce')));
			return;
		}

		$dry_run = ! empty($_POST['dry_run']) && '1' === sanitize_text_field(wp_unslash($_POST['dry_run']));
		$result  = self::start_run('rollback', $dry_run);

		if (is_wp_error($result)) {
			wp_send_json_error(
				array(
					'message' => $result->get_error_message(),
					'code'    => $result->get_error_code(),
				)
			);
			return;
		}

		wp_send_json_success($result);
	}

	/**
	 * AJAX: get importer status.
	 *
	 * @return void
	 */
	public static function ajax_get_import_status()
	{
		if (! current_user_can('manage_woocommerce')) {
			wp_send_json_error(array('message' => __('Insufficient permissions.', 'peachpay-for-woocommerce')));
			return;
		}

		$nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
		if (! wp_verify_nonce($nonce, 'peachpay_convesiopay_subscription_importer')) {
			wp_send_json_error(array('message' => __('Security check failed.', 'peachpay-for-woocommerce')));
			return;
		}

		// Ensure latest logs table schema before any SELECT that expects
		// recently-added columns like `description`.
		self::ensure_logs_table();

		$active_run = get_option(self::ACTIVE_RUN_OPTION, array());
		$runs       = self::get_runs();
		$latest_run = ! empty($runs) ? $runs[0] : array();

		$run_id = '';
		if (! empty($active_run['run_id'])) {
			$run_id = $active_run['run_id'];
		} elseif (! empty($latest_run['run_id'])) {
			$run_id = $latest_run['run_id'];
		}

		$active_summary = array();
		if (! empty($active_run['run_id'])) {
			$active_summary = self::get_run_summary($active_run['run_id']);
			$active_run_for_status = $active_run;
			unset($active_run_for_status['subscription_ids']);
			$active_summary = array_merge($active_summary, $active_run_for_status);
		}

		$force_refresh = ! empty($_POST['force_refresh']) && '1' === sanitize_text_field(wp_unslash($_POST['force_refresh']));

		// During an active run use a small live-tail limit so polling stays fast.
		// For any completed/paused/failed state, return all logs so the admin can
		// see the full picture without manually paginating.
		$run_status = '';
		if (! empty($active_run['status'])) {
			$run_status = (string) $active_run['status'];
		} elseif (! empty($latest_run['status'])) {
			$run_status = (string) $latest_run['status'];
		}
		$logs_limit = 'running' === $run_status ? 50 : 2000;

		wp_send_json_success(
			array(
				'active_run' => $active_summary,
				'latest_run' => $latest_run,
				'runs'       => array_slice($runs, 0, 10),
				'logs'       => self::get_recent_logs($run_id, $logs_limit),
				'overview'   => self::get_subscription_overview_counts($force_refresh),
			)
		);
	}

	/**
	 * AJAX: cancel active run.
	 *
	 * @return void
	 */
	public static function ajax_cancel_run()
	{
		if (! current_user_can('manage_woocommerce')) {
			wp_send_json_error(array('message' => __('Insufficient permissions.', 'peachpay-for-woocommerce')));
			return;
		}

		$nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
		if (! wp_verify_nonce($nonce, 'peachpay_convesiopay_subscription_importer')) {
			wp_send_json_error(array('message' => __('Security check failed.', 'peachpay-for-woocommerce')));
			return;
		}

		$result = self::pause_active_run();
		if (is_wp_error($result)) {
			wp_send_json_error(array('message' => $result->get_error_message()));
			return;
		}

		wp_send_json_success(array('message' => __('Run paused.', 'peachpay-for-woocommerce')));
	}

	/**
	 * AJAX: resume paused run.
	 *
	 * @return void
	 */
	public static function ajax_resume_run()
	{
		if (! current_user_can('manage_woocommerce')) {
			wp_send_json_error(array('message' => __('Insufficient permissions.', 'peachpay-for-woocommerce')));
			return;
		}

		$nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
		if (! wp_verify_nonce($nonce, 'peachpay_convesiopay_subscription_importer')) {
			wp_send_json_error(array('message' => __('Security check failed.', 'peachpay-for-woocommerce')));
			return;
		}

		$result = self::resume_active_run();
		if (is_wp_error($result)) {
			wp_send_json_error(array('message' => $result->get_error_message()));
			return;
		}

		wp_send_json_success(array('message' => __('Run resumed.', 'peachpay-for-woocommerce')));
	}

	/**
	 * AJAX: clear migration runs and logs.
	 *
	 * @return void
	 */
	public static function ajax_clear_logs()
	{
		if (! current_user_can('manage_woocommerce')) {
			wp_send_json_error(array('message' => __('Insufficient permissions.', 'peachpay-for-woocommerce')));
			return;
		}

		$nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
		if (! wp_verify_nonce($nonce, 'peachpay_convesiopay_subscription_importer')) {
			wp_send_json_error(array('message' => __('Security check failed.', 'peachpay-for-woocommerce')));
			return;
		}

		$result = self::clear_migration_history();
		if (is_wp_error($result)) {
			wp_send_json_error(array('message' => $result->get_error_message()));
			return;
		}

		wp_send_json_success(
			array(
				'message' => __('Migration logs and run history cleared.', 'peachpay-for-woocommerce'),
			)
		);
	}

	/**
	 * Start run.
	 *
	 * @param string $operation import|rollback.
	 * @param bool  $dry_run Dry-run mode.
	 * @param array $options Additional options.
	 * @return array|WP_Error
	 */
	public static function start_run($operation, $dry_run = false, $options = array())
	{
		if (! in_array($operation, array('import', 'rollback'), true)) {
			self::log_event('warning', 'start_run_invalid_operation', array('operation' => $operation));
			return new WP_Error('invalid_operation', __('Invalid operation.', 'peachpay-for-woocommerce'));
		}

		self::ensure_logs_table();

		$active = get_option(self::ACTIVE_RUN_OPTION, array());
		if (! empty($active['status']) && in_array($active['status'], array('running', 'paused'), true)) {
			self::log_event(
				'warning',
				'start_run_rejected_active_exists',
				array(
					'requested_operation' => $operation,
					'active_run_id'       => isset($active['run_id']) ? $active['run_id'] : '',
					'active_status'       => isset($active['status']) ? $active['status'] : '',
				)
			);
			return new WP_Error('already_running', __('A migration run is already active.', 'peachpay-for-woocommerce'));
		}

		$limit_count              = isset($options['limit_count']) ? max(0, (int) $options['limit_count']) : 0;
		$candidate_subscription_ids = 'import' === $operation ? self::get_candidate_import_subscription_ids() : self::get_candidate_rollback_subscription_ids();
		$subscription_ids         = $candidate_subscription_ids;
		if ('import' === $operation && $limit_count > 0) {
			sort($subscription_ids, SORT_NUMERIC);
			$subscription_ids = array_slice($subscription_ids, 0, $limit_count);
		}
		$run_id = self::generate_run_id();
		$now    = current_time('mysql');

		$run_summary = array(
			'run_id'            => $run_id,
			'operation'         => $operation,
			'status'            => 'running',
			'dry_run'           => (bool) $dry_run,
			'limit_count'       => $limit_count,
			'started_at'        => $now,
			'last_activity_at'  => $now,
			'completed_at'      => '',
			'total_scanned'     => count($subscription_ids),
			'processed'         => 0,
			'migrated'          => 0,
			'skipped'           => 0,
			'failed'            => 0,
		);

		$active_run = array(
			'run_id'           => $run_id,
			'operation'        => $operation,
			'status'           => 'running',
			'dry_run'          => (bool) $dry_run,
			'limit_count'      => $limit_count,
			'batch_size'       => self::DEFAULT_BATCH_SIZE,
			'cursor'           => 0,
			'subscription_ids' => $subscription_ids,
		);

		update_option(self::ACTIVE_RUN_OPTION, $active_run, false);
		self::upsert_run_summary($run_summary);

		self::schedule_next_batch($run_id, $operation);
		self::log_event(
			'info',
			'run_started',
			array(
				'run_id'        => $run_id,
				'operation'     => $operation,
				'dry_run'       => (bool) $dry_run,
				'limit_count'   => $limit_count,
				'total_scanned' => count($subscription_ids),
				'total_candidates' => count($candidate_subscription_ids),
			)
		);

		return array(
			'run_id'        => $run_id,
			'operation'     => $operation,
			'total_scanned' => $run_summary['total_scanned'],
			'dry_run'       => (bool) $dry_run,
			'limit_count'   => $limit_count,
		);
	}

	/**
	 * Action Scheduler batch callback.
	 *
	 * @param string $run_id Run id.
	 * @return void
	 */
	public static function process_batch($run_id)
	{
		$active_run = get_option(self::ACTIVE_RUN_OPTION, array());
		if (empty($active_run['run_id']) || $active_run['run_id'] !== $run_id) {
			self::log_event('debug', 'batch_skip_run_mismatch', array('run_id' => $run_id));
			return;
		}
		if (empty($active_run['status']) || 'running' !== $active_run['status']) {
			self::log_event(
				'debug',
				'batch_skip_not_running',
				array(
					'run_id' => $run_id,
					'status' => isset($active_run['status']) ? $active_run['status'] : '',
				)
			);
			return;
		}

		if (get_transient(self::RUN_LOCK_KEY)) {
			self::log_event('warning', 'batch_lock_contention', array('run_id' => $run_id));
			self::schedule_next_batch($run_id, isset($active_run['operation']) ? (string) $active_run['operation'] : 'import');
			return;
		}

		// 5 minutes gives slow stores enough headroom to complete a 25-sub
		// batch (saves + API + order notes can legitimately run past 60s)
		// while still clearing quickly enough if a worker truly died mid-batch.
		set_transient(self::RUN_LOCK_KEY, '1', 5 * MINUTE_IN_SECONDS);
		try {
			$ids        = isset($active_run['subscription_ids']) && is_array($active_run['subscription_ids']) ? $active_run['subscription_ids'] : array();
			$operation  = isset($active_run['operation']) ? (string) $active_run['operation'] : 'import';
			$cursor     = isset($active_run['cursor']) ? (int) $active_run['cursor'] : 0;
			$batch_size = isset($active_run['batch_size']) ? (int) $active_run['batch_size'] : self::DEFAULT_BATCH_SIZE;
			$batch_size = $batch_size > 0 ? $batch_size : self::DEFAULT_BATCH_SIZE;
			$dry_run    = ! empty($active_run['dry_run']);

			$batch_ids = array_slice($ids, $cursor, $batch_size);
			self::log_event(
				'info',
				'batch_started',
				array(
					'run_id'      => $run_id,
					'operation'   => $operation,
					'cursor'      => $cursor,
					'batch_size'  => $batch_size,
					'batch_count' => count($batch_ids),
					'total_ids'   => count($ids),
					'dry_run'     => $dry_run,
				)
			);
			if (empty($batch_ids)) {
				self::complete_run($run_id);
				return;
			}

			$payments_by_id = array();
			if ('import' === $operation) {
				$payment_ids = array();
				foreach ($batch_ids as $subscription_id) {
					$subscription = function_exists('wcs_get_subscription') ? wcs_get_subscription((int) $subscription_id) : null;
					if (! $subscription || ! is_callable(array($subscription, 'get_parent_id'))) {
						continue;
					}
					$parent_order_id = (int) $subscription->get_parent_id();
					if ($parent_order_id <= 0) {
						continue;
					}
					$parent_order = wc_get_order($parent_order_id);
					if (! $parent_order) {
						continue;
					}
					$legacy_payment_id = self::resolve_legacy_payment_id($parent_order, $subscription);
					if ('' !== $legacy_payment_id) {
						$payment_ids[] = $legacy_payment_id;
					}
				}
				$payment_ids     = array_values(array_unique($payment_ids));
				$payments_by_id  = self::fetch_convesiopay_payments_list_by_ids($payment_ids);
				self::log_event(
					'info',
					'batch_prefetch_payments_completed',
					array(
						'run_id'             => $run_id,
						'requested_ids'      => count($payment_ids),
						'resolved_payments'  => count($payments_by_id),
						'batch_subscription_count' => count($batch_ids),
					)
				);
			}

			foreach ($batch_ids as $subscription_id) {
				$result = 'rollback' === $operation
					? self::process_rollback_subscription((int) $subscription_id, $run_id, $dry_run)
					: self::process_import_subscription((int) $subscription_id, $run_id, $dry_run, $payments_by_id);
				self::increment_run_counters($run_id, $result['status']);
			}

			$latest_active_run = get_option(self::ACTIVE_RUN_OPTION, array());
			if (empty($latest_active_run['run_id']) || $latest_active_run['run_id'] !== $run_id) {
				self::log_event(
					'warning',
					'batch_skip_state_persist_run_mismatch',
					array(
						'run_id' => $run_id,
					)
				);
				return;
			}
			$latest_active_run['cursor'] = $cursor + count($batch_ids);
			update_option(self::ACTIVE_RUN_OPTION, $latest_active_run, false);
			self::touch_run_activity($run_id);

			if (empty($latest_active_run['status']) || 'running' !== (string) $latest_active_run['status']) {
				self::log_event(
					'info',
					'batch_halted_state_changed',
					array(
						'run_id' => $run_id,
						'status' => isset($latest_active_run['status']) ? (string) $latest_active_run['status'] : '',
					)
				);
				return;
			}

			if ($latest_active_run['cursor'] >= count($ids)) {
				self::log_event('info', 'batch_completed_final', array('run_id' => $run_id));
				self::complete_run($run_id);
			} else {
				self::schedule_next_batch($run_id, $operation);
			}
		} catch (Throwable $e) {
			self::mark_run_failed($run_id);
			self::log_event(
				'error',
				'batch_failed',
				array(
					'run_id'  => $run_id,
					'message' => $e->getMessage(),
				)
			);
		} finally {
			delete_transient(self::RUN_LOCK_KEY);
		}
	}

	/**
	 * Process one subscription atomically.
	 *
	 * @param int    $subscription_id Subscription id.
	 * @param string $run_id          Run id.
	 * @param bool   $dry_run         Dry run mode.
	 * @return array
	 */
	private static function process_import_subscription($subscription_id, $run_id, $dry_run, $payments_by_id = array())
	{
		self::log_event(
			'debug',
			'import_subscription_start',
			array(
				'run_id'          => $run_id,
				'subscription_id' => $subscription_id,
				'dry_run'         => $dry_run,
			)
		);
		$subscription = function_exists('wcs_get_subscription') ? wcs_get_subscription($subscription_id) : null;
		if (! $subscription || ! is_callable(array($subscription, 'get_parent_id'))) {
			self::insert_log($run_id, $subscription_id, 0, 'failed', 'subscription_not_found');
			self::log_event('warning', 'import_subscription_not_found', array('run_id' => $run_id, 'subscription_id' => $subscription_id));
			return array('status' => 'failed', 'reason' => 'subscription_not_found');
		}

		// Idempotent early-return: if this subscription already carries the
		// 'cpay' migration marker AND is already on the new gateway, the
		// migration for this sub is fully complete. Re-processing would be
		// wasteful and — under batch-replay scenarios (e.g., worker crash
		// mid-batch) — would double-count the sub in run counters.
		//
		// The marker-only check is intentionally paired with the payment
		// method check: in the rare case a previous attempt set the marker
		// but failed before flipping the subscription's payment method
		// (uncaught fatal between lines ~753-762), we still want to retry
		// and finish the switch.
		$existing_source_marker       = (string) $subscription->get_meta(self::MARKER_SOURCE_KEY);
		$existing_subscription_method = (string) $subscription->get_payment_method();
		if ('cpay' === $existing_source_marker && self::NEW_SUBSCRIPTION_PAYMENT_METHOD === $existing_subscription_method) {
			$parent_order_id_for_log = (int) $subscription->get_parent_id();
			self::insert_log($run_id, $subscription_id, $parent_order_id_for_log, 'skipped', 'already_migrated');
			self::log_event(
				'debug',
				'import_subscription_skipped_already_migrated',
				array(
					'run_id'          => $run_id,
					'subscription_id' => $subscription_id,
					'parent_order_id' => $parent_order_id_for_log,
				)
			);
			return array('status' => 'skipped', 'reason' => 'already_migrated');
		}

		$parent_order_id = (int) $subscription->get_parent_id();
		if ($parent_order_id <= 0) {
			self::insert_log($run_id, $subscription_id, 0, 'failed', 'missing_parent_order');
			self::log_event('warning', 'import_subscription_missing_parent_id', array('run_id' => $run_id, 'subscription_id' => $subscription_id));
			return array('status' => 'failed', 'reason' => 'missing_parent_order');
		}

		$parent_order = wc_get_order($parent_order_id);
		if (! $parent_order) {
			self::insert_log($run_id, $subscription_id, $parent_order_id, 'failed', 'missing_parent_order');
			self::log_event('warning', 'import_subscription_missing_parent_order', array('run_id' => $run_id, 'subscription_id' => $subscription_id, 'parent_order_id' => $parent_order_id));
			return array('status' => 'failed', 'reason' => 'missing_parent_order');
		}

		$legacy_shopper_reference          = trim((string) $parent_order->get_meta('_adn_shopper_reference'));
		$legacy_recurring_detail_reference = trim((string) $parent_order->get_meta('_adn_recurringDetailReference'));
		if ('' === $legacy_shopper_reference) {
			$legacy_shopper_reference = trim((string) $subscription->get_meta('_adn_shopper_reference'));
		}
		if ('' === $legacy_recurring_detail_reference) {
			$legacy_recurring_detail_reference = trim((string) $subscription->get_meta('_adn_recurringDetailReference'));
		}
		if ('' === $legacy_shopper_reference) {
			self::insert_log($run_id, $subscription_id, $parent_order_id, 'failed', 'missing_adn_shopper_reference');
			self::log_event(
				'warning',
				'import_subscription_missing_adn_shopper_reference',
				array(
					'run_id'                         => $run_id,
					'subscription_id'                => $subscription_id,
					'parent_order_id'                => $parent_order_id,
					'legacy_shopper_reference'       => $legacy_shopper_reference,
					'legacy_recurring_detail_exists' => '' !== $legacy_recurring_detail_reference,
				)
			);
			return array('status' => 'failed', 'reason' => 'missing_adn_shopper_reference');
		}

		$legacy_payment_id = self::resolve_legacy_payment_id($parent_order, $subscription);
		$payment_data      = '' !== $legacy_payment_id && isset($payments_by_id[$legacy_payment_id]) && is_array($payments_by_id[$legacy_payment_id])
			? $payments_by_id[$legacy_payment_id]
			: array();
		$api_customer_id   = isset($payment_data['customerId']) ? (string) $payment_data['customerId'] : '';
		$api_stored_token  = self::extract_stored_payment_method_id($payment_data);

		$existing_pp_customer = (string) $parent_order->get_meta('_convesiopay_customer_id');
		$existing_pp_token    = (string) $parent_order->get_meta('_convesiopay_stored_payment_method_id');
		$existing_payment_id  = (string) $parent_order->get_meta('_convesiopay_payment_id');
		$existing_original_id = (string) $parent_order->get_meta('_convesiopay_original_payment_id');
		$target_customer_id   = '' !== $existing_pp_customer ? $existing_pp_customer : $api_customer_id;
		$target_stored_token  = '' !== $existing_pp_token ? $existing_pp_token : $api_stored_token;
		$target_payment_id    = '' !== $existing_payment_id ? $existing_payment_id : $legacy_payment_id;
		$target_original_id   = '' !== $existing_original_id ? $existing_original_id : $legacy_payment_id;

		// It's no more required to be present as for subscription renewal of legacy plugin via peachpay plugin using stored-card request, _adn_shopper_reference should be used as customerId
		if ('' === $target_customer_id) {
			// self::insert_log($run_id, $subscription_id, $parent_order_id, 'failed', 'missing_convesiopay_customer_id');
			self::log_event(
				'warning',
				'import_subscription_missing_customer_id',
				array(
					'run_id'          => $run_id,
					'subscription_id' => $subscription_id,
					'parent_order_id' => $parent_order_id,
					'legacy_payment_id' => $legacy_payment_id,
				)
			);
			// return array('status' => 'failed', 'reason' => 'missing_convesiopay_customer_id');
		}

		// It's no more required to be present as for subscription renewal of legacy plugin via peachpay plugin using stored-card request, _adn_recurringDetailReference should be used as storedPaymentMethodId
		if ('' === $target_stored_token) {
			// self::insert_log($run_id, $subscription_id, $parent_order_id, 'failed', 'missing_convesiopay_stored_payment_method_id');
			self::log_event(
				'warning',
				'import_subscription_missing_stored_payment_method_id',
				array(
					'run_id'          => $run_id,
					'subscription_id' => $subscription_id,
					'parent_order_id' => $parent_order_id,
					'legacy_payment_id' => $legacy_payment_id,
				)
			);
			// return array('status' => 'failed', 'reason' => 'missing_convesiopay_stored_payment_method_id');
		}


		$original_order_method        = '';
		$original_subscription_method = '';
		$parent_order_transaction_id = '';
		
		try {
			$original_order_method        = (string) $parent_order->get_payment_method();
			$original_subscription_method = (string) $subscription->get_payment_method();
			$parent_order_transaction_id  = (string) $parent_order->get_transaction_id();
		} catch (Throwable $e) {
			self::log_event(
				'error',
				'import_subscription_failed',
				array(
					'run_id'          => $run_id,
					'subscription_id' => $subscription_id,
					'parent_order_id' => $parent_order_id,
					'message'         => $e->getMessage(),
				)
			);
			self::insert_log($run_id, $subscription_id, $parent_order_id, 'failed', 'failed_to_get_original_methods', array(
				'message' => $e->getMessage(),
			));
			return array('status' => 'failed', 'reason' => 'failed_to_get_original_methods');
		}

		if ($dry_run) {
			self::insert_log($run_id, $subscription_id, $parent_order_id, 'skipped', 'dry_run', array(
				'subscription_source' => $original_subscription_method,
				'subscription_target' => self::NEW_SUBSCRIPTION_PAYMENT_METHOD,
				'order_source' => $original_order_method,
				'order_target' => self::NEW_ORDER_PAYMENT_METHOD,
				'customerId' => $api_customer_id !== '' ? $api_customer_id : 'N.A',
				'storedPaymentMethodId' => $api_stored_token !== '' ? $api_stored_token : 'N.A',
				'transactionId' => $parent_order_transaction_id !== '' ? $parent_order_transaction_id : 'N.A',
				'_adn_payment_pspReference' => $legacy_payment_id !== '' ? $legacy_payment_id : 'N.A',
				'_adn_shopper_reference' => $legacy_shopper_reference !== '' ? $legacy_shopper_reference : 'N.A',
				'_adn_recurringDetailReference' => $legacy_recurring_detail_reference !== '' ? $legacy_recurring_detail_reference : 'N.A'
			));
			self::log_event('debug', 'import_subscription_dry_run', array('run_id' => $run_id, 'subscription_id' => $subscription_id, 'parent_order_id' => $parent_order_id));
			return array('status' => 'skipped', 'reason' => 'dry_run');
		}

		try {

			// Atomic flow: write missing metadata first, then markers, then switch both gateways.
			if ('' === $existing_pp_customer && '' !== $target_customer_id) {
				$parent_order->update_meta_data('_convesiopay_customer_id', $target_customer_id);
			}
			if ('' === $existing_pp_token && '' !== $target_stored_token) {
				$parent_order->update_meta_data('_convesiopay_stored_payment_method_id', $target_stored_token);
			}
			if ('' === $existing_payment_id && '' !== $target_payment_id) {
				$parent_order->update_meta_data('_convesiopay_payment_id', $target_payment_id);
			}
			if ('' === $existing_original_id && '' !== $target_original_id) {
				$parent_order->update_meta_data('_convesiopay_original_payment_id', $target_original_id);
			}
			// Preserve the first captured pre-migration order gateway. If a
			// previous batch already wrote this meta and then fatalled between
			// flipping the parent order's payment method and flipping the
			// subscription's, $original_order_method on the retry equals the
			// NEW PeachPay gateway, not the true pre-migration one. An
			// unconditional overwrite here would corrupt the stored "original"
			// to the new gateway, and a later rollback would restore the
			// parent order to PeachPay instead of the legacy Adyen gateway.
			// Mirror the conservative pattern used by the sibling meta writes
			// above (line 860-870): only write when no value is captured yet.
			$existing_original_order_method = (string) $parent_order->get_meta(self::ORDER_ORIGINAL_PAYMENT_METHOD_KEY);
			if ('' === $existing_original_order_method && '' !== $original_order_method) {
				$parent_order->update_meta_data(self::ORDER_ORIGINAL_PAYMENT_METHOD_KEY, $original_order_method);
			}
			$parent_order->save();

			$subscription->update_meta_data(self::MARKER_SOURCE_KEY, 'cpay');
			$subscription->update_meta_data(self::MARKER_RUN_ID_KEY, $run_id);
			$subscription->update_meta_data(self::MARKER_TIMESTAMP_KEY, current_time('mysql'));
			$subscription->save();

			$parent_order->set_payment_method(self::NEW_ORDER_PAYMENT_METHOD);
			$parent_order->save();

			$subscription->set_payment_method(self::NEW_SUBSCRIPTION_PAYMENT_METHOD);
			$subscription->save();
			self::add_payment_method_change_notes(
				$subscription,
				$parent_order,
				$run_id,
				'import',
				$original_subscription_method,
				self::NEW_SUBSCRIPTION_PAYMENT_METHOD,
				$original_order_method,
				self::NEW_ORDER_PAYMENT_METHOD
			);

			self::insert_log($run_id, $subscription_id, $parent_order_id, 'migrated', 'migrated', array(
				'subscription_source' => $original_subscription_method,
				'subscription_target' => self::NEW_SUBSCRIPTION_PAYMENT_METHOD,
				'order_source' => $original_order_method,
				'order_target' => self::NEW_ORDER_PAYMENT_METHOD,
				'customerId' => $api_customer_id !== '' ? $api_customer_id : 'N.A',
				'storedPaymentMethodId' => $api_stored_token !== '' ? $api_stored_token : 'N.A',
				'transactionId' => $parent_order_transaction_id !== '' ? $parent_order_transaction_id : 'N.A',
				'_adn_payment_pspReference' => $legacy_payment_id !== '' ? $legacy_payment_id : 'N.A',
				'_adn_shopper_reference' => $legacy_shopper_reference !== '' ? $legacy_shopper_reference : 'N.A',
				'_adn_recurringDetailReference' => $legacy_recurring_detail_reference !== '' ? $legacy_recurring_detail_reference : 'N.A'
			));
			self::log_event('info', 'import_subscription_migrated', array('run_id' => $run_id, 'subscription_id' => $subscription_id, 'parent_order_id' => $parent_order_id));
			return array('status' => 'migrated', 'reason' => 'migrated');
		} catch (Throwable $e) {
			if (isset($original_order_method) && isset($original_subscription_method)) {
				try {
					$fresh_parent_order = wc_get_order($parent_order_id);
					if ($fresh_parent_order) {
						$fresh_parent_order->set_payment_method($original_order_method);
						$fresh_parent_order->save();
					}
					$fresh_subscription = function_exists('wcs_get_subscription') ? wcs_get_subscription($subscription_id) : null;
					if ($fresh_subscription) {
						$fresh_subscription->set_payment_method($original_subscription_method);
						$fresh_subscription->save();
					}
				} catch (Throwable $rollback_error) {
					self::log_event(
						'error',
						'import_subscription_atomic_rollback_failed',
						array(
							'run_id'          => $run_id,
							'subscription_id' => $subscription_id,
							'parent_order_id' => $parent_order_id,
							'message'         => $rollback_error->getMessage(),
						)
					);
				}
			}

			self::insert_log($run_id, $subscription_id, $parent_order_id, 'failed', 'write_failed', array(
				'subscription_source' => $original_subscription_method,
				'subscription_target' => self::NEW_SUBSCRIPTION_PAYMENT_METHOD,
				'order_source' => $original_order_method,
				'order_target' => self::NEW_ORDER_PAYMENT_METHOD,
				'customerId' => $api_customer_id !== '' ? $api_customer_id : 'N.A',
				'storedPaymentMethodId' => $api_stored_token !== '' ? $api_stored_token : 'N.A',
				'transactionId' => $parent_order_transaction_id !== '' ? $parent_order_transaction_id : 'N.A',
				'_adn_payment_pspReference' => $legacy_payment_id !== '' ? $legacy_payment_id : 'N.A',
				'_adn_shopper_reference' => $legacy_shopper_reference !== '' ? $legacy_shopper_reference : 'N.A',
				'_adn_recurringDetailReference' => $legacy_recurring_detail_reference !== '' ? $legacy_recurring_detail_reference : 'N.A',
				'message' => $e->getMessage()
			));
			self::log_event(
				'error',
				'import_subscription_failed',
				array(
					'run_id'          => $run_id,
					'subscription_id' => $subscription_id,
					'parent_order_id' => $parent_order_id,
					'message'         => $e->getMessage(),
				)
			);
			return array('status' => 'failed', 'reason' => 'write_failed');
		}
	}

	/**
	 * Resolve legacy ConvesioPay payment id from parent/subscription metadata.
	 *
	 * @param WC_Order        $parent_order Parent order.
	 * @param WC_Subscription $subscription Subscription.
	 * @return string
	 */
	private static function resolve_legacy_payment_id($parent_order, $subscription)
	{
		$payment_id = (string) $parent_order->get_meta('_adn_payment_pspReference');
		if ('' === $payment_id) {
			$payment_id = (string) $parent_order->get_meta('_cpay_payment_pspReference');
		}
		if ('' === $payment_id) {
			$payment_id = (string) $subscription->get_meta('_adn_payment_pspReference');
		}
		if ('' === $payment_id) {
			$payment_id = (string) $subscription->get_meta('_cpay_payment_pspReference');
		}
		return trim($payment_id);
	}

	/**
	 * Fetch payments in one request by ids and return payment map.
	 *
	 * @param array $payment_ids Payment ids.
	 * @return array
	 */
	private static function fetch_convesiopay_payments_list_by_ids($payment_ids)
	{
		$map         = array();
		$payment_ids = array_values(array_unique(array_filter(array_map('trim', is_array($payment_ids) ? $payment_ids : array()))));
		if (empty($payment_ids)) {
			return $map;
		}
		if (! class_exists('PeachPay_ConvesioPay_Integration') || ! is_callable(array('PeachPay_ConvesioPay_Integration', 'config'))) {
			self::log_event('warning', 'payment_list_lookup_skipped_missing_integration_class');
			return $map;
		}

		$config  = PeachPay_ConvesioPay_Integration::config();
		$api_url = isset($config['api_url']) ? trim((string) $config['api_url']) : '';
		$secret  = isset($config['secret_key']) ? trim((string) $config['secret_key']) : '';
		if ('' === $api_url) {
			self::log_event('warning', 'payment_list_lookup_skipped_missing_api_url');
			return $map;
		}
		if ('' === $secret) {
			self::log_event('warning', 'payment_list_lookup_skipped_missing_secret');
			return $map;
		}

		$base_url = rtrim($api_url, '/');
		if (! preg_match('#/v1$#', $base_url)) {
			$base_url .= '/v1';
		}

		// API contract expects a raw comma-separated id list (do not encode commas).
		$id_param = 'id=' . implode(',', $payment_ids);
		$query_params = array(
			$id_param,
			'crossIntegrationPayments=true'
		);
		$url = $base_url . '/payments/list?' . implode('&', $query_params);

		// Transient-failure retry. A single dropped request (or a 5xx blip) on
		// the API would otherwise fail 25 subscriptions per batch with a
		// misleading 'missing_convesiopay_customer_id' reason. Retry only on
		// network errors (WP_Error) or 5xx responses — 4xx responses are
		// deterministic and won't get better with a retry.
		$response     = null;
		$attempts     = 0;
		$max_attempts = 3;

		while ($attempts < $max_attempts) {
			$attempts++;
			$response = wp_remote_get(
				$url,
				array(
					'headers' => array(
						'Authorization' => $secret,
						'Content-Type'  => 'application/json',
					),
					'timeout' => 30,
				)
			);

			if (is_wp_error($response)) {
				if ($attempts < $max_attempts) {
					// 500ms, 1000ms between attempts.
					usleep($attempts * 500000);
					continue;
				}
				break;
			}

			$code = (int) wp_remote_retrieve_response_code($response);
			if ($code >= 500 && $attempts < $max_attempts) {
				usleep($attempts * 500000);
				continue;
			}
			break;
		}

		if (is_wp_error($response)) {
			self::log_event(
				'warning',
				'payment_list_lookup_wp_error',
				array(
					'message'  => $response->get_error_message(),
					'attempts' => $attempts,
				)
			);
			return $map;
		}

		$code = (int) wp_remote_retrieve_response_code($response);
		$body = wp_remote_retrieve_body($response);
		$data = json_decode((string) $body, true);
		if (200 !== $code || ! is_array($data) || empty($data['payments']) || ! is_array($data['payments'])) {
			self::log_event(
				'warning',
				'payment_list_lookup_invalid_response',
				array(
					'status_code' => $code,
					'attempts'    => $attempts,
					'body'        => is_string($body) ? substr($body, 0, 400) : '',
				)
			);
			return $map;
		}

		foreach ($data['payments'] as $payment) {
			if (! is_array($payment)) {
				continue;
			}
			$payment_id = isset($payment['id']) ? trim((string) $payment['id']) : '';
			if ('' === $payment_id) {
				continue;
			}
			$map[$payment_id] = $payment;
		}
		return $map;
	}

	/**
	 * Extract stored payment method id from payment payload.
	 *
	 * @param array $payment_data Payment response payload.
	 * @return string
	 */
	private static function extract_stored_payment_method_id($payment_data)
	{
		if (! is_array($payment_data)) {
			return '';
		}
		if (isset($payment_data['storedPaymentMethodId']) && '' !== trim((string) $payment_data['storedPaymentMethodId'])) {
			return trim((string) $payment_data['storedPaymentMethodId']);
		}
		if (isset($payment_data['paymentMethodDetails']) && is_array($payment_data['paymentMethodDetails']) && isset($payment_data['paymentMethodDetails']['storedPaymentMethodId'])) {
			return trim((string) $payment_data['paymentMethodDetails']['storedPaymentMethodId']);
		}
		return '';
	}

	/**
	 * Process one rollback subscription.
	 *
	 * @param int    $subscription_id Subscription id.
	 * @param string $run_id          Run id.
	 * @param bool   $dry_run         Dry run mode.
	 * @return array
	 */
	private static function process_rollback_subscription($subscription_id, $run_id, $dry_run)
	{
		self::log_event(
			'debug',
			'rollback_subscription_start',
			array(
				'run_id'          => $run_id,
				'subscription_id' => $subscription_id,
				'dry_run'         => $dry_run,
			)
		);
		$subscription = function_exists('wcs_get_subscription') ? wcs_get_subscription($subscription_id) : null;
		if (! $subscription || ! is_callable(array($subscription, 'get_parent_id'))) {
			self::insert_log($run_id, $subscription_id, 0, 'failed', 'subscription_not_found');
			self::log_event('warning', 'rollback_subscription_not_found', array('run_id' => $run_id, 'subscription_id' => $subscription_id));
			return array('status' => 'failed', 'reason' => 'subscription_not_found');
		}

		$source = (string) $subscription->get_meta(self::MARKER_SOURCE_KEY);
		if ('cpay' !== $source) {
			self::insert_log($run_id, $subscription_id, 0, 'skipped', 'not_migrated_subscription');
			self::log_event('info', 'rollback_subscription_skipped_not_migrated', array('run_id' => $run_id, 'subscription_id' => $subscription_id));
			return array('status' => 'skipped', 'reason' => 'not_migrated_subscription');
		}

		$parent_order_id = (int) $subscription->get_parent_id();
		if ($parent_order_id <= 0) {
			self::insert_log($run_id, $subscription_id, 0, 'failed', 'missing_parent_order');
			self::log_event('warning', 'rollback_subscription_missing_parent_id', array('run_id' => $run_id, 'subscription_id' => $subscription_id));
			return array('status' => 'failed', 'reason' => 'missing_parent_order');
		}

		$parent_order = wc_get_order($parent_order_id);
		if (! $parent_order) {
			self::insert_log($run_id, $subscription_id, $parent_order_id, 'failed', 'missing_parent_order');
			self::log_event('warning', 'rollback_subscription_missing_parent_order', array('run_id' => $run_id, 'subscription_id' => $subscription_id, 'parent_order_id' => $parent_order_id));
			return array('status' => 'failed', 'reason' => 'missing_parent_order');
		}

		$original_subscription_method = (string) $subscription->get_payment_method();
		$original_order_method        = (string) $parent_order->get_payment_method();
		$saved_original_order_method  = (string) $parent_order->get_meta(self::ORDER_ORIGINAL_PAYMENT_METHOD_KEY);
		$target_order_method          = '' !== $saved_original_order_method ? $saved_original_order_method : self::OLD_PAYMENT_METHOD;
		if (self::OLD_PAYMENT_METHOD === $original_subscription_method) {
			self::insert_log($run_id, $subscription_id, $parent_order_id, 'skipped', 'rollback_skipped');
			self::log_event('debug', 'rollback_subscription_skipped_already_old_gateway', array('run_id' => $run_id, 'subscription_id' => $subscription_id, 'parent_order_id' => $parent_order_id));
			return array('status' => 'skipped', 'reason' => 'rollback_skipped');
		}

		if ($dry_run) {
			self::insert_log($run_id, $subscription_id, $parent_order_id, 'skipped', 'dry_run', array(
				'subscription_source' => $original_subscription_method,
				'subscription_target' => self::OLD_PAYMENT_METHOD,
				'order_source' => $original_order_method,
				'order_target' => $target_order_method,
			));
			self::log_event('debug', 'rollback_subscription_dry_run', array('run_id' => $run_id, 'subscription_id' => $subscription_id, 'parent_order_id' => $parent_order_id));
			return array('status' => 'skipped', 'reason' => 'dry_run');
		}

		try {
			$subscription->set_payment_method(self::OLD_PAYMENT_METHOD);
			$subscription->save();

			$parent_order->set_payment_method($target_order_method);
			$parent_order->save();
			self::add_payment_method_change_notes(
				$subscription,
				$parent_order,
				$run_id,
				'rollback',
				$original_subscription_method,
				self::OLD_PAYMENT_METHOD,
				$original_order_method,
				$target_order_method
			);

			self::insert_log($run_id, $subscription_id, $parent_order_id, 'migrated', 'rolled_back', array(
				'subscription_source' => $original_subscription_method,
				'subscription_target' => self::OLD_PAYMENT_METHOD,
				'order_source' => $original_order_method,
				'order_target' => $target_order_method,
			));
			self::log_event('info', 'rollback_subscription_completed', array('run_id' => $run_id, 'subscription_id' => $subscription_id, 'parent_order_id' => $parent_order_id));
			return array('status' => 'migrated', 'reason' => 'rolled_back');
		} catch (Throwable $e) {
			self::insert_log($run_id, $subscription_id, $parent_order_id, 'failed', 'write_failed');
			self::log_event(
				'error',
				'rollback_subscription_failed',
				array(
					'run_id'          => $run_id,
					'subscription_id' => $subscription_id,
					'parent_order_id' => $parent_order_id,
					'message'         => $e->getMessage(),
				)
			);
			return array('status' => 'failed', 'reason' => 'write_failed');
		}
	}

	/**
	 * Get candidate subscription ids.
	 *
	 * @return int[]
	 */
	private static function get_candidate_import_subscription_ids()
	{
		$ids = self::get_subscription_ids_for_query(
			array(
				'payment_method' => self::OLD_PAYMENT_METHOD,
			)
		);
		$ids = self::filter_migratable_subscription_ids($ids);
		self::log_event('debug', 'candidate_import_ids_loaded', array('count' => count($ids)));
		return $ids;
	}

	/**
	 * Get candidate rollback subscription ids.
	 *
	 * @return int[]
	 */
	private static function get_candidate_rollback_subscription_ids()
	{
		$ids = self::get_subscription_ids_for_query(
			array(
				'payment_method' => self::NEW_SUBSCRIPTION_PAYMENT_METHOD,
				'meta_query'     => array(
					array(
						'key'     => self::MARKER_SOURCE_KEY,
						'value'   => 'cpay',
						'compare' => '=',
					),
				),
			)
		);
		$ids = self::filter_migratable_subscription_ids($ids);
		self::log_event('debug', 'candidate_rollback_ids_loaded', array('count' => count($ids)));
		return $ids;
	}

	/**
	 * Schedule next batch.
	 *
	 * @param string $run_id Run id.
	 * @return void
	 */
	private static function schedule_next_batch($run_id, $operation = 'import')
	{
		$hook = 'rollback' === $operation ? self::ROLLBACK_ACTION_HOOK : self::IMPORT_ACTION_HOOK;

		if (function_exists('as_schedule_single_action')) {
			as_schedule_single_action(
				time() + 1,
				$hook,
				array($run_id),
				'peachpay-convesiopay'
			);
			self::log_event('debug', 'batch_scheduled_action_scheduler', array('run_id' => $run_id, 'operation' => $operation, 'hook' => $hook));
			return;
		}

		wp_schedule_single_event(time() + 1, $hook, array($run_id));
		self::log_event('debug', 'batch_scheduled_wp_cron', array('run_id' => $run_id, 'operation' => $operation, 'hook' => $hook));
	}

	/**
	 * Pause active run.
	 *
	 * @return true|WP_Error
	 */
	private static function pause_active_run()
	{
		$active_run = get_option(self::ACTIVE_RUN_OPTION, array());
		if (empty($active_run['run_id']) || empty($active_run['status']) || 'running' !== $active_run['status']) {
			return new WP_Error('no_running_run', __('No running migration found.', 'peachpay-for-woocommerce'));
		}

		$active_run['status'] = 'paused';
		update_option(self::ACTIVE_RUN_OPTION, $active_run, false);
		self::update_run_status($active_run['run_id'], 'paused');
		self::unschedule_run_actions($active_run['run_id']);
		self::log_event('info', 'run_paused', array('run_id' => $active_run['run_id']));
		return true;
	}

	/**
	 * Resume active paused run.
	 *
	 * @return true|WP_Error
	 */
	private static function resume_active_run()
	{
		$active_run = get_option(self::ACTIVE_RUN_OPTION, array());
		if (empty($active_run['run_id']) || empty($active_run['status']) || 'paused' !== $active_run['status']) {
			return new WP_Error('no_paused_run', __('No paused migration found.', 'peachpay-for-woocommerce'));
		}

		$active_run['status'] = 'running';
		update_option(self::ACTIVE_RUN_OPTION, $active_run, false);
		self::update_run_status($active_run['run_id'], 'running');
		self::schedule_next_batch(
			$active_run['run_id'],
			isset($active_run['operation']) ? (string) $active_run['operation'] : 'import'
		);
		self::log_event(
			'info',
			'run_resumed',
			array(
				'run_id'    => $active_run['run_id'],
				'operation' => isset($active_run['operation']) ? (string) $active_run['operation'] : 'import',
			)
		);
		return true;
	}

	/**
	 * Unschedule pending actions for a run id.
	 *
	 * @param string $run_id Run id.
	 * @return void
	 */
	private static function unschedule_run_actions($run_id)
	{
		if (function_exists('as_unschedule_all_actions')) {
			as_unschedule_all_actions(self::IMPORT_ACTION_HOOK, array($run_id), 'peachpay-convesiopay');
			as_unschedule_all_actions(self::ROLLBACK_ACTION_HOOK, array($run_id), 'peachpay-convesiopay');
		}
	}

	/**
	 * Complete run.
	 *
	 * @param string $run_id Run id.
	 * @return void
	 */
	private static function complete_run($run_id)
	{
		self::update_run_status($run_id, 'completed', true);
		delete_option(self::ACTIVE_RUN_OPTION);
		self::invalidate_overview_counts_cache();
		self::log_event('info', 'run_completed', array('run_id' => $run_id));
	}

	/**
	 * Mark run failed.
	 *
	 * @param string $run_id Run id.
	 * @return void
	 */
	private static function mark_run_failed($run_id)
	{
		self::update_run_status($run_id, 'failed', true);
		delete_option(self::ACTIVE_RUN_OPTION);
		self::invalidate_overview_counts_cache();
		self::log_event('error', 'run_failed', array('run_id' => $run_id));
	}

	/**
	 * Increment run counters.
	 *
	 * @param string $run_id Run id.
	 * @param string $status migrated|skipped|failed.
	 * @return void
	 */
	private static function increment_run_counters($run_id, $status)
	{
		$runs = self::get_runs();
		foreach ($runs as &$run) {
			if (isset($run['run_id']) && $run['run_id'] === $run_id) {
				$run['processed'] = isset($run['processed']) ? (int) $run['processed'] + 1 : 1;
				if ('migrated' === $status) {
					$run['migrated'] = isset($run['migrated']) ? (int) $run['migrated'] + 1 : 1;
				} elseif ('skipped' === $status) {
					$run['skipped'] = isset($run['skipped']) ? (int) $run['skipped'] + 1 : 1;
				} else {
					$run['failed'] = isset($run['failed']) ? (int) $run['failed'] + 1 : 1;
				}
				$run['last_activity_at'] = current_time('mysql');
				break;
			}
		}
		unset($run);
		update_option(self::RUNS_OPTION, $runs, false);
	}

	/**
	 * Update run activity timestamp.
	 *
	 * @param string $run_id Run id.
	 * @return void
	 */
	private static function touch_run_activity($run_id)
	{
		$runs = self::get_runs();
		foreach ($runs as &$run) {
			if (isset($run['run_id']) && $run['run_id'] === $run_id) {
				$run['last_activity_at'] = current_time('mysql');
				break;
			}
		}
		unset($run);
		update_option(self::RUNS_OPTION, $runs, false);
	}

	/**
	 * Insert per-subscription log record.
	 *
	 * @param string $run_id          Run id.
	 * @param int    $subscription_id Subscription id.
	 * @param int    $parent_order_id Parent order id.
	 * @param string $status          migrated|skipped|failed.
	 * @param string $reason          reason code.
	 * @param array  $args            Additional context payload (stored in description as JSON).
	 * @return void
	 */
	private static function insert_log($run_id, $subscription_id, $parent_order_id, $status, $reason, $args = array())
	{
		global $wpdb;
		$description_json = '';
		if (! empty($args) && is_array($args)) {
			$encoded = wp_json_encode($args);
			$description_json = false !== $encoded ? $encoded : '';
		}
		$data = array(
			'run_id'          => (string) $run_id,
			'subscription_id' => (int) $subscription_id,
			'parent_order_id' => (int) $parent_order_id,
			'status'          => (string) $status,
			'reason'          => (string) $reason,
			'description'     => $description_json,
			'created_at'      => current_time('mysql'),
		);
		$wpdb->insert(
			self::logs_table_name(),
			$data,
			array('%s', '%d', '%d', '%s', '%s', '%s', '%s')
		);
	}

	/**
	 * Get recent logs for a run.
	 *
	 * @param string $run_id Run id.
	 * @param int    $limit  Limit.
	 * @return array
	 */
	private static function get_recent_logs($run_id, $limit = 20)
	{
		if ('' === $run_id) {
			return array();
		}

		global $wpdb;
		$table_name = self::logs_table_name();
		$sql_with_description = $wpdb->prepare(
			"SELECT run_id, subscription_id, parent_order_id, status, reason, description, created_at
			FROM {$table_name}
			WHERE run_id = %s
			ORDER BY id DESC
			LIMIT %d",
			$run_id,
			$limit
		);
		$rows = $wpdb->get_results($sql_with_description, ARRAY_A);

		// Backward-compatible fallback for older sites where the table
		// exists but `description` is not yet present for any reason.
		if (! is_array($rows) && is_string($wpdb->last_error) && false !== stripos($wpdb->last_error, 'Unknown column') && false !== stripos($wpdb->last_error, 'description')) {
			$sql_without_description = $wpdb->prepare(
				"SELECT run_id, subscription_id, parent_order_id, status, reason, created_at
				FROM {$table_name}
				WHERE run_id = %s
				ORDER BY id DESC
				LIMIT %d",
				$run_id,
				$limit
			);
			$rows = $wpdb->get_results($sql_without_description, ARRAY_A);
			if (is_array($rows)) {
				foreach ($rows as &$row) {
					$row['description'] = '';
				}
				unset($row);
			}
		}

		return is_array($rows) ? $rows : array();
	}

	/**
	 * Get all runs (latest first).
	 *
	 * @return array
	 */
	private static function get_runs()
	{
		$runs = get_option(self::RUNS_OPTION, array());
		return is_array($runs) ? $runs : array();
	}

	/**
	 * Get overview counts shown in migration UI.
	 *
	 * @return array
	 */
	private static function get_subscription_overview_counts($force_refresh = false)
	{
		$cache_key = 'pp_subscription_migration_overview_counts';
		$cached    = $force_refresh ? false : get_transient($cache_key);
		if (! $force_refresh && is_array($cached)) {
			return $cached;
		}

		$cpay_status_counts = self::get_subscription_status_counts(
			array(
				'payment_method' => self::OLD_PAYMENT_METHOD,
			)
		);

		$peachpay_convesiopay_status_counts = self::get_subscription_status_counts(
			array(
				'payment_method' => self::NEW_SUBSCRIPTION_PAYMENT_METHOD,
				'meta_query'     => array(
					array(
						'key'     => self::MARKER_SOURCE_KEY,
						'value'   => 'cpay',
						'compare' => '=',
					),
				),
			)
		);

		$migrated_status_counts = self::get_subscription_status_counts(
			array(
				'meta_query' => array(
					array(
						'key'     => self::MARKER_SOURCE_KEY,
						'value'   => 'cpay',
						'compare' => '=',
					),
				),
			)
		);

		$counts = array(
			'cpay_subscriptions'                        => (int) $cpay_status_counts['total'],
			'cpay_status_counts'                        => $cpay_status_counts,
			'peachpay_convesiopay_subscriptions'        => (int) $peachpay_convesiopay_status_counts['total'],
			'peachpay_convesiopay_status_counts'        => $peachpay_convesiopay_status_counts,
			'already_migrated_subscriptions'            => (int) $migrated_status_counts['total'],
			'already_migrated_status_counts'            => $migrated_status_counts,
			'migrated_currently_on_peachpay'            => (int) $peachpay_convesiopay_status_counts['total'],
			'migrated_currently_on_legacy'              => count(self::get_subscription_ids_for_query(
				array(
					'payment_method' => self::OLD_PAYMENT_METHOD,
					'meta_query'     => array(
						array(
							'key'     => self::MARKER_SOURCE_KEY,
							'value'   => 'cpay',
							'compare' => '=',
						),
					),
				)
			)),
		);

		set_transient($cache_key, $counts, 20);
		self::log_event('debug', 'overview_counts_computed', $counts);
		return $counts;
	}

	/**
	 * Get per-status subscription counts for overview rows.
	 *
	 * @param array $base_args Base wc_get_orders args.
	 * @return array
	 */
	private static function get_subscription_status_counts($base_args = array())
	{
		$counts = array(
			'total'          => 0,
			'active'         => 0,
			'pending'        => 0,
			'on-hold'        => 0,
		);
		if (! function_exists('wc_get_orders')) {
			return $counts;
		}

		$subscription_ids = self::get_subscription_ids_for_query($base_args);
		if (empty($subscription_ids)) {
			return $counts;
		}

		// Read status in bulk instead of hydrating each WC_Subscription just
		// to call ->get_status(). Hydration does not scale — on large stores
		// (thousands of subscriptions) it exhausts memory_limit and fatally
		// crashes the AJAX status endpoint.
		//
		// Storage location differs depending on whether the site uses High
		// Performance Order Storage (HPOS) or the classic posts table:
		//   - HPOS: subscriptions live in {$wpdb->prefix}wc_orders, status
		//     stored without the 'wc-' prefix, id column is `id`.
		//   - CPT : subscriptions live in {$wpdb->posts}, status stored with
		//     the 'wc-' prefix, id column is `ID`.
		// The normalization below strips any 'wc-' prefix so both storages
		// produce the same bucket keys the $counts array expects.
		global $wpdb;

		$use_hpos = false;
		if (class_exists('\\Automattic\\WooCommerce\\Utilities\\OrderUtil')
			&& method_exists('\\Automattic\\WooCommerce\\Utilities\\OrderUtil', 'custom_orders_table_usage_is_enabled')
		) {
			$use_hpos = (bool) \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
		}

		$placeholders = implode(',', array_fill(0, count($subscription_ids), '%d'));

		if ($use_hpos) {
			$hpos_table = $wpdb->prefix . 'wc_orders';
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$statuses = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT status FROM {$hpos_table} WHERE id IN ($placeholders)",
					$subscription_ids
				)
			);
			// phpcs:enable
		} else {
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$statuses = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT post_status FROM {$wpdb->posts} WHERE ID IN ($placeholders)",
					$subscription_ids
				)
			);
			// phpcs:enable
		}

		if (! is_array($statuses)) {
			return $counts;
		}

		foreach ($statuses as $raw_status) {
			$status = preg_replace('/^wc-/', '', (string) $raw_status);
			if (! isset($counts[$status])) {
				continue;
			}
			$counts[$status] += 1;
			$counts['total'] += 1;
		}

		return $counts;
	}

	/**
	 * Get matching subscription ids by query args.
	 *
	 * @param array $args Query arguments.
	 * @return int[]
	 */
	private static function get_subscription_ids_for_query($args)
	{
		$ids   = array();
		$page  = 1;
		$batch = 200;

		do {
			$query_args = array_merge(
				array(
					'type'   => 'shop_subscription',
					'status' => 'any',
					'limit'  => $batch,
					'paged'  => $page,
					'return' => 'ids',
				),
				is_array($args) ? $args : array()
			);
			$results = wc_get_orders($query_args);
			$results = is_array($results) ? $results : array();
			foreach ($results as $subscription_id) {
				$ids[] = (int) $subscription_id;
			}
			$page++;
		} while (count($results) === $batch);

		return array_values(array_unique($ids));
	}

	/**
	 * Add audit notes when payment methods are switched.
	 *
	 * @param WC_Subscription $subscription Subscription object.
	 * @param WC_Order        $parent_order Parent order object.
	 * @param string          $run_id       Migration run id.
	 * @param string          $operation    import|rollback.
	 * @param string          $sub_from     Previous subscription gateway.
	 * @param string          $sub_to       New subscription gateway.
	 * @param string          $order_from   Previous order gateway.
	 * @param string          $order_to     New order gateway.
	 * @return void
	 */
	private static function add_payment_method_change_notes($subscription, $parent_order, $run_id, $operation, $sub_from, $sub_to, $order_from, $order_to)
	{
		$subscription_note = sprintf(
			__('ConvesioPay subscription migration (%1$s): payment method changed from %2$s to %3$s. Run ID: %4$s', 'peachpay-for-woocommerce'),
			$operation,
			$sub_from,
			$sub_to,
			$run_id
		);
		$order_note        = sprintf(
			__('ConvesioPay parent order migration (%1$s): payment method changed from %2$s to %3$s. Run ID: %4$s', 'peachpay-for-woocommerce'),
			$operation,
			$order_from,
			$order_to,
			$run_id
		);

		try {
			if ($subscription && is_callable(array($subscription, 'add_order_note'))) {
				$subscription->add_order_note($subscription_note);
			}
		} catch (Throwable $e) {
			self::log_event(
				'warning',
				'add_subscription_note_failed',
				array(
					'run_id'  => $run_id,
					'message' => $e->getMessage(),
				)
			);
		}

		try {
			if ($parent_order && is_callable(array($parent_order, 'add_order_note'))) {
				$parent_order->add_order_note($order_note);
			}
		} catch (Throwable $e) {
			self::log_event(
				'warning',
				'add_parent_order_note_failed',
				array(
					'run_id'  => $run_id,
					'message' => $e->getMessage(),
				)
			);
		}
	}

	/**
	 * Filter subscription ids to migratable statuses only.
	 *
	 * Reads status in bulk from the HPOS orders table (or the classic posts
	 * table when HPOS is disabled) instead of hydrating each WC_Subscription
	 * via wcs_get_subscription(). Hydration does not scale — on large stores
	 * (thousands of subscriptions) it exhausts memory_limit and fatally
	 * crashes start_run() before the run can even be registered.
	 *
	 * Storage location differs depending on whether the site uses High
	 * Performance Order Storage (HPOS) or the classic posts table:
	 *   - HPOS: subscriptions live in {$wpdb->prefix}wc_orders, status
	 *     stored without the 'wc-' prefix, id column is `id`.
	 *   - CPT : subscriptions live in {$wpdb->posts}, status stored with
	 *     the 'wc-' prefix, id column is `ID`.
	 * The normalization below strips any 'wc-' prefix so both storages
	 * produce the same values the $allowed_statuses list expects.
	 *
	 * @param int[] $subscription_ids Subscription ids.
	 * @return int[]
	 */
	private static function filter_migratable_subscription_ids($subscription_ids)
	{
		if (empty($subscription_ids) || ! is_array($subscription_ids)) {
			return array();
		}

		$allowed_statuses = array('active', 'pending', 'on-hold');

		// Normalize and de-duplicate input ids up-front so the SQL IN-list stays tight.
		$subscription_ids = array_values(array_unique(array_map('intval', $subscription_ids)));
		$subscription_ids = array_values(
			array_filter(
				$subscription_ids,
				static function ($id) {
					return $id > 0;
				}
			)
		);
		if (empty($subscription_ids)) {
			return array();
		}

		global $wpdb;

		$use_hpos = false;
		if (class_exists('\\Automattic\\WooCommerce\\Utilities\\OrderUtil')
			&& method_exists('\\Automattic\\WooCommerce\\Utilities\\OrderUtil', 'custom_orders_table_usage_is_enabled')
		) {
			$use_hpos = (bool) \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
		}

		// Chunk the IN-list so very large stores don't exceed MySQL's
		// max_allowed_packet. 1000 placeholders per query keeps us well
		// under the default 4MB packet limit with minimal round-trips.
		$filtered = array();
		foreach (array_chunk($subscription_ids, 1000) as $chunk) {
			$placeholders = implode(',', array_fill(0, count($chunk), '%d'));

			if ($use_hpos) {
				$hpos_table = $wpdb->prefix . 'wc_orders';
				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT id, status FROM {$hpos_table} WHERE id IN ($placeholders)",
						$chunk
					),
					ARRAY_A
				);
				// phpcs:enable
			} else {
				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT ID AS id, post_status AS status FROM {$wpdb->posts} WHERE ID IN ($placeholders)",
						$chunk
					),
					ARRAY_A
				);
				// phpcs:enable
			}

			if (! is_array($rows)) {
				continue;
			}

			foreach ($rows as $row) {
				$status = preg_replace('/^wc-/', '', (string) $row['status']);
				if (! in_array($status, $allowed_statuses, true)) {
					continue;
				}
				$filtered[] = (int) $row['id'];
			}
		}

		return array_values(array_unique($filtered));
	}

	/**
	 * Clear cached overview counts so UI reflects latest state.
	 *
	 * @return void
	 */
	private static function invalidate_overview_counts_cache()
	{
		delete_transient('pp_subscription_migration_overview_counts');
		self::log_event('debug', 'overview_counts_cache_invalidated');
	}

	/**
	 * Clear migration log table and run history options.
	 *
	 * @return array|WP_Error
	 */
	private static function clear_migration_history()
	{
		$active_run = get_option(self::ACTIVE_RUN_OPTION, array());
		$status     = isset($active_run['status']) ? (string) $active_run['status'] : '';
		if (in_array($status, array('running', 'paused'), true)) {
			return new WP_Error('run_active', __('Cannot clear logs while a migration run is active.', 'peachpay-for-woocommerce'));
		}

		global $wpdb;
		self::ensure_logs_table();
		$deleted_rows = $wpdb->query('DELETE FROM ' . self::logs_table_name());

		delete_option(self::RUNS_OPTION);
		delete_option(self::ACTIVE_RUN_OPTION);
		self::invalidate_overview_counts_cache();

		self::log_event(
			'info',
			'migration_history_cleared',
			array(
				'deleted_rows' => false === $deleted_rows ? -1 : (int) $deleted_rows,
			)
		);

		return array(
			'deleted_rows' => false === $deleted_rows ? 0 : (int) $deleted_rows,
		);
	}

	/**
	 * Get run summary by run id.
	 *
	 * @param string $run_id Run id.
	 * @return array
	 */
	private static function get_run_summary($run_id)
	{
		$runs = self::get_runs();
		foreach ($runs as $run) {
			if (! empty($run['run_id']) && $run['run_id'] === $run_id) {
				return $run;
			}
		}
		return array();
	}

	/**
	 * Update run status.
	 *
	 * @param string $run_id     Run id.
	 * @param string $status     Status value.
	 * @param bool   $is_final   Whether status is terminal.
	 * @return void
	 */
	private static function update_run_status($run_id, $status, $is_final = false)
	{
		$runs = self::get_runs();
		foreach ($runs as &$run) {
			if (isset($run['run_id']) && $run['run_id'] === $run_id) {
				$run['status']           = $status;
				$run['last_activity_at'] = current_time('mysql');
				if ($is_final) {
					$run['completed_at'] = current_time('mysql');
				}
				break;
			}
		}
		unset($run);
		update_option(self::RUNS_OPTION, $runs, false);
	}

	/**
	 * Upsert run summary in option.
	 *
	 * @param array $summary Summary.
	 * @return void
	 */
	private static function upsert_run_summary($summary)
	{
		$runs = self::get_runs();

		$filtered_runs = array();
		foreach ($runs as $run) {
			if (empty($run['run_id']) || $run['run_id'] !== $summary['run_id']) {
				$filtered_runs[] = $run;
			}
		}

		array_unshift($filtered_runs, $summary);
		$filtered_runs = array_slice($filtered_runs, 0, 50);
		update_option(self::RUNS_OPTION, $filtered_runs, false);
	}

	/**
	 * Generate run id.
	 *
	 * @return string
	 */
	private static function generate_run_id()
	{
		return 'pp_migration_' . gmdate('Ymd_His') . '_' . wp_generate_password(6, false, false);
	}
}

PeachPay_ConvesioPay_Subscription_Migrator::init_hooks();
