<?php
/**
 * REST endpoints for site-to-site migration.
 *
 * The migration flow is split across two sites:
 *   - DESTINATION (receives): issues keys, accepts handshakes + chunked
 *                             uploads, runs restore on finalize.
 *   - SOURCE (sends):         validates a pasted key, builds a backup,
 *                             pushes chunks to the destination.
 *
 * This controller exposes:
 *
 *   Admin-only (manage_options + nonce):
 *     POST   /migrate/keys            — generate a new key
 *     GET    /migrate/keys            — list active keys
 *     DELETE /migrate/keys/{id}       — revoke a key
 *
 *   Public-but-signed (no admin login; the bearer token + signature is the auth):
 *     POST   /migrate/handshake       — phase 2
 *     POST   /migrate/upload-chunk    — phase 3
 *     POST   /migrate/finalize        — phase 3
 *     POST   /migrate/abort           — phase 6
 *     GET    /migrate/status          — phase 7
 *
 * Phase 1 implements only the admin-only key management. The public
 * endpoints land in subsequent phases.
 *
 * @package Backreload\Api
 */

namespace Backreload\Api;

use Backreload\Core\MigrationPushEngine;
use Backreload\Models\BackupRecord;
use Backreload\Models\DestinationRecord;
use Backreload\Models\MigrationKey;
use Backreload\Models\MigrationSession;
use WP_REST_Request;

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

class MigrationController extends BaseController {

	/**
	 * Option holding the ext_id of the migration currently being pushed FROM
	 * this site, so the Send page can re-attach to live progress after a
	 * reload. Set in source_start, cleared in source_current (on terminal) /
	 * source_abort.
	 */
	private const ACTIVE_OPTION = 'backreload_active_migration';

	/**
	 * Allowed key TTLs, in seconds. Restricted to a fixed list so a
	 * forged-input value can't request a 100-year key.
	 *
	 * @var array<string,int>
	 */
	private const ALLOWED_TTL = [
		'1h'  => HOUR_IN_SECONDS,
		'8h'  => 8  * HOUR_IN_SECONDS,
		'24h' => 24 * HOUR_IN_SECONDS,
		'7d'  => 7  * DAY_IN_SECONDS,
	];

