<?php
/**
 * Orchestrates the full backup flow:
 *   1. Create a BackupRecord (status=running)
 *   2. Dump the database
 *   3. Build an archive (db + selected file roots)
 *   4. (Optional) encrypt the archive
 *   5. Upload to each selected destination
 *   6. Verify integrity (sha-256)
 *   7. Mark record as success / failed
 *
 * Hooks:
 *   action: backreload_before_archive( BackupRecord $rec, array $sources )
 *   action: backreload_after_archive ( BackupRecord $rec, string $staging_dir )
 *   filter: backreload_excluded_paths( array $patterns, BackupRecord $rec )
 *   filter: backreload_file_sources  ( array $sources,  BackupRecord $rec )
 *   action: backreload_completed     ( BackupRecord $rec )
 *   action: backreload_failed        ( BackupRecord $rec, \Throwable $error )
 *
 * @package Backreload\Core
 */

namespace Backreload\Core;

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

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

class BackupEngine {

	private Plugin $plugin;
	private Logger $logger;
	private Options $options;

	/** Wall-clock time (microtime float) at which the current tick must yield.
	 *  0 = no budget (run to completion). Set per execute() call. */
	private float $deadline = 0.0;

	/** True after a tick yielded with work still remaining — the cron handler
	 *  reads this to decide whether to schedule the next tick. False on a
	 *  terminal outcome OR a mutex bail (the owning tick drives the chain). */
	private bool $tick_continues = false;

	/**
	 * Per-backup tick mutex. Stored as a non-autoloaded `wp_options` row whose
	 * value is the time() stamp at acquire. The UNIQUE(option_name) index on
	 * wp_options makes `add_option()` a race-free atomic INSERT — two ticks
	 * firing in the same second both call add_option, but only one of them
	 * wins (the other returns false WITHOUT inserting). The old transient
	 * pair (`get_transient` → `set_transient`) was a TOCTOU race: both ticks
	 * saw "lock missing" between the read and the write, and both proceeded
	 * to upload, producing duplicate parts on destinations.
	 *
	 * The TTL is enforced lazily — `acquire_tick_lock()` checks staleness on
	 * acquire failure. Reading `get_option()` does NOT auto-expire. The
	 * paired `release_tick_lock()` is called explicitly on terminal paths
	 * AND via `register_shutdown_function` so a PHP-fatalled tick clears it
	 * promptly (without waiting for TTL).
	 *
	 * Prefix lives under `backreload_*` so the uninstall.php LIKE sweep
	 * catches it on plugin removal.
	 */
	const TICK_LOCK_PREFIX   = 'backreload_tick_lock_';
	const TICK_LOCK_TTL_SECS = 300; // 5 minutes — same ceiling the old transient TTL used.

	public function __construct( Plugin $plugin ) {
		$this->plugin  = $plugin;
		$this->logger  = $plugin->get( 'logger' );
		$this->options = $plugin->get( 'options' );
	}

	/**
	 * Atomically try to claim the tick mutex for this backup. Returns true on
	 * success (caller must pair with {@see release_tick_lock()}), false if
	 * another tick is still holding it.
	 *
	 * The race-free part is the second `add_option()` after a stale-sweep:
	 * if a concurrent tick grabbed the lock in the microseconds between our
	 * `delete_option()` and our retry insert, our retry returns false and we
	 * back off cleanly.
	 */
	private function acquire_tick_lock( string $ext_id ): bool {
		$key = self::TICK_LOCK_PREFIX . $ext_id;
		if ( add_option( $key, (string) time(), '', 'no' ) ) {
			return true;
		}
		// Existing lock — check whether it's stale (e.g. previous tick
		// PHP-fatalled before the shutdown handler ran). Stale-sweep + retry.
		$held_at = (int) get_option( $key, 0 );
		if ( $held_at > 0 && ( time() - $held_at ) > self::TICK_LOCK_TTL_SECS ) {
			delete_option( $key );
			return (bool) add_option( $key, (string) time(), '', 'no' );
		}
		return false;
	}

	private function release_tick_lock( string $ext_id ): void {
		delete_option( self::TICK_LOCK_PREFIX . $ext_id );
	}

	/**
	 * Create a pending backup record. Does NOT do any work — call {@see execute()} after.
	 *
	 * @param array{type?:string,components?:array<int,string>,destinations?:array<int,string>} $args
	 */
	public function create_pending( array $args = [] ): BackupRecord {
		// Hard refusal during/right after restore. This is the single choke
		// point every code path goes through to create a backup record
		// (manual Backup-now, scheduled cron, Resume, CLI). The user
		// reported a "running" backup row appearing seconds after a restore
		// completes despite our cron-handler transient checks. The handler
		// guards bail BEFORE create_pending, but if anything else routes
		// here (a race, an external WP-CLI hit, a queued AJAX call that
		// fired between SQL-commit and reschedule), this stops it cold.
		// The window is the 30-min backreload_restore_in_progress transient set
		// by RestoreEngine::run().
		if ( get_transient( 'backreload_restore_in_progress' ) ) {
			throw new \RuntimeException(
				 esc_html( 'Refusing to create a new backup while a restore is in progress. ' .
				'Wait for the restore to finish, then trigger Backup now. ' .
				'If you are not currently running a restore, the lock may be stale; clear the transient via Schedule > Purge cron events, or wait 30 minutes for auto-expiry.'
			 ) );
		}

		$type         = $args['type']         ?? 'full';
		$kind         = $args['kind']         ?? 'manual';
		$components   = $args['components']   ?? $this->default_components_for( $type );
		$destinations = $args['destinations'] ?? $this->default_destinations();

		// Snapshot the settings the engine will use at the time of creation.
		// Without this, if a user toggles encryption (or changes exclusions /
		// compression level) between create_pending and execute, the run can
		// produce inconsistent output — e.g. a half-encrypted file set. The
		// snapshot is stored on the record itself via the encrypted/components
		// fields (encryption flag) and via a `context` blob threaded through
		// to execute() (see settings_snapshot()).
		//
		// We persist the snapshot inline as a transient keyed by ext_id; it
		// lives for 24h which is plenty for any reasonable backup duration.
		// (Adding a dedicated `settings_snapshot` column would require a
		// migration; the transient keeps the schema unchanged.)
		$rec = BackupRecord::create( [
			'type'         => $type,
			'kind'         => $kind,
			'status'       => 'pending',
			'components'   => $components,
			'destinations' => $destinations,
			'source_url'   => untrailingslashit( (string) home_url() ),
			'source_path'  => untrailingslashit( ABSPATH ),
		] );
		set_transient( 'backreload_settings_snap_' . $rec->ext_id, $this->snapshot_settings(), DAY_IN_SECONDS );
		return $rec;
	}

	/**
	 * Capture every setting the engine reads. Returned array is opaque to
	 * callers — engine reads it back via load_settings_snapshot() in execute().
	 *
	 * @return array<string,mixed>
	 */
	private function snapshot_settings(): array {
		return [
			'compression_level'     => (int) $this->options->get( 'compression_level', 6 ),
			'exclusions'            => (array) $this->options->get( 'exclusions', [] ),
			'encryption_enabled'    => (bool) $this->options->get( 'encryption', false ),
			'encryption_passphrase' => (string) $this->options->get( 'encryption_passphrase', '' ),
		];
	}

	/**
	 * Did the last execute() yield with work still to do? The cron tick handler
	 * uses this to decide whether to schedule a follow-up tick. False after a
	 * terminal outcome or a mutex bail.
	 */
	public function tick_should_continue(): bool {
		return $this->tick_continues;
	}

	/**
	 * Run a backup synchronously end-to-end. Convenience for CLI / cron tick.
	 *
	 * @param array{type?:string,components?:array<int,string>,destinations?:array<int,string>} $args
	 */
	public function run( array $args = [] ): BackupRecord {
		$rec = $this->create_pending( $args );
		$this->execute( $rec );
		return $rec;
	}

	/**
	 * Execute a previously-created pending backup. Safe to call from wp-cron.
	 *
	 * @param BackupRecord $rec
	 * @param int          $budget Seconds of work this call may do before yielding.
	 *                             0 = run to completion (WP-CLI / sync use).
	 *                             >0 = "tick" mode: after the budget is spent the
	 *                             engine throws TimeBudgetExceeded, saves progress,
	 *                             leaves the backup 'running', and returns so a
	 *                             follow-up call can resume. This is what makes
	 *                             big-site backups survive PHP request timeouts.
	 */
	public function execute( BackupRecord $rec, int $budget = 0 ): BackupRecord {
		// Reset the continue-signal for this tick. Set true only if we yield.
		$this->tick_continues = false;

		if ( 'pending' !== $rec->status && 'running' !== $rec->status ) {
			return $rec; // Already done — don't re-run
		}

		// Mirror the create_pending guard. A backup record might already
		// exist (created before the restore started, then snapshot replay
		// brought back its pending/running status) — refuse to actually
		// execute it during the restore window. Logging only, not throwing,
		// because this is a recoverable yield: the record stays where it
		// is; once the restore finishes and the transient clears, the next
		// tick (or a Resume click) can pick it up.
		if ( get_transient( 'backreload_restore_in_progress' ) ) {
			$this->logger->info(
				'Backup tick suppressed: a restore is currently in progress',
				[],
				$rec->ext_id
			);
			return $rec;
		}

		// Atomic claim. If the claim fails the row is either terminal (genuinely
		// done — bail) or already 'running'. A 'running' row here is normal in
		// tick mode: it's a continuation of a backup that yielded earlier, so we
		// proceed. Only a terminal status means "nothing to do".
		if ( ! BackupRecord::claim_for_execution( $rec->ext_id ) ) {
			$fresh = BackupRecord::find( $rec->ext_id );
			if ( ! $fresh ) {
				return $rec;
			}
			// Cancel-then-claim race: the user clicked Cancel between ticks, so
			// flag_cancel() flipped status from 'running' to 'cancelling'. Our
			// claim then matched 0 rows because the UPDATE only accepts
			// pending/running. Left alone, status sits at 'cancelling' forever
			// and the UI spins on "Stopping…". No tick is alive to honor the
			// cancel, so finalize it here.
			if ( 'cancelling' === $fresh->status ) {
				$fresh->status       = 'cancelled';
				$fresh->completed_at = current_time( 'mysql', true );
				$fresh->save();
				$this->logger->info( 'Backup cancelled (no live tick to honor it, finalized at claim)', [], $fresh->ext_id );
				$this->release_tick_lock( $fresh->ext_id );
				return $fresh;
			}
			if ( ! in_array( $fresh->status, [ 'pending', 'running' ], true ) ) {
				return $fresh; // terminal — done
			}
			// status is 'running' — continuation tick, fall through and resume.
		}

		// Tick budget: after this point in wall-clock time, checkpoint() will
		// throw TimeBudgetExceeded to yield. 0 budget => deadline 0 => never yields.
		$this->deadline = $budget > 0 ? ( microtime( true ) + $budget ) : 0.0;

		// Tick mutex. The cron loopback chain is naturally serial, but WP-Cron's
		// own lock expires after 60s — if a single unit of work runs longer than
		// that, a second cron pass could start a concurrent tick on the same
		// backup. The mutex guards against that: the loser of the atomic
		// `add_option()` race sees acquire fail and bails immediately.
		//
		// History: was transient-based (`get_transient` → `set_transient`) which
		// was TOCTOU — two concurrent ticks both observed "no lock" between the
		// read and the write, and both proceeded, producing duplicate uploads
		// to destinations. The atomic INSERT on a UNIQUE option_name closes
		// that hole. See {@see acquire_tick_lock()} above.
		if ( ! $this->acquire_tick_lock( $rec->ext_id ) ) {
			$this->logger->info( 'Another tick is already running this backup; skipping overlap', [], $rec->ext_id );
			return $rec;
		}
		// Belt-and-braces release: a PHP fatal or OOM between here and the
		// explicit release at the end would otherwise leave the lock sitting
		// until TICK_LOCK_TTL_SECS elapses. `register_shutdown_function` fires
		// even on fatal errors. Static closure (no $this capture) — the
		// release is a one-liner against `delete_option()`.
		$ext_id_for_shutdown = $rec->ext_id;
		register_shutdown_function( static function () use ( $ext_id_for_shutdown ) {
			delete_option( self::TICK_LOCK_PREFIX . $ext_id_for_shutdown );
		} );

		@set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Long-running backup/restore needs an extended time/memory limit, scoped to the engine's own execution.
		@ini_set( 'memory_limit', '512M' ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Long-running backup/restore needs an extended time/memory limit, scoped to the engine's own execution.

		$type         = $rec->type;
		$components   = $rec->components;
		$destinations = $rec->destinations;

		$rec->status = 'running';
		// claim_for_execution already set it in the DB; keep in-memory in sync.
		// Don't call $rec->save() here — it would race against another field set
		// by claim_for_execution if the row's other columns were stale.
		$this->current_rec_id = $rec->ext_id;

		$start = microtime( true );
		$this->logger->info( "Starting {$type} backup", [ 'components' => $components ], $rec->ext_id );

		try {
			$this->check_cancellation( $rec );

			// Pre-flight: short-circuit when none of the requested destinations
			// are usable. Without this check the engine would happily build
			// every archive part into staging (taking minutes and CPU), then
			// fail at the upload step because every destination is paused or
			// in error state — leaving a "failed" row on the list each time
			// the schedule fires. The user reported exactly this loop after a
			// post-restore cron storm. Marking the record `skipped` instead
			// keeps the schedule alive (it'll succeed once destinations are
			// fixed) without spamming the list with cosmetic failures.
			$usable_destinations = [];
			foreach ( (array) $rec->destinations as $did ) {
				$d = DestinationRecord::find( (string) $did );
				if ( ! $d ) continue;
				if ( 'connected' !== $d->status ) continue;
				if ( ! empty( $d->paused ) ) continue;
				$usable_destinations[] = $d->ext_id;
			}
			if ( empty( $usable_destinations ) ) {
				$reasons = [];
				foreach ( (array) $rec->destinations as $did ) {
					$d = DestinationRecord::find( (string) $did );
					if ( ! $d ) { $reasons[] = "{$did}: destination has been removed"; continue; }
					if ( ! empty( $d->paused ) ) { $reasons[] = "{$d->name}: paused"; continue; }
					if ( 'connected' !== $d->status ) { $reasons[] = "{$d->name}: status={$d->status}"; continue; }
				}
				$msg = 'No usable destinations; skipping this backup. ' .
					( $reasons ? ( '(' . implode( '; ', $reasons ) . ')' ) : '' ) .
					' Resume Local Storage or fix the FTP/SFTP/S3 connection in Destinations, then trigger Backup now (or wait for the next scheduled run).';
				$this->logger->warn( $msg, [ 'destinations' => $rec->destinations ], $rec->ext_id );
				$rec->status       = 'skipped';
				$rec->error        = $msg;
				$rec->completed_at = current_time( 'mysql', true );
				$rec->save();
				$this->release_tick_lock( $rec->ext_id );
				delete_transient( 'backreload_settings_snap_' . $rec->ext_id );
				return $rec;
			}

			// Pre-flight: bail early on conditions guaranteed to fail mid-run
			// (no disk, no destinations, no ZipArchive, etc.). Skipped on
			// resume — those checks were already met on the prior attempt and
			// the source size estimate would be wrong if we'd already pruned.
			if ( empty( $rec->files ) ) {
				( new Preflight( $this->logger ) )->check( $rec );
			}

			$staging = $this->staging_dir( $rec->ext_id );

			// Read from the snapshot we captured at create_pending() time.
			// If the snapshot expired (very rare — TTL is 24h), fall back to
			// the live options so the run can proceed safely even if it's
			// been days since create_pending.
			$snap = get_transient( 'backreload_settings_snap_' . $rec->ext_id ) ?: $this->snapshot_settings();

			$exclusions = apply_filters( 'backreload_excluded_paths', (array) ( $snap['exclusions'] ?? [] ), $rec );
			$level      = (int) ( $snap['compression_level'] ?? 6 );
			$chunk_mb   = ArchiveWriter::recommended_chunk_mb();
			$this->logger->info( "Chunk threshold: {$chunk_mb} MB per zip part (auto-tuned)", [], $rec->ext_id );

			// Resumption: if files[] is non-empty, this is a retry. Drop any
			// half-written .tmp files in staging (from interrupted atomic
			// writes) and let the per-category guard below decide whether to
			// rebuild or reuse each section.
			if ( ! empty( $rec->files ) ) {
				$cleaned = $this->purge_orphan_tmp( $staging );
				if ( $cleaned > 0 ) {
					$this->logger->info( "Resume cleanup: removed {$cleaned} orphan .tmp file(s) from staging", [], $rec->ext_id );
				}
				$this->logger->info(
					'Resuming previous attempt; checking which categories are already built',
					[ 'existing_parts' => count( $rec->files ) ],
					$rec->ext_id
				);
			}

			do_action( 'backreload_before_archive', $rec, [] );

			// 1. Build the per-category zip-sets.
			$category_map = [
				// Honour the `UPLOADS` constant / wp_get_upload_dir() — some sites
				// store uploads outside wp-content (CDN-backed installs, custom
				// disk mounts, etc.). Hardcoding wp-content/uploads would miss
				// their actual content on those installs.
				'uploads' => $this->uploads_dir(),
				'themes'  => get_theme_root(),
				'plugins' => WP_PLUGIN_DIR,
				'mu'      => WPMU_PLUGIN_DIR,
			];

			// Per-backup folder prefix for filenames in $rec->files. Each archive
			// part is named relative to the destination root (e.g.
			// "bk_xyz/plugins-1.zip") so the Local handler stores it inside a
			// per-backup subfolder. Old backups with flat names ("bk_xyz-plugins-1.zip")
			// keep working because handlers use $name as-is — the path just
			// doesn't have a slash.
			$prefix = $rec->ext_id . '/';

			foreach ( $components as $component ) {
				if ( $component === 'db' )     continue; // handled separately below
				if ( $component === 'others' ) continue; // handled separately below (scans wp-content)
				if ( ! isset( $category_map[ $component ] ) || ! is_dir( $category_map[ $component ] ) ) continue;

				$this->check_cancellation( $rec );

				// Resumption guard. If the previous attempt completed this category's
				// build, files[] holds entries with verifiable checksums. Reuse them.
				// If staging files are missing but the parts were uploaded to a
				// destination, fetch them back from there before continuing.
				if ( $this->category_already_built( $rec, $component, $staging ) ) {
					$count = count( $rec->files_in( $component ) );
					$this->logger->info( "{$component}: {$count} part(s) already built; skipping rebuild", [], $rec->ext_id );
					continue;
				}

				// Discard any stale entries for this category — the previous attempt
				// failed partway and we can't trust partial state. We'll rebuild fresh.
				$this->discard_category_entries( $rec, $component );

				$source_dir = $category_map[ $component ];
				$this->logger->info( "Building {$component} archive", [], $rec->ext_id );

				$writer = new ArchiveWriter( $exclusions, $this->logger, $level, $chunk_mb, \Backreload\Storage::root() );
				// Bare basename — no ext_id prefix in the filename, the subfolder
				// already identifies the backup. Cleaner Finder/Explorer view.
				$base   = $staging . '/' . $component;
				$parts  = $writer->create_split( $base, [ $source_dir ] );

				foreach ( $parts as $p ) {
					$rec->add_file( $prefix . $p['name'], $component, $p['size'], $p['checksum'] );
				}
				// FORCED save (not throttled). A category is built atomically by
				// create_split(); persisting its parts immediately means a tick
				// that dies right after building an expensive category (a
				// multi-GB `uploads`, say) resumes by SKIPPING the rebuild
				// instead of redoing it. There are only ~6 categories, so this
				// is ~6 writes total — nowhere near the per-upload thrash that
				// save_progress() exists to throttle (files × destinations).
				$rec->save();
				$this->logger->success( "{$component}: " . count( $parts ) . ' part(s) built', [], $rec->ext_id );
			}

			// "Other" category: everything in wp-content/ that doesn't belong to
			// the known categories above. Catches translations (languages/),
			// caches (cache/, upgrade/), and plugin-created folders like
			// elementor/, wflogs/, wpml-translations/, ai1wm-backups/, etc.
			//
			// Each child becomes its own source path passed to ArchiveWriter, so
			// zip entries come out as "languages/foo.mo", "elementor/bar/baz",
			// etc. — direct children of wp-content. Restore then extracts the
			// archive straight into WP_CONTENT_DIR.
			if ( in_array( 'others', $components, true ) ) {
				$this->check_cancellation( $rec );
				if ( $this->category_already_built( $rec, 'others', $staging ) ) {
					$count = count( $rec->files_in( 'others' ) );
					$this->logger->info( "others: {$count} part(s) already built; skipping rebuild", [], $rec->ext_id );
				} else {
					$this->discard_category_entries( $rec, 'others' );
					$other_sources = $this->collect_other_sources();
					if ( ! empty( $other_sources ) ) {
					$this->logger->info(
						'Building others archive',
						[ 'sources' => count( $other_sources ) ],
						$rec->ext_id
					);
					$writer = new ArchiveWriter( $exclusions, $this->logger, $level, $chunk_mb, \Backreload\Storage::root() );
					$base   = $staging . '/others';
					$parts  = $writer->create_split( $base, $other_sources );
					foreach ( $parts as $p ) {
						$rec->add_file( $prefix . $p['name'], 'others', $p['size'], $p['checksum'] );
					}
					$rec->save(); // forced — same resilience rationale as the category loop above
					$this->logger->success( 'others: ' . count( $parts ) . ' part(s) built', [], $rec->ext_id );
					} else {
						$this->logger->info(
							'No "other" content found in wp-content; nothing outside the known categories.',
							[],
							$rec->ext_id
						);
					}
				}
			}

			// 2. DB dump → its own gzipped file.
			if ( in_array( 'db', $components, true ) ) {
				$this->check_cancellation( $rec );
				if ( $this->category_already_built( $rec, 'db', $staging ) ) {
					$this->logger->info( 'db: dump already present; skipping', [], $rec->ext_id );
				} else {
					$this->discard_category_entries( $rec, 'db' );
					$sql_path = $staging . '/database.sql';
					$this->logger->info( 'Dumping database', [], $rec->ext_id );
					( new DatabaseDumper() )->dump( $sql_path, $this->logger );

					$gz_base = 'db.sql.gz';                         // local-staging basename
					$gz_name = $prefix . $gz_base;                  // "bk_xyz/db.sql.gz" — DB-level identifier
					$gz_path = $staging . '/' . $gz_base;
					$this->gzip_file( $sql_path, $gz_path );
					@unlink( $sql_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.

					$rec->add_file( $gz_name, 'db', (int) @filesize( $gz_path ), hash_file( 'sha256', $gz_path ) );
					// FORCED save — the DB dump can be the single most expensive
					// step on a large site. Persisting it immediately means a
					// tick that dies before the next throttle window doesn't
					// re-dump the whole database on resume. One write.
					$rec->save();
					$this->logger->success( 'Database dump complete', [ 'size' => @filesize( $gz_path ) ], $rec->ext_id );
				}
			}

			do_action( 'backreload_after_archive', $rec, $staging );

			// 3. Optional encryption (per-file). Read from snapshot so toggling
			// encryption mid-run can't produce a half-encrypted file set.
			$enc     = $this->plugin->get( 'encryption' );
			$pass    = (string) ( $snap['encryption_passphrase'] ?? '' );
			$encrypt = ! empty( $snap['encryption_enabled'] ) && '' !== $pass;
			if ( $encrypt ) {
				$this->logger->info( 'Encrypting archive parts (AES-256-GCM)', [], $rec->ext_id );
				foreach ( $rec->files as $idx => $f ) {
					$this->check_cancellation( $rec );
					// Already encrypted on a previous tick — skip. Without this
					// guard a resumed backup would re-encrypt the .enc file into
					// a double-encrypted .enc.enc.
					if ( ! empty( $f['encrypted'] ) ) continue;
					// $f['name'] is the destination-relative path (may contain a
					// subfolder); the local staging file is the basename only.
					$src_base = basename( $f['name'] );
					$src      = $staging . '/' . $src_base;
					if ( ! is_file( $src ) ) continue;
					$enc_base = $src_base . '.enc';
					$enc_path = $staging . '/' . $enc_base;
					$enc_name = $f['name'] . '.enc'; // keep the subfolder prefix on the remote name
					if ( $enc->encrypt_file( $src, $enc_path, $pass ) ) {
						@unlink( $src ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
						$rec->files[ $idx ]['name']      = $enc_name;
						$rec->files[ $idx ]['size']      = (int) @filesize( $enc_path );
						$rec->files[ $idx ]['checksum']  = hash_file( 'sha256', $enc_path );
						$rec->files[ $idx ]['encrypted'] = true;
					} else {
						$this->logger->warn( "Encryption failed for {$f['name']}; uploading plaintext", [], $rec->ext_id );
					}
				}
				// Per-file sizes changed (encryption adds a small overhead) — resync the total.
				$rec->size      = array_sum( array_column( $rec->files, 'size' ) );
				$rec->encrypted = true;
				// FORCED save (not save_progress) — the encryption pass
				// rewrote every file's name/size/checksum/encrypted flag,
				// and the next phase (upload) depends on those values being
				// in the DB. Skipping this write would mean a tick that
				// yields during upload would resume with stale plaintext
				// metadata and re-encrypt already-encrypted parts into
				// `.enc.enc` files.
				$rec->save();
			}

			$this->check_cancellation( $rec );

			// 4. Upload each file to each destination.
			$manager = $this->plugin->get( 'destinations' );
			foreach ( $rec->files as $idx => $f ) {
				$this->check_cancellation( $rec );

				// Which destinations still need this part? Compute this FIRST.
				// If every selected destination already has the file, skip it
				// entirely — crucially, don't refetch it into staging. The
				// refetch is a download from the destination; doing it for an
				// already-finished part means re-downloading every part on
				// every resumption tick (the bug that made resumes crawl).
				$pending_dests = array_values( array_filter(
					$destinations,
					fn( $dest_id ) => ! $rec->is_uploaded( (string) $f['name'], (string) $dest_id )
				) );
				if ( empty( $pending_dests ) ) {
					// Fully uploaded everywhere — free any leftover staging copy.
					@unlink( $staging . '/' . basename( $f['name'] ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
					continue;
				}

				// $f['name'] is the destination-relative path (may include a
				// subfolder). The staging copy lives under just its basename.
				$local_path = $staging . '/' . basename( $f['name'] );

				// Resumption fallback: staging was wiped (server reboot, cron
				// cleanup, an earlier tick freed it) but a destination still
				// needs this part — fetch it back from one that already has it.
				if ( ! is_file( $local_path ) ) {
					$this->refetch_from_destination( $rec, $f, $local_path );
				}

				if ( ! is_file( $local_path ) ) continue;

				foreach ( $pending_dests as $dest_id ) {
					$this->check_cancellation( $rec );
					$dest = DestinationRecord::find( $dest_id );
					if ( ! $dest ) {
						$this->logger->warn( "Destination missing: {$dest_id}", [], $rec->ext_id );
						continue;
					}
					// Paused destinations stay configured (and visible) but the
					// engine skips them entirely — no upload attempt, no retries,
					// no destination-failed notification noise. They resume
					// picking up new backups the moment the user un-pauses.
					if ( ! empty( $dest->paused ) ) {
						$this->logger->info( "Destination paused, skipping: {$dest->name}", [], $rec->ext_id );
						continue;
					}

					// Cross-tick retry cap. The per-attempt loop below resets each
					// time a tick yields mid-retry, so an unreachable destination
					// (typo'd hostname, firewall blocking, dead bucket) would
					// retry forever — attempts 1+2 each tick, the time budget
					// pauses before attempt 3, next tick starts fresh, repeat.
					//
					// We track total failures for this (backup, destination)
					// pair in a transient and bail when it exceeds the cap.
					// The notification fires once at the cap so the user knows
					// the destination got abandoned; other destinations in this
					// backup continue normally and the backup can complete as a
					// partial success.
					$attempt_key = 'backreload_dest_attempts_' . $rec->ext_id . '_' . $dest->ext_id;
					$total_attempts_before = (int) get_transient( $attempt_key );
					$cross_tick_cap = 5;
					if ( $total_attempts_before >= $cross_tick_cap ) {
						$this->logger->info(
							"Skipping {$dest->name}: already failed {$total_attempts_before} time(s) earlier in this backup",
							[],
							$rec->ext_id
						);
						continue;
					}
					$handler = $manager->handler( $dest->type );

					// Per-tick retry with exponential backoff. Auth/permission
					// errors short-circuit. Each failure increments the
					// cross-tick counter; success clears it.
					$max_attempts = 3;
					for ( $attempt = 1; $attempt <= $max_attempts; $attempt++ ) {
						$this->check_cancellation( $rec );
						try {
							$attempt_str = $attempt > 1 ? " (retry {$attempt}/{$max_attempts})" : '';
							$this->logger->info( "Uploading {$f['name']} → {$dest->name}{$attempt_str}", [], $rec->ext_id );
							$handler->upload( $dest, $local_path, $f['name'] );
							$rec->mark_uploaded( $f['name'], $dest_id );
							// THIS is the hottest save in the engine — fires
							// once per (file × destination), so 20 parts × 4
							// destinations = 80 LONGTEXT writes. The throttle
							// collapses them to ~one-per-2s of wall clock,
							// which is plenty for the polling UI. A tick that
							// yields mid-upload still records up-to-the-last-
							// debounce-boundary progress because every
							// subsequent path through `mark_uploaded` adds to
							// the in-memory `uploaded_to` array; the next
							// terminal save() flushes everything.
							$rec->save_progress();
							$this->logger->success( "Uploaded {$f['name']} → {$dest->name}", [], $rec->ext_id );
							// A success means the destination is reachable now —
							// reset the cross-tick counter so a one-off blip
							// doesn't poison the rest of the backup.
							delete_transient( $attempt_key );
							break; // success — leave the retry loop
						} catch ( \Throwable $e ) {
							$msg = $e->getMessage();
							$is_terminal = $this->is_terminal_upload_error( $msg );
							// Bump cross-tick counter on every failure.
							$total_attempts_before++;
							set_transient( $attempt_key, $total_attempts_before, HOUR_IN_SECONDS );
							$hit_cross_tick_cap = $total_attempts_before >= $cross_tick_cap;

							if ( $is_terminal || $attempt === $max_attempts || $hit_cross_tick_cap ) {
								$why = $is_terminal
									? ' (not retrying)'
									: ( $hit_cross_tick_cap
										? " (giving up on {$dest->name} for this backup after {$total_attempts_before} attempts)"
										: ' (all retries exhausted)'
									);
								$this->logger->error(
									"Upload failed for {$f['name']} → {$dest->name}: {$msg}{$why}",
									[],
									$rec->ext_id
								);
								// Fire a destination-down notification ONCE per
								// (backup, destination) so users with Slack /
								// Telegram / email configured hear about a broken
								// remote even when the rest of the backup succeeds
								// elsewhere. Subsequent part failures to the same
								// destination in this backup would just be noise.
								$fired_key = 'backreload_destfail_' . $rec->ext_id . '_' . $dest->ext_id;
								if ( ! get_transient( $fired_key ) ) {
									set_transient( $fired_key, 1, HOUR_IN_SECONDS );
									do_action( 'backreload_destination_failed', $rec, $dest, $f, $e );
								}
								break;
							}
							$wait = (int) pow( 2, $attempt - 1 ); // 1s, 2s, 4s
							// Don't burn the tick budget sleeping. If sleeping
							// would carry us within ~1s of the deadline, yield
							// cooperatively instead — the next tick picks up
							// at this same attempt (the per-tick attempt
							// counter resets; cross-tick counter persists in
							// $attempt_key) without losing progress. Without
							// this, a sleep(4) inside a 20s tick burning ~4s
							// at the end could trigger an OS-level request
							// kill on shared hosting BEFORE we get to throw
							// our own TimeBudgetExceeded.
							if ( $this->deadline > 0 && ( microtime( true ) + $wait + 1 ) >= $this->deadline ) {
								$this->logger->info(
									"Upload retry would exceed tick budget; yielding (will retry in next tick)",
									[],
									$rec->ext_id
								);
								throw new TimeBudgetExceeded( 'budget spent before retry' );
							}
							$this->logger->warn(
								"Upload error on {$f['name']} > {$dest->name}: {$msg}, retrying in {$wait}s",
								[],
								$rec->ext_id
							);
							sleep( $wait );
						}
					}
				}
				// Once at least one destination has it, we can free local disk.
				if ( ! empty( $rec->files[ $idx ]['uploaded_to'] ) ) {
					@unlink( $local_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
				}
			}

			// 4b. Write + upload a manifest so the backup is self-describing and
			// can be discovered/imported on another server without access to this
			// site's database. Non-fatal: a manifest that fails to upload doesn't
			// fail the backup — it just means that copy isn't browsable.
			try {
				$manifest_extra = $rec->encrypted
					? [ 'encryption' => $enc->manifest_params() ]
					: [];
				$manifest_data   = Manifest::build( $rec, $manifest_extra );
				$manifest_local  = $staging . '/' . Manifest::FILENAME;
				$manifest_remote = $prefix . Manifest::FILENAME;
				if ( Manifest::write( $manifest_local, $manifest_data ) ) {
					foreach ( $destinations as $dest_id ) {
						$dest = DestinationRecord::find( $dest_id );
						if ( ! $dest ) {
							continue;
						}
						try {
							$manager->handler( $dest->type )->upload( $dest, $manifest_local, $manifest_remote );
						} catch ( \Throwable $e ) {
							$this->logger->warn( "Manifest upload to {$dest->name} failed: {$e->getMessage()}", [], $rec->ext_id );
						}
					}
					@unlink( $manifest_local ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
					$this->logger->info( 'Backup manifest written', [], $rec->ext_id );
				}
			} catch ( \Throwable $e ) {
				$this->logger->warn( 'Manifest step skipped: ' . $e->getMessage(), [], $rec->ext_id );
			}

			// 5. Cleanup staging.
			$this->cleanup_staging( $rec );

			// Final cancellation check — catches a Stop that arrived between the last
			// upload and the success transition. Without this, the engine would mark
			// 'success' over the user's 'cancelling' flag and the click would feel
			// like a no-op.
			$this->check_cancellation( $rec );

			$rec->status       = 'success';
			$rec->checksum     = $this->combined_checksum( $rec->files );
			$rec->duration     = (int) $rec->duration + (int) ceil( microtime( true ) - $start );
			$rec->completed_at = current_time( 'mysql', true );
			$rec->save();

			$this->logger->success(
				'Backup complete',
				[ 'parts' => count( $rec->files ), 'size' => $rec->size, 'duration' => $rec->duration ],
				$rec->ext_id
			);
			do_action( 'backreload_completed', $rec );
			delete_transient( 'backreload_settings_snap_' . $rec->ext_id ); // terminal — snapshot no longer needed

		} catch ( TimeBudgetExceeded $e ) {
			// Cooperative yield, NOT an error. The backup keeps its 'running'
			// status and its incrementally-saved progress. A follow-up tick
			// (cron self-reschedule or the browser AJAX driver) calls execute()
			// again and the resumption guards continue from here.
			//
			// We deliberately do NOT: mark terminal, set completed_at, fire
			// completed/failed hooks, or delete the settings snapshot — the
			// next tick still needs all of that.
			$this->logger->flush();
			$this->logger->info(
				'Backup paused at time-budget checkpoint; will resume on the next tick',
				[ 'parts_so_far' => count( $rec->files ) ],
				$rec->ext_id
			);
			// Accumulate the real work seconds this tick spent. `duration` is a
			// running total across ticks — NOT wall-clock created→now, which
			// would also count idle time waiting for cron between ticks.
			$rec->duration = (int) $rec->duration + (int) ceil( microtime( true ) - $start );
			$rec->save();
			$this->tick_continues = true;       // signal the cron handler to schedule the next tick
			// Release the option-backed tick mutex so the NEXT tick can claim
			// immediately. (Was `delete_transient($lock_key)` before the B.2
			// TOCTOU fix swapped the transient for an atomic option; $lock_key
			// no longer exists, so that line both emitted a PHP 8 undefined-
			// variable warning AND failed to release — leaving the next tick
			// to wait on the shutdown-handler net or the 300s TTL.)
			$this->release_tick_lock( $rec->ext_id );
			return $rec;

		} catch ( CancelledException $e ) {
			// The user explicitly asked to stop. Anything we'd already shipped to
			// destinations is partial work — it can't be restored from, and leaving
			// it there both wastes disk and misleads the UI ("cancelled · 444 MB · 6
			// parts"). Best-effort delete from every destination it landed on, then
			// zero out the record so it reads as a clean abort.
			$this->cleanup_partial_uploads( $rec );

			$rec->status       = 'cancelled';
			$rec->error        = 'Cancelled by user';
			$rec->duration     = (int) $rec->duration + (int) ceil( microtime( true ) - $start );
			$rec->completed_at = current_time( 'mysql', true );
			$rec->files        = [];
			$rec->size         = 0;
			$rec->save();
			$this->logger->warn( 'Backup cancelled by user', [], $rec->ext_id );
			$this->cleanup_staging( $rec );
			do_action( 'backreload_cancelled', $rec );
			delete_transient( 'backreload_settings_snap_' . $rec->ext_id ); // terminal
		} catch ( \Throwable $e ) {
			$rec->status   = 'failed';
			$rec->error    = $e->getMessage();
			$rec->duration = (int) $rec->duration + (int) ceil( microtime( true ) - $start );
			$rec->completed_at = current_time( 'mysql', true );
			$rec->save();
			$this->logger->error( 'Backup failed: ' . $e->getMessage(), [ 'trace' => $e->getTraceAsString() ], $rec->ext_id );
			$this->cleanup_staging( $rec );
			do_action( 'backreload_failed', $rec, $e );
			delete_transient( 'backreload_settings_snap_' . $rec->ext_id ); // terminal
		}

		// Terminal outcome reached — release the option-backed tick mutex.
		// (Same B.2 fix as the yield path above: $lock_key was the removed
		// transient key; release via the proper helper.)
		$this->release_tick_lock( $rec->ext_id );
		return $rec;
	}


	/**
	 * Checkpoint between pipeline steps. Two jobs:
	 *   1. Honour a user cancellation (throws CancelledException).
	 *   2. Yield if the tick's time budget is spent (throws TimeBudgetExceeded).
	 *
	 * Called at every major step boundary — that cadence is exactly where it's
	 * safe to stop and resume, since the engine's resumption logic picks up at
	 * category / file / upload granularity.
	 */
	private function check_cancellation( BackupRecord $rec ): void {
		// Flush the logger so buffered entries become visible to the polling
		// REST request that drives the in-progress UI.
		$this->logger->flush();

		$fresh = BackupRecord::find( $rec->ext_id );
		if ( $fresh && 'cancelling' === $fresh->status ) {
			throw new CancelledException( 'Cancelled by user' );
		}

		// Time budget spent? Yield cooperatively. The backup stays 'running';
		// the next tick resumes via the engine's existing resumption guards.
		if ( $this->deadline > 0.0 && microtime( true ) >= $this->deadline ) {
			throw new TimeBudgetExceeded();
		}
	}

	/**
	 * Recognize upload errors that are pointless to retry — auth/permission/
	 * "bad request" type failures. Retrying these just wastes time and quota.
	 */
	private function is_terminal_upload_error( string $msg ): bool {
		$m = strtolower( $msg );
		// HTTP 4xx-style auth/permission/bad-request signals.
		foreach ( [ '401', '403', 'unauthorized', 'forbidden', 'authentication', 'access denied', 'invalid_grant', 'bad request' ] as $needle ) {
			if ( false !== strpos( $m, $needle ) ) return true;
		}
		return false;
	}

	/** Gzip a file in-place to a new path. Streams in 1MB chunks to stay memory-bounded. */
	private function gzip_file( string $src, string $dst ): void {
		$in  = @fopen( $src, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
		$out = @gzopen( $dst, 'wb6' );
		if ( ! $in || ! $out ) {
			if ( $in )  fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
			if ( $out ) gzclose( $out );
			throw new \RuntimeException( esc_html(  "Failed to gzip {$src}"  )  );
		}
		try {
			while ( ! feof( $in ) ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
				$buf = fread( $in, 1048576 ); // 1 MB
				if ( false === $buf ) {
					throw new \RuntimeException( esc_html( "Failed to read {$src} while gzipping" ) );
				}
				if ( '' === $buf ) {
					break; // legitimate EOF on some streams; feof() will flip true next iter
				}
				$len     = strlen( $buf );
				$written = gzwrite( $out, $buf );
				// gzwrite returns the number of UNCOMPRESSED bytes written, or
				// 0 / false on failure. Anything less than $len means the OS
				// couldn't accept the rest — the most common cause is
				// out-of-disk on the staging filesystem. The old code
				// swallowed that return value silently, so a full disk
				// produced a TRUNCATED `db.sql.gz` that passed every size
				// check (the file existed, file size was non-zero) but
				// silently broke restore. Fail loudly here instead — the
				// backup record will then go to `failed` with an actionable
				// message about disk space rather than completing "successfully"
				// with a corrupt archive.
				if ( false === $written || $written < $len ) {
					$shortfall = $len - (int) $written;
					throw new \RuntimeException( esc_html(
						"gzip short write on {$dst}: {$written}/{$len} bytes accepted ({$shortfall} bytes lost). Most likely the staging disk is full."
					) );
				}
			}
		} finally {
			fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
			gzclose( $out );
		}
	}

	/** Combined sha-256 across all parts — gives a single identifier for the whole backup set. */
	private function combined_checksum( array $files ): string {
		$concat = implode( '|', array_map( fn( $f ) => $f['checksum'] ?? '', $files ) );
		return hash( 'sha256', $concat );
	}

	/**
	 * Has this category been fully built by a previous attempt?
	 *
	 * Verification is deliberately CHEAP — existence + recorded size, no
	 * hashing. This runs on every resumption tick; re-hashing gigabytes of
	 * already-built parts here would burn the entire tick budget and the
	 * backup would never make forward progress (it would just re-verify the
	 * same parts forever).
	 *
	 * A size check is sufficient: ArchiveWriter writes to a .tmp file and only
	 * renames it to the final name AFTER ZipArchive's CHECKCONS verification
	 * passes — so a file present at its final path is already a verified,
	 * complete archive. Restore independently re-checks the sha256 on download,
	 * so genuine bit-rot is still caught at the point it matters.
	 *
	 * If staging is missing a part but it's already uploaded to a destination,
	 * that counts as built too — the upload loop refetches on demand.
	 */
	private function category_already_built( BackupRecord $rec, string $category, string $staging ): bool {
		$entries = $rec->files_in( $category );
		if ( empty( $entries ) ) return false;

		foreach ( $entries as $f ) {
			$local = $staging . '/' . basename( $f['name'] );
			if ( is_file( $local ) ) {
				if ( (int) @filesize( $local ) === (int) ( $f['size'] ?? -1 ) ) {
					continue; // present at recorded size — trust it
				}
				// Size mismatch — truncated / corrupt. Drop and rebuild.
				@unlink( $local ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
			}
			// Missing locally (or just dropped). Acceptable only if a
			// destination still holds the part for the refetch path.
			if ( empty( $f['uploaded_to'] ) ) {
				return false; // can't reconstruct — full rebuild needed
			}
		}
		return true;
	}

	/**
	 * Strip every files[] entry for a category (and its staging copies).
	 * Used when a previous attempt's category entries are unrecoverable, so
	 * we don't end up appending fresh parts onto stale ones.
	 */
	private function discard_category_entries( BackupRecord $rec, string $category ): void {
		$staging = \Backreload\Storage::root() . '/.staging/' . $rec->ext_id;
		$kept    = [];
		$total   = 0;
		foreach ( $rec->files as $f ) {
			if ( ( $f['category'] ?? '' ) === $category ) {
				@unlink( $staging . '/' . basename( $f['name'] ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
				continue;
			}
			$kept[]  = $f;
			$total  += (int) ( $f['size'] ?? 0 );
		}
		if ( count( $kept ) !== count( $rec->files ) ) {
			$rec->files = $kept;
			$rec->size  = $total;
		}
	}

	/**
	 * Resumption helper: refetch a file from any destination that already has
	 * it, into local staging. Lets the engine continue uploads to OTHER
	 * destinations when staging was wiped between attempts (server reboot,
	 * tmp cleanup, etc.). Tries each destination in order until one succeeds.
	 */
	private function refetch_from_destination( BackupRecord $rec, array $f, string $local_path ): void {
		if ( empty( $f['uploaded_to'] ) ) return;
		$manager = $this->plugin->get( 'destinations' );
		foreach ( (array) $f['uploaded_to'] as $dest_id ) {
			$dest = DestinationRecord::find( $dest_id );
			if ( ! $dest ) continue;
			try {
				$handler = $manager->handler( $dest->type );
				if ( $handler->download( $dest, $f['name'], $local_path ) ) {
					$this->logger->info(
						"Refetched {$f['name']} from {$dest->name} for resume",
						[],
						$rec->ext_id
					);
					return;
				}
			} catch ( \Throwable $e ) {
				$this->logger->warn(
					"Refetch from {$dest->name} failed: " . $e->getMessage(),
					[],
					$rec->ext_id
				);
			}
		}
	}

	/**
	 * Delete leftover .tmp files in staging. They're orphans from interrupted
	 * atomic writes — ArchiveWriter writes to *.tmp first and renames on
	 * success, so a .tmp file means the previous attempt died mid-write.
	 *
	 * @return int Number of files cleaned up.
	 */
	private function purge_orphan_tmp( string $staging ): int {
		if ( ! is_dir( $staging ) ) return 0;
		$count = 0;
		foreach ( glob( trailingslashit( $staging ) . '*.tmp' ) ?: [] as $f ) {
			if ( @unlink( $f ) ) $count++; // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
		}
		return $count;
	}

	/**
	 * Best-effort removal of any archive parts we already shipped to destinations.
	 * Called on user-initiated cancel so the destination doesn't accumulate
	 * unrestorable partial sets.
	 *
	 * Failures are logged but never rethrown — we're already in a cancellation
	 * path and the user wants out, not a second error.
	 */
	private function cleanup_partial_uploads( BackupRecord $rec ): void {
		if ( empty( $rec->files ) ) return;
		$manager = $this->plugin->get( 'destinations' );
		foreach ( $rec->files as $f ) {
			$name  = $f['name']        ?? '';
			$dests = (array) ( $f['uploaded_to'] ?? [] );
			if ( '' === $name || empty( $dests ) ) continue;
			foreach ( $dests as $dest_id ) {
				$dest = DestinationRecord::find( $dest_id );
				if ( ! $dest ) continue;
				try {
					$manager->handler( $dest->type )->delete( $dest, $name );
					$this->logger->info( "Removed partial upload {$name} from {$dest->name}", [], $rec->ext_id );
				} catch ( \Throwable $e ) {
					$this->logger->warn(
						"Couldn't remove partial upload {$name} from {$dest->name}: " . $e->getMessage(),
						[],
						$rec->ext_id
					);
				}
			}
		}
	}

	private function cleanup_staging( BackupRecord $rec ): void {
		$dir = \Backreload\Storage::root() . '/.staging/' . $rec->ext_id;
		if ( ! is_dir( $dir ) ) return;
		foreach ( glob( $dir . '/*' ) ?: [] as $f ) {
			@unlink( $f ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
		}
		@rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Removes the plugin's own staging directories under wp-content/backreload-backups.
	}

	/**
	 * Schedule a one-off cron event that will execute the backup in a separate request.
	 * Triggers spawn_cron() so the event fires immediately via a non-blocking loopback.
	 */
	public function dispatch_async( BackupRecord $rec ): void {
		// If WP-Cron is disabled (DISABLE_WP_CRON or ALTERNATE_WP_CRON missing),
		// fall back to inline execution — better blocking once than silently never running.
		if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
			$this->logger->info( 'WP-Cron is disabled; running backup inline', [], $rec->ext_id );
			$this->execute( $rec );
			return;
		}
		wp_schedule_single_event( time(), 'backreload_execute', [ $rec->ext_id ] );
		if ( ! defined( 'DOING_CRON' ) && function_exists( 'spawn_cron' ) ) {
			spawn_cron();
		}
	}

	private function default_components_for( string $type ): array {
		// 'mu' and 'others' are included by default — the engine + ArchiveWriter
		// both skip categories that don't have any content, so listing them
		// here is harmless on sites without mu-plugins or stray wp-content
		// folders, and ensures backups are complete on sites that do.
		switch ( $type ) {
			case 'database': return [ 'db' ];
			case 'files':    return [ 'uploads', 'themes', 'plugins', 'mu', 'others' ];
			case 'full':
			default:         return [ 'db', 'uploads', 'themes', 'plugins', 'mu', 'others' ];
		}
	}

	private function default_destinations(): array {
		$out = [];
		foreach ( DestinationRecord::all() as $d ) {
			// Skip paused destinations — they'd just be filtered out at upload
			// time anyway, but including them here would lie on the backup row
			// (L/F badges) about where copies actually exist. Pause means
			// "leave me out of new work until told otherwise."
			if ( 'connected' === $d->status && empty( $d->paused ) ) {
				$out[] = $d->ext_id;
			}
		}
		return $out;
	}

	private function resolve_sources( array $components, BackupRecord $rec ): array {
		$map = [
			'uploads' => wp_get_upload_dir()['basedir'],
			'themes'  => get_theme_root(),
			'plugins' => WP_PLUGIN_DIR,
			'mu'      => WPMU_PLUGIN_DIR,
		];
		$sources = [];
		foreach ( $components as $c ) {
			if ( 'db' === $c ) continue;
			if ( isset( $map[ $c ] ) && is_dir( $map[ $c ] ) ) {
				$sources[] = $map[ $c ];
			}
		}
		return apply_filters( 'backreload_file_sources', $sources, $rec );
	}

	/**
	 * Direct-child names of wp-content/ that the Others archive should NEVER
	 * include. Two reasons something ends up here:
	 *
	 *   1. It's our own working directory ('backreload') — we'd be backing up
	 *      our own old backups.
	 *   2. It's a well-known third-party tool's storage. Backing up another
	 *      backup plugin's archives, or backing up regenerable cache data,
	 *      bloats the archive for zero recovery value. Most backup plugins
	 *      apply a similar deny-list — UpdraftPlus, Duplicator, etc.
	 *
	 * Pattern entries (those with a `*`) are checked via fnmatch().
	 *
	 * Users can extend this via the `backreload_others_excluded_dirs` filter.
	 */
	private function others_excluded_dirs(): array {
		$default = [
			// Our own storage / staging
			'backreload',

			// WordPress core working directories
			'upgrade',
			'upgrade-temp-backup',

			// Other backup plugins — never back up someone else's backups
			'updraft',
			'updraft_backups',
			'updraftplus',
			'ai1wm-backups',
			'backuply',           // Backuply
			'backuply_temp',
			'backupbuddy_backups',
			'backwpup-*',
			'wp-staging',
			'duplicator',
			'duplicator-pro',
			'wpvivid_uploads',
			'wpvivid-backups',

			// Security-plugin working dirs (logs / quarantine — regenerable)
			'wflogs',             // Wordfence
			'sfs-backups',
			'sfs-quarantine',
			'aiowps_backups',     // All In One WP Security

			// Regenerable caches (most backup plugins skip these by default)
			'cache',
			'w3tc-cache',
			'w3tc-config',
			'wp-rocket-cache',
			'et-cache',           // Divi
			'webp-express',
			'litespeed',
			'autoptimize_cache',
			'breeze-cache',
		];
		return (array) apply_filters( 'backreload_others_excluded_dirs', $default );
	}

	/**
	 * WordPress drop-in files that live in wp-content/ root and must NEVER be
	 * bundled into a backup/migration. They hook the very earliest stage of the
	 * load and are bound to the host's environment; moving them to another
	 * server (or restoring onto a site whose backend differs) produces a fatal
	 * on every request. The owning plugin regenerates its drop-in on activation.
	 *
	 * Lower-cased for case-insensitive matching. Extend via the filter.
	 *
	 * @return array<int,string>
	 */
	private function others_excluded_dropins(): array {
		$default = [
			'object-cache.php',   // persistent object cache (Redis/Memcached/LiteSpeed)
			'advanced-cache.php', // page cache (W3TC/WP Super Cache/WP Rocket/LiteSpeed)
			'db.php',             // database drop-in (LudicrousDB/Query Monitor/HyperDB)
			'db-error.php',       // custom DB-error page
			'object-cache-mu.php',
			'sunrise.php',        // multisite domain mapping — env-specific
			'php-error.php',      // custom PHP fatal-error page
			'fatal-error-handler.php',
			'maintenance.php',    // custom maintenance page (WP writes its own)
			'wp-cache-config.php', // WP Super Cache config (absolute paths)
			'advanced-cache-config.php',
		];
		return array_map( 'strtolower', (array) apply_filters( 'backreload_others_excluded_dropins', $default ) );
	}

	/**
	 * List every direct child of wp-content/ that isn't a known category and
	 * isn't on the deny-list (see others_excluded_dirs()). Logs each match
	 * with its size so the user can see exactly what's being bundled into the
	 * Others archive — handy when troubleshooting an unexpectedly large
	 * "Other" entry.
	 *
	 * Includes dotfiles like .htaccess (those can be important to the user).
	 * Per-file filtering (logs, .DS_Store, etc.) is the job of the user-
	 * configurable exclusions list applied by ArchiveWriter.
	 *
	 * @return array<int,string> Absolute paths to include in the others archive.
	 */
	private function collect_other_sources(): array {
		$known    = [ 'plugins', 'themes', 'uploads', 'mu-plugins' ];
		$excluded = $this->others_excluded_dirs();
		$dropins  = $this->others_excluded_dropins();

		$included = [];
		$skipped  = [];
		$dh = @opendir( WP_CONTENT_DIR );
		if ( ! $dh ) return $included;
		while ( false !== ( $name = readdir( $dh ) ) ) {
			if ( '.' === $name || '..' === $name ) continue;
			if ( in_array( $name, $known, true ) ) continue;

			// NEVER bundle WordPress drop-ins. They live in wp-content/ root
			// and load extremely early (object-cache.php + db.php load BEFORE
			// plugins, before most of WP). They are tightly coupled to the
			// server's environment (Redis/Memcached/LiteSpeed extensions, a
			// specific caching plugin). Carrying them to another host — where
			// that backend doesn't exist — fatals on EVERY request, taking down
			// the front end AND wp-admin ("There has been a critical error").
			// Each drop-in's owning plugin recreates it on activation, so
			// skipping them loses nothing and prevents the classic migration
			// white-screen. Applies to backup + migration alike.
			if ( in_array( strtolower( $name ), $dropins, true ) ) {
				$skipped[] = $name;
				continue;
			}

			// Match against the deny-list (exact name OR glob pattern).
			$is_excluded = false;
			foreach ( $excluded as $pattern ) {
				if ( false !== strpos( $pattern, '*' ) ) {
					if ( fnmatch( $pattern, $name ) ) { $is_excluded = true; break; }
				} elseif ( $pattern === $name ) {
					$is_excluded = true; break;
				}
			}
			if ( $is_excluded ) { $skipped[] = $name; continue; }

			$included[] = WP_CONTENT_DIR . '/' . $name;
		}
		closedir( $dh );

		// Transparency: log what we kept and what we deliberately skipped so
		// the user can confirm or extend the deny-list via the filter above.
		if ( ! empty( $skipped ) ) {
			$this->logger->info(
				'Others: skipped third-party / cache directories: ' . implode( ', ', $skipped ),
				[],
				$this->current_rec_id ?? null
			);
		}
		return $included;
	}

	/** Tracks the running record's ext_id so helpers like collect_other_sources
	 *  can log against the right backup. Set in execute(). */
	private ?string $current_rec_id = null;

	/**
	 * Where wp-content uploads actually live. Most installs default to
	 * wp-content/uploads, but sites can override via the `UPLOADS` constant
	 * or by changing the `upload_path` option in wp-admin. wp_get_upload_dir()
	 * is the authoritative source.
	 */
	private function uploads_dir(): string {
		$info = wp_get_upload_dir();
		$dir  = ! empty( $info['basedir'] ) ? (string) $info['basedir'] : wp_get_upload_dir()['basedir'];
		return rtrim( $dir, '/\\' );
	}

	private function staging_dir( string $backup_id ): string {
		$dir = \Backreload\Storage::root() . '/.staging/' . $backup_id;
		wp_mkdir_p( $dir );
		return $dir;
	}

	// Retention is enforced per-schedule in Scheduler::run_schedule(). Removed
	// the legacy global retention pass.
}
