Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.41% covered (success)
92.41%
426 / 461
76.00% covered (warning)
76.00%
19 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Backup_Abilities
92.41% covered (success)
92.41%
426 / 461
76.00% covered (warning)
76.00%
19 / 25
136.28
0.00% covered (danger)
0.00%
0 / 1
 get_category_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_category_definition
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 register_category
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_abilities
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 backup_is_loaded
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_abilities
100.00% covered (success)
100.00%
244 / 244
100.00% covered (success)
100.00%
1 / 1
1
 can_view_backups
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 can_manage_backups
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute_get_backup_overview
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 execute_list_backups
49.02% covered (danger)
49.02%
25 / 51
0.00% covered (danger)
0.00%
0 / 1
167.68
 execute_list_restores
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 pick_backup_near_timestamp
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
11
 extract_rewindable_items
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
8.06
 summarize_backup_event
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
12.02
 map_event_status
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 event_has_warnings
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 parse_timestamp
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 execute_request_backup
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 unwrap_response
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 apply_id_or_pagination
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 summarize_last_backup
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
5.67
 summarize_backup
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 summarize_restore
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 summarize_schedule
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 summarize_storage
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * Jetpack Backup Abilities Registration.
4 *
5 * Registers Jetpack Backup abilities with the WordPress Abilities API so AI
6 * agents can read backup status and trigger on-demand backups through the
7 * standard `wp-abilities/v1` REST surface.
8 *
9 * @package automattic/jetpack-backup
10 */
11
12// @phan-file-suppress PhanUndeclaredFunction, PhanUndeclaredClassMethod @phan-suppress-current-line UnusedSuppression -- Abilities API added in WP 6.9; suppressions for older-WP compatibility runs.
13
14namespace Automattic\Jetpack\Backup\V0005\Abilities;
15
16use Automattic\Jetpack\Backup\V0005\Jetpack_Backup;
17use Automattic\Jetpack\My_Jetpack\Products\Backup as My_Jetpack_Backup;
18use Automattic\Jetpack\WP_Abilities\Registrar;
19use WP_Error;
20use WP_REST_Response;
21
22/**
23 * Registers Jetpack Backup abilities with the WordPress Abilities API.
24 *
25 * Exposes a small, agent-friendly surface for site backups:
26 *
27 * - `jetpack-backup/get-backup-overview` — single-call site backup health snapshot.
28 * - `jetpack-backup/list-backups` — recent backups with optional id/pagination filters.
29 * - `jetpack-backup/list-restores` — recent restores with optional id/pagination filters.
30 * - `jetpack-backup/request-backup` — enqueue an on-demand backup.
31 */
32class Backup_Abilities extends Registrar {
33
34    const PER_PAGE_DEFAULT = 20;
35    const PER_PAGE_MAX     = 100;
36
37    /**
38     * Return the ability category slug.
39     *
40     * @return string
41     */
42    public static function get_category_slug(): string {
43        return 'site';
44    }
45
46    /**
47     * Required by the abstract parent, but unused: the `site` category is
48     * already registered upstream (WordPress core / wpcom), so we don't
49     * re-declare it. Kept so the contract holds if a consumer ever asks for
50     * the definition we *would* use.
51     *
52     * @return array
53     */
54    public static function get_category_definition(): array {
55        return array(
56            'label'       => __( 'Site', 'jetpack-backup-pkg' ),
57            'description' => __( 'Site-wide management abilities (registered upstream).', 'jetpack-backup-pkg' ),
58        );
59    }
60
61    /**
62     * Override the Registrar lifecycle so the backup abilities only register
63     * on sites that actually have a Jetpack Backup product provisioned.
64     * Mirrors the gating done in the Jetpack dashboard / My Jetpack — there's
65     * no point exposing tool surfaces an agent can never use, and on free
66     * sites the upstream wpcom endpoints either silently accept writes (e.g.
67     * `request-backup` reported `enqueued: true`) or return null payloads
68     * that confuse callers.
69     *
70     * The `site` category is registered upstream by WordPress core / wpcom,
71     * so this class never tries to register a category — `register_category`
72     * is a no-op even though the parent hooks it.
73     *
74     * @return void
75     */
76    public static function register_category() {
77        // No-op: `site` is registered upstream; re-registering would either
78        // no-op or trigger "already registered" notices.
79    }
80
81    /**
82     * Register every ability returned by `get_abilities()`, gated on the
83     * Backup product being loaded. See `register_category()` for why we
84     * never register a category from here.
85     *
86     * @return void
87     */
88    public static function register_abilities() {
89        if ( ! self::backup_is_loaded() ) {
90            return;
91        }
92        parent::register_abilities();
93    }
94
95    /**
96     * Is the Jetpack Backup product actually loaded on this site?
97     *
98     * Defaults to `My_Jetpack\Products\Backup::is_active()` — the same
99     * boolean the Jetpack dashboard uses to decide whether the Backup
100     * product is usable. That returns true when the plugin is active and
101     * the site has a Backup plan (covering `STATUS_ACTIVE`,
102     * `STATUS_EXPIRING_SOON`, and the `STATUS_NEEDS_ATTENTION__*` states),
103     * and false for `STATUS_EXPIRED`, `STATUS_NEEDS_PLAN`,
104     * `STATUS_MODULE_DISABLED`, and the connection-error states. The plan
105     * lookup is cached for 15s in `MY_JETPACK_SITE_FEATURES_TRANSIENT_KEY`,
106     * so the cost on a real wpcom call is paid at most once per 15 seconds
107     * across the whole My Jetpack surface.
108     *
109     * The `jetpack_backup_abilities_should_load` filter lets consumers and
110     * tests override the answer without round-tripping through the My
111     * Jetpack product class.
112     *
113     * @return bool
114     */
115    private static function backup_is_loaded(): bool {
116        $default = class_exists( My_Jetpack_Backup::class ) && My_Jetpack_Backup::is_active();
117
118        /**
119         * Filters whether the Jetpack Backup abilities should register on
120         * this site. Defaults to `My_Jetpack\Products\Backup::is_active()`.
121         *
122         * @since 0.1.0
123         *
124         * @param bool $should_load Whether to register the backup abilities.
125         */
126        return (bool) apply_filters( 'jetpack_backup_abilities_should_load', $default );
127    }
128
129    /**
130     * Return the abilities this Registrar exposes, keyed by slug.
131     *
132     * @return array<string, array<string, mixed>>
133     */
134    public static function get_abilities(): array {
135        // `id` is the rewind_id (a timestamp-fractional string like
136        // "1752860369.781") — the single cross-system identifier exposed by
137        // the wpcom rewind, restore, and activity-log APIs. Use it whenever
138        // referring to a specific backup across abilities.
139        //
140        // For in-progress backup attempts the rewind_id isn't assigned yet,
141        // in which case `id` is null. Such backups can't be looked up by id
142        // anywhere — wait for completion and re-query.
143        $backup_item_schema = array(
144            'type'       => 'object',
145            'properties' => array(
146                'id'            => array( 'type' => array( 'string', 'null' ) ),
147                'started'       => array( 'type' => array( 'string', 'null' ) ),
148                'last_updated'  => array( 'type' => array( 'string', 'null' ) ),
149                'status'        => array( 'type' => array( 'string', 'null' ) ),
150                'period'        => array( 'type' => array( 'string', 'integer', 'null' ) ),
151                'is_rewindable' => array( 'type' => array( 'boolean', 'null' ) ),
152                'has_warnings'  => array( 'type' => array( 'boolean', 'null' ) ),
153            ),
154        );
155
156        // Same convention applies to restores: `id` is the rewind_id of the
157        // backup being restored to.
158        $restore_item_schema = array(
159            'type'       => 'object',
160            'properties' => array(
161                'id'           => array( 'type' => array( 'string', 'null' ) ),
162                'started'      => array( 'type' => array( 'string', 'null' ) ),
163                'last_updated' => array( 'type' => array( 'string', 'null' ) ),
164                'status'       => array( 'type' => array( 'string', 'null' ) ),
165                'progress'     => array( 'type' => array( 'integer', 'null' ) ),
166            ),
167        );
168
169        return array(
170            'jetpack-backup/get-backup-overview' => array(
171                'label'               => __( 'Get backup overview', 'jetpack-backup-pkg' ),
172                'description'         => __(
173                    'Return a single-call snapshot of the site backup state: { last_backup, recent_backup_count, schedule, storage }. Use this to answer "is my site protected?" before deciding whether to call list-backups, list-restores, or request-backup. Read-only and idempotent. Fields whose backing service is unreachable come back as null rather than failing the call. Requires the manage_options capability.',
174                    'jetpack-backup-pkg'
175                ),
176                'input_schema'        => array(
177                    'type'                 => 'object',
178                    'default'              => array(),
179                    'properties'           => array(),
180                    'additionalProperties' => false,
181                ),
182                'output_schema'       => array(
183                    'type'       => 'object',
184                    'properties' => array(
185                        'recent_backup_count' => array( 'type' => array( 'integer', 'null' ) ),
186                        'last_backup'         => array(
187                            'type'       => array( 'object', 'null' ),
188                            'properties' => array(
189                                'id'            => array( 'type' => array( 'string', 'null' ) ),
190                                'last_updated'  => array( 'type' => array( 'string', 'null' ) ),
191                                'status'        => array( 'type' => array( 'string', 'null' ) ),
192                                'is_rewindable' => array( 'type' => array( 'boolean', 'null' ) ),
193                                'has_warnings'  => array( 'type' => array( 'boolean', 'null' ) ),
194                            ),
195                        ),
196                        'schedule'            => array(
197                            'type'       => array( 'object', 'null' ),
198                            'properties' => array(
199                                'hour'   => array( 'type' => array( 'integer', 'null' ) ),
200                                'minute' => array( 'type' => array( 'integer', 'null' ) ),
201                            ),
202                        ),
203                        'storage'             => array(
204                            'type'       => array( 'object', 'null' ),
205                            'properties' => array(
206                                'used_bytes'  => array( 'type' => array( 'integer', 'null' ) ),
207                                'limit_bytes' => array( 'type' => array( 'integer', 'null' ) ),
208                            ),
209                        ),
210                    ),
211                ),
212                'execute_callback'    => array( __CLASS__, 'execute_get_backup_overview' ),
213                'permission_callback' => array( __CLASS__, 'can_view_backups' ),
214                'meta'                => array(
215                    'annotations'  => array(
216                        'readonly'    => true,
217                        'destructive' => false,
218                        'idempotent'  => true,
219                    ),
220                    'mcp'          => array(
221                        'public' => true,
222                        'type'   => 'tool',
223                    ),
224                    'show_in_rest' => true,
225                ),
226            ),
227
228            'jetpack-backup/list-backups'        => array(
229                'label'               => __( 'List backups', 'jetpack-backup-pkg' ),
230                'description'         => __(
231                    'Return zero or more backups as an array. Each item summarises one backup: { id, rewind_id, started, last_updated, status, period, is_rewindable, has_warnings }. Combine filters to narrow the result without making multiple calls. `id` returns a 0- or 1-element array for a single rewind_id. `date_from` and `date_to` window the results (ISO 8601 datetimes; server-side filter). `date` + `match` ("on_or_before" default, "on_or_after", "closest") pick a single backup near a target datetime — useful for "find a restore point near this incident"; the response stays a 0- or 1-element array. `status` filters by mapped status (e.g. "finished", "error"). `page` + `per_page` paginate the result; iterate `page=1,2,...` until you get an empty array. A page may come back with fewer than `per_page` items even when more pages exist — `status` is applied client-side, and non-backup events are filtered out — so only an empty page reliably signals end of history. Read-only and idempotent. Backed by the wpcom activity-log feed.',
232                    'jetpack-backup-pkg'
233                ),
234                'input_schema'        => array(
235                    'type'                 => 'object',
236                    'default'              => array(),
237                    'properties'           => array(
238                        'id'        => array(
239                            'type'        => 'string',
240                            'description' => __( 'Return only the backup with this rewind_id. Unknown ids yield an empty array.', 'jetpack-backup-pkg' ),
241                            'minLength'   => 1,
242                        ),
243                        'date_from' => array(
244                            'type'        => 'string',
245                            'format'      => 'date-time',
246                            'description' => __( 'Lower bound (inclusive) on backup `started` time. ISO 8601 datetime, e.g. "2026-04-01T00:00:00Z".', 'jetpack-backup-pkg' ),
247                            'minLength'   => 1,
248                        ),
249                        'date_to'   => array(
250                            'type'        => 'string',
251                            'format'      => 'date-time',
252                            'description' => __( 'Upper bound (inclusive) on backup `started` time. ISO 8601 datetime, e.g. "2026-04-30T23:59:59Z".', 'jetpack-backup-pkg' ),
253                            'minLength'   => 1,
254                        ),
255                        'date'      => array(
256                            'type'        => 'string',
257                            'format'      => 'date-time',
258                            'description' => __( 'Target datetime to find a single matching backup. When set, the response is a 0- or 1-element array. Pair with `match` to choose direction.', 'jetpack-backup-pkg' ),
259                            'minLength'   => 1,
260                        ),
261                        'match'     => array(
262                            'type'        => 'string',
263                            'enum'        => array( 'on_or_before', 'on_or_after', 'closest' ),
264                            'default'     => 'on_or_before',
265                            'description' => __( 'How to interpret `date`. "on_or_before" (default; the latest backup at or before the target — typical for restores), "on_or_after" (earliest backup at or after), or "closest" (smallest absolute time difference). Ignored when `date` is not set.', 'jetpack-backup-pkg' ),
266                        ),
267                        'status'    => array(
268                            'type'        => 'string',
269                            'description' => __( 'Filter by mapped status string (e.g. "finished", "error"). Applied client-side after the server query.', 'jetpack-backup-pkg' ),
270                            'minLength'   => 1,
271                        ),
272                        'page'      => array(
273                            'type'        => 'integer',
274                            'description' => __( '1-based page number. Ignored when `id` or `date` is set (those are single-result lookups).', 'jetpack-backup-pkg' ),
275                            'default'     => 1,
276                            'minimum'     => 1,
277                        ),
278                        'per_page'  => array(
279                            'type'        => 'integer',
280                            'description' => __( 'Cap on items returned per page (default 20, max 100). Also bounds the server-side query window the date filters are evaluated against.', 'jetpack-backup-pkg' ),
281                            'default'     => self::PER_PAGE_DEFAULT,
282                            'minimum'     => 1,
283                            'maximum'     => self::PER_PAGE_MAX,
284                        ),
285                    ),
286                    'additionalProperties' => false,
287                ),
288                'output_schema'       => array(
289                    'type'  => 'array',
290                    'items' => $backup_item_schema,
291                ),
292                'execute_callback'    => array( __CLASS__, 'execute_list_backups' ),
293                'permission_callback' => array( __CLASS__, 'can_view_backups' ),
294                'meta'                => array(
295                    'annotations'  => array(
296                        'readonly'    => true,
297                        'destructive' => false,
298                        'idempotent'  => true,
299                    ),
300                    'mcp'          => array(
301                        'public' => true,
302                        'type'   => 'tool',
303                    ),
304                    'show_in_rest' => true,
305                ),
306            ),
307
308            'jetpack-backup/list-restores'       => array(
309                'label'               => __( 'List restores', 'jetpack-backup-pkg' ),
310                'description'         => __(
311                    'Return zero or more recent restore operations as an array. Each item: { id, rewind_id, started, last_updated, status, progress }. Pass id to fetch a single restore (returns 0- or 1-element array). Otherwise paginate with page and per_page (default 20, max 100). Read-only and idempotent.',
312                    'jetpack-backup-pkg'
313                ),
314                'input_schema'        => array(
315                    'type'                 => 'object',
316                    'default'              => array(),
317                    'properties'           => array(
318                        'id'       => array(
319                            'type'        => 'string',
320                            'description' => __( 'Return only the restore with this id. Unknown ids yield an empty array.', 'jetpack-backup-pkg' ),
321                            'minLength'   => 1,
322                        ),
323                        'page'     => array(
324                            'type'        => 'integer',
325                            'description' => __( 'Page number, 1-based.', 'jetpack-backup-pkg' ),
326                            'default'     => 1,
327                            'minimum'     => 1,
328                        ),
329                        'per_page' => array(
330                            'type'        => 'integer',
331                            'description' => __( 'Items per page (default 20, max 100).', 'jetpack-backup-pkg' ),
332                            'default'     => self::PER_PAGE_DEFAULT,
333                            'minimum'     => 1,
334                            'maximum'     => self::PER_PAGE_MAX,
335                        ),
336                    ),
337                    'additionalProperties' => false,
338                ),
339                'output_schema'       => array(
340                    'type'  => 'array',
341                    'items' => $restore_item_schema,
342                ),
343                'execute_callback'    => array( __CLASS__, 'execute_list_restores' ),
344                'permission_callback' => array( __CLASS__, 'can_view_backups' ),
345                'meta'                => array(
346                    'annotations'  => array(
347                        'readonly'    => true,
348                        'destructive' => false,
349                        'idempotent'  => true,
350                    ),
351                    'mcp'          => array(
352                        'public' => true,
353                        'type'   => 'tool',
354                    ),
355                    'show_in_rest' => true,
356                ),
357            ),
358
359            'jetpack-backup/request-backup'      => array(
360                'label'               => __( 'Request a backup', 'jetpack-backup-pkg' ),
361                'description'         => __(
362                    'Enqueue an on-demand backup of this site. Returns { enqueued: bool, message: string }. Each successful call queues a new backup job; this is a state-changing write, not idempotent. Use get-backup-overview or list-backups afterwards to track progress. Requires the manage_options capability. Returns jetpack_backup_data_unavailable when the upstream service rejects the request.',
363                    'jetpack-backup-pkg'
364                ),
365                'input_schema'        => array(
366                    'type'                 => 'object',
367                    'default'              => array(),
368                    'properties'           => array(),
369                    'additionalProperties' => false,
370                ),
371                'output_schema'       => array(
372                    'type'       => 'object',
373                    'properties' => array(
374                        'enqueued' => array( 'type' => 'boolean' ),
375                        'message'  => array( 'type' => 'string' ),
376                    ),
377                ),
378                'execute_callback'    => array( __CLASS__, 'execute_request_backup' ),
379                'permission_callback' => array( __CLASS__, 'can_manage_backups' ),
380                'meta'                => array(
381                    'annotations'  => array(
382                        'readonly'    => false,
383                        'destructive' => false,
384                        'idempotent'  => false,
385                    ),
386                    'mcp'          => array(
387                        'public' => true,
388                        'type'   => 'tool',
389                    ),
390                    'show_in_rest' => true,
391                ),
392            ),
393        );
394    }
395
396    /**
397     * Permission check for read abilities. Gates on `manage_options` to
398     * match the existing REST controller (see
399     * Jetpack_Backup::backups_permissions_callback). Kept separate from
400     * `can_manage_backups()` so the read and write surfaces can diverge
401     * later without touching every spec.
402     *
403     * @return bool
404     */
405    public static function can_view_backups(): bool {
406        return current_user_can( 'manage_options' );
407    }
408
409    /**
410     * Permission check for write abilities. See `can_view_backups()`.
411     *
412     * @return bool
413     */
414    public static function can_manage_backups(): bool {
415        return current_user_can( 'manage_options' );
416    }
417
418    /**
419     * Composite read: each subfield is null on upstream failure rather than
420     * failing the whole call, so a partial wpcom outage degrades to "missing
421     * pieces" instead of "no data." Registration is gated on a Backup product
422     * being loaded (see register_abilities), so this callback assumes the
423     * site has one and only reports on the data it can fetch.
424     *
425     * @param mixed $input Unused; ability accepts no input. Typed `mixed` because
426     *                     the Abilities API may pass the raw caller-supplied value
427     *                     (string/null/array) before our `additionalProperties:false`
428     *                     schema runs — a strict array type would fatal on garbage input.
429     * @return array
430     */
431    public static function execute_get_backup_overview( $input = null ): array {
432        unset( $input );
433
434        $backups       = self::unwrap_response( Jetpack_Backup::get_recent_backups() );
435        $schedule_data = self::unwrap_response( Jetpack_Backup::get_site_backup_schedule_time() );
436        $size_data     = self::unwrap_response( Jetpack_Backup::get_site_backup_size() );
437
438        return array(
439            'recent_backup_count' => is_array( $backups ) ? count( $backups ) : null,
440            'last_backup'         => self::summarize_last_backup( is_array( $backups ) ? ( $backups[0] ?? null ) : null ),
441            'schedule'            => self::summarize_schedule( $schedule_data ),
442            'storage'             => self::summarize_storage( $size_data ),
443        );
444    }
445
446    /**
447     * Consolidated read: queries the wpcom activity-log rewindable feed
448     * (server-side date filtering, up to 1000 items/page) and reshapes
449     * activity events back into the backup-item schema. All input filters
450     * land here; the picker is invoked when a `date` + `match` is set.
451     *
452     * @param mixed $input See input_schema on `jetpack-backup/list-backups`.
453     * @return array|WP_Error
454     */
455    public static function execute_list_backups( $input = null ) {
456        $input = is_array( $input ) ? $input : array();
457
458        // Validate `date` / `date_from` / `date_to` ahead of the round-trip so
459        // agents get a specific error rather than a 200 with mysterious empty
460        // results. Schema's `format: date-time` is advisory in WP REST.
461        foreach ( array( 'date', 'date_from', 'date_to' ) as $key ) {
462            if ( isset( $input[ $key ] ) && '' !== $input[ $key ] && null === self::parse_timestamp( $input[ $key ] ) ) {
463                return new WP_Error(
464                    'jetpack_backup_invalid_date',
465                    /* translators: %s is an input parameter name. */
466                    sprintf( __( 'The `%s` parameter must be a valid ISO 8601 datetime (e.g. "2026-05-13T14:30:00Z").', 'jetpack-backup-pkg' ), $key )
467                );
468            }
469        }
470
471        $per_page = min(
472            self::PER_PAGE_MAX,
473            max( 1, isset( $input['per_page'] ) ? (int) $input['per_page'] : self::PER_PAGE_DEFAULT )
474        );
475        $page     = max( 1, isset( $input['page'] ) ? (int) $input['page'] : 1 );
476
477        // `page` is suppressed on the single-result lookups (id, date+match)
478        // — those resolve from page 1 and walking later pages would skip
479        // candidates without an obvious benefit.
480        $is_single_lookup = ( isset( $input['id'] ) && '' !== $input['id'] )
481            || ( isset( $input['date'] ) && '' !== $input['date'] );
482
483        $query = array(
484            'number'     => $per_page,
485            'page'       => $is_single_lookup ? 1 : $page,
486            'sort_order' => 'desc',
487        );
488        if ( isset( $input['date_from'] ) && '' !== $input['date_from'] ) {
489            $query['after'] = (string) $input['date_from'];
490        }
491        if ( isset( $input['date_to'] ) && '' !== $input['date_to'] ) {
492            $query['before'] = (string) $input['date_to'];
493        }
494
495        $envelope = self::unwrap_response( Jetpack_Backup::list_backup_events( $query ) );
496        $events   = self::extract_rewindable_items( $envelope );
497        if ( ! is_array( $events ) ) {
498            return array();
499        }
500
501        $items = array_values( array_filter( array_map( array( __CLASS__, 'summarize_backup_event' ), $events ) ) );
502
503        // Single-id filter — same convention as the old endpoint: 0/1-element array.
504        if ( isset( $input['id'] ) && is_string( $input['id'] ) && '' !== $input['id'] ) {
505            foreach ( $items as $item ) {
506                if ( isset( $item['id'] ) && (string) $item['id'] === $input['id'] ) {
507                    return array( $item );
508                }
509            }
510            return array();
511        }
512
513        // Client-side status filter (server-side filters by event name, not status).
514        if ( isset( $input['status'] ) && is_string( $input['status'] ) && '' !== $input['status'] ) {
515            $want  = $input['status'];
516            $items = array_values(
517                array_filter(
518                    $items,
519                    static function ( $i ) use ( $want ) {
520                        return ( $i['status'] ?? null ) === $want;
521                    }
522                )
523            );
524        }
525
526        // Single-match shortcut.
527        if ( isset( $input['date'] ) && '' !== $input['date'] ) {
528            $target = self::parse_timestamp( $input['date'] );
529            $match  = isset( $input['match'] ) && is_string( $input['match'] ) ? $input['match'] : 'on_or_before';
530            if ( ! in_array( $match, array( 'on_or_before', 'on_or_after', 'closest' ), true ) ) {
531                $match = 'on_or_before';
532            }
533            $pick = self::pick_backup_near_timestamp( $items, (int) $target, $match );
534            return null === $pick ? array() : array( $pick );
535        }
536
537        return array_slice( $items, 0, $per_page );
538    }
539
540    /**
541     * Execute callback for `jetpack-backup/list-restores`.
542     *
543     * @param mixed $input See input_schema on the ability.
544     * @return array
545     */
546    public static function execute_list_restores( $input = null ): array {
547        $restores = self::unwrap_response( Jetpack_Backup::get_recent_restores() );
548        if ( ! is_array( $restores ) ) {
549            return array();
550        }
551
552        $summarized = array_map( array( __CLASS__, 'summarize_restore' ), $restores );
553        return self::apply_id_or_pagination( $summarized, is_array( $input ) ? $input : array() );
554    }
555
556    /**
557     * Pure picker for the `date` + `match` shortcut. Operates on already-
558     * summarized backup items (so it works regardless of which upstream
559     * helper produced them) and uses `started` as the comparison timestamp.
560     *
561     * @param array  $items     Summarized backup items.
562     * @param int    $target_ts Unix timestamp the caller is searching around.
563     * @param string $match     'on_or_before' | 'on_or_after' | 'closest'.
564     * @return array|null The winning item or null when nothing matches.
565     */
566    private static function pick_backup_near_timestamp( array $items, int $target_ts, string $match ): ?array {
567        $best       = null;
568        $best_score = null;
569
570        foreach ( $items as $item ) {
571            $ts = self::parse_timestamp( $item['started'] ?? null );
572            if ( null === $ts ) {
573                continue;
574            }
575
576            $diff  = $ts - $target_ts;
577            $score = 0;
578            switch ( $match ) {
579                case 'on_or_after':
580                    if ( $diff < 0 ) {
581                        continue 2;
582                    }
583                    $score = $diff;
584                    break;
585                case 'closest':
586                    $score = abs( $diff );
587                    break;
588                case 'on_or_before':
589                default:
590                    if ( $diff > 0 ) {
591                        continue 2;
592                    }
593                    $score = -$diff;
594                    break;
595            }
596
597            if ( null === $best_score || $score < $best_score ) {
598                $best       = $item;
599                $best_score = $score;
600            }
601        }
602
603        return $best;
604    }
605
606    /**
607     * Pull the activity-event array out of the W3C ActivityStreams envelope
608     * that `/activity/rewindable` returns. The endpoint puts the items in
609     * `current.orderedItems`; older proxy shapes used `orderedItems` at the
610     * top level, so check both before giving up.
611     *
612     * @param mixed $envelope Raw decoded response body.
613     * @return array|null
614     */
615    private static function extract_rewindable_items( $envelope ): ?array {
616        if ( ! is_array( $envelope ) && ! is_object( $envelope ) ) {
617            return null;
618        }
619        $envelope = (array) $envelope;
620        if ( isset( $envelope['current'] ) ) {
621            $current = (array) $envelope['current'];
622            if ( isset( $current['orderedItems'] ) && is_array( $current['orderedItems'] ) ) {
623                return $current['orderedItems'];
624            }
625        }
626        if ( isset( $envelope['orderedItems'] ) && is_array( $envelope['orderedItems'] ) ) {
627            return $envelope['orderedItems'];
628        }
629        return null;
630    }
631
632    /**
633     * Translate one /activity/rewindable event into the same backup-item
634     * shape `summarize_backup()` produces, so the ability's output schema
635     * stays stable across the upstream switch. Returns null for events that
636     * don't look like backups (no `rewind_id`).
637     *
638     * @param mixed $raw One element from `current.orderedItems`.
639     * @return array|null
640     */
641    private static function summarize_backup_event( $raw ): ?array {
642        if ( ! is_array( $raw ) && ! is_object( $raw ) ) {
643            return null;
644        }
645        $raw = (array) $raw;
646
647        $rewind_id = $raw['rewind_id'] ?? null;
648        if ( null === $rewind_id || '' === $rewind_id ) {
649            return null;
650        }
651
652        $published     = isset( $raw['published'] ) && is_string( $raw['published'] ) ? $raw['published'] : null;
653        $status_raw    = isset( $raw['status'] ) && is_string( $raw['status'] ) ? $raw['status'] : null;
654        $name          = isset( $raw['name'] ) && is_string( $raw['name'] ) ? $raw['name'] : '';
655        $is_rewindable = isset( $raw['is_rewindable'] ) ? (bool) $raw['is_rewindable'] : null;
656
657        return array(
658            'id'            => (string) $rewind_id,
659            'started'       => $published,
660            'last_updated'  => $published,
661            'status'        => self::map_event_status( $status_raw, $name ),
662            'period'        => self::parse_timestamp( $rewind_id ),
663            'is_rewindable' => $is_rewindable,
664            'has_warnings'  => self::event_has_warnings( $status_raw, $name ),
665        );
666    }
667
668    /**
669     * Map activity-event status / action-name to the status vocabulary the
670     * ability's output schema uses (the same labels as the old
671     * `/rewind/backups` endpoint: "finished", "error", ...). Falls back to
672     * the raw status when no mapping fits so the caller still sees signal.
673     *
674     * @param string|null $status_raw Activity event status (e.g. "success", "warning", "error").
675     * @param string      $name       Activity name, e.g. "rewind__backup_complete_full".
676     * @return string|null
677     */
678    private static function map_event_status( ?string $status_raw, string $name ): ?string {
679        if ( 'success' === $status_raw || false !== strpos( $name, 'backup_complete' ) ) {
680            return 'finished';
681        }
682        return $status_raw;
683    }
684
685    /**
686     * Derive `has_warnings` from an activity event's status / name.
687     *
688     * @param string|null $status_raw Activity event status.
689     * @param string      $name       Activity name.
690     * @return bool|null
691     */
692    private static function event_has_warnings( ?string $status_raw, string $name ): ?bool {
693        if ( 'warning' === $status_raw ) {
694            return true;
695        }
696        if ( 'success' === $status_raw || false !== strpos( $name, 'backup_complete' ) ) {
697            return false;
698        }
699        return null;
700    }
701
702    /**
703     * Coerce an ISO 8601 string, RFC-style date string, or numeric unix
704     * timestamp to an int unix timestamp. Returns null for anything that
705     * can't be unambiguously parsed (instead of strtotime's `false`, which
706     * is also a valid timestamp for 1969-12-31).
707     *
708     * Fractional numeric strings (e.g. rewind_id "1778804242.107") are
709     * accepted — the fractional part is truncated.
710     *
711     * @param mixed $value Source value (string, int, float, or anything else).
712     * @return int|null
713     */
714    private static function parse_timestamp( $value ): ?int {
715        if ( is_int( $value ) ) {
716            return $value;
717        }
718        if ( is_float( $value ) ) {
719            return (int) $value;
720        }
721        if ( is_string( $value ) && '' !== $value ) {
722            if ( is_numeric( $value ) ) {
723                return (int) $value;
724            }
725            $ts = strtotime( $value );
726            return false === $ts ? null : $ts;
727        }
728        return null;
729    }
730
731    /**
732     * Enqueue an on-demand backup. Registration is gated on a Backup product
733     * being loaded so we assume one exists by the time this runs. Returns
734     * WP_Error only when the upstream connection itself fails so agents can
735     * retry strategically.
736     *
737     * @param mixed $input Unused; see note on execute_get_backup_overview().
738     * @return array|WP_Error
739     */
740    public static function execute_request_backup( $input = null ) {
741        unset( $input );
742
743        // wpcom can return HTTP 200 with `{ success: false, error: ... }`; treat
744        // that as a failure rather than reporting the backup was enqueued.
745        $result = self::unwrap_response( Jetpack_Backup::enqueue_backup() );
746        if ( ! is_array( $result ) || empty( $result['success'] ) ) {
747            return new WP_Error(
748                'jetpack_backup_data_unavailable',
749                __( 'The backup service did not accept the request. The connection to WordPress.com may be temporarily unavailable; retry shortly.', 'jetpack-backup-pkg' )
750            );
751        }
752
753        return array(
754            'enqueued' => true,
755            'message'  => __( 'Backup enqueued. Use jetpack-backup/list-backups to monitor progress.', 'jetpack-backup-pkg' ),
756        );
757    }
758
759    /**
760     * Normalize a Jetpack_Backup helper result (WP_REST_Response, array, null,
761     * or WP_Error) to a plain value or null. Jetpack_Backup uses
762     * `rest_ensure_response()` on success and returns null on http failure, so
763     * abilities need both shapes flattened before summarising.
764     *
765     * @param mixed $maybe_response Result of a Jetpack_Backup helper call.
766     * @return mixed
767     */
768    private static function unwrap_response( $maybe_response ) {
769        if ( null === $maybe_response || is_wp_error( $maybe_response ) ) {
770            return null;
771        }
772        if ( $maybe_response instanceof WP_REST_Response ) {
773            return $maybe_response->get_data();
774        }
775        return $maybe_response;
776    }
777
778    /**
779     * Slice the (already-summarized) list down to a single id, or apply
780     * page/per_page pagination. Always returns the same item shape.
781     *
782     * @param array $items Summarized items.
783     * @param array $input Sanitized input.
784     * @return array
785     */
786    private static function apply_id_or_pagination( array $items, array $input ): array {
787        if ( isset( $input['id'] ) && is_string( $input['id'] ) && '' !== $input['id'] ) {
788            foreach ( $items as $item ) {
789                if ( isset( $item['id'] ) && (string) $item['id'] === $input['id'] ) {
790                    return array( $item );
791                }
792            }
793            return array();
794        }
795
796        $page     = max( 1, (int) ( $input['page'] ?? 1 ) );
797        $per_page = min( self::PER_PAGE_MAX, max( 1, (int) ( $input['per_page'] ?? self::PER_PAGE_DEFAULT ) ) );
798
799        return array_slice( $items, ( $page - 1 ) * $per_page, $per_page );
800    }
801
802    /**
803     * High-signal summary used inside `last_backup` for the overview. Same as
804     * `summarize_backup` minus the `started`/`period` fields which the agent
805     * doesn't need at a glance.
806     *
807     * @param mixed $raw One element from the upstream backups list.
808     * @return array|null
809     */
810    private static function summarize_last_backup( $raw ): ?array {
811        if ( ! is_array( $raw ) && ! is_object( $raw ) ) {
812            return null;
813        }
814        return array_diff_key(
815            self::summarize_backup( $raw ),
816            array_flip( array( 'started', 'period' ) )
817        );
818    }
819
820    /**
821     * Summarize a `/rewind/backups` payload item using `rewind_id` as the
822     * canonical `id`. The numeric attempt id wpcom also exposes is
823     * internal to VaultPress and can't be looked up via any other endpoint,
824     * so it's intentionally dropped from the agent-facing shape — see the
825     * note on `$backup_item_schema` in `get_abilities()`.
826     *
827     * @param mixed $raw Upstream backup item.
828     * @return array
829     */
830    private static function summarize_backup( $raw ): array {
831        $raw       = (array) $raw;
832        $rewind_id = $raw['rewind_id'] ?? null;
833        return array(
834            'id'            => ( null === $rewind_id || '' === $rewind_id ) ? null : (string) $rewind_id,
835            'started'       => $raw['started'] ?? null,
836            'last_updated'  => $raw['last_updated'] ?? null,
837            'status'        => $raw['status'] ?? null,
838            'period'        => $raw['period'] ?? null,
839            'is_rewindable' => isset( $raw['is_rewindable'] ) ? (bool) $raw['is_rewindable'] : null,
840            'has_warnings'  => isset( $raw['has_warnings'] ) ? (bool) $raw['has_warnings'] : null,
841        );
842    }
843
844    /**
845     * Summarize a `/rewind/restores` payload item. `id` is the rewind_id
846     * of the backup being restored to — same canonical id system as
847     * `summarize_backup()`.
848     *
849     * @param mixed $raw Upstream restore item.
850     * @return array
851     */
852    private static function summarize_restore( $raw ): array {
853        $raw       = (array) $raw;
854        $rewind_id = $raw['rewind_id'] ?? null;
855        return array(
856            'id'           => ( null === $rewind_id || '' === $rewind_id ) ? null : (string) $rewind_id,
857            'started'      => $raw['started'] ?? null,
858            'last_updated' => $raw['last_updated'] ?? null,
859            'status'       => $raw['status'] ?? null,
860            'progress'     => isset( $raw['progress'] ) ? (int) $raw['progress'] : null,
861        );
862    }
863
864    /**
865     * Summarize the wpcom schedule payload to `{ hour, minute }`.
866     *
867     * @param mixed $raw Upstream schedule payload.
868     * @return array|null
869     */
870    private static function summarize_schedule( $raw ): ?array {
871        if ( ! is_array( $raw ) && ! is_object( $raw ) ) {
872            return null;
873        }
874        $raw = (array) $raw;
875        return array(
876            'hour'   => isset( $raw['hour'] ) ? (int) $raw['hour'] : null,
877            'minute' => isset( $raw['minute'] ) ? (int) $raw['minute'] : null,
878        );
879    }
880
881    /**
882     * Maps both the production wpcom field names (`size_in_bytes`, `storage_limit_bytes`)
883     * and shorter aliases (`used_bytes`, `limit_bytes`) so the ability stays stable
884     * if the upstream payload is renamed.
885     *
886     * @param mixed $raw Upstream storage payload.
887     * @return array|null
888     */
889    private static function summarize_storage( $raw ): ?array {
890        if ( ! is_array( $raw ) && ! is_object( $raw ) ) {
891            return null;
892        }
893        $raw         = (array) $raw;
894        $used_bytes  = $raw['size_in_bytes'] ?? ( $raw['used_bytes'] ?? null );
895        $limit_bytes = $raw['storage_limit_bytes'] ?? ( $raw['limit_bytes'] ?? null );
896        return array(
897            'used_bytes'  => null === $used_bytes ? null : (int) $used_bytes,
898            'limit_bytes' => null === $limit_bytes ? null : (int) $limit_bytes,
899        );
900    }
901}