<?php
/**
 * Source-side migration push engine.
 *
 * Orchestrates the cross-site flow once the user clicks "Start migration":
 *
 *   1. validate     — handshake with destination, get session token
 *   2. building     — run the existing BackupEngine to produce archives
 *   3. pushing      — stream the archives to the destination one chunk at a time
 *   4. finalizing   — call destination's /migrate/finalize with the manifest
 *   5. complete     — done, surface the destination's backup id to the user
 *
 * Tick-based throughout: each WordPress cron tick advances the migration by
 * a bounded amount of work, then yields. A 50 GB push survives shared-host
 * `max_execution_time=30` because no single tick has to do more than build
 * a couple of archive parts OR push a few chunks before rescheduling.
 *
 * State lives in:
 *   - A BackupRecord with kind='migrating' — owns the build-phase progress
 *     (reuses every existing piece of backup-engine state: files[], status,
 *     duration, etc.)
 *   - A side option `backreload_migration_<ext_id>` keyed by ext_id holding the
 *     push-phase state (session token, chunks done, current phase). The
 *     session_token is encrypted via the existing Encryption service so
 *     it never sits in wp_options in plaintext.
 *
 * Why a side option instead of new BackupRecord columns:
 *   - No schema migration needed
 *   - Migrations are rare events — no perf concern with a separate option
 *   - Cleaner separation: kill the migration state without touching the
 *     backup record (which may still be useful as a regular local backup)
 *
 * @package Backreload\Core
 */

namespace Backreload\Core;

use Backreload\Models\BackupRecord;
use Backreload\Models\DestinationRecord;
use Backreload\Plugin;

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

class MigrationPushEngine {

	/** WP cron hook name for migration push ticks. */
	const HOOK_TICK = 'backreload_migration_tick';

	/** Per-migration state option key prefix. Suffixed with the ext_id. */
	const STATE_KEY_PREFIX = 'backreload_migration_state_';

	/**
	 * Per-migration cron lock prefix. Prevents two ticks for the same
	 * ext_id from running concurrently — without this, two cron events
	 * firing on top of each other would push overlapping chunk ranges
	 * to the destination, corrupting the staged file.
	 *
	 * Stored as a wp_options row (NOT a transient) so we can use
	 * `add_option()` for atomic acquire — duplicate INSERT fails at the
	 * UNIQUE-key level. TTL is enforced manually on read.
	 */
	const LOCK_KEY_PREFIX  = 'backreload_migration_lock_';
	const LOCK_TTL_SECONDS = 60; // generous — a normal tick is well under 30s

	/**
	 * How many chunks we attempt to upload per tick. Multiplied by chunk
	 * size to get bytes-per-tick — at 5 MB chunks × 4 chunks = 20 MB/tick.
	 * On a 5 GB backup that's ~250 ticks, ~125 minutes at 30-second cron
	 * cadence. Cron loopback (below) compresses this to seconds.
	 */
	const CHUNKS_PER_TICK = 4;

	/**
	 * Per-chunk upload retry count before we mark the migration failed.
	 * Each retry waits 2^n seconds (1, 2, 4) — gives a transient network
	 * blip a chance to recover without burning the migration.
	 */
	const CHUNK_RETRIES = 3;

	private Plugin $plugin;

	public function __construct( Plugin $plugin ) {
		$this->plugin = $plugin;
		add_action( self::HOOK_TICK, [ $this, 'tick_hook' ], 10, 1 );
	}

	/**
	 * Begin a new migration. Called from the REST `start` handler.
	 *
	 * Synchronous: handshake + BackupRecord create happen in the request.
	 * After this returns, the build + push run in background cron ticks.
	 *
	 * @param array{key_url:string,content:string,destination_url:string,
	 *              session_id:string,session_token:string,chunk_size:int,
	 *              auto_restore?:bool} $args
	 * @return BackupRecord  The migration's BackupRecord (kind='migrating')
	 */
	public function start( array $args ): BackupRecord {
		// Create the BackupRecord that owns the build phase. The
		// content choice maps onto the existing backup type enum.
		$type_map = [
			'full'     => 'full',
			'files'    => 'files',
			'database' => 'database',
		];
		$type = $type_map[ $args['content'] ] ?? 'full';

		// The backup engine requires at least one usable destination —
		// it'll mark the record `skipped` with "No usable destinations"
		// otherwise. Auto-attach a Local destination so the push engine
		// has a known place to read the assembled parts from. The
		// migration backup is also useful as a local copy after the
		// push completes (nice side-effect, doesn't cost anything since
		// the engine was going to write there anyway).
		$local = $this->find_connected_local();
		if ( ! $local ) {
			// We tried to auto-create one and it still failed — almost
			// always a wp-content writability problem. Surface a clear
			// remediation hint instead of a bare "no destination" error.
			throw new \RuntimeException( esc_html(
				'Cannot start migration: could not provision a Local Storage destination. ' .
				'wp-content/uploads/backreload is not writable. ' .
				'Fix the permission on wp-content (typically chmod 755 and correct owner), then try again.'
			) );
		}

		$engine = $this->plugin->get( 'engine' );
		$rec    = $engine->create_pending( [
			'type'         => $type,
			'kind'         => 'migrating',
			'components'   => null,
			'destinations' => [ $local->ext_id ],
		] );

		// Force encryption OFF for the build phase, regardless of the
		// site-wide setting. Reasoning:
		//
		//   - The destination doesn't have the source's passphrase, so an
		//     auto-restore that decrypts can't succeed. Users with
		//     encryption-on were silently hitting "decryption failed" on
		//     the destination after a successful 30-minute push.
		//   - The transport channel is already HTTPS — encrypting at rest
		//     in our local staging adds no protection, since the file is
		//     pushed and consumed within minutes.
		//
		// `create_pending` already wrote the settings snapshot (the engine
		// reads it during execute()). We rewrite that snapshot with
		// encryption disabled before the build starts, then mark the
		// record itself as non-encrypted.
		$snap = get_transient( 'backreload_settings_snap_' . $rec->ext_id );
		if ( is_array( $snap ) ) {
			$snap['encryption_enabled']    = false;
			$snap['encryption_passphrase'] = '';
			set_transient( 'backreload_settings_snap_' . $rec->ext_id, $snap, DAY_IN_SECONDS );
		}
		$rec->encrypted = false;
		$rec->save();

		// Persist the push-phase state. Session token is encrypted at
		// rest via the Encryption service (the same one that protects
		// destination passwords).
		$enc = $this->plugin->get( 'encryption' );
		$this->save_state( $rec->ext_id, [
			'phase'             => 'building',
			'destination_url'   => (string) $args['destination_url'],
			'session_id'        => (string) $args['session_id'],
			'session_token_enc' => $enc->encrypt_string( (string) $args['session_token'] ),
			'chunk_size'        => (int) $args['chunk_size'],
			'local_dest_id'     => $local->ext_id, // where to read the parts from
			'current_file_idx'  => 0,
			'current_chunk_idx' => 0,
			'current_chunk_total' => 0,
			'bytes_pushed'      => 0,
			'started_at'        => time(),
			'error'             => null,
			'finalized_backup_id' => null, // destination's BackupRecord ext_id
			// Default true — opt-out semantics matches the UI checkbox.
			'auto_restore'      => isset( $args['auto_restore'] ) ? (bool) $args['auto_restore'] : true,
			// Surfaced in source_status so the UI can pick the right
			// completion copy ("destination is restoring" vs "go run the
			// restore manually").
			'auto_restore_dispatched' => false,
			// Shared secret authenticating the background loopback driver that
			// advances this migration when WP-Cron is disabled. Never exposed
			// in any status response.
			'tick_secret'       => wp_generate_password( 40, false ),
		] );

		// Kick off the backup build asynchronously via the existing dispatcher.
		$engine->dispatch_async( $rec );

		// Schedule our own poll to detect when the build completes.
		// The first tick fires in 20s — gives the build a head start.
		$this->schedule_next_tick( $rec->ext_id, 20 );

		// On sites where WP-Cron is disabled the scheduled ticks never fire,
		// so start a self-continuing background loopback driver that carries
		// the migration through push + finalize without needing the browser
		// tab to stay open. Best-effort: if the host blocks loopback requests,
		// the Send page's status poll still advances it (see source_status).
		if ( $this->is_cron_disabled() ) {
			$this->spawn_background_tick( $rec->ext_id );
		}

		$this->plugin->get( 'logger' )->success(
			"Migration started, pushing to {$args['destination_url']}",
			[
				'session_id' => $args['session_id'],
				'content'    => $args['content'],
			],
			$rec->ext_id
		);

		return $rec;
	}

	/**
	 * Cron hook entry. Resolves the record + state and routes to the
	 * appropriate phase handler. Reschedules itself on TimeBudgetExceeded
	 * so big sites complete across many ticks.
	 */
	public function tick_hook( string $ext_id ): void {
		// Acquire the per-migration lock first. WP-Cron can fire the same
		// event twice (loopback + system cron racing, or two pageviews
		// triggering spawn_cron in quick succession). Without this, two
		// ticks read the same state, both push chunk N to the destination,
		// and we end up with corrupted staged files on the receive side.
		if ( ! $this->acquire_lock( $ext_id ) ) {
			return; // another tick is already running for this migration
		}

		try {
			$rec   = BackupRecord::find( $ext_id );
			$state = $this->load_state( $ext_id );
			if ( ! $rec || ! $state ) {
				return; // gone — nothing to do
			}
			try {
				$this->tick( $rec, $state );
			} catch ( \Throwable $e ) {
				$this->fail( $rec, $state, $e->getMessage() );
			}
		} finally {
			$this->release_lock( $ext_id );
		}
	}

	/**
	 * Atomically claim the per-migration lock. Returns true on success.
	 *
	 * Two stages:
	 *   1. `add_option()` inserts the lock row. The UNIQUE-key constraint
	 *      on wp_options.option_name makes this fail atomically if the
	 *      lock already exists.
	 *   2. If insert fails, check whether the existing lock is stale
	 *      (older than LOCK_TTL_SECONDS — e.g. a previous tick PHP-fatal'd
	 *      without releasing). If stale, delete and retry the insert.
	 *      A genuine concurrent tick will lose the second insert race and
	 *      we back off cleanly.
	 */
	private function acquire_lock( string $ext_id ): bool {
		$key = self::LOCK_KEY_PREFIX . $ext_id;
		// `false` autoload, empty deprecated arg.
		if ( add_option( $key, (string) time(), '', 'no' ) ) {
			return true;
		}
		// Existing lock — check if stale.
		$held_at = (int) get_option( $key, 0 );
		if ( $held_at > 0 && ( time() - $held_at ) > self::LOCK_TTL_SECONDS ) {
			delete_option( $key );
			// Race-safe retry: another tick may have grabbed it in the
			// microseconds between our delete and re-insert. If so, we
			// correctly fail to acquire.
			return (bool) add_option( $key, (string) time(), '', 'no' );
		}
		return false;
	}

	private function release_lock( string $ext_id ): void {
		delete_option( self::LOCK_KEY_PREFIX . $ext_id );
	}

	/**
	 * Schedule the next tick for a migration. Mirrors BackupEngine's
	 * dispatch_async pattern: register the cron event AND fire spawn_cron
	 * so the loopback happens within seconds rather than waiting for the
	 * host's next system-cron tick (which can be 5-15 minutes on managed
	 * WP hosts that disable WP-Cron and run wp-cron.php via system cron).
	 *
	 * Without this, a migration that transitions push → finalize stalls
	 * for minutes between the transition and the next tick — the user
	 * sees "Finalizing on destination" hang seemingly forever even
	 * though no work is actually broken.
	 */
	private function schedule_next_tick( string $ext_id, int $delay_seconds = 1 ): void {
		wp_schedule_single_event( time() + max( 1, $delay_seconds ), self::HOOK_TICK, [ $ext_id ] );
		if ( ! defined( 'DOING_CRON' ) && function_exists( 'spawn_cron' ) ) {
			spawn_cron();
		}
	}

	/**
	 * Whether WP-Cron is disabled on this site. When it is, the scheduled
	 * migration ticks never fire on their own, so the push must be advanced
	 * from foreground requests instead (the Send page's status poll drives it
	 * — see MigrationController::source_status). Mirrors the same DISABLE_WP_CRON
	 * check the backup engine uses to run builds inline.
	 */
	public function is_cron_disabled(): bool {
		return defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON;
	}

	/**
	 * Fire a non-blocking loopback request that drives this migration's tick
	 * loop in the background. Used only when WP-Cron is disabled, so the push
	 * completes even with no browser tab open. Authenticated by the migration's
	 * own tick_secret so the endpoint needs no login cookie (loopback has none).
	 *
	 * Fire-and-forget: a 0.01s timeout means we don't wait for the response;
	 * the receiving request runs the actual work. Loopback failures are
	 * non-fatal — the Send page's status poll is the fallback driver.
	 */
	public function spawn_background_tick( string $ext_id ): void {
		$state  = $this->load_state( $ext_id );
		$secret = is_array( $state ) ? (string) ( $state['tick_secret'] ?? '' ) : '';
		if ( '' === $secret ) {
			return;
		}
		wp_remote_post(
			rest_url( 'backreload/v1/migrate/source/tick' ),
			[
				'timeout'   => 0.01,
				'blocking'  => false,
				'sslverify' => false, // loopback to our own host
				'body'      => [ 'ext_id' => $ext_id, 'secret' => $secret ],
			]
		);
	}

	/**
	 * Verify a background-driver secret against the stored one, timing-safely.
	 */
	public function verify_tick_secret( string $ext_id, string $secret ): bool {
		$state  = $this->load_state( $ext_id );
		$stored = is_array( $state ) ? (string) ( $state['tick_secret'] ?? '' ) : '';
		return '' !== $stored && '' !== $secret && hash_equals( $stored, $secret );
	}

	/**
	 * One unit of work. Decides what phase we're in and does some of it,
	 * then either rescheduling or transitioning to the next phase.
	 *
	 * @param array{phase:string,destination_url:string,session_id:string,
	 *               session_token_enc:string,chunk_size:int,
	 *               current_file_idx:int,current_chunk_idx:int,
	 *               current_chunk_total:int,bytes_pushed:int,
	 *               started_at:int,error:?string,finalized_backup_id:?string} $state
	 */
	private function tick( BackupRecord $rec, array $state ): void {
		switch ( $state['phase'] ) {
			case 'building':
				$this->tick_building( $rec, $state );
				break;
			case 'pushing':
				$this->tick_pushing( $rec, $state );
				break;
			case 'finalizing':
				$this->tick_finalizing( $rec, $state );
				break;
			default:
				// 'complete' / 'failed' / 'aborted' — nothing more to do.
				return;
		}
	}

	/**
	 * Build phase: just watch the BackupRecord status. When the underlying
	 * backup engine flips status to 'success', transition to pushing. If
	 * it goes 'failed' or 'cancelled', propagate.
	 */
	private function tick_building( BackupRecord $rec, array $state ): void {
		// Refresh from DB — the engine has been writing while we were idle.
		$fresh = BackupRecord::find( $rec->ext_id );
		if ( ! $fresh ) {
			$this->fail( $rec, $state, 'Backup record disappeared during build.' );
			return;
		}
		if ( 'success' === $fresh->status ) {
			$state['phase']  = 'pushing';
			$state['files_total'] = count( (array) $fresh->files );
			$this->save_state( $rec->ext_id, $state );
			$this->plugin->get( 'logger' )->info( 'Build complete, switching to push phase', [], $rec->ext_id );
			$this->schedule_next_tick( $rec->ext_id, 1 );
			return;
		}
		if ( in_array( $fresh->status, [ 'failed', 'cancelled' ], true ) ) {
			$this->fail( $rec, $state, "Backup build {$fresh->status}: " . (string) ( $fresh->error ?? 'unknown error' ) );
			return;
		}
		// Still building — check back later. Engine has its own loopback
		// keeping the build moving; we just poll for completion.
		$this->schedule_next_tick( $rec->ext_id, 30 );
	}

	/**
	 * Push phase: stream chunks from the local backup files to the
	 * destination via repeated POST /migrate/upload-chunk calls.
	 *
	 * Bounded work: CHUNKS_PER_TICK chunks per invocation. On success,
	 * reschedule immediately (loopback-style fast cadence). On 4xx,
	 * stop with error. On transient 5xx / network error, retry with
	 * backoff up to CHUNK_RETRIES.
	 */
	private function tick_pushing( BackupRecord $rec, array $state ): void {
		$fresh = BackupRecord::find( $rec->ext_id );
		if ( ! $fresh ) {
			$this->fail( $rec, $state, 'Backup record disappeared during push.' );
			return;
		}
		$files = (array) $fresh->files;
		if ( empty( $files ) ) {
			$this->fail( $rec, $state, 'Backup has no files to push.' );
			return;
		}
		// Locate the directory where the Local destination stored the
		// finished parts. The backup engine deletes staging files after
		// they're uploaded — so the parts only live at the Local
		// destination's configured path, never in .staging at this point.
		$staging = $this->local_backup_root( (string) ( $state['local_dest_id'] ?? '' ) );
		if ( '' === $staging ) {
			$this->fail( $rec, $state, 'Local storage directory unavailable (Local destination missing or unconfigured).' );
			return;
		}

		$session_token = $this->plugin->get( 'encryption' )->decrypt_string( (string) $state['session_token_enc'] );
		if ( '' === $session_token ) {
			$this->fail( $rec, $state, 'Session token unreadable (encryption key changed?).' );
			return;
		}
		$dest_url    = (string) $state['destination_url'];
		$chunk_size  = (int) $state['chunk_size'];

		// Push up to CHUNKS_PER_TICK chunks this turn.
		for ( $i = 0; $i < self::CHUNKS_PER_TICK; $i++ ) {
			// Honour an Abort that landed from another request before spending
			// time (and bandwidth) on the next slow chunk. Re-read the live
			// phase — our in-memory $state is stale the moment the user clicks
			// Abort, and pushing/saving here would resurrect an aborted run.
			$live = $this->load_state( $rec->ext_id );
			if ( ! $live || in_array( (string) ( $live['phase'] ?? '' ), [ 'aborted', 'failed', 'cancelled', 'complete' ], true ) ) {
				return;
			}

			if ( $state['current_file_idx'] >= count( $files ) ) {
				// Push complete — also send the manifest as one final
				// "file", then transition to finalize.
				if ( ! $this->push_manifest( $rec, $state, $dest_url, $session_token, $chunk_size, $files, $staging ) ) {
					return; // error already recorded
				}
				$state['phase'] = 'finalizing';
				$this->save_state( $rec->ext_id, $state );
				$this->schedule_next_tick( $rec->ext_id, 1 );
				return;
			}

			$file_entry = $files[ $state['current_file_idx'] ];
			$file_name  = (string) ( $file_entry['name'] ?? '' );
			$file_path  = trailingslashit( $staging ) . $file_name;
			if ( ! is_file( $file_path ) ) {
				$this->fail( $rec, $state, "Missing local file: {$file_name}" );
				return;
			}
			$file_size = (int) filesize( $file_path );

			// Calculate chunk_total for this file once (cached in state).
			if ( 0 === $state['current_chunk_total'] ) {
				$state['current_chunk_total'] = max( 1, (int) ceil( $file_size / $chunk_size ) );
			}

			$chunk_idx   = (int) $state['current_chunk_idx'];
			$chunk_total = (int) $state['current_chunk_total'];

			$res = $this->upload_one_chunk( $dest_url, $session_token, $file_path, $file_name, $chunk_idx, $chunk_total, $chunk_size );
			if ( empty( $res['ok'] ) ) {
				$reason = (string) ( $res['reason'] ?? 'Unknown upload error.' );
				$this->fail(
					$rec,
					$state,
					"Failed pushing {$file_name} (chunk " . ( $chunk_idx + 1 ) . "/{$chunk_total}). {$reason}"
				);
				return;
			}

			$state['bytes_pushed']     += $this->chunk_byte_length( $file_size, $chunk_idx, $chunk_total, $chunk_size );
			$state['current_chunk_idx'] = $chunk_idx + 1;

			if ( $state['current_chunk_idx'] >= $chunk_total ) {
				// File complete — advance to next file.
				$state['current_file_idx']++;
				$state['current_chunk_idx']    = 0;
				$state['current_chunk_total']  = 0;
			}

			// The chunk upload above is slow; an Abort may have landed while it
			// ran. Re-read the live phase and stop WITHOUT saving — otherwise
			// our stale 'pushing' state would overwrite the 'aborted' phase and
			// the migration would resume. This is what makes Abort reliable.
			$live = $this->load_state( $rec->ext_id );
			if ( ! $live || in_array( (string) ( $live['phase'] ?? '' ), [ 'aborted', 'failed', 'cancelled', 'complete' ], true ) ) {
				return;
			}
			$this->save_state( $rec->ext_id, $state );
		}

		// More chunks to send — keep going. Loopback-style: tiny delay so
		// the cron stack doesn't accumulate, but we don't wait for the
		// next 30s cron tick either.
		$this->schedule_next_tick( $rec->ext_id, 1 );
	}

