<?php
/**
 * WP-Cron scheduler — supports a list of named schedules, each with its own
 * type / cadence / time / retention. Stored in the `backreload_schedules` option.
 *
 * Each schedule registers a unique WP-Cron event using the schedule id as the
 * arg, so cron entries are listable + cancellable independently.
 *
 * @package Backreload\Core
 */

namespace Backreload\Core;

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

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

class Scheduler {

	/** Cron hook used for every schedule. The schedule id is passed as an arg. */
	const HOOK_RUN     = 'backreload_run_schedule';
	/** Cron hook for one-off "Backup now" dispatches via async. */
	const HOOK_EXECUTE = 'backreload_execute';
	/** Daily maintenance cron: log pruning + future housekeeping. */
	const HOOK_DAILY   = 'backreload_daily_maintenance';

	/**
	 * Hourly orphan-record sweep. Runs `BackupRecord::sweep_stale()` so a
	 * backup whose tick PHP-fatalled (no shutdown handler reach, no
	 * terminal status flip) gets healed-or-failed within the hour
	 * regardless of UI activity. Previously the sweep piggybacked on
	 * every REST list call — every dashboard refresh paid a throttle
	 * get_transient round-trip + a candidate SELECT just to discover
	 * "nothing to do yet". The cron handler is the rate-limiter now.
	 */
	const HOOK_STALE_SWEEP = 'backreload_stale_sweep';

	const OPTION = 'backreload_schedules';

	private Plugin $plugin;

	public function __construct( Plugin $plugin ) {
		$this->plugin = $plugin;
	}

	public function boot(): void {
		add_action( self::HOOK_RUN,         [ $this, 'run_schedule' ],     10, 1 );
		add_action( self::HOOK_EXECUTE,     [ $this, 'execute_one' ],      10, 1 );
		add_action( self::HOOK_DAILY,       [ $this, 'run_daily_maintenance' ] );
		add_action( self::HOOK_STALE_SWEEP, [ self::class, 'run_stale_sweep' ] );
		add_action( 'init',                 [ $this, 'sync_events' ] );
		add_filter( 'cron_schedules',       [ $this, 'register_schedules' ] );

		// Retention used to run inside run_schedule() right after dispatching
		// the new backup, which counted only the PREVIOUSLY successful rows
		// (the new one was still pending). Result: every site was permanently
		// 1 over the configured limit (the reported "retention=6 but 7 rows"
		// bug). Hook into completion so retention runs after the new backup
		// has been counted, giving an exact match.
		add_action( 'backreload_completed', [ $this, 'on_backup_completed' ], 10, 1 );

		$this->migrate_legacy_option();
	}

	/**
	 * Apply retention for every schedule whose type matches a backup that
	 * just completed. Fires off the engine's `backreload_completed` action.
	 *
	 * Manual ("Backup now") runs also fire this hook but with kind='manual'.
	 * We honor retention for ANY scheduled-type run because the user's intent
	 * is "keep at most N successful backups of this type" regardless of how
	 * each backup got triggered — otherwise a manual run sneaks past the
	 * limit, and the next scheduled run still leaves +1 over because of the
	 * same timing.
	 */
	public function on_backup_completed( $rec ): void {
		if ( ! $rec || ! is_object( $rec ) || ! isset( $rec->status ) ) {
			return;
		}
		// Only when terminal-successful — failed/cancelled runs don't
		// reset retention math because they didn't add a successful row.
		if ( 'success' !== $rec->status ) {
			return;
		}
		$type = (string) $rec->type;

		// Effective retention for this type = the MOST GENEROUS limit among
		// all enabled schedules of this type. Backups aren't tagged with which
		// schedule produced them, so every schedule of a given type shares one
		// pool (e.g. all `full` backups). The old code pruned that shared pool
		// once PER schedule — so a "weekly full, keep 4" schedule would delete
		// everything past the newest 4, destroying a "monthly full, keep 12"
		// schedule's history (the exact weekly+monthly pattern the readme
		// advertises). Pruning ONCE to the max keeps enough for the most
		// generous schedule, so no schedule's backups are ever destroyed by a
		// different schedule's smaller limit. Keeping more than a stricter
		// schedule asked for is the safe direction — we never delete a backup
		// some schedule wanted to retain.
		$effective = 0;
		foreach ( $this->all() as $schedule ) {
			if ( ( $schedule['type'] ?? '' ) !== $type ) {
				continue;
			}
			if ( empty( $schedule['enabled'] ) ) {
				continue;
			}
			$r = (int) ( $schedule['retention'] ?? 7 );
			if ( $r > $effective ) {
				$effective = $r;
			}
		}
		if ( $effective < 1 ) {
			return; // no enabled schedule of this type — nothing to prune against
		}
		$this->apply_retention_for_type( $type, $effective );
	}

	/**
	 * Daily housekeeping. Currently:
	 *   - Prune log entries older than the configured retention (default 30 days).
	 *
	 * Designed so adding future maintenance is just another call in here.
	 */
	public function run_daily_maintenance(): void {
		$logger = $this->plugin->get( 'logger' );
		$days   = (int) $this->plugin->get( 'options' )->get( 'log_retention_days', 30 );
		if ( $days > 0 ) {
			$deleted = $logger->prune( $days );
			if ( $deleted > 0 ) {
				$logger->info( "Daily maintenance: pruned {$deleted} log row(s) older than {$days} days" );
			}
		}
		// Belt-and-braces: also sweep stale "running" rows here, in case
		// the hourly sweep cron got unscheduled by an admin manually
		// purging cron events. Daily is too slow for the user-facing
		// "stuck running badge" UX, but it's a free safety net.
		\Backreload\Models\BackupRecord::sweep_stale();
	}

	/**
	 * Hourly cron handler — one pass of stale-record detection.
	 * Static so it can hook the cron event without needing the Plugin
	 * instance (BackupRecord is a static-ish model).
	 */
	public static function run_stale_sweep(): void {
		\Backreload\Models\BackupRecord::sweep_stale();
	}

	/** Add custom cron intervals on top of WP's defaults. */
	public function register_schedules( $schedules ) {
		$schedules['backreload_monthly'] = [ 'interval' => 30 * DAY_IN_SECONDS, 'display' => __( 'Every 30 days', 'backreload' ) ];
		return $schedules;
	}

	/* ─────────────────────────────  Storage  ───────────────────────────── */

	/** @return array<int,array<string,mixed>> */
	public function all(): array {
		return (array) get_option( self::OPTION, [] );
	}

	public function find( string $id ): ?array {
		foreach ( $this->all() as $s ) {
			if ( ( $s['id'] ?? null ) === $id ) return $s;
		}
		return null;
	}

	public function save_all( array $list ): void {
		update_option( self::OPTION, array_values( $list ) );
		$this->sync_events();
	}

	/** Build a normalized schedule from user input. */
	public function normalize( array $input, ?string $existing_id = null ): array {
		// Destinations may arrive as a list of ext_ids OR be absent.
		// Empty list (or absent) means "use every connected destination",
		// matching the prior implicit behaviour. We sanitise each entry
		// to a string so the picker can't push weird types into wp_options.
		$dest_in = $input['destinations'] ?? [];
		if ( ! is_array( $dest_in ) ) {
			$dest_in = [];
		}
		$destinations = array_values( array_filter( array_map(
			static fn( $v ) => is_string( $v ) ? sanitize_text_field( $v ) : '',
			$dest_in
		) ) );

		return [
			'id'           => $existing_id ?: ( $input['id'] ?? 's_' . wp_generate_password( 8, false, false ) ),
			'name'         => sanitize_text_field( (string) ( $input['name'] ?? 'Schedule' ) ),
			'type'         => in_array( $input['type'] ?? '', [ 'full', 'database', 'files' ], true ) ? $input['type'] : 'full',
			'frequency'    => in_array( $input['frequency'] ?? '', [ 'hourly', 'daily', 'weekly', 'monthly' ], true ) ? $input['frequency'] : 'daily',
			'time_of_day'  => preg_match( '/^\d{2}:\d{2}$/', $input['time_of_day'] ?? '' ) ? $input['time_of_day'] : '03:00',
			'retention'    => max( 1, min( 365, (int) ( $input['retention'] ?? 7 ) ) ),
			'enabled'      => (bool) ( $input['enabled'] ?? true ),
			'destinations' => $destinations,
			'created_at'   => $input['created_at'] ?? current_time( 'mysql', true ),
		];
	}

	/* ─────────────────────────  WP-Cron sync  ─────────────────────────── */

	/**
	 * Reconcile WP-Cron with our schedules option:
	 *   - Schedule events for every enabled schedule that doesn't have one
	 *   - Cancel events for schedules that are disabled or deleted
	 */
	public function sync_events(): void {
		// Self-register the daily maintenance event once. Runs forever after.
		if ( ! wp_next_scheduled( self::HOOK_DAILY ) ) {
			wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::HOOK_DAILY );
		}
		// Self-register the hourly stale-sweep event (the cron that used
		// to be a REST-piggyback). Idempotent — re-registers only if a
		// previous WP-Cron flush dropped the event.
		if ( ! wp_next_scheduled( self::HOOK_STALE_SWEEP ) ) {
			wp_schedule_event( time() + 5 * MINUTE_IN_SECONDS, 'hourly', self::HOOK_STALE_SWEEP );
		}

		$schedules = $this->all();
		$known_ids = [];

		foreach ( $schedules as $s ) {
			if ( empty( $s['enabled'] ) ) continue;
			$known_ids[] = $s['id'];
			$next = wp_next_scheduled( self::HOOK_RUN, [ $s['id'] ] );
			if ( ! $next ) {
				wp_schedule_event(
					$this->next_run_at( $s ),
					$this->map_frequency( $s['frequency'] ),
					self::HOOK_RUN,
					[ $s['id'] ]
				);
			}
		}