	public function register( string $namespace ): void {
		// ── Admin-only key management (destination-side) ──────────────────
		register_rest_route( $namespace, '/migrate/keys', [
			[
				'methods'             => 'GET',
				'callback'            => [ $this, 'list_keys' ],
				'permission_callback' => [ $this, 'check_perm' ],
			],
			[
				'methods'             => 'POST',
				'callback'            => [ $this, 'create_key' ],
				'permission_callback' => [ $this, 'check_perm' ],
			],
		] );
		register_rest_route( $namespace, '/migrate/keys/(?P<id>mk_[a-zA-Z0-9_-]+)', [
			'methods'             => 'DELETE',
			'callback'            => [ $this, 'revoke_key' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );

		// ── Public destination-side endpoint (token-authed in body) ───────
		// Lightweight, idempotent: confirms a key exists, is active, and not
		// expired. Does NOT consume the key — that's the handshake's job
		// (which lands in Phase 3). The source uses probe() during paste-key
		// validation so the user can review the destination details before
		// committing to a one-time redemption.
		register_rest_route( $namespace, '/migrate/probe', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'probe' ],
			// Public by design (called server-to-server by the source site),
			// but authenticated: the permission callback rate-limits by IP and
			// requires the body token to resolve to a live migration key.
			'permission_callback' => [ $this, 'perm_probe' ],
		] );

		// ── Source-side bridge (admin-only) ───────────────────────────────
		// The React UI calls THIS endpoint (same-origin, nonce-authed) and
		// it makes a server-to-server wp_remote_post() to the destination's
		// /migrate/probe. Keeps cross-origin complexity out of the browser.
		register_rest_route( $namespace, '/migrate/source/validate', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'source_validate' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );
		register_rest_route( $namespace, '/migrate/source/start', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'source_start' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );
		register_rest_route( $namespace, '/migrate/source/status', [
			'methods'             => 'GET',
			'callback'            => [ $this, 'source_status' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );
		register_rest_route( $namespace, '/migrate/source/current', [
			'methods'             => 'GET',
			'callback'            => [ $this, 'source_current' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );
		// Advisory pre-flight scan: which active plugins on THIS (source) site
		// are known to interfere with a migration (cache, security/firewall,
		// redirect, page-builder). The Send page surfaces these so the user can
		// deactivate/flush them before pushing, and knows to regenerate a page
		// builder's CSS afterward. Purely informational — never blocks.
		register_rest_route( $namespace, '/migrate/preflight', [
			'methods'             => 'GET',
			'callback'            => [ $this, 'preflight' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );
		register_rest_route( $namespace, '/migrate/source/abort', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'source_abort' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );
		// Background loopback driver for WP-Cron-disabled sites. Public route,
		// but authenticated by the per-migration tick_secret in the body — it
		// carries no user privilege and only advances a migration that already
		// exists, so it needs no login cookie (a loopback request has none).
		register_rest_route( $namespace, '/migrate/source/tick', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'source_tick' ],
			'permission_callback' => '__return_true',
		] );
		// Background loopback that runs an auto-restore on the DESTINATION when
		// WP-Cron is disabled. Token-authed (per-restore secret), no cookie.
		register_rest_route( $namespace, '/restore/run', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'restore_run' ],
			'permission_callback' => '__return_true',
		] );
		// Destination-side: live progress of an incoming migration's restore,
		// so the Receive page can show it. Admin-only.
		register_rest_route( $namespace, '/migrate/receive/status', [
			'methods'             => 'GET',
			'callback'            => [ $this, 'receive_status' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );

		// ── Public destination-side receive endpoints (session-authed) ────
		// Phase 3a: source can redeem a key, upload chunks, finalize.
		// All four endpoints share the same auth model: the source presents
		// the session bearer token via the X-Backreload-Migration-Token header.
		// Handshake is the exception — it accepts the original key token in
		// the body and ISSUES the session token in its response.
		// Handshake redeems the one-time key token (in the body) and issues a
		// session bearer token; it's authed by the key token.
		register_rest_route( $namespace, '/migrate/handshake', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'handshake' ],
			'permission_callback' => [ $this, 'perm_key_token' ],
		] );
		// The remaining receive endpoints are authed by the session bearer
		// token presented in the X-Backreload-Migration-Token header.
		register_rest_route( $namespace, '/migrate/upload-chunk', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'upload_chunk' ],
			'permission_callback' => [ $this, 'perm_session_token' ],
		] );
		register_rest_route( $namespace, '/migrate/finalize', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'finalize' ],
			'permission_callback' => [ $this, 'perm_session_token' ],
		] );
		register_rest_route( $namespace, '/migrate/status', [
			'methods'             => 'GET',
			'callback'            => [ $this, 'status' ],
			'permission_callback' => [ $this, 'perm_session_token' ],
		] );
		register_rest_route( $namespace, '/migrate/abort', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'abort' ],
			'permission_callback' => [ $this, 'perm_session_token' ],
		] );
	}

	/* ──────────────────  Permission callbacks (public, token-authed)  ───────
	 *
	 * These endpoints can't use a logged-in capability check: they're called
	 * server-to-server by the *source* site, which has no WordPress session on
	 * the destination. Authentication is therefore the migration credential
	 * itself — a one-time key token (handshake/probe) or the session bearer
	 * token (chunk/finalize/status/abort), each a 256-bit secret validated by
	 * hash compare. The handlers re-validate as defence in depth.
	 */

	/**
	 * Probe auth: rate-limit by IP, then require a valid key token in the body.
	 *
	 * @param WP_REST_Request $req Request.
	 * @return bool|\WP_Error
	 */
	public function perm_probe( WP_REST_Request $req ) {
		if ( ! $this->probe_rate_ok() ) {
			return new \WP_Error( 'rate_limited', 'Too many probe attempts. Wait a minute and try again.', [ 'status' => 429 ] );
		}
		return $this->valid_key_token( (string) $req->get_param( 'token' ) );
	}

	/**
	 * Handshake auth: require a valid one-time key token in the body.
	 *
	 * @param WP_REST_Request $req Request.
	 * @return bool
	 */
	public function perm_key_token( WP_REST_Request $req ): bool {
		return $this->valid_key_token( (string) $req->get_param( 'token' ) );
	}

	/**
	 * Session-endpoint auth: require a valid session bearer token in the
	 * X-Backreload-Migration-Token header.
	 *
	 * @param WP_REST_Request $req Request.
	 * @return bool
	 */
	public function perm_session_token( WP_REST_Request $req ): bool {
		$token = (string) $req->get_header( 'x_backreload_migration_token' );
		if ( '' === $token ) {
			$token = (string) $req->get_header( 'X-Backreload-Migration-Token' );
		}
		if ( '' === $token ) {
			return false;
		}
		return (bool) MigrationSession::find_by_token( $token );
	}

	/**
	 * True when $token is well-formed and resolves to a live migration key.
	 *
	 * @param string $token Candidate token.
	 * @return bool
	 */
	private function valid_key_token( string $token ): bool {
		if ( '' === $token || strlen( $token ) < 16 || strlen( $token ) > 256 ) {
			return false;
		}
		return (bool) MigrationKey::find_by_token( $token );
	}

	/** GET /migrate/keys — admin lists every active/redeemed key. */
	public function list_keys( WP_REST_Request $req ) {
		$items = array_map(
			static fn( $k ) => $k->to_public_array(),
			MigrationKey::all()
		);
		return $this->ok( [ 'items' => $items ] );
	}

	/**
	 * POST /migrate/keys — generate a fresh key and return the one-time
	 * key URL + record. The URL is shown to the user, copied, and pasted
	 * into the source site. After this response the plaintext token is
	 * unrecoverable from server state — only verifiable via hash compare.
	 *
	 * Body:
	 *   { "ttl": "1h" | "8h" | "24h" | "7d" }   (default: "8h")
	 *
	 * Returns:
	 *   { key: <public array>, key_url: <string — show to user> }
	 */
	public function create_key( WP_REST_Request $req ) {
		$ttl_label = (string) ( $req->get_param( 'ttl' ) ?: '8h' );
		if ( ! isset( self::ALLOWED_TTL[ $ttl_label ] ) ) {
			return $this->error(
				'bad_ttl',
				'Pick one of: ' . implode( ', ', array_keys( self::ALLOWED_TTL ) ),
				400
			);
		}
		// Destination-side opt-in for auto-restore. Defaults FALSE: the
		// receive-side admin generated a key meaning "accept a backup",
		// not "execute a destructive restore." Explicit opt-in required.
		$allow_auto_restore = (bool) $req->get_param( 'allow_auto_restore' );
		try {
			$out = MigrationKey::create( self::ALLOWED_TTL[ $ttl_label ], $allow_auto_restore );
		} catch ( \Throwable $e ) {
			// random_bytes() can theoretically fail if the system entropy
			// pool is unavailable. Surface a clear error instead of letting
			// the exception leak.
			return $this->error(
				'random_failed',
				'Could not generate a secure key (system entropy unavailable).',
				500
			);
		}

		$key = $out['key'];
		$url = MigrationKey::build_url( $out['token'], $key->expires_at );

		$this->plugin->get( 'logger' )->info(
			'Migration key issued',
			[
				'key_id'     => $key->id,
				'ttl_label'  => $ttl_label,
				'expires_at' => gmdate( 'c', $key->expires_at ),
			]
		);

		return $this->ok( [
			'key'     => $key->to_public_array(),
			'key_url' => $url,
		], 201 );
	}

	/** DELETE /migrate/keys/{id} — revoke a key. */
	public function revoke_key( WP_REST_Request $req ) {
		$id  = (string) $req['id'];
		$rec = MigrationKey::find( $id );
		if ( ! $rec ) {
			return $this->error( 'not_found', 'Key not found.', 404 );
		}
		$rec->revoke();
		$this->plugin->get( 'logger' )->info( 'Migration key revoked', [ 'key_id' => $id ] );
		return $this->ok( [ 'revoked' => true ] );
	}

	/**
	 * POST /migrate/probe   (public, token-in-body)
	 *
	 * Source asks "is this key still valid + what site does it belong to?"
	 * — without consuming the key. Used by the source's paste-key UI to
	 * pre-flight before committing to redemption.
	 *
	 * Body: { token: "<plaintext>" }
	 * Returns on success: { ok:true, destination_url:..., expires_at:..., wp_max_upload_bytes:... }
	 *
	 * Rate-limited by IP: 30 probes / minute. Even though 256-bit tokens
	 * are uncrackable, an attacker spraying random tokens would still
	 * burn destination CPU on hash compares — the limit caps that.
	 */
	public function probe( WP_REST_Request $req ) {
		// Rate-limiting + key-token validation already ran in perm_probe();
		// we re-resolve the key here to build the response.
		$token = (string) $req->get_param( 'token' );
		if ( '' === $token || strlen( $token ) < 16 || strlen( $token ) > 256 ) {
			return $this->error( 'bad_token', 'Token missing or malformed.', 400 );
		}
		$key = MigrationKey::find_by_token( $token );
		if ( ! $key ) {
			// Generic message — never reveal whether the token "almost"
			// matched, expired, or never existed. Same response for all
			// failure modes.
			return $this->error( 'invalid_or_expired', 'Key is not valid or has expired.', 401 );
		}
		return $this->ok( [
			'ok'                  => true,
			'destination_url'     => untrailingslashit( home_url() ),
			'expires_at'          => $key->expires_at,
			'wp_max_upload_bytes' => (int) wp_max_upload_size(),
			'plugin_version'      => defined( 'BACKRELOAD_VERSION' ) ? BACKRELOAD_VERSION : '',
		] );
	}

	/**
	 * POST /migrate/source/validate   (admin-only, source-side bridge)
	 *
	 * React's Send tab calls this with the pasted key URL. We:
	 *   1. Parse the URL to extract destination + token + expiry.
	 *   2. Sanity-check the expiry client-side (no point asking the dest
	 *      if we know it's expired).
	 *   3. Make a server-to-server wp_remote_post() to the destination's
	 *      /migrate/probe, presenting the token in the body.
	 *   4. Return the destination's response unchanged, plus a derived
	 *      `safe_to_proceed: bool` flag.
	 *
	 * Body: { key_url: "https://destination.com?...&token=...&expires=..." }
	 * Returns on success: { destination_url, expires_at, expires_at_iso,
	 *                       wp_max_upload_bytes, plugin_version, source_url }
	 */
	public function source_validate( WP_REST_Request $req ) {
		$url = (string) $req->get_param( 'key_url' );
		if ( '' === $url ) {
			return $this->error( 'bad_key', 'Paste the full key URL.', 400 );
		}

		// Parse the key URL. Must be a well-formed https:// URL with the
		// four expected query params. Anything else = malformed paste.
		$parts = wp_parse_url( $url );
		if ( ! is_array( $parts ) || empty( $parts['host'] ) || empty( $parts['scheme'] ) ) {
			return $this->error( 'bad_key', 'Key is not a valid URL. Generate a fresh one on the destination site.', 400 );
		}
		if ( 'https' !== $parts['scheme'] && 'http' !== $parts['scheme'] ) {
			return $this->error( 'bad_key', 'Key URL must start with http:// or https://.', 400 );
		}
		parse_str( (string) ( $parts['query'] ?? '' ), $q );
		$domain    = (string) ( $q['domain']  ?? '' );
		$token     = (string) ( $q['token']   ?? '' );
		$expires   = (int)    ( $q['expires'] ?? 0 );
		if ( '' === $domain || '' === $token || 0 === $expires ) {
			return $this->error( 'bad_key', 'Key URL is missing required fields. Generate a fresh one on the destination.', 400 );
		}
		if ( $expires < time() ) {
			return $this->error( 'expired', 'Key has expired. Generate a fresh one on the destination.', 410 );
		}
		// Domain must match the host of the URL itself (defends against
		// a key whose `domain` param was rewritten to point somewhere else).
		$domain_host = wp_parse_url( $domain, PHP_URL_HOST );
		if ( strcasecmp( (string) $domain_host, (string) $parts['host'] ) !== 0 ) {
			return $this->error( 'bad_key', 'Key URL host does not match its embedded domain. Suspected tampering; generate a fresh key.', 400 );
		}
		// Refuse to push to OURSELVES — that's never a real migration and
		// is almost certainly the result of pasting a key on the same site
		// it was generated on.
		if ( 0 === strcasecmp( (string) $domain_host, (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ) {
			return $this->error( 'same_site', 'This key was generated on this same site. Migration keys move data BETWEEN sites. Paste on the SOURCE site, not the destination.', 400 );
		}

		// Server-to-server probe. wp_remote_post is the right tool: this
		// is plain HTTP, no browser involved, no CORS preflight needed.
		$probe_url = untrailingslashit( $domain ) . '/wp-json/backreload/v1/migrate/probe';
		// SSRF guard. Rejects loopback, link-local, and metadata-endpoint
		// targets BEFORE we send any bytes — otherwise a key URL whose host
		// resolves to 127.0.0.1 / 169.254.169.254 / .local would happily
		// be probed and the response would surface back to the admin.
		$reason = $this->unsafe_remote_url_reason( $probe_url );
		if ( null !== $reason ) {
			return $this->error( 'unsafe_target', $reason, 400 );
		}
		$res = wp_remote_post( $probe_url, [
			'timeout'   => 15,
			'sslverify' => true,
			'headers'   => [
				'Content-Type' => 'application/json',
				'User-Agent'   => 'Backreload/' . ( defined( 'BACKRELOAD_VERSION' ) ? BACKRELOAD_VERSION : '0' ),
			],
			'body'      => wp_json_encode( [ 'token' => $token ] ),
		] );
		if ( is_wp_error( $res ) ) {
			$msg = $res->get_error_message();
			// Detect common reachability failures + surface user-friendly
			// hints. The raw curl message is rarely actionable to a
			// non-technical user.
			$lower = strtolower( $msg );
			if ( false !== strpos( $lower, 'could not resolve' ) || false !== strpos( $lower, 'name or service not known' ) ) {
				return $this->error( 'unreachable', "Couldn't resolve the destination domain. Check the URL, and that DNS has propagated if you just set up the site.", 502 );
			}
			if ( false !== strpos( $lower, 'connection refused' ) ) {
				return $this->error( 'unreachable', 'Destination refused the connection. Is the site actually running?', 502 );
			}
			if ( false !== strpos( $lower, 'ssl' ) || false !== strpos( $lower, 'certificate' ) ) {
				return $this->error( 'tls_error', 'TLS / certificate error reaching destination. Fix the destination\'s HTTPS setup, or generate a new key from a publicly-reachable HTTPS site.', 502 );
			}
			if ( false !== strpos( $lower, 'timed out' ) || false !== strpos( $lower, 'timeout' ) ) {
				return $this->error( 'unreachable', 'Destination timed out. The site may be slow to respond, or outbound traffic from this host is being firewalled.', 504 );
			}
			return $this->error( 'unreachable', 'Could not reach destination: ' . $msg, 502 );
		}

		$status = (int) wp_remote_retrieve_response_code( $res );
		$body   = json_decode( (string) wp_remote_retrieve_body( $res ), true );
		if ( 200 !== $status || ! is_array( $body ) ) {
			$dest_msg = is_array( $body ) ? ( $body['message'] ?? "HTTP {$status}" ) : "HTTP {$status}";
			// Map specific dest-side errors to clearer messages.
			$dest_code = is_array( $body ) ? (string) ( $body['code'] ?? '' ) : '';
			if ( 'invalid_or_expired' === $dest_code || 401 === $status ) {
				return $this->error( 'invalid_or_expired', 'Destination rejected the key. Either it has expired or already been used; generate a fresh one.', 410 );
			}
			if ( 429 === $status ) {
				return $this->error( 'rate_limited', 'Destination is rate-limiting probe requests. Wait a minute and try again.', 429 );
			}
			return $this->error( 'probe_failed', 'Destination probe failed: ' . $dest_msg, 502 );
		}

		// Probe succeeded — return the destination's metadata to the UI
		// (plus a couple of derived convenience values).
		return $this->ok( [
			'destination_url'     => (string) ( $body['destination_url'] ?? $domain ),
			'expires_at'          => (int)    ( $body['expires_at']      ?? $expires ),
			'wp_max_upload_bytes' => (int)    ( $body['wp_max_upload_bytes'] ?? 0 ),
			'plugin_version'      => (string) ( $body['plugin_version'] ?? '' ),
			'source_url'          => untrailingslashit( home_url() ),
		] );
	}

	/**
	 * POST /migrate/source/start   (admin-only)
	 *
	 * The "Start migration" button on the Send tab hits this. We:
	 *   1. Re-parse the key URL and re-validate freshness (defends against
	 *      a stale UI state where the key expired between validate + start).
	 *   2. POST /migrate/handshake on the destination. THIS BURNS THE KEY —
	 *      from here on subsequent requests use the session bearer, not the
	 *      key. The destination returns session_id, session_token, chunk_size.
	 *   3. Hand the credentials to MigrationPushEngine::start() which sets
	 *      up the BackupRecord + state option + schedules the first tick.
	 *   4. Return the migration's ext_id so the UI can poll source_status.
	 *
	 * Body: { key_url, content: full|files|database }
	 * Response: { ext_id, destination_url }
	 */
	public function source_start( WP_REST_Request $req ) {
		$key_url = (string) $req->get_param( 'key_url' );
		$content = (string) $req->get_param( 'content' );
		if ( ! in_array( $content, MigrationSession::ALLOWED_CONTENT, true ) ) {
			$content = 'full';
		}
		// `auto_restore` defaults to true (opt-out via UI checkbox). Honour
		// an explicit `false` from the client; treat absence as true.
		$auto_restore_param = $req->get_param( 'auto_restore' );
		$auto_restore = ( null === $auto_restore_param ) ? true : (bool) $auto_restore_param;
		$parsed = $this->parse_key_url( $key_url );
		if ( $parsed instanceof \WP_REST_Response ) {
			return $parsed; // error
		}
		$dest_url = $parsed['domain'];
		$token    = $parsed['token'];

		// Handshake — burns the key, returns the session credentials.
		// expected_size = 0 is fine: the destination uses it only as a
		// progress-bar hint, and the real per-file chunk_total goes on
		// every /migrate/upload-chunk request.
		$handshake_url = untrailingslashit( $dest_url ) . '/wp-json/backreload/v1/migrate/handshake';
		// SSRF guard, second chance. source_validate ran the same check on
		// the probe URL; redo it here because the admin could have called
		// source_start without going through validate first (the UI does,
		// but an integration / CLI / curl might not), and because the
		// handshake POST carries the bearer token in the body — any leak
		// here gives an attacker a live migration session.
		$reason = $this->unsafe_remote_url_reason( $handshake_url );
		if ( null !== $reason ) {
			return $this->error( 'unsafe_target', $reason, 400 );
		}
		$res = wp_remote_post( $handshake_url, [
			'timeout'   => 30,
			'sslverify' => true,
			'headers'   => [
				'Content-Type' => 'application/json',
				'User-Agent'   => 'Backreload/' . ( defined( 'BACKRELOAD_VERSION' ) ? BACKRELOAD_VERSION : '0' ),
			],
			'body'      => wp_json_encode( [
				'token'         => $token,
				'source_url'    => untrailingslashit( home_url() ),
				'content'       => $content,
				'expected_size' => 0,
			] ),
		] );
		if ( is_wp_error( $res ) ) {
			return $this->error( 'handshake_failed', 'Could not reach destination for handshake: ' . $res->get_error_message(), 502 );
		}
		$status = (int) wp_remote_retrieve_response_code( $res );
		$body   = json_decode( (string) wp_remote_retrieve_body( $res ), true );
		if ( 201 !== $status || ! is_array( $body ) ) {
			$msg = is_array( $body ) ? ( $body['message'] ?? "HTTP {$status}" ) : "HTTP {$status}";
			return $this->error( 'handshake_rejected', 'Destination rejected handshake: ' . $msg, 502 );
		}

		$session_id    = (string) ( $body['session_id']    ?? '' );
		$session_token = (string) ( $body['session_token'] ?? '' );
		$chunk_size    = (int)    ( $body['chunk_size']    ?? 0 );
		if ( '' === $session_id || '' === $session_token || $chunk_size <= 0 ) {
			return $this->error( 'handshake_malformed', 'Destination returned an incomplete handshake response.', 502 );
		}

		try {
			$engine = $this->plugin->get( 'migration_push' );
			$rec    = $engine->start( [
				'key_url'         => $key_url,
				'content'         => $content,
				'destination_url' => $dest_url,
				'session_id'      => $session_id,
				'session_token'   => $session_token,
				'chunk_size'      => $chunk_size,
				'auto_restore'    => $auto_restore,
			] );
		} catch ( \Throwable $e ) {
			return $this->error( 'start_failed', $e->getMessage(), 400 );
		}

		// Remember which migration is being pushed FROM this site so the Send
		// page can re-attach to its live progress after a browser reload — the
		// push keeps running server-side; the browser just loses the ext_id it
		// was polling. Cleared once the migration reaches a terminal phase
		// (see source_current) or when the user aborts (see source_abort).
		update_option( self::ACTIVE_OPTION, $rec->ext_id, false );

		return $this->ok( [
			'ext_id'          => $rec->ext_id,
			'destination_url' => $dest_url,
			'session_id'      => $session_id,
			'chunk_size'      => $chunk_size,
		], 201 );
	}

	/**
	 * GET /migrate/source/current
	 *
	 * Returns the ext_id of the migration currently being pushed from this
	 * site, if one is in flight. The Send page calls this on load so a page
	 * reload (or reopening the tab) re-attaches to the live progress instead
	 * of losing it. Lazily clears the pointer once the migration is terminal.
	 */
	public function source_current( WP_REST_Request $req ) {
		unset( $req );
		$ext_id = (string) get_option( self::ACTIVE_OPTION, '' );
		if ( '' === $ext_id ) {
			return $this->ok( [ 'active' => null ] );
		}
		$state = $this->plugin->get( 'migration_push' )->load_state( $ext_id );
		$phase = is_array( $state ) ? (string) ( $state['phase'] ?? '' ) : '';
		if ( ! $state || in_array( $phase, [ 'complete', 'failed', 'aborted', 'cancelled' ], true ) ) {
			delete_option( self::ACTIVE_OPTION );
			return $this->ok( [ 'active' => null ] );
		}
		return $this->ok( [ 'active' => $ext_id ] );
	}

	/**
	 * GET /migrate/preflight — advisory scan of active plugins on this site
	 * that commonly interfere with a migration. Grouped by category so the
	 * Send page can tell the user what to deactivate/flush first. Informational
	 * only; it never blocks a migration.
	 */
	public function preflight( WP_REST_Request $req ) {
		unset( $req );

		// slug (plugin folder) => [ category, human name ]. Matched against the
		// directory segment of each active plugin so a plugin's main-file name
		// doesn't matter. Categories: cache, security, redirect, builder.
		$known = [
			// Caching / optimization — stale caches + cache drop-ins break a moved site.
			'litespeed-cache'                       => [ 'cache', 'LiteSpeed Cache' ],
			'wp-rocket'                             => [ 'cache', 'WP Rocket' ],
			'w3-total-cache'                        => [ 'cache', 'W3 Total Cache' ],
			'wp-super-cache'                        => [ 'cache', 'WP Super Cache' ],
			'wp-fastest-cache'                      => [ 'cache', 'WP Fastest Cache' ],
			'autoptimize'                           => [ 'cache', 'Autoptimize' ],
			'breeze'                                => [ 'cache', 'Breeze' ],
			'sg-cachepress'                         => [ 'cache', 'SiteGround Optimizer' ],
			'cache-enabler'                         => [ 'cache', 'Cache Enabler' ],
			'hummingbird-performance'               => [ 'cache', 'Hummingbird' ],
			'nitropack'                             => [ 'cache', 'NitroPack' ],
			'wp-optimize'                           => [ 'cache', 'WP-Optimize' ],
			'redis-cache'                           => [ 'cache', 'Redis Object Cache' ],
			'comet-cache'                           => [ 'cache', 'Comet Cache' ],
			'swift-performance-lite'                => [ 'cache', 'Swift Performance' ],
			'flying-press'                          => [ 'cache', 'FlyingPress' ],
			'perfmatters'                           => [ 'cache', 'Perfmatters' ],
			'optimazz'                              => [ 'cache', 'Optimazz' ],
			// Security / firewall — WAFs and login lockdowns can block the transfer
			// or lock you out after the users table changes.
			'all-in-one-wp-security-and-firewall'   => [ 'security', 'All-In-One WP Security' ],
			'wordfence'                             => [ 'security', 'Wordfence' ],
			'sucuri-scanner'                        => [ 'security', 'Sucuri Security' ],
			'better-wp-security'                    => [ 'security', 'Solid Security (iThemes)' ],
			'ithemes-security-pro'                  => [ 'security', 'Solid Security Pro' ],
			'wp-cerber'                             => [ 'security', 'WP Cerber' ],
			'ninjafirewall'                         => [ 'security', 'NinjaFirewall' ],
			'defender-security'                     => [ 'security', 'Defender' ],
			// Redirects — old→new domain rules can trap requests after the move.
			'redirection'                           => [ 'redirect', 'Redirection' ],
			'simple-301-redirects'                  => [ 'redirect', 'Simple 301 Redirects' ],
			'eps-301-redirects'                     => [ 'redirect', '301 Redirects (EPS)' ],
			'301-redirects'                         => [ 'redirect', '301 Redirects' ],
			'safe-redirect-manager'                 => [ 'redirect', 'Safe Redirect Manager' ],
			'quick-pagepost-redirect-plugin'        => [ 'redirect', 'Quick Page/Post Redirect' ],
			// Page builders — cache generated CSS keyed to the old URL; regenerate
			// after the move or the front end can error (e.g. Elementor).
			'elementor'                             => [ 'builder', 'Elementor' ],
			'elementor-pro'                         => [ 'builder', 'Elementor Pro' ],
			'elementskit-lite'                      => [ 'builder', 'ElementsKit Lite' ],
			'beaver-builder-lite-version'           => [ 'builder', 'Beaver Builder' ],
			'brizy'                                 => [ 'builder', 'Brizy' ],
			'js_composer'                           => [ 'builder', 'WPBakery Page Builder' ],
			'oxygen'                                => [ 'builder', 'Oxygen' ],
			'bricks'                                => [ 'builder', 'Bricks' ],
			'fusion-builder'                        => [ 'builder', 'Avada Fusion Builder' ],
			'siteorigin-panels'                     => [ 'builder', 'SiteOrigin Page Builder' ],
		];

		// Collect active plugin slugs (site + network-activated).
		$active = (array) get_option( 'active_plugins', [] );
		if ( is_multisite() ) {
			$active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', [] ) ) );
		}
		$slugs = [];
		foreach ( $active as $entry ) {
			$slugs[ strtok( (string) $entry, '/' ) ] = true;
		}

		$found = [ 'cache' => [], 'security' => [], 'redirect' => [], 'builder' => [] ];
		foreach ( $known as $slug => $meta ) {
			if ( isset( $slugs[ $slug ] ) ) {
				$found[ $meta[0] ][] = $meta[1];
			}
		}

		return $this->ok( [
			'cache'      => array_values( array_unique( $found['cache'] ) ),
			'security'   => array_values( array_unique( $found['security'] ) ),
			'redirect'   => array_values( array_unique( $found['redirect'] ) ),
			'builder'    => array_values( array_unique( $found['builder'] ) ),
			// Elementor's post-migration CSS regen is the single most common
			// "white screen after move" cause — flag it for a targeted tip.
			'elementor'  => isset( $slugs['elementor'] ),
		] );
	}

	/**
	 * POST /migrate/source/tick — background loopback driver (no login).
	 *
	 * On WP-Cron-disabled sites this is what actually carries a migration
	 * through push + finalize with no browser tab required. It runs the tick
	 * loop for a bounded time budget, then re-fires itself if the migration is
	 * still in flight. Authenticated purely by the per-migration tick_secret —
	 * it carries no user privilege and only advances a migration that already
	 * exists, so it needs no login cookie (loopback requests have none).
	 */
	public function source_tick( WP_REST_Request $req ) {
		$ext_id = (string) $req->get_param( 'ext_id' );
		$secret = (string) $req->get_param( 'secret' );
		$engine = $this->plugin->get( 'migration_push' );
		if ( '' === $ext_id || ! $engine->verify_tick_secret( $ext_id, $secret ) ) {
			return $this->error( 'bad_tick', 'Invalid tick.', 403 );
		}

		// This request intentionally runs long — it drives many ticks. The
		// loop below is time-bounded regardless.
		@set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged,WordPress.PHP.NoSilencedErrors -- long-running background migration driver; loop is time-bounded.

		$terminal = [ 'complete', 'failed', 'aborted', 'cancelled' ];
		$deadline = time() + 20; // seconds of push work per background request
		$phase    = '';
		do {
			$engine->tick_hook( $ext_id );
			$state = $engine->load_state( $ext_id );
			$phase = is_array( $state ) ? (string) ( $state['phase'] ?? '' ) : '';
			if ( '' === $phase || in_array( $phase, $terminal, true ) ) {
				return $this->ok( [ 'phase' => ( '' === $phase ? 'gone' : $phase ), 'done' => true ] );
			}
			// Build runs elsewhere (inline in start); don't hammer while we wait.
			if ( 'building' === $phase ) {
				sleep( 3 );
			}
		} while ( time() < $deadline );

		// Still running after the budget — hand off to a fresh background
		// request so we don't hold one ever-growing PHP process.
		$engine->spawn_background_tick( $ext_id );
		return $this->ok( [ 'phase' => $phase, 'done' => false ] );
	}

	/**
	 * POST /restore/run — background loopback that runs a dispatched auto-restore
	 * on the destination (no login; token-authed by the per-restore secret).
	 * This is what makes auto-restore complete on WP-Cron-disabled hosts without
	 * blocking the finalize request.
	 */
	public function restore_run( WP_REST_Request $req ) {
		$backup_id = (string) $req->get_param( 'backup_id' );
		$secret    = (string) $req->get_param( 'secret' );
		if ( '' === $backup_id ) {
			return $this->error( 'bad_id', 'backup_id is required.', 400 );
		}
		$ran = $this->plugin->get( 'restore' )->run_background( $backup_id, $secret );
		return $ran ? $this->ok( [ 'ran' => true ] ) : $this->error( 'bad_tick', 'Invalid restore token.', 403 );
	}

	/**
	 * GET /migrate/receive/status — live progress of an incoming migration's
	 * auto-restore, for the destination's Receive page. Returns { active:false }
	 * when no migration restore is running. Also re-fires the background restore
	 * if it was dispatched but the loopback never landed (host blocks loopback).
	 */
	public function receive_status( WP_REST_Request $req ) {
		unset( $req );
		$engine = $this->plugin->get( 'restore' );
		$id     = $engine->active_restore_id();
		if ( '' === $id ) {
			return $this->ok( [ 'active' => false ] );
		}
		$prog = $engine->get_progress( $id );
		if ( ! is_array( $prog ) || empty( $prog['via_migration'] ) ) {
			return $this->ok( [ 'active' => false ] );
		}
		$phase = (string) ( $prog['phase'] ?? 'starting' );
		// Fallback driver: if the restore is still 'starting' and its one-shot
		// token is unspent, the background loopback never landed — re-fire it.
		if ( 'starting' === $phase && $engine->is_restore_pending( $id ) ) {
			$engine->retry_background_restore( $id );
		}
		return $this->ok( [
			'active'      => true,
			'backup_id'   => $id,
			'phase'       => $phase,
			'label'       => (string) ( $prog['label'] ?? '' ),
			'files_done'  => (int) ( $prog['files_done'] ?? 0 ),
			'files_total' => (int) ( $prog['files_total'] ?? 0 ),
			'source_url'  => (string) ( $prog['source_url'] ?? '' ),
			'error'       => $prog['error'] ?? null,
			'started_at'  => (int) ( $prog['started_at'] ?? 0 ),
		] );
	}

	/**
	 * GET /migrate/source/status?ext_id=...
	 *
	 * Polled by the React progress panel. Combines the underlying
	 * BackupRecord (build progress) with the push engine's state option
	 * (push + finalize progress) so the UI can render one progress bar.
	 */
	public function source_status( WP_REST_Request $req ) {
		$ext_id = (string) $req->get_param( 'ext_id' );
		if ( '' === $ext_id ) {
			return $this->error( 'bad_id', 'ext_id is required.', 400 );
		}

		// IMPORTANT: check state BEFORE BackupRecord. Terminal cleanup
		// (on migration complete / fail / abort) deletes the underlying
		// BackupRecord but leaves the state option behind specifically
		// so the frontend can poll one more time and see the final phase.
		// Looking up the record first would return 404 here and the UI
		// would show a misleading "Migration not found, will retry" toast.
		$engine = $this->plugin->get( 'migration_push' );
		$state  = $engine->load_state( $ext_id );
		$rec    = BackupRecord::find( $ext_id );
		$rec_ok = ( $rec && 'migrating' === $rec->kind );

		// WP-Cron fallback. On sites where WP-Cron is disabled (many managed
		// hosts, and cache plugins like LiteSpeed), the scheduled migration
		// tick never fires on its own — so a migration would build and then
		// stall forever waiting to push. The Send page polls this endpoint
		// continuously while a migration runs, so we use each poll to advance
		// the push one bounded step. tick_hook() is lock-protected, so this
		// can never collide with a real cron tick if one does happen to fire.
		$phase = is_array( $state ) ? (string) ( $state['phase'] ?? '' ) : '';
		if ( in_array( $phase, [ 'building', 'pushing', 'finalizing' ], true ) && $engine->is_cron_disabled() ) {
			$engine->tick_hook( $ext_id );
			// Re-read: the tick just advanced the phase / pushed chunks.
			$state  = $engine->load_state( $ext_id );
			$rec    = BackupRecord::find( $ext_id );
			$rec_ok = ( $rec && 'migrating' === $rec->kind );
		}

		if ( ! $state && ! $rec_ok ) {
			// Truly nothing — never existed, or was cleaned up long ago.
			return $this->error( 'not_found', 'Migration not found.', 404 );
		}

		// State exists but record is gone — terminal cleanup ran. Return
		// the terminal info straight from state. files_total comes from
		// the saved state if we captured it (we do, in tick_building).
		if ( $state && ! $rec_ok ) {
			return $this->ok( [
				'phase'                   => (string) ( $state['phase'] ?? 'complete' ),
				'backup_status'           => 'success',
				'destination_url'         => (string) ( $state['destination_url'] ?? '' ),
				'bytes_pushed'            => (int) ( $state['bytes_pushed'] ?? 0 ),
				'files_total'             => (int) ( $state['files_total'] ?? 0 ),
				'files_done'              => (int) ( $state['current_file_idx'] ?? 0 ),
				'current_file'            => null,
				'finalized_backup_id'     => $state['finalized_backup_id'] ?? null,
				'auto_restore_dispatched' => ! empty( $state['auto_restore_dispatched'] ),
				'error'                   => $state['error'] ?? null,
				'started_at'              => (int) ( $state['started_at'] ?? 0 ),
			] );
		}

		// Record exists but state is gone (older edge case — state TTL'd
		// before cleanup). Treat as complete to stop polling.
		if ( $rec_ok && ! $state ) {
			return $this->ok( [
				'phase'                   => 'complete',
				'backup_status'           => $rec->status,
				'destination_url'         => '',
				'bytes_pushed'            => 0,
				'files_total'             => count( (array) $rec->files ),
				'current_file'            => null,
				'finalized_backup_id'     => null,
				'auto_restore_dispatched' => false,
				'error'                   => null,
			] );
		}

		// Normal in-flight case: both record and state exist. Read state keys
		// defensively — right after the build→push transition (which the
		// WP-Cron fallback above can perform inside this very request) the
		// push-progress keys aren't populated yet, so default them to 0/null.
		$files    = (array) $rec->files;
		$cur_idx  = (int) ( $state['current_file_idx'] ?? 0 );
		$cur_file = null;
		if ( isset( $files[ $cur_idx ] ) ) {
			$cur_file = [
				'name'        => (string) ( $files[ $cur_idx ]['name'] ?? '' ),
				'chunk_idx'   => (int) ( $state['current_chunk_idx'] ?? 0 ),
				'chunk_total' => (int) ( $state['current_chunk_total'] ?? 0 ),
			];
		}

		return $this->ok( [
			'phase'                   => (string) ( $state['phase'] ?? '' ),
			'backup_status'           => $rec->status,
			'destination_url'         => (string) ( $state['destination_url'] ?? '' ),
			'bytes_pushed'            => (int) ( $state['bytes_pushed'] ?? 0 ),
			'files_total'             => count( $files ),
			'files_done'              => $cur_idx,
			'current_file'            => $cur_file,
			'finalized_backup_id'     => $state['finalized_backup_id'] ?? null,
			'auto_restore_dispatched' => ! empty( $state['auto_restore_dispatched'] ),
			'error'                   => $state['error'] ?? null,
			'started_at'              => (int) ( $state['started_at'] ?? 0 ),
			// On a failed migration, this carries the phase that was
			// active at the moment of failure — e.g. 'building',
			// 'pushing', 'finalizing'. The UI uses it to highlight the
			// right step in the phase stepper instead of guessing
			// "probably pushing" the way it used to.
			'failed_at_phase'         => isset( $state['failed_at_phase'] ) ? (string) $state['failed_at_phase'] : null,
		] );
	}

	/**
	 * POST /migrate/source/abort
	 *
	 * Stop a running migration. We cancel the local backup (if still
	 * building) AND notify the destination so it can release its session
	 * + reclaim staging disk. Best-effort on the remote call — if the
	 * destination is unreachable, we still mark the local migration aborted.
	 */
	public function source_abort( WP_REST_Request $req ) {
		$ext_id = (string) $req->get_param( 'ext_id' );
		if ( '' === $ext_id ) {
			return $this->error( 'bad_id', 'ext_id is required.', 400 );
		}
		$rec = BackupRecord::find( $ext_id );
		if ( ! $rec || 'migrating' !== $rec->kind ) {
			return $this->error( 'not_found', 'Migration not found.', 404 );
		}
		$engine = $this->plugin->get( 'migration_push' );
		$state  = $engine->load_state( $ext_id );

		// If still building, ask the engine to cancel the backup run. Use
		// the partial-UPDATE helper — a full $rec->save() would race the
		// engine's in-flight full save and the cancel flag could be lost.
		if ( in_array( $rec->status, [ 'running', 'pending' ], true ) ) {
			BackupRecord::flag_cancel( $ext_id );
		}

		// Best-effort: tell the destination so it releases the session.
		if ( $state && ! empty( $state['destination_url'] ) && ! empty( $state['session_token_enc'] ) ) {
			$token = $this->plugin->get( 'encryption' )->decrypt_string( (string) $state['session_token_enc'] );
			if ( '' !== $token ) {
				wp_remote_post( untrailingslashit( $state['destination_url'] ) . '/wp-json/backreload/v1/migrate/abort', [
					'timeout'   => 10,
					'sslverify' => true,
					'headers'   => [
						'Content-Type'          => 'application/json',
						'X-Backreload-Migration-Token' => $token,
						'User-Agent'            => 'Backreload/' . ( defined( 'BACKRELOAD_VERSION' ) ? BACKRELOAD_VERSION : '0' ),
					],
					'body'      => wp_json_encode( [] ),
				] );
			}
		}

		// Drop the active-migration pointer so the Send page won't re-attach
		// to an aborted push after a reload.
		if ( (string) get_option( self::ACTIVE_OPTION, '' ) === $ext_id ) {
			delete_option( self::ACTIVE_OPTION );
		}

		// Mark the push state aborted so the next tick exits.
		if ( $state ) {
			$state['phase'] = 'aborted';
			$state['error'] = 'Aborted by user';
			update_option( MigrationPushEngine::STATE_KEY_PREFIX . $ext_id, $state, false );

			// Clean up the local on-disk parts + the migrating-kind
			// BackupRecord. Same rationale as the push-engine terminal
			// paths: aborted migrations leave no useful artifact behind,
			// and unpruned parts pile up over repeated retries.
			$this->cleanup_aborted_local_artifacts( $rec, $state );
		}

		$this->plugin->get( 'logger' )->info( 'Migration aborted by user', [], $ext_id );

		return $this->ok( [ 'ok' => true ] );
	}

	/**
	 * Mirror of MigrationPushEngine::cleanup_local_artifacts(), reachable
	 * from the abort path here. Kept thin and bounded — drops the
	 * bk_<ext_id>/ folder under the auto-created Local destination and
	 * deletes the migrating-kind BackupRecord.
	 */
	private function cleanup_aborted_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 ) ) {
						// Use the wp-content-bounded variant — the
						// staging-bounded `rrmdir` above refuses paths
						// outside staging_root() and would silently no-op.
						$this->rrmdir_wpcontent( $folder );
					}
				}
			}
		}
		try {
			$rec->delete();
		} catch ( \Throwable $e ) {
			$this->plugin->get( 'logger' )->warn(
				'Aborted-migration cleanup: could not delete BackupRecord: ' . $e->getMessage(),
				[],
				$rec->ext_id
			);
		}
	}

	/**
	 * Recursive rmdir bounded to WP_CONTENT_DIR — for cleaning local
	 * Migration-staging Local destinations after abort. Refuses anything
	 * resolving outside wp-content.
	 */
	private function rrmdir_wpcontent( string $path ): void {
		$content_root = realpath( WP_CONTENT_DIR );
		$real         = realpath( $path );
		if ( ! $real || ! $content_root || 0 !== strpos( $real, $content_root ) ) {
			return;
		}
		foreach ( glob( $real . '/*' ) ?: [] as $item ) {
			if ( is_dir( $item ) ) {
				$this->rrmdir_wpcontent( $item );
			} else {
				wp_delete_file( $item );
			}
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Removes the plugin's own staging directories.
		@rmdir( $real );
	}

	/**
	 * Shared key-URL parser used by source_validate + source_start. Returns
	 * the extracted { domain, token, expires } on success or a WP_REST_Response
	 * error on failure. Centralised so both endpoints reject identically.
	 *
	 * @return array{domain:string,token:string,expires:int}|\WP_REST_Response
	 */
	private function parse_key_url( string $url ) {
		if ( '' === $url ) {
			return $this->error( 'bad_key', 'Paste the full key URL.', 400 );
		}
		$parts = wp_parse_url( $url );
		if ( ! is_array( $parts ) || empty( $parts['host'] ) || empty( $parts['scheme'] ) ) {
			return $this->error( 'bad_key', 'Key is not a valid URL.', 400 );
		}
		if ( 'https' !== $parts['scheme'] && 'http' !== $parts['scheme'] ) {
			return $this->error( 'bad_key', 'Key URL must start with http:// or https://.', 400 );
		}
		parse_str( (string) ( $parts['query'] ?? '' ), $q );
		$domain  = (string) ( $q['domain']  ?? '' );
		$token   = (string) ( $q['token']   ?? '' );
		$expires = (int)    ( $q['expires'] ?? 0 );
		if ( '' === $domain || '' === $token || 0 === $expires ) {
			return $this->error( 'bad_key', 'Key URL is missing required fields.', 400 );
		}
		if ( $expires < time() ) {
			return $this->error( 'expired', 'Key has expired. Generate a fresh one on the destination.', 410 );
		}
		$domain_host = wp_parse_url( $domain, PHP_URL_HOST );
		if ( strcasecmp( (string) $domain_host, (string) $parts['host'] ) !== 0 ) {
			return $this->error( 'bad_key', 'Key URL host does not match its embedded domain. Generate a fresh key.', 400 );
		}
		if ( 0 === strcasecmp( (string) $domain_host, (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ) {
			return $this->error( 'same_site', 'This key was generated on this same site. Paste on the SOURCE, not the destination.', 400 );
		}
		return [ 'domain' => $domain, 'token' => $token, 'expires' => $expires ];
	}

	/**
	 * Simple per-IP rate limit for /migrate/probe. 30 attempts per minute
	 * is a generous human ceiling but a brick wall for a scraping bot.
	 *
	 * Uses a transient — survives across requests, auto-expires, no DB
	 * schema changes.
	 *
	 * SECURITY: keys off REMOTE_ADDR (not the trust-proxy-aware client_ip())
	 * because an attacker can spoof X-Forwarded-For. The old behavior of
	 * honoring the forwarded header here gave every spoofed header value a
	 * fresh rate-limit bucket — defeating the entire purpose of the limit.
	 * Behind Cloudflare / a reverse proxy, REMOTE_ADDR is the proxy IP
	 * (so legitimate humans share the proxy's bucket), but that's the
	 * correct trade-off: a shared bucket among legitimate users is no worse
	 * than no limit at all, and we still block adversaries from forging
	 * fresh buckets at will. The audit-log `source_ip` field on sessions
	 * stays on the trust-aware path because operator audit is the use case
	 * for the trust-proxy setting.
	 */
	private function probe_rate_ok(): bool {
		$ip = isset( $_SERVER['REMOTE_ADDR'] )
			? sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) )
			: '';
		$key = 'backreload_migrate_probe_rl_' . md5( $ip );
		$hits = (int) get_transient( $key );
		if ( $hits >= 30 ) {
			return false;
		}
		set_transient( $key, $hits + 1, MINUTE_IN_SECONDS );
		return true;
	}

	/* ─── Phase 3a: receive-side endpoints (destination) ─────────────── */

	/**
	 * POST /migrate/handshake   (public, key-token in body)
	 *
	 * Source presents the key token + tells us about the incoming backup.
	 * We:
	 *   1. Validate the token (single-use). Burning the key here is the
	 *      whole point of handshake — subsequent probes are a no-op for
	 *      that key going forward.
	 *   2. Calculate a chunk size we can swallow: floor(wp_max_upload_size
	 *      × 0.6) so the multipart envelope + base64 expansion fit under
	 *      the server's POST limit. Hard ceiling at 8 MB so we don't issue
	 *      stupid-large chunks even on hosts that allow huge POSTs.
	 *   3. Create a session, mark the key as redeemed, return the session
	 *      bearer token + chunk size + valid_until to the source.
	 *
	 * Body: { token, source_url, content: full|files|database, expected_size }
	 * Response: { session_id, session_token, chunk_size, valid_until }
	 */
	public function handshake( WP_REST_Request $req ) {
		if ( ! $this->probe_rate_ok() ) {
			return $this->error( 'rate_limited', 'Too many attempts. Wait a minute and try again.', 429 );
		}
		$token         = (string) $req->get_param( 'token' );
		$source_url    = (string) $req->get_param( 'source_url' );
		$content       = (string) $req->get_param( 'content' );
		$expected_size = (int)    $req->get_param( 'expected_size' );

		if ( '' === $token || strlen( $token ) < 16 || strlen( $token ) > 256 ) {
			return $this->error( 'bad_token', 'Token missing or malformed.', 400 );
		}
		if ( '' === $source_url || ! wp_http_validate_url( $source_url ) ) {
			return $this->error( 'bad_source', 'Source URL missing or malformed.', 400 );
		}
		if ( ! in_array( $content, MigrationSession::ALLOWED_CONTENT, true ) ) {
			$content = 'full';
		}
		// `expected_size` is a hint for chunks_total math — clamp to a
		// sane range so a malicious source can't allocate a huge counter.
		$expected_size = max( 0, min( $expected_size, 200 * 1024 * 1024 * 1024 ) ); // 200 GB ceiling

		$key = MigrationKey::find_by_token( $token );
		if ( ! $key ) {
			return $this->error( 'invalid_or_expired', 'Key is not valid or has expired.', 401 );
		}

		// Chunk size negotiation. wp_max_upload_size returns the SMALLER
		// of post_max_size / upload_max_filesize. Subtract for multipart
		// envelope + base64 inflation, hard cap at 8 MB.
		$wp_max     = (int) wp_max_upload_size();
		$chunk_size = max( 256 * 1024, min( 8 * 1024 * 1024, (int) floor( $wp_max * 0.6 ) ) );

		// chunks_total is only an estimate — we use it for progress UX.
		// Set to 0 when expected_size is unknown; source must report real
		// counts in upload-chunk requests.
		$chunks_total = $expected_size > 0 ? (int) ceil( $expected_size / $chunk_size ) : 0;

		$opened = MigrationSession::create( [
			'key_id'           => $key->id,
			'source_url'       => $source_url,
			'source_ip'        => $this->client_ip(),
			'content'          => $content,
			'expected_size'    => $expected_size,
			'chunk_size_limit' => $chunk_size,
			'chunks_total'     => $chunks_total,
		] );

		// Mark the key redeemed AFTER session create so a transient race
		// doesn't burn the key without producing a usable session.
		$key->mark_redeemed( $opened['session']->id, $source_url, $this->client_ip() );

		// Pre-create the staging directory so chunk uploads don't have to.
		$this->ensure_staging_dir( $opened['session']->id );

		$this->plugin->get( 'logger' )->success(
			"Migration handshake from {$source_url}",
			[
				'session_id'    => $opened['session']->id,
				'key_id'        => $key->id,
				'content'       => $content,
				'expected_size' => $expected_size,
			]
		);

		return $this->ok( [
			'session_id'    => $opened['session']->id,
			'session_token' => $opened['token'],
			'chunk_size'    => $chunk_size,
			'valid_until'   => $opened['session']->valid_until,
			'chunks_total'  => $chunks_total,
		], 201 );
	}

	/**
	 * POST /migrate/upload-chunk   (public, session bearer in header)
	 *
	 * Source streams the backup to us, one piece per request. Wire format
	 * mirrors the existing /backups/import endpoint so we get firewall +
	 * mod_security tolerance for free:
	 *
	 *   - body is multipart with a `chunk` file upload whose CONTENT is
	 *     base64( gzip( raw chunk bytes ) ). Sending it AS A FILE clears
	 *     the firewall's "request body" size rule, and the gzip layer
	 *     keeps decoded bytes opaque to content-pattern WAFs.
	 *
	 *   - per-request fields: file_name, chunk_index, chunk_total
	 *
	 * The destination stages each file at:
	 *   wp-content/backreload-backups/.migrate/<session_id>/<file_name>.part
	 * appended in order. When chunk_index === chunk_total - 1 the staged
	 * file is treated as complete. finalize() reads the manifest to know
	 * which files to expect and which to move into place.
	 */
	public function upload_chunk( WP_REST_Request $req ) {
		$session = $this->session_from_request( $req );
		if ( $session instanceof \WP_REST_Response ) {
			return $session; // error response
		}
		// Bump session activity so a long push doesn't expire mid-stream.
		$session->touch();

		$files = $req->get_file_params();
		$chunk = $files['chunk'] ?? null;
		if ( ! $chunk || empty( $chunk['tmp_name'] ) ) {
			return $this->error( 'no_chunk', 'No chunk data received.', 400 );
		}

		// Decode base64(gzip(raw)). The same two-layer transport used by
		// /backups/import — keeps WAFs that pattern-match request bodies
		// from flagging zipped plugin source code etc.
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- $chunk['tmp_name'] is a PHP-managed upload tmp file, not user-controllable.
		$encoded = (string) file_get_contents( $chunk['tmp_name'] );
		$decoded = base64_decode( $encoded, true );
		$chunk_bytes = ( false !== $decoded ) ? @gzdecode( $decoded ) : false;
		if ( false === $chunk_bytes ) {
			return $this->error( 'bad_encoding', 'Chunk data is not valid base64(gzip(...)).', 400 );
		}

		$file_name   = (string) $req->get_param( 'file_name' );
		$chunk_index = (int)    $req->get_param( 'chunk_index' );
		$chunk_total = (int)    $req->get_param( 'chunk_total' );

		if ( '' === $file_name || ! $this->is_safe_remote_name( $file_name ) ) {
			return $this->error( 'bad_name', 'Chunk file_name is missing or contains unsafe characters.', 400 );
		}
		if ( $chunk_total < 1 || $chunk_index < 0 || $chunk_index >= $chunk_total ) {
			return $this->error( 'bad_index', 'chunk_index / chunk_total out of range.', 400 );
		}

		$staging = $this->ensure_staging_dir( $session->id );
		$path    = trailingslashit( $staging ) . $file_name . '.part';
		$parent  = dirname( $path );
		if ( ! is_dir( $parent ) ) {
			wp_mkdir_p( $parent );
		}

		// First chunk truncates any prior contents so a retry of a WHOLE
		// transfer (or a previous aborted one) starts clean.
		if ( 0 === $chunk_index && is_file( $path ) ) {
			wp_delete_file( $path );
		}

		$chunk_len    = strlen( $chunk_bytes );
		$cap_bytes    = 12 * 1024 * 1024 * 1024; // 12 GB per-file ceiling
		// Byte offset this chunk occupies in the assembled file. The push
		// engine sends it explicitly (chunk_index * chunk_size). Positional
		// writing makes a re-sent chunk OVERWRITE its slot instead of
		// appending a duplicate — the old FILE_APPEND path silently doubled
		// any chunk whose HTTP response was lost and got retried, producing a
		// longer, corrupt assembled file that nothing downstream detected.
		$offset_param = $req->get_param( 'chunk_offset' );
		$has_offset   = ( null !== $offset_param && '' !== $offset_param && ctype_digit( (string) $offset_param ) );

		if ( $has_offset ) {
			$chunk_offset  = (int) $offset_param;
			$projected_end = $chunk_offset + $chunk_len;
			if ( $projected_end > $cap_bytes ) {
				if ( is_file( $path ) ) { wp_delete_file( $path ); }
				return $this->error( 'too_large', 'Uploaded file exceeds the maximum size.', 400 );
			}
			// 'cb' = create-if-missing, read/write, do NOT truncate. We seek
			// to the chunk's slot and overwrite in place.
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- positional seek+write needs a real handle; WP_Filesystem can't.
			$fp = @fopen( $path, 'cb' );
			if ( false === $fp ) {
				return $this->error( 'stage_failed', 'Could not open staging file.', 500 );
			}
			// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- WP_Filesystem buffers whole files and cannot stream; the import/migration transport reads & writes multi-GB archives via direct handles under the plugin's own paths.
			if ( ! @flock( $fp, LOCK_EX ) ) {
				fclose( $fp );
				return $this->error( 'stage_failed', 'Could not lock staging file.', 500 );
			}
			$stat     = fstat( $fp );
			$cur_size = is_array( $stat ) ? (int) $stat['size'] : 0;
			// No-gap invariant: a chunk may overwrite an existing slot
			// (retry) or extend by exactly one chunk (in-order), but it may
			// NOT start beyond the current end — that would leave a sparse
			// hole and a corrupt file. Out-of-order / missing-predecessor
			// delivery is rejected so the push engine resends from the gap.
			if ( $chunk_offset > $cur_size ) {
				@flock( $fp, LOCK_UN );
				fclose( $fp );
				return $this->error( 'chunk_gap', "Chunk {$chunk_index} starts past the current end of the staged file; resend the missing earlier chunk(s).", 409 );
			}
			@fseek( $fp, $chunk_offset, SEEK_SET );
			$wrote = @fwrite( $fp, $chunk_bytes );
			// On the final chunk, clamp the file to exactly this chunk's end
			// so any tail left by a longer previous transfer is discarded.
			if ( $chunk_index === $chunk_total - 1 ) {
				@fflush( $fp );
				@ftruncate( $fp, $chunk_offset + $chunk_len );
			}
			@fflush( $fp );
			@flock( $fp, LOCK_UN );
			fclose( $fp );
			// phpcs:enable WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
			if ( false === $wrote || $wrote < $chunk_len ) {
				return $this->error( 'stage_failed', 'Could not write chunk to staging.', 500 );
			}
		} else {
			// Legacy fallback: a source running a pre-positional-write plugin
			// version sends no chunk_offset. Keep the old append behaviour so
			// cross-version migrations still work. (The integrity check at
			// finalize — size + sha256 vs the manifest — is the backstop that
			// catches any duplication this path could still produce.)
			$cur = is_file( $path ) ? (int) filesize( $path ) : 0;
			if ( $cur + $chunk_len > $cap_bytes ) {
				if ( is_file( $path ) ) { wp_delete_file( $path ); }
				return $this->error( 'too_large', 'Uploaded file exceeds the maximum size.', 400 );
			}
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- append-mode + LOCK_EX for concurrent chunk safety.
			$wrote = @file_put_contents( $path, $chunk_bytes, FILE_APPEND | LOCK_EX );
			if ( false === $wrote ) {
				return $this->error( 'stage_failed', 'Could not write chunk to staging.', 500 );
			}
		}

		$session->chunks_received++;
		$session->save();

		return $this->ok( [
			'received'  => $chunk_index + 1,
			'of'        => $chunk_total,
			'file_done' => $chunk_index === $chunk_total - 1,
		] );
	}

	/**
	 * POST /migrate/finalize   (public, session bearer)
	 *
	 * All chunks arrived. Source sends the manifest in the body so we know
	 * exactly which files to move into the BackupRecord. We:
	 *   1. Verify every file mentioned in the manifest exists in staging.
	 *   2. Find a Local destination (or auto-pick a default).
	 *   3. Move staged files into bk_<ext_id>/ under the local destination.
	 *   4. Create a BackupRecord with kind='migrated', files[] populated.
	 *   5. Mark session complete.
	 *   6. Dispatch the restore engine asynchronously via the existing
	 *      restore-by-id route (so progress is visible in the destination's
	 *      Restore wizard / live log).
	 *
	 * The destination's admin can monitor the restore from the Restore page
	 * just like any other restore; no special migration UI on the receive
	 * side is needed.
	 */
	public function finalize( WP_REST_Request $req ) {
		$session = $this->session_from_request( $req );
		if ( $session instanceof \WP_REST_Response ) {
			return $session;
		}

		$manifest = $req->get_param( 'manifest' );
		if ( ! is_array( $manifest ) || empty( $manifest['files'] ) ) {
			return $this->error( 'bad_manifest', 'Finalize body must include a `manifest` object with files[].', 400 );
		}

		// Sanity: only allow our own manifest format.
		if ( empty( $manifest['backreload_manifest'] ) ) {
			return $this->error( 'bad_manifest', 'Manifest is not in Backreload format.', 400 );
		}

		// ── Trust-boundary validation ──────────────────────────────────────
		// The session is bearer-token authed but a holder of that token can
		// still ship a manifest with attacker-chosen URLs/paths. Cross-check
		// the high-impact fields against values we ALREADY trusted at
		// handshake time (when the key was burned) or that we can audit cheap.

		// `source_url` in the manifest MUST match what the source declared
		// at handshake. Mismatch = something is wrong (token reused from
		// another session, manifest hand-crafted, etc.) and we refuse before
		// the migration engine writes it into the restore-time URL rewrite.
		$man_source_url = (string) ( $manifest['source_url'] ?? '' );
		if ( '' !== $man_source_url && '' !== $session->source_url
			&& untrailingslashit( $man_source_url ) !== untrailingslashit( $session->source_url ) ) {
			return $this->error(
				'bad_manifest_source',
				'Manifest source_url does not match the source that opened this session.',
				400
			);
		}

		// `source_path` becomes a search-replace needle for the migration
		// engine. Refuse values short enough to match unrelated DB content
		// (e.g. "/" would replace every slash). Real source_path is an
		// absolute filesystem path like /var/www/html — minimum 4 chars
		// + must start with a separator.
		$man_source_path = (string) ( $manifest['source_path'] ?? '' );
		if ( '' !== $man_source_path ) {
			$trimmed = trim( $man_source_path );
			if ( strlen( $trimmed ) < 4
				|| '/' === $trimmed
				|| ( '/' !== ( $trimmed[0] ?? '' ) && ! preg_match( '#^[A-Za-z]:[/\\\\]#', $trimmed ) ) ) {
				return $this->error(
					'bad_manifest_path',
					'Manifest source_path is missing or too short to be a safe search-replace needle.',
					400
				);
			}
		}

		// Components must be a whitelisted set — the BackupEngine writes
		// these into the restore plan; an attacker-supplied value like
		// `db` on a request that never actually shipped a SQL file would
		// confuse the restore step.
		$allowed_components = [ 'db', 'uploads', 'themes', 'plugins', 'mu', 'others' ];
		$bad_components = array_diff(
			array_values( (array) ( $manifest['components'] ?? [] ) ),
			$allowed_components
		);
		if ( $bad_components ) {
			return $this->error(
				'bad_manifest_components',
				'Manifest components include unknown values: ' . implode( ', ', $bad_components ),
				400
			);
		}

		// Cap files[] count. The current architecture produces a handful
		// of category zips; anything in the thousands is either a buggy
		// source or a fabricated manifest trying to exhaust disk.
		if ( count( (array) $manifest['files'] ) > 500 ) {
			return $this->error( 'manifest_too_large', 'Manifest claims more than 500 files; refusing as a safety guard.', 400 );
		}

		$staging = trailingslashit( $this->staging_root() ) . $session->id;
		if ( ! is_dir( $staging ) ) {
			return $this->error( 'no_staging', 'Staging directory missing; was upload interrupted?', 500 );
		}

		// Verify every manifest file landed in staging, is the EXACT size the
		// manifest claims, and (when the manifest carries a checksum) hashes
		// to the expected sha256. This is the backstop that makes the whole
		// transfer trustworthy: without it, a truncated upload, a duplicated
		// chunk, or bit-rot in transit would be accepted as "success" and —
		// with auto-restore on — deployed straight over the destination's
		// live site. We refuse to finalize a transfer we can't prove intact.
		//
		// We also collect names so a file sneaked into staging OUTSIDE the
		// manifest can't survive (finalize only moves manifest entries).
		$missing   = [];
		$corrupt   = [];
		$claimed   = [];
		foreach ( (array) $manifest['files'] as $f ) {
			$name = (string) ( $f['name'] ?? '' );
			if ( '' === $name || ! $this->is_safe_remote_name( $name ) ) {
				return $this->error( 'unsafe_name', "Manifest file name unsafe: {$name}", 400 );
			}
			$claimed[ $name ] = true;

			$part_path = trailingslashit( $staging ) . $name . '.part';
			if ( ! is_file( $part_path ) ) {
				$missing[] = $name;
				continue;
			}

			// Size check — cheap, catches truncation + chunk duplication.
			// Only enforced when the manifest actually carries a size (older
			// or hand-built manifests may omit it; we don't block those, the
			// checksum below is the stronger gate when present).
			$want_size = (int) ( $f['size'] ?? 0 );
			if ( $want_size > 0 ) {
				$got_size = (int) @filesize( $part_path );
				if ( $got_size !== $want_size ) {
					$corrupt[] = "{$name} (size {$got_size}, expected {$want_size})";
					continue;
				}
			}

			// Checksum check — the authoritative integrity gate. sha256 over
			// the staged bytes must equal the manifest's. Migration backups
			// are transferred unencrypted, so the staged .part is byte-for-
			// byte identical to the source part the checksum was computed on.
			$want_sum = (string) ( $f['checksum'] ?? '' );
			if ( '' !== $want_sum ) {
				$got_sum = (string) @hash_file( 'sha256', $part_path );
				if ( '' === $got_sum || ! hash_equals( strtolower( $want_sum ), strtolower( $got_sum ) ) ) {
					$corrupt[] = "{$name} (checksum mismatch)";
				}
			}
		}
		if ( $missing ) {
			return $this->error( 'incomplete', 'Some files never finished uploading: ' . implode( ', ', $missing ), 400 );
		}
		if ( $corrupt ) {
			// 422: the upload completed but the bytes are wrong. The push
			// engine surfaces this so the operator knows to retry rather
			// than trusting a corrupt migration.
			return $this->error(
				'integrity_failed',
				'Transfer integrity check failed for: ' . implode( ', ', $corrupt ) . '. The migration was NOT applied. Retry the migration.',
				422
			);
		}

		// Find a Local destination so the BackupRecord has somewhere to
		// point at. Reuse the existing helper from BackupsController.
		$local = null;
		foreach ( DestinationRecord::all() as $d ) {
			if ( 'local' === $d->type && 'connected' === $d->status ) {
				$local = $d;
				break;
			}
		}
		if ( ! $local ) {
			return $this->error( 'no_local', 'Destination has no connected Local Storage to land the migration.', 500 );
		}
		$dest_root = (string) ( $local->config['path'] ?? '' );
		if ( '' === $dest_root || ! is_dir( $dest_root ) ) {
			return $this->error( 'no_local_dir', 'Local Storage path is unusable.', 500 );
		}

		// Create the BackupRecord that the restore engine will consume.
		// Reuse manifest's ext_id when present so the bk_<id>/ folder name
		// matches what the source built, keeps logs and paths coherent.
		$ext_id = (string) ( $manifest['ext_id'] ?? '' );
		if ( '' === $ext_id || ! preg_match( '/^bk_[a-zA-Z0-9_-]+$/', $ext_id ) ) {
			// Fall back to a fresh id rather than reject; source may have
			// stripped manifest['ext_id'] for privacy.
			$ext_id = 'bk_' . wp_generate_password( 10, false, false );
		}

		// Collision check. The source uses wp_generate_password(12) which
		// has ~6×10^20 entropy, so two real migrations colliding is
		// effectively impossible. But a USER might re-migrate the same
		// source (after deleting locally), and BackupRecord::create
		// silently overwrites on duplicate ext_id, AND the @rename loop
		// below would overwrite parts in the existing bk_<id>/ folder.
		// If we hit an existing record, mint a fresh id instead. The
		// manifest's source still gets logged for traceability.
		if ( BackupRecord::find( $ext_id ) ) {
			$original = $ext_id;
			$ext_id = 'bk_' . wp_generate_password( 10, false, false );
			$this->plugin->get( 'logger' )->info(
				"Manifest ext_id {$original} already exists on this destination; minted {$ext_id} for the incoming migration to avoid clobbering the previous backup.",
				[ 'session_id' => $session->id, 'source_url' => $session->source_url ],
				$ext_id
			);
		}
		$rec = BackupRecord::create( [
			'ext_id'       => $ext_id,
			'type'         => (string) ( $manifest['type'] ?? 'full' ),
			'kind'         => 'migrated',
			'status'       => 'success',
			'components'   => array_values( (array) ( $manifest['components'] ?? [] ) ),
			'destinations' => [ $local->ext_id ],
			'encrypted'    => ! empty( $manifest['encrypted'] ),
			'source_url'   => (string) ( $manifest['source_url'] ?? $session->source_url ) ?: null,
			'source_path'  => (string) ( $manifest['source_path'] ?? '' ) ?: null,
		] );

		// Move each staged file into the local destination.
		$target_dir = trailingslashit( $dest_root ) . $ext_id;
		if ( ! is_dir( $target_dir ) ) {
			wp_mkdir_p( $target_dir );
		}
		$files = [];
		foreach ( (array) $manifest['files'] as $f ) {
			$name        = (string) $f['name'];
			$src_path    = trailingslashit( $staging ) . $name . '.part';
			$dst_relname = $ext_id . '/' . basename( $name );
			$dst_path    = trailingslashit( $dest_root ) . $dst_relname;
			// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- WP_Filesystem::move() isn't always available at runtime; fall back to native rename which works everywhere.
			if ( ! @rename( $src_path, $dst_path ) ) {
				$rec->delete();
				return $this->error( 'move_failed', "Failed to install '{$name}' into local storage.", 500 );
			}
			$files[] = [
				'name'        => $dst_relname,
				'category'    => (string) ( $f['category']  ?? '' ),
				'size'        => (int)    ( $f['size']      ?? @filesize( $dst_path ) ),
				'checksum'    => (string) ( $f['checksum']  ?? '' ),
				'encrypted'   => ! empty( $f['encrypted'] ),
				'uploaded_to' => [ $local->ext_id ],
			];
		}
		$rec->files        = $files;
		$rec->size         = array_sum( array_column( $files, 'size' ) );
		$rec->completed_at = current_time( 'mysql', true );
		$rec->save();

		// Drop the staging dir; the destination has everything it needs now.
		$this->rrmdir( $staging );

		$session->mark_complete( $rec->ext_id );

		$this->plugin->get( 'logger' )->success(
			"Migration push completed from {$session->source_url}",
			[
				'session_id'    => $session->id,
				'backup_ext_id' => $rec->ext_id,
				'file_count'    => count( $files ),
			],
			$rec->ext_id
		);

		// Auto-restore decision combines TWO consents:
		//   1. Destination-side: the admin who issued the key opted in
		//      to auto-restore at generation time (allow_auto_restore).
		//      Defaults FALSE — explicit consent required.
		//   2. Source-side: the user clicking "Start migration" chose
		//      to auto-restore (sent in the finalize body).
		//
		// Both must be true. If the destination admin didn't opt in,
		// the migration lands as an imported backup ready for manual
		// Restore — even if the source asked for auto-restore.
		$key = MigrationKey::find( $session->key_id );
		$dest_allows = ( $key && $key->allow_auto_restore );

		$source_wants = $req->get_param( 'auto_restore' );
		$source_wants = ( null === $source_wants ) ? true : (bool) $source_wants;

		$auto_restore = $dest_allows && $source_wants;

		if ( ! $dest_allows && $source_wants ) {
			$this->plugin->get( 'logger' )->info(
				'Source requested auto-restore but destination key did not opt in. ' .
				'Backup imported; manual Restore required.',
				[ 'key_id' => $session->key_id ],
				$rec->ext_id
			);
		}

		if ( $auto_restore ) {
			$this->plugin->get( 'restore' )->dispatch_async( $rec->ext_id, [
				// Migration mode — turns on URL/path rewriting so the
				// source site's URLs (in the dumped DB) get rewritten to
				// the destination's URL on the way in.
				'migrate'     => true,
				// Site-to-site migration restores plugins/themes/mu-plugins too,
				// so the destination site actually works after the move. This is
				// an authenticated, opt-in transfer (the destination issued the
				// one-time key) between two sites the same admin controls — not
				// the arbitrary-upload Import path, which stays data-only.
				'restore_code' => true,
				'source_url'  => (string) ( $manifest['source_url']  ?? $session->source_url ),
				'target_url'  => untrailingslashit( (string) home_url() ),
				'source_path' => (string) ( $manifest['source_path'] ?? '' ),
				'target_path' => untrailingslashit( ABSPATH ),
			] );
			$this->plugin->get( 'logger' )->info(
				'Auto-restore dispatched on destination',
				[ 'backup_ext_id' => $rec->ext_id ],
				$rec->ext_id
			);
		}

		return $this->ok( [
			'status'              => $auto_restore ? 'restoring' : 'imported',
			'backup_ext_id'       => $rec->ext_id,
			'auto_restore'        => $auto_restore,
			'next'                => $auto_restore
				? 'restore running on destination, check the destination Restore page for live progress'
				: 'open Restore page and run the restore manually',
		] );
	}

	/**
	 * GET /migrate/status   (public, session bearer)
	 *
	 * Source polls this during the upload phase to drive its own progress
	 * UI. Returns the destination's view of session progress + final
	 * backup id once finalize has completed.
	 */
	public function status( WP_REST_Request $req ) {
		$session = $this->session_from_request( $req );
		if ( $session instanceof \WP_REST_Response ) {
			return $session;
		}
		return $this->ok( $session->to_public_array() );
	}

	/**
	 * POST /migrate/abort   (public, session bearer)
	 *
	 * Source signals it's giving up. Mark session aborted and drop the
	 * staging directory so disk doesn't fill up with abandoned chunks.
	 */
	public function abort( WP_REST_Request $req ) {
		$session = $this->session_from_request( $req );
		if ( $session instanceof \WP_REST_Response ) {
			return $session;
		}
		$session->mark_aborted();
		$staging = trailingslashit( $this->staging_root() ) . $session->id;
		if ( is_dir( $staging ) ) {
			$this->rrmdir( $staging );
		}
		$this->plugin->get( 'logger' )->warn(
			"Migration session aborted by source ({$session->source_url})",
			[ 'session_id' => $session->id ]
		);
		return $this->ok( [ 'ok' => true ] );
	}

	/* ─── helpers ─────────────────────────────────────────────────── */

	/**
	 * Validate the X-Backreload-Migration-Token header and return the session,
	 * OR return a WP_REST_Response error so the caller can `return` it
	 * directly. Single source of truth for session auth across every
	 * receive endpoint.
	 */
	private function session_from_request( WP_REST_Request $req ) {
		$token = (string) $req->get_header( 'x_backreload_migration_token' );
		if ( '' === $token ) {
			// REST API normalises header case → underscored lowercase.
			$token = (string) $req->get_header( 'X-Backreload-Migration-Token' );
		}
		if ( '' === $token ) {
			return $this->error( 'no_session', 'Missing session token.', 401 );
		}
		$session = MigrationSession::find_by_token( $token );
		if ( ! $session ) {
			return $this->error( 'invalid_session', 'Session invalid, expired, or aborted.', 401 );
		}
		return $session;
	}

	/**
	 * Root staging dir for migrations.
	 *
	 * Lives at wp-content/backreload-backups/.migrate, NOT under uploads/. Reason:
	 * the previous location (wp-content/uploads/sf-migrate) relied on an
	 * .htaccess deny to keep staged chunks unreachable over HTTP — but
	 * nginx hosts ignore .htaccess, leaving the partials web-accessible
	 * (gzip+base64'd, but still: user backup contents in flight).
	 *
	 * The dot-prefix on `.migrate` mirrors the existing convention used
	 * by `.staging` / `.restore` elsewhere in the plugin. The wp-content/
	 * level is the right scope — same place WP itself stores transient
	 * working state.
	 *
	 * Old-path migration: if the legacy uploads/sf-migrate root exists,
	 * we leave it alone — any in-flight migration there would have a live
	 * session pointing at the old path and we don't want to break it
	 * mid-stream. Cleanup happens via the existing 7-day session GC.
	 */
	private function staging_root(): string {
		$root = \Backreload\Storage::root() . '/.migrate';
		if ( ! is_dir( $root ) ) {
			wp_mkdir_p( $root );
			// Belt-and-braces. The path is already out of any predictable
			// web-accessible location, but the deny + index files make us
			// safe against atypical Apache configs that DO expose
			// wp-content directly (yes, that exists in the wild).
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- WP_Filesystem unavailable in REST context; one-time setup of a deny rule.
			@file_put_contents( $root . '/.htaccess', "deny from all\n" );
			@file_put_contents( $root . '/index.php', "<?php // silence is golden\n" );
		}
		return $root;
	}

	/** Per-session staging dir. Created lazily; safe to call repeatedly. */
	private function ensure_staging_dir( string $session_id ): string {
		$dir = trailingslashit( $this->staging_root() ) . $session_id;
		if ( ! is_dir( $dir ) ) {
			wp_mkdir_p( $dir );
		}
		return $dir;
	}

	/**
	 * Recursive rmdir for cleaning staging after finalize / abort. Stays
	 * inside the .migrate staging root — refuses to traverse outside as a
	 * defence against a future bug passing a wrong path.
	 */
	private function rrmdir( string $path ): void {
		$root = realpath( $this->staging_root() );
		$real = realpath( $path );
		if ( ! $real || ! $root || 0 !== strpos( $real, $root ) ) {
			return; // outside staging — refuse
		}
		if ( is_dir( $real ) ) {
			foreach ( glob( $real . '/*' ) ?: [] as $item ) {
				if ( is_dir( $item ) ) {
					$this->rrmdir( $item );
				} else {
					wp_delete_file( $item );
				}
			}
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Removes the plugin's own staging directories.
			@rmdir( $real );
		}
	}

	/**
	 * SSRF guard for outbound URLs derived from an admin-pasted key URL.
	 *
	 * Without this check, `wp_remote_post()` will happily POST to
	 * `http://127.0.0.1:6379/`, `http://169.254.169.254/latest/meta-data/`,
	 * or any other internal endpoint an admin (or attacker exploiting
	 * admin XSS / CSRF) puts inside the key URL. The migration handshake
	 * sends a real bearer token in the request body, so a successful
	 * SSRF could ALSO exfiltrate that token through whatever error path
	 * the internal service surfaces. Same blocking the notifier subsystem
	 * applies to webhooks (see AbstractNotifier::assert_safe_remote_url) —
	 * we re-implement here rather than reach across subsystem boundaries
	 * for one helper.
	 *
	 * The `backreload_allow_private_webhook` filter is intentionally shared
	 * with notifiers — one toggle turns off both surfaces for sites that
	 * legitimately backup to a LAN address.
	 *
	 * @return string|null  null when safe; a human-readable reason when not.
	 */
	private function unsafe_remote_url_reason( string $url ): ?string {
		if ( apply_filters( 'backreload_allow_private_webhook', false, $url ) ) {
			return null;
		}
		$host = (string) wp_parse_url( $url, PHP_URL_HOST );
		if ( '' === $host ) {
			return 'URL has no host.';
		}
		$lower     = strtolower( $host );
		$bad_names = [ 'localhost', 'metadata.google.internal' ];
		foreach ( $bad_names as $bad ) {
			if ( $lower === $bad ) {
				return "Host {$host} is not allowed as a migration target.";
			}
		}
		if ( '.local' === substr( $lower, -6 ) ) {
			return "Host {$host} is not allowed as a migration target.";
		}
		$ips = @gethostbynamel( $host );
		if ( false === $ips || empty( $ips ) ) {
			// Couldn't resolve. Don't false-positive — let wp_remote_post
			// surface the real network error so the user gets the actual
			// reason ("DNS not propagated yet" reads cleaner than a
			// pre-emptive "unsafe target" guess).
			return null;
		}
		foreach ( $ips as $ip ) {
			if ( false === filter_var(
				$ip,
				FILTER_VALIDATE_IP,
				FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
			) ) {
				return "Host {$host} resolves to a private/loopback address ({$ip}). Migration targets must be publicly reachable.";
			}
		}
		return null;
	}

	/**
	 * Mirror of BackupsController::is_safe_remote_name() — reject paths
	 * we wouldn't safely write to disk. Keeps file_name parameters from
	 * escaping their intended subdirectory or referencing PHP scripts.
	 *
	 * Note the dotfile reject. A backup's legitimate parts never start
	 * with `.` (they're `db.sql.gz`, `uploads-1.zip`, etc.), but a
	 * malicious manifest from an attacker-controlled destination could
	 * claim `.htaccess` / `.user.ini` / `.htpasswd` as a part name. The
	 * finalize / commit paths would then write those files INSIDE the
	 * per-backup folder under `wp-content/backreload-backups/bk_*` — where
	 * Apache / LiteSpeed honor `.htaccess` for files served from that
	 * directory. That gives the attacker URL rewrites, auth bypass,
	 * PHP-engine swaps, and arbitrary header injection scoped to the
	 * backup folder. Reject up front.
	 */
	private function is_safe_remote_name( string $name ): bool {
		// Reject empty, absolute, backslashed, NUL-byte, `..` traversal,
		// or any segment starting with `.` (covers `.htaccess` at the
		// top level AND `subdir/.htaccess` in nested layouts).
		return '' !== $name
			&& '/' !== ( $name[0] ?? '' )
			&& '.' !== ( $name[0] ?? '' )
			&& false === strpos( $name, '\\' )
			&& false === strpos( $name, "\0" )
			&& ! preg_match( '#(^|/)\.\.?(/|$)#', $name )
			&& ! preg_match( '#/\.#', $name );
	}

	/**
	 * Best-effort client IP. Honors a forwarded-for header only if the
	 * `trust_forwarded_ip` setting is on (mirrors the maintenance-mode
	 * bypass-IP logic so admins don't have to configure trust twice).
	 */
	private function client_ip(): string {
		$trust = (bool) $this->plugin->get( 'options' )->get( 'trust_forwarded_ip', false );
		if ( $trust ) {
			$candidates = [ 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR' ];
			foreach ( $candidates as $h ) {
				if ( ! empty( $_SERVER[ $h ] ) ) {
					$val = sanitize_text_field( wp_unslash( (string) $_SERVER[ $h ] ) );
					// X-Forwarded-For can be a comma-separated chain; first IP is the client.
					$first = trim( explode( ',', $val )[0] );
					if ( '' !== $first ) return $first;
				}
			}
		}
		$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) ) : '';
		return $ip ?: '0.0.0.0';
	}
}