	/**
	 * Finalize phase: tell the destination to assemble and import. Single
	 * POST — no looping needed. On success, mark migration complete and
	 * surface the destination's backup id back to the UI via state.
	 */
	private function tick_finalizing( BackupRecord $rec, array $state ): void {
		$session_token = $this->plugin->get( 'encryption' )->decrypt_string( (string) $state['session_token_enc'] );
		$dest_url      = (string) $state['destination_url'];

		// Build the manifest from the local backup record. The destination
		// uses this to know what files to expect + what restore metadata
		// (source_url, components, etc.) to attach.
		$manifest = \Backreload\Core\Manifest::build( BackupRecord::find( $rec->ext_id ) );

		$res = wp_remote_post( $this->endpoint( $dest_url, '/migrate/finalize' ), [
			// Generous timeout: finalize now streams a sha256 over every
			// staged part to verify transfer integrity before accepting.
			// On a multi-GB migration that hashing takes real wall-clock
			// time; 60s could false-fail a legitimate large transfer. A
			// genuine timeout here is fail-safe (the operator retries), but
			// we'd rather not provoke one needlessly.
			'timeout'   => 180,
			'sslverify' => true,
			'headers'   => [
				'Content-Type'              => 'application/json',
				'X-Backreload-Migration-Token'     => $session_token,
				'User-Agent'                => $this->user_agent(),
			],
			'body'      => wp_json_encode( [
				'manifest'     => $manifest,
				'auto_restore' => ! empty( $state['auto_restore'] ),
			] ),
		] );
		if ( is_wp_error( $res ) ) {
			$this->fail( $rec, $state, 'Finalize request failed: ' . $res->get_error_message() );
			return;
		}
		$status = (int) wp_remote_retrieve_response_code( $res );
		$body   = self::decode_remote_json_lenient( (string) wp_remote_retrieve_body( $res ) );
		if ( 200 !== $status || ! is_array( $body ) ) {
			$msg = is_array( $body ) ? ( $body['message'] ?? "HTTP {$status}" ) : "HTTP {$status}";
			$this->fail( $rec, $state, 'Finalize rejected: ' . $msg );
			return;
		}

		$state['phase']               = 'complete';
		$state['finalized_backup_id'] = (string) ( $body['backup_ext_id'] ?? '' );
		// Destination tells us whether it kicked off the restore. We
		// surface this in source_status so the UI can show either "go run
		// the restore" or "restore is running on destination."
		$state['auto_restore_dispatched'] = ! empty( $body['auto_restore'] );
		$this->save_state( $rec->ext_id, $state );

		$this->plugin->get( 'logger' )->success(
			$state['auto_restore_dispatched']
				? 'Migration push complete: destination is restoring'
				: 'Migration push complete: destination accepted backup',
			[ 'destination_backup_id' => $state['finalized_backup_id'] ],
			$rec->ext_id
		);

		// Clean up the local parts now that the push has succeeded — they
		// were a transient byproduct of the migration, not something the
		// user asked us to store. Without this they pile up forever in
		// the auto-created "Migration staging" destination.
		$this->cleanup_local_artifacts( $rec, $state );
	}