		// Clear events for deleted/disabled schedules, AND legacy/orphan
		// events that no longer have a corresponding handler in the codebase.
		//
		// The "no schedules but a backup still fires" mystery the user just
		// reported almost certainly comes from one of these:
		//
		//   1. Old plugin versions registered cron events under the legacy
		//      hook name `backreload_run` (no `_schedule` suffix). Those
		//      events sit in the cron table forever — WP-Cron tries to fire
		//      them on every page load, finds no listener, and they just
		//      keep recurring.
		//   2. The reverted pre-restore-snapshot feature (#77) added cron
		//      events under `backreload_pre_restore_*` that were never
		//      explicitly removed when the feature was deleted.
		//   3. HOOK_EXECUTE queue events for backup records that have since
		//      been deleted (the executor handler would create-pending +
		//      run a new backup against the stale id).
		//
		// We aggressively sweep all of these on every init request so the
		// orphans can't outlive their schedules.
		$legacy_hook_names = [
			'backreload_run',                  // pre-multi-schedule single-hook version
			'backreload_pre_restore_snapshot', // reverted feature (#77)
			'backreload_pre_restore_prune',    // reverted feature (#77)
		];
		$crons = _get_cron_array();
		if ( is_array( $crons ) ) {
			foreach ( $crons as $timestamp => $hooks ) {
				// (a) HOOK_RUN events whose schedule_id no longer exists.
				if ( isset( $hooks[ self::HOOK_RUN ] ) ) {
					foreach ( $hooks[ self::HOOK_RUN ] as $event ) {
						$id = $event['args'][0] ?? null;
						if ( ! $id || ! in_array( $id, $known_ids, true ) ) {
							wp_unschedule_event( $timestamp, self::HOOK_RUN, $event['args'] ?? [] );
						}
					}
				}
				// (b) Every legacy hook name — sweep all matches, no
				// matter the args.
				foreach ( $legacy_hook_names as $legacy ) {
					if ( ! isset( $hooks[ $legacy ] ) ) continue;
					foreach ( $hooks[ $legacy ] as $event ) {
						wp_unschedule_event( $timestamp, $legacy, $event['args'] ?? [] );
					}
				}
				// (c) HOOK_EXECUTE events for backup records that no longer
				// exist (orphan tick events for deleted backups). These
				// silently no-op via execute_one(), but they cycle in the
				// cron loop and add noise.
				if ( isset( $hooks[ self::HOOK_EXECUTE ] ) ) {
					foreach ( $hooks[ self::HOOK_EXECUTE ] as $event ) {
						$ext_id = $event['args'][0] ?? null;
						if ( ! $ext_id ) continue;
						if ( ! \Backreload\Models\BackupRecord::find( (string) $ext_id ) ) {
							wp_unschedule_event( $timestamp, self::HOOK_EXECUTE, $event['args'] ?? [] );
						}
					}
				}
			}
		}
	}

	/**
	 * Nuclear option: wipe every backreload-related cron event from the
	 * cron table EXCEPT the daily maintenance prune. Exposed via a button
	 * on the Schedule page so the user can manually break out of a
	 * cron-replay loop (e.g. after restoring a backup whose old DB had a
	 * stale schedule registration that keeps re-firing).
	 *
	 * Returns the count of events removed for the response message.
	 */
	public function purge_all_scheduled_events(): int {
		$to_purge = [ self::HOOK_RUN, self::HOOK_EXECUTE, 'backreload_run', 'backreload_pre_restore_snapshot', 'backreload_pre_restore_prune' ];
		$removed  = 0;
		$crons = _get_cron_array();
		if ( is_array( $crons ) ) {
			foreach ( $crons as $timestamp => $hooks ) {
				foreach ( $to_purge as $hook ) {
					if ( ! isset( $hooks[ $hook ] ) ) continue;
					foreach ( $hooks[ $hook ] as $event ) {
						if ( wp_unschedule_event( $timestamp, $hook, $event['args'] ?? [] ) ) {
							$removed++;
						}
					}
				}
			}
		}
		return $removed;
	}

	/**
	 * Post-restore reset of every scheduled-backup cron event.
	 *
	 * Problem this fixes: when the user restores an OLDER backup, the SQL
	 * import replays the snapshot's wp_options / cron state — including the
	 * `next_run_at` timestamps for each schedule. Those timestamps are now
	 * in the past (the snapshot is from before today). WP-Cron checks its
	 * queue on the next request, sees the events as "overdue", and fires a
	 * fresh backup immediately. Users see a new `running`/`success` row
	 * appear seconds after the restore completes — confusing at best,
	 * destination-spamming at worst.
	 *
	 * Approach: nuke every HOOK_RUN event in the cron table, then walk the
	 * (now-restored) schedules option and re-register each enabled schedule
	 * with a FRESH next_run_at() computed against current time. That returns
	 * the next legitimate occurrence in the future (e.g. tomorrow at 02:00),
	 * so nothing fires immediately and the schedule resumes its normal cadence.
	 *
	 * Idempotent — safe to call multiple times. Returns the number of
	 * schedules re-registered for diagnostic logging.
	 */
	public function reschedule_after_restore(): int {
		// 1. Drop EVERY queued backreload_* event — not just HOOK_RUN. The
		// restored cron table can carry stale HOOK_EXECUTE tick events for
		// backups that were mid-flight at snapshot time, plus legacy hook
		// names from older plugin versions. Any of these can fire during
		// the restore window or right after, and the user reported seeing
		// a new "running" backup appear seconds after each restore — which
		// matches a queued HOOK_EXECUTE event running execute() against a
		// record whose status was 'running' in the snapshot (so the engine
		// happily continues it as if the previous tick just yielded).
		$this->purge_all_scheduled_events();

		// 2. Re-register each currently-enabled schedule with a future time.
		$schedules = $this->all();
		$count     = 0;
		foreach ( $schedules as $s ) {
			if ( empty( $s['enabled'] ) ) continue;
			wp_schedule_event(
				$this->next_run_at( $s ), // computes the next FUTURE occurrence
				$this->map_frequency( $s['frequency'] ),
				self::HOOK_RUN,
				[ $s['id'] ]
			);
			$count++;
		}
		return $count;
	}

	/* ───────────────────────  Cron callbacks  ─────────────────────────── */

	public function run_schedule( string $schedule_id ): void {
		// If a restore is currently in flight, bail. WP-Cron firing a backup
		// mid-restore is the most reliable way to get a "phantom backup
		// appeared right after restore" report — the restore replays cron
		// rows from the snapshot, WP-Cron sees them as due, fires a new
		// backup before the restore's own cleanup pass runs. We trip this
		// transient at the start of restore and clear it at the end.
		if ( get_transient( 'backreload_restore_in_progress' ) ) {
			$this->plugin->get( 'logger' )->info(
				"Scheduled backup tick ({$schedule_id}) suppressed: a restore is currently in progress",
				[],
				$schedule_id
			);
			return;
		}

		$s = $this->find( $schedule_id );
		if ( ! $s || empty( $s['enabled'] ) ) return;

		$this->plugin->get( 'logger' )->info( "Scheduled backup tick ({$s['name']})" );

		// Create the record and dispatch it through the SAME ticked path that
		// "Backup now" uses. Previously this ran the backup synchronously inside
		// the cron request, which would hit PHP timeouts on big sites. Now the
		// scheduled backup runs as budgeted ticks that self-reschedule.
		//
		// Per-schedule destination targeting: when the schedule was saved with
		// a non-empty destinations list, the backup goes ONLY to those. An
		// empty list (legacy schedules, or "all destinations" intent) falls
		// back to create_pending's default which is every connected destination.
		$args = [
			'type' => $s['type'],
			'kind' => 'scheduled',
		];
		if ( ! empty( $s['destinations'] ) && is_array( $s['destinations'] ) ) {
			$args['destinations'] = $s['destinations'];
		}
		$engine = $this->plugin->get( 'engine' );
		$rec    = $engine->create_pending( $args );
		$engine->dispatch_async( $rec );

		// Retention runs on the `backreload_completed` hook (see
		// on_backup_completed) AFTER the new backup is committed, so it counts
		// correctly. We deliberately do NOT prune here at dispatch time — the
		// backup is still 'pending', so pruning now would operate on a stale
		// count (the off-by-one this codebase already fixed once). Centralising
		// retention at completion keeps the math right and avoids double work.
	}

	/**
	 * Cron handler for one backup tick. Runs the engine for a bounded time
	 * budget, then — if the backup yielded with work still to do — schedules
	 * + spawns the next tick. This is the "call self to prevent timeout" loop.
	 */
	public function execute_one( string $backup_id ): void {
		// Same restore-lock guard as run_schedule(). A queued HOOK_EXECUTE
		// tick event surviving from a snapshot's cron table is the most
		// likely source of "a new running backup appeared right after I
		// restored" — the engine would happily resume a backup whose status
		// is 'running' in the restored DB, treating it as a continuation
		// of the previous tick that never actually happened on this site.
		if ( get_transient( 'backreload_restore_in_progress' ) ) {
			$this->plugin->get( 'logger' )->info(
				"Backup tick suppressed for {$backup_id}: a restore is currently in progress",
				[],
				$backup_id
			);
			return;
		}

		$rec = BackupRecord::find( $backup_id );
		if ( ! $rec ) {
			$this->plugin->get( 'logger' )->error(
				"Cron worker fired for backup {$backup_id} but the record is missing from the database. " .
				"This usually means the row failed to insert (schema mismatch?). " .
				"Try deactivating + reactivating the plugin to re-run migrations.",
				[],
				$backup_id
			);
			return;
		}
		if ( 'cancelling' === $rec->status ) {
			$rec->status       = 'cancelled';
			$rec->error        = 'Cancelled before start';
			$rec->completed_at = current_time( 'mysql', true );
			$rec->save();
			$this->plugin->get( 'logger' )->warn( 'Backup cancelled before start', [], $rec->ext_id );
			return;
		}
		if ( ! in_array( $rec->status, [ 'pending', 'running' ], true ) ) {
			return; // already terminal — nothing to do
		}

		// Per-tick time budget. Default 20s — comfortably under the 30s floor
		// of even the cheapest shared hosting. Filterable for hosts that allow
		// longer requests (a bigger budget = fewer ticks = faster overall).
		$budget = (int) apply_filters( 'backreload_tick_budget', 20 );

		$engine = $this->plugin->get( 'engine' );
		$engine->execute( $rec, $budget );

		// Only schedule the next tick if THIS tick genuinely yielded with work
		// still to do. A terminal outcome — or a mutex bail where another tick
		// already owns the chain — leaves this false, so we don't pile up
		// redundant cron events.
		if ( $engine->tick_should_continue() ) {
			$this->spawn_next_tick( $backup_id );
		}
	}

	/**
	 * Schedule + immediately trigger the next backup tick.
	 *
	 * We can't rely on spawn_cron() here — execute_one() runs inside a cron
	 * pass (DOING_CRON is set), and spawn_cron() no-ops in that state. So we
	 * fire our own non-blocking loopback to wp-cron.php, which starts a fresh
	 * request that picks up the event we just scheduled. This is the actual
	 * "call self" mechanism — the same trick Backuply / UpdraftPlus use.
	 */
	private function spawn_next_tick( string $backup_id ): void {
		// Schedule slightly in the future so it's a distinct event; if the
		// loopback below fails (some hosts block loopback) the next natural
		// wp-cron pass still picks it up.
		wp_schedule_single_event( time() + 2, self::HOOK_EXECUTE, [ $backup_id ] );

		$cron_url = site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) );
		wp_remote_post( $cron_url, [
			'timeout'   => 0.01,
			'blocking'  => false,
			'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress filter (https_local_ssl_verify), not a plugin-defined hook.
		] );
	}

	/* ─────────────────────────  Preview  ──────────────────────────────── */

	/**
	 * Combined next runs across all schedules — used for the UI preview.
	 *
	 * @return array<int,array{schedule_id:string,schedule_name:string,type:string,at:string}>
	 */
	public function combined_preview( int $n = 5 ): array {
		$intervals = [
			'hourly'      => HOUR_IN_SECONDS,
			'daily'       => DAY_IN_SECONDS,
			'weekly'      => WEEK_IN_SECONDS,
			'backreload_monthly' => 30 * DAY_IN_SECONDS,
		];

		$rows = [];
		foreach ( $this->all() as $s ) {
			if ( empty( $s['enabled'] ) ) continue;
			$next = wp_next_scheduled( self::HOOK_RUN, [ $s['id'] ] );
			if ( ! $next ) continue;
			$step = $intervals[ $this->map_frequency( $s['frequency'] ) ] ?? DAY_IN_SECONDS;
			for ( $i = 0; $i < $n; $i++ ) {
				$rows[] = [
					'schedule_id'   => $s['id'],
					'schedule_name' => $s['name'],
					'type'          => $s['type'],
					'at'            => gmdate( 'c', $next + $i * $step ),
				];
			}
		}

		usort( $rows, fn( $a, $b ) => strcmp( $a['at'], $b['at'] ) );
		return array_slice( $rows, 0, $n );
	}

	/* ─────────────────────────  Helpers  ──────────────────────────────── */

	private function map_frequency( string $freq ): string {
		return [
			'hourly'  => 'hourly',
			'daily'   => 'daily',
			'weekly'  => 'weekly',
			'monthly' => 'backreload_monthly',
		][ $freq ] ?? 'daily';
	}

	private function next_run_at( array $schedule ): int {
		$time = $schedule['time_of_day'] ?? '03:00';
		list( $h, $m ) = array_pad( explode( ':', $time ), 2, 0 );
		$ts = strtotime( gmdate( 'Y-m-d', time() ) . " {$h}:{$m}:00 UTC" );
		if ( $ts < time() ) {
			$ts += DAY_IN_SECONDS;
		}
		return $ts;
	}

	/**
	 * Retention for a backup TYPE: keep the newest $retention successful
	 * backups of $type, delete the rest. $retention is the effective limit
	 * computed by on_backup_completed() as the max across all enabled
	 * schedules of this type (see the rationale there) — callers pass the
	 * already-resolved number, not a schedule.
	 *
	 * Performance: previously this loaded up to 999 records (each with their
	 * LONGTEXT `files` blob), then for every retained-for-deletion record
	 * looked up each destination one-by-one (N+1). We now:
	 *   1. Use a targeted query that LIMIT/OFFSETs to just the rows we want to delete
	 *   2. Pre-load every destination once into a map
	 *   3. Pass the map to delete_with_remote_files() so it doesn't refetch
	 */
	private function apply_retention_for_type( string $type, int $retention ): void {
		if ( $retention < 1 ) return;

		$expired = BackupRecord::successful_older_than( $type, $retention );
		if ( empty( $expired ) ) return;

		$logger = $this->plugin->get( 'logger' );

		// Pre-load every destination once so delete_with_remote_files() doesn't
		// re-query for each backup we're cleaning up.
		$dest_cache = [];
		foreach ( \Backreload\Models\DestinationRecord::all() as $d ) {
			$dest_cache[ $d->ext_id ] = $d;
		}

		foreach ( $expired as $rec ) {
			$logger->info( "Retention ({$type}, keep {$retention}): deleting old backup {$rec->ext_id}", [], $rec->ext_id );
			$rec->delete_with_remote_files( $dest_cache );
		}
	}

	/* ─────────────────────────  Legacy migration  ─────────────────────── */

	private function migrate_legacy_option(): void {
		if ( get_option( self::OPTION ) ) return;
		$legacy = get_option( 'backreload_schedule' );
		if ( ! is_array( $legacy ) ) return;

		$out = [];
		if ( ! empty( $legacy['files_enabled'] ) ) {
			$out[] = $this->normalize( [
				'name'        => 'Files backup',
				'type'        => 'files',
				'frequency'   => $legacy['files_frequency'] ?? 'daily',
				'time_of_day' => $legacy['time_of_day'] ?? '03:00',
				'retention'   => $legacy['retention'] ?? 7,
				'enabled'     => true,
			] );
		}
		if ( ! empty( $legacy['db_enabled'] ) ) {
			$out[] = $this->normalize( [
				'name'        => 'Database backup',
				'type'        => 'database',
				'frequency'   => $legacy['db_frequency'] ?? 'daily',
				'time_of_day' => $legacy['time_of_day'] ?? '03:00',
				'retention'   => $legacy['retention'] ?? 7,
				'enabled'     => true,
			] );
		}
		if ( $out ) {
			update_option( self::OPTION, $out );
			$this->sync_events();
		}
		// Either way, the legacy option is no longer consulted — clean it up.
		delete_option( 'backreload_schedule' );
	}
}