	/**
	 * Upload one chunk via /migrate/upload-chunk. Returns true on
	 * success, false on terminal failure. Retries transient failures
	 * (network, 5xx) up to CHUNK_RETRIES with exponential backoff.
	 */
	/**
	 * Upload one chunk. Returns:
	 *   [ 'ok' => true ] on success
	 *   [ 'ok' => false, 'reason' => 'short human message', 'status' => int ]
	 *     on terminal failure. The caller surfaces `reason` to the user.
	 *
	 * The previous version returned only bool, so the user-facing "Chunk
	 * upload failed" message was generic regardless of whether the
	 * destination rejected on auth, on a malformed chunk, or on its WAF
	 * blocking the request. Now we propagate the destination's `{code,
	 * message}` body (or HTTP status fallback) up to fail().
	 *
	 * @return array{ok:bool,reason?:string,status?:int}
	 */
	private function upload_one_chunk(
		string $dest_url,
		string $session_token,
		string $file_path,
		string $file_name,
		int $chunk_idx,
		int $chunk_total,
		int $chunk_size
	): array {
		$offset    = $chunk_idx * $chunk_size;
		$file_size = (int) filesize( $file_path );
		$length    = min( $chunk_size, $file_size - $offset );
		if ( $length <= 0 ) {
			return [ 'ok' => false, 'reason' => 'Chunk offset past end of file (file changed mid-push?).' ];
		}

		// Read just this chunk's bytes (stream-friendly, no whole-file load).
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- WP_Filesystem doesn't support seek+read; we need bounded memory.
		$fh = @fopen( $file_path, 'rb' );
		if ( ! $fh ) {
			return [ 'ok' => false, 'reason' => "Could not read part {$file_name} from local storage." ];
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
		@fseek( $fh, $offset );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
		$raw = (string) fread( $fh, $length );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		@fclose( $fh );

		// Wire format mirrors /backups/import: base64(gzip(raw)) inside a
		// file-upload field. Inherits all the WAF / mod_security tolerance
		// the existing chunked import has.
		$encoded = base64_encode( (string) gzencode( $raw, 6 ) );

		// Build multipart body manually so we can stream a "file" without
		// a real disk file. wp_remote_post supports an array body but
		// doesn't multipart-encode files, so we use a manual boundary.
		$boundary = 'backreload-mig-' . wp_generate_password( 16, false, false );
		$body  = '';
		$body .= "--{$boundary}\r\n";
		$body .= "Content-Disposition: form-data; name=\"chunk\"; filename=\"chunk.b64\"\r\n";
		$body .= "Content-Type: text/plain\r\n\r\n";
		$body .= $encoded . "\r\n";
		foreach ( [
			'file_name'    => $file_name,
			'chunk_index'  => (string) $chunk_idx,
			'chunk_total'  => (string) $chunk_total,
			// Explicit byte offset so the receiver writes positionally
			// (overwrite-in-place) rather than appending. Makes a retried
			// chunk idempotent — without it, a lost-response retry of a
			// mid-file chunk doubled bytes on the destination.
			'chunk_offset' => (string) $offset,
		] as $k => $v ) {
			$body .= "--{$boundary}\r\n";
			$body .= "Content-Disposition: form-data; name=\"{$k}\"\r\n\r\n";
			$body .= $v . "\r\n";
		}
		$body .= "--{$boundary}--\r\n";

		$last_network_err = '';
		$last_status      = 0;
		$last_body_msg    = '';
		for ( $attempt = 0; $attempt < self::CHUNK_RETRIES; $attempt++ ) {
			if ( $attempt > 0 ) {
				sleep( (int) pow( 2, $attempt - 1 ) ); // 1s, 2s, 4s
			}
			$res = wp_remote_post( $this->endpoint( $dest_url, '/migrate/upload-chunk' ), [
				'timeout'   => 120,
				'sslverify' => true,
				'headers'   => [
					'Content-Type'           => 'multipart/form-data; boundary=' . $boundary,
					'X-Backreload-Migration-Token'  => $session_token,
					'User-Agent'             => $this->user_agent(),
				],
				'body'      => $body,
			] );
			if ( is_wp_error( $res ) ) {
				$last_network_err = $res->get_error_message();
				continue; // network, retry
			}
			$status = (int) wp_remote_retrieve_response_code( $res );
			$last_status = $status;
			if ( 200 === $status ) {
				return [ 'ok' => true ];
			}
			// Try to extract `{code, message}` from the destination's JSON
			// body so the user sees what the destination actually said.
			$body_arr = self::decode_remote_json_lenient( (string) wp_remote_retrieve_body( $res ) );
			$last_body_msg = is_array( $body_arr ) && ! empty( $body_arr['message'] )
				? (string) $body_arr['message']
				: '';
			if ( $status >= 500 ) {
				continue; // server hiccup, retry
			}
			// 4xx, terminal. Use the destination's message if it had one,
			// otherwise a derived hint based on the status code.
			$reason = $this->humanise_upload_error( $status, $last_body_msg );
			return [ 'ok' => false, 'status' => $status, 'reason' => $reason ];
		}

		// Exhausted retries. Distinguish network failure from 5xx.
		if ( $last_status >= 500 ) {
			$reason = "Destination kept returning {$last_status} after {self::CHUNK_RETRIES} attempts" .
				( '' !== $last_body_msg ? ": {$last_body_msg}" : '. Check the destination site logs.' );
		} elseif ( '' !== $last_network_err ) {
			$reason = 'Network error reaching destination: ' . $last_network_err;
		} else {
			$reason = 'Upload exhausted retries with no usable response from destination.';
		}
		return [ 'ok' => false, 'status' => $last_status, 'reason' => $reason ];
	}

	/**
	 * Turn an HTTP status + optional body message into a short copy that
	 * tells the user what to actually do. Falls back to body message or
	 * a generic "HTTP N" if we can't recognise the code.
	 */
	private function humanise_upload_error( int $status, string $body_msg ): string {
		switch ( $status ) {
			case 401:
				return 'Session token rejected by destination. The migration session may have expired; start a new migration with a fresh key.';
			case 403:
				return 'Destination refused the upload. A WAF / security plugin may be blocking the request. Check the destination site logs.';
			case 404:
				return 'Destination endpoint not found. The destination may be running an older plugin version that does not support migration. Update it first.';
			case 413:
				return 'Destination rejected the chunk as too large. Lower upload_max_filesize on the destination, or report this so we can renegotiate chunk size.';
			case 429:
				return 'Destination is rate-limiting uploads. The retries already paced themselves out; consider waiting a few minutes and retrying.';
			default:
				if ( '' !== $body_msg ) {
					return "Destination returned HTTP {$status}: {$body_msg}";
				}
				return "Destination returned HTTP {$status} with no message body.";
		}
	}

	/**
	 * Push the manifest.json as the final "file". For typical backups
	 * the manifest is a few KB — one chunk; for sites with thousands of
	 * categorised parts it can grow past the destination's chunk_size,
	 * in which case we split it across multiple chunks identical to how
	 * regular parts are streamed. The destination's chunk-staging code
	 * reassembles by (file_name, chunk_idx) the same way for the
	 * manifest as for any other file.
	 */
	private function push_manifest( BackupRecord $rec, array &$state, string $dest_url, string $session_token, int $chunk_size, array $files, string $staging ): bool {
		// Reuse Manifest::build to ensure the source-side manifest matches
		// what the destination expects.
		$manifest_json = (string) wp_json_encode( \Backreload\Core\Manifest::build( $rec ) );
		$manifest_len  = strlen( $manifest_json );

		// Write to a temp file so upload_one_chunk's offset+length pattern
		// works uniformly. Use PHP's native tempnam() rather than
		// wp_tempnam() — the latter lives in wp-admin/includes/file.php
		// which is NOT auto-loaded in REST/cron contexts, so calling it
		// from a tick handler crashes the run.
		$tmp = tempnam( get_temp_dir(), 'backreload-manifest-' );
		if ( ! $tmp ) {
			$this->fail( $rec, $state, 'Could not create manifest temp file.' );
			return false;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		@file_put_contents( $tmp, $manifest_json );

		$chunk_total = (int) max( 1, ceil( $manifest_len / max( 1, $chunk_size ) ) );
		$remote_name = $rec->ext_id . '/' . \Backreload\Core\Manifest::FILENAME;

		// Multi-chunk push. Single-chunk path stays one iteration; large
		// manifests transparently fan out across as many chunks as the
		// destination's chunk_size requires. Each chunk reuses the same
		// gzip+base64+multipart wire format that file chunks use, so
		// there's no special-casing on the destination side.
		for ( $chunk_idx = 0; $chunk_idx < $chunk_total; $chunk_idx++ ) {
			$res = $this->upload_one_chunk(
				$dest_url, $session_token, $tmp,
				$remote_name,
				$chunk_idx, $chunk_total, $chunk_size
			);
			if ( empty( $res['ok'] ) ) {
				$reason = (string) ( $res['reason'] ?? 'Unknown error pushing manifest.' );
				wp_delete_file( $tmp );
				$this->fail( $rec, $state, "Manifest push failed (chunk {$chunk_idx} of {$chunk_total}). {$reason}" );
				return false;
			}
		}
		wp_delete_file( $tmp );
		return true;
	}

	private function chunk_byte_length( int $file_size, int $chunk_idx, int $chunk_total, int $chunk_size ): int {
		if ( $chunk_idx < $chunk_total - 1 ) return $chunk_size;
		return $file_size - ( $chunk_idx * $chunk_size );
	}

	private function endpoint( string $dest_url, string $path ): string {
		return untrailingslashit( $dest_url ) . '/wp-json/backreload/v1' . $path;
	}

	private function user_agent(): string {
		return 'Backreload-Migration/' . ( defined( 'BACKRELOAD_VERSION' ) ? BACKRELOAD_VERSION : '0' );
	}

	private function fail( BackupRecord $rec, array $state, string $reason ): void {
		// Snapshot the phase we were IN when the failure happened so the
		// UI's phase stepper can highlight the broken step accurately.
		// Without this, the stepper used to hard-code "pushing" as the
		// failure phase — misleading on a failure during `building` or
		// `finalizing`. Stored alongside the new `phase = failed` so the
		// controller can surface both.
		if ( ! isset( $state['failed_at_phase'] ) ) {
			$state['failed_at_phase'] = (string) ( $state['phase'] ?? '' );
		}
		$state['phase'] = 'failed';
		$state['error'] = $reason;
		$this->save_state( $rec->ext_id, $state );
		$this->plugin->get( 'logger' )->error( "Migration push failed: {$reason}", [], $rec->ext_id );
		// Failure is terminal — drop the local parts. Keeping them would
		// waste disk on a backup that can't be restored anyway (its
		// destinations list points only at our internal Migration-staging
		// destination, and its source_url targets a different site).
		$this->cleanup_local_artifacts( $rec, $state );
	}

	/**
	 * Drop the local on-disk artifacts of a migration once it's reached a
	 * terminal phase. Removes the bk_<ext_id>/ folder from the auto-created
	 * Migration-staging destination AND deletes the BackupRecord row.
	 *
	 * Why aggressive: migrating-kind records are hidden from the Backups
	 * list (they target a different site's URL and can't be restored
	 * locally), so leaving them lying around just consumes disk + DB rows
	 * forever. The push completed (or didn't) — there's no scenario where
	 * the user needs to "go look at" the local copy.
	 *
	 * Best-effort throughout: any IO error is logged and ignored. The
	 * migration's terminal state stays correct even if cleanup fails.
	 */
	private function cleanup_local_artifacts( BackupRecord $rec, array $state ): void {
		$local_dest_id = (string) ( $state['local_dest_id'] ?? '' );
		if ( '' !== $local_dest_id ) {
			$dest = DestinationRecord::find( $local_dest_id );
			if ( $dest && 'local' === $dest->type ) {
				$root = rtrim( (string) ( $dest->config['path'] ?? '' ), '/\\' );
				if ( '' !== $root && is_dir( $root ) ) {
					$folder = $root . '/' . $rec->ext_id;
					if ( is_dir( $folder ) ) {
						$this->rrmdir( $folder );
					}
				}
			}
		}
		// Delete the BackupRecord row itself. It's kind='migrating' which
		// is hidden from the UI, has no other useful purpose, and would
		// otherwise accumulate forever.
		try {
			$rec->delete();
		} catch ( \Throwable $e ) {
			$this->plugin->get( 'logger' )->warn(
				'Migration cleanup: could not delete BackupRecord: ' . $e->getMessage(),
				[],
				$rec->ext_id
			);
		}
	}

	/**
	 * Recursive rmdir bounded to the WP-content tree as a defence against
	 * a future bug passing the wrong path. We never recurse outside
	 * WP_CONTENT_DIR even if asked to.
	 */
	private function rrmdir( string $path ): void {
		$content_root = realpath( WP_CONTENT_DIR );
		$real         = realpath( $path );
		if ( ! $real || ! $content_root || 0 !== strpos( $real, $content_root ) ) {
			return; // refuse anything outside wp-content
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP_Filesystem doesn't support reliable recursive delete across hosts.
		foreach ( glob( $real . '/*' ) ?: [] as $item ) {
			if ( is_dir( $item ) ) {
				$this->rrmdir( $item );
			} else {
				wp_delete_file( $item );
			}
		}
		@rmdir( $real ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Removes the plugin's own staging directories under wp-content/backreload-backups.
	}

	/**
	 * Decode a cross-site JSON response body, tolerating an HTML comment that a
	 * cache/optimization plugin on the OTHER site may staple onto every response
	 * (the exact thing that makes a probe/handshake/finalize return HTTP 200
	 * with an unparseable body — surfacing as a confusing "HTTP 200" error).
	 * Mirrors the client-side salvage in src/lib/api.ts. Bounded indexOf-style
	 * stripping — never a backtracking regex. Returns the decoded array or null.
	 *
	 * @return array<string,mixed>|null
	 */
	public static function decode_remote_json_lenient( string $raw ): ?array {
		$data = json_decode( $raw, true );
		if ( is_array( $data ) ) {
			return $data;
		}
		$s = trim( $raw );
		// Strip a bounded number of trailing then leading well-formed comments.
		for ( $i = 0; $i < 8 && '-->' === substr( $s, -3 ); $i++ ) {
			$open = strrpos( $s, '<!--' );
			if ( false === $open ) {
				break;
			}
			$s = rtrim( substr( $s, 0, $open ) );
		}
		for ( $i = 0; $i < 8 && '<!--' === substr( $s, 0, 4 ); $i++ ) {
			$close = strpos( $s, '-->' );
			if ( false === $close ) {
				break;
			}
			$s = ltrim( substr( $s, $close + 3 ) );
		}
		if ( '' === $s || $s === trim( $raw ) ) {
			return null;
		}
		$data = json_decode( $s, true );
		return is_array( $data ) ? $data : null;
	}

	public function load_state( string $ext_id ): ?array {
		$raw = get_option( self::STATE_KEY_PREFIX . $ext_id, null );
		return is_array( $raw ) ? $raw : null;
	}

	private function save_state( string $ext_id, array $state ): void {
		update_option( self::STATE_KEY_PREFIX . $ext_id, $state, false );
	}

	/**
	 * Resolve the directory where the Local destination stored this
	 * backup's parts. After a successful build, the engine deletes the
	 * .staging copies and the parts only live at:
	 *     {local_dest.config[path]}/bk_<ext_id>/<part>
	 * The `files[].name` entries already include the `bk_<ext_id>/`
	 * prefix, so callers can join `<root>/<name>` directly.
	 */
	private function local_backup_root( string $local_dest_id ): string {
		if ( '' === $local_dest_id ) {
			return '';
		}
		$dest = DestinationRecord::find( $local_dest_id );
		if ( ! $dest || 'local' !== $dest->type ) {
			return '';
		}
		$path = (string) ( $dest->config['path'] ?? '' );
		return ( '' !== $path && is_dir( $path ) ) ? $path : '';
	}

	/**
	 * Find (or auto-create) a Local destination the push engine can read
	 * built parts from.
	 *
	 * Strategy:
	 *   1. Re-use any existing connected Local destination — if the user
	 *      already has one configured, that's where their local copies
	 *      live and the migration backup may as well join them.
	 *   2. Otherwise create a managed "Migration staging" destination
	 *      pointed at wp-content/backreload-backups/migrations. Most users will
	 *      only have cloud destinations (S3, Drive, OneDrive); forcing
	 *      them to add a Local one by hand just to migrate would be a
	 *      hostile UX — and the path we pick is one we already own and
	 *      can clean up, so it costs nothing.
	 *
	 * The auto-created destination is left in the user's destinations
	 * list after the migration completes (named clearly so it's not
	 * surprising). They can delete it any time; the next migration will
	 * recreate it if needed.
	 */
	private function find_connected_local(): ?DestinationRecord {
		foreach ( DestinationRecord::all() as $dest ) {
			if ( 'local' !== $dest->type )       continue;
			if ( $dest->paused )                 continue;
			if ( 'connected' !== $dest->status ) continue;
			$path = (string) ( $dest->config['path'] ?? '' );
			if ( '' === $path || ! is_dir( $path ) ) continue;
			return $dest;
		}
		return $this->auto_create_local();
	}

	/**
	 * Provision a managed Local destination for migration parts. Path is
	 * wp-content/backreload-backups/migrations — sits inside the path-containment
	 * allowlist (WP_CONTENT_DIR) so the existing Local-destination guard
	 * accepts it.
	 *
	 * Returns null if we can't make the dir writable (rare — would block
	 * any backup anyway, not just migrations).
	 */
	private function auto_create_local(): ?DestinationRecord {
		$path = \Backreload\Storage::root() . '/migrations';
		if ( ! is_dir( $path ) ) {
			wp_mkdir_p( $path );
		}
		if ( ! is_dir( $path ) || ! is_writable( $path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Read-only writability probe of a target path before backup/restore.
			return null;
		}
		// Drop an index.html so the folder isn't directory-listable on
		// hosts with autoindex on. Same defence as the .staging dir.
		$index = $path . '/index.html';
		if ( ! is_file( $index ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			@file_put_contents( $index, '' );
		}

		$dest = DestinationRecord::create( [
			'type'   => 'local',
			'name'   => 'Migration staging (auto)',
			'config' => [ 'path' => $path ],
			'status' => 'connected',
		] );

		$this->plugin->get( 'logger' )->info(
			'Auto-created Local destination for migration staging',
			[ 'path' => $path, 'dest_id' => $dest->ext_id ]
		);

		return $dest;
	}
}
