Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.14% covered (success)
95.14%
763 / 802
63.89% covered (warning)
63.89%
23 / 36
CRAP
0.00% covered (danger)
0.00%
0 / 1
Stats_Abilities
95.14% covered (success)
95.14%
763 / 802
63.89% covered (warning)
63.89%
23 / 36
195
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
 get_abilities
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 spec_get_site_overview
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
1
 spec_get_top_content
100.00% covered (success)
100.00%
69 / 69
100.00% covered (success)
100.00%
1 / 1
1
 spec_get_post_views
100.00% covered (success)
100.00%
61 / 61
100.00% covered (success)
100.00%
1 / 1
1
 spec_get_visits
100.00% covered (success)
100.00%
65 / 65
100.00% covered (success)
100.00%
1 / 1
1
 spec_get_followers
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
1
 spec_get_settings
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
1
 spec_update_settings
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
1 / 1
1
 settings_output_properties
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 can_view_stats
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 can_manage_settings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_site_overview
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
12
 get_top_content
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
5
 get_post_views
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
7
 get_visits
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 get_followers
86.36% covered (warning)
86.36%
38 / 44
0.00% covered (danger)
0.00%
0 / 1
21.01
 get_settings
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 update_settings
77.46% covered (warning)
77.46%
55 / 71
0.00% covered (danger)
0.00%
0 / 1
21.71
 settings_snapshot
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 compose_subcalls
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 first_string
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 pick_period
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 get_wpcom_stats
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 fetch_top_content_raw
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
10.01
 normalize_top_content_items
95.00% covered (success)
95.00%
38 / 40
0.00% covered (danger)
0.00%
0 / 1
28
 first_day
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
5.39
 rank_and_cap
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 extract_streak_summary
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 extract_top_referrer
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
 extract_post_views_series
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
11.80
 normalize_visits_series
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
12.17
 sanitize_date
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 clamp_int
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
4.84
 as_int
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Jetpack Stats Abilities Registration.
4 *
5 * Registers Jetpack Stats abilities with the WordPress Abilities API.
6 *
7 * @package automattic/jetpack-stats
8 */
9
10namespace Automattic\Jetpack\Stats\Abilities;
11
12use Automattic\Jetpack\Stats\Options;
13use Automattic\Jetpack\Stats\WPCOM_Stats;
14use Automattic\Jetpack\WP_Abilities\Registrar;
15use WP_Error;
16
17/**
18 * Registers Jetpack Stats abilities with the WordPress Abilities API.
19 *
20 * Exposes a small, consolidated surface for reading Jetpack Stats traffic
21 * insights and managing site-level Stats settings so AI agents can
22 * answer site-owner questions through the standard `wp-abilities/v1` REST
23 * surface. Seven abilities wrap ~25 atomic WPCOM Stats endpoints plus the
24 * `stats_options` WP option.
25 */
26class Stats_Abilities extends Registrar {
27
28    const CATEGORY_SLUG = 'jetpack-stats';
29    const ERROR_PREFIX  = 'jetpack_stats_';
30
31    /**
32     * Whitelist of stats_options keys exposed via the settings abilities.
33     *
34     * Internal keys (`blog_id`, `notices`, `views`, `collapse_nudges`,
35     * `version`, `odyssey_stats_changed_at`) are deliberately excluded —
36     * agents can't act on them and they'd bloat the response.
37     * `enable_odyssey_stats` is also excluded: it's a UI dashboard toggle
38     * with no meaningful agent use case. The per-key type (bool /
39     * role-array) is read from `Options::get_defaults()` at runtime, not
40     * duplicated here.
41     */
42    const SETTINGS_KEYS = array( 'admin_bar', 'roles', 'count_roles', 'do_not_track' );
43
44    /**
45     * Allowed `type` values for `get-top-content`.
46     */
47    const TOP_CONTENT_TYPES = array( 'posts', 'referrers', 'search-terms', 'clicks', 'tags', 'authors', 'countries', 'downloads', 'video-plays' );
48
49    /**
50     * Allowed aggregation periods for timeseries + top-content reads.
51     */
52    const PERIODS = array( 'day', 'week', 'month', 'year' );
53
54    /**
55     * Allowed metric fields for `get-visits`.
56     */
57    const VISIT_FIELDS = array( 'views', 'visitors', 'likes', 'comments' );
58
59    /**
60     * Default metric fields for `get-visits` when the caller omits `fields`.
61     */
62    const DEFAULT_VISIT_FIELDS = array( 'views', 'visitors' );
63
64    /**
65     * Normalization table for `get-top-content`.
66     *
67     * Each entry describes how to project a WPCOM `days -> <date> -> <list>`
68     * array of rows into the uniform `{ rank, label, value, href? }` shape.
69     * `countries` (needs `country-info` join) and `tags` (flat `tags` array,
70     * no `days` envelope) are special-cased in the callback.
71     */
72    const TOP_CONTENT_MAP = array(
73        'posts'        => array(
74            'list'  => 'postviews',
75            'label' => 'title',
76            'value' => 'views',
77            'href'  => 'href',
78        ),
79        'referrers'    => array(
80            // WPCOM `stats/referrers` keys per-day data under `groups`, not `referrers` —
81            // each group exposes `name`, `total`, and (sometimes) `url`.
82            'list'  => 'groups',
83            'label' => 'name',
84            'value' => 'total',
85            'href'  => 'url',
86        ),
87        'search-terms' => array(
88            'list'  => 'search_terms',
89            'label' => 'term',
90            'value' => 'views',
91        ),
92        'clicks'       => array(
93            'list'           => 'clicks',
94            'label'          => 'name',
95            'value'          => 'views',
96            'href'           => 'url',
97            'label_fallback' => 'url',
98        ),
99        'authors'      => array(
100            'list'  => 'authors',
101            'label' => 'name',
102            'value' => 'views',
103        ),
104        'downloads'    => array(
105            'list'           => 'files',
106            'label'          => 'filename',
107            'value'          => 'download_count',
108            'href'           => 'relative_url',
109            'label_fallback' => 'relative_url',
110        ),
111        'video-plays'  => array(
112            'list'  => 'plays',
113            'label' => 'title',
114            'value' => 'plays',
115        ),
116    );
117
118    /**
119     * {@inheritDoc}
120     */
121    public static function get_category_slug(): string {
122        return self::CATEGORY_SLUG;
123    }
124
125    /**
126     * {@inheritDoc}
127     */
128    public static function get_category_definition(): array {
129        return array(
130            // "Jetpack" is a product name and should not be translated.
131            'label'       => 'Jetpack Stats',
132            'description' => __( 'Abilities for reading Jetpack Stats traffic insights and managing site-level Stats settings.', 'jetpack-stats' ),
133        );
134    }
135
136    /**
137     * {@inheritDoc}
138     */
139    public static function get_abilities(): array {
140        return array(
141            'jetpack-stats/get-site-overview' => self::spec_get_site_overview(),
142            'jetpack-stats/get-top-content'   => self::spec_get_top_content(),
143            'jetpack-stats/get-post-views'    => self::spec_get_post_views(),
144            'jetpack-stats/get-visits'        => self::spec_get_visits(),
145            'jetpack-stats/get-followers'     => self::spec_get_followers(),
146            'jetpack-stats/get-settings'      => self::spec_get_settings(),
147            'jetpack-stats/update-settings'   => self::spec_update_settings(),
148        );
149    }
150
151    /*
152    ---------------------------------------------------------------------
153     * Ability specs
154     * ---------------------------------------------------------------------
155     */
156
157    /**
158     * Spec: jetpack-stats/get-site-overview.
159     */
160    private static function spec_get_site_overview(): array {
161        return array(
162            'label'               => __( 'Get site stats overview', 'jetpack-stats' ),
163            'description'         => __(
164                'Return a single zero-argument snapshot answering "how is my site doing right now?" — today\'s views/visitors, this week/month totals, the current posting streak, today\'s top post, and top referrer. Shape: { date, views_today, visitors_today, views_week, views_month, streak: { current_length, longest_length, longest_start, longest_end }, top_post: { id, title, views }, top_referrer: { name, views }, partial: bool, errors?: [string] }. Composes the WPCOM stats/summary, stats/highlights, and stats/streak endpoints — if any sub-call fails, `partial` is true and `errors` lists the failed sub-calls; when `partial` is true, count fields owned by the failed sub-call(s) are placeholder zeros rather than confirmed counts (cross-reference `errors` before treating a `0` as authoritative). If every sub-call fails, returns `jetpack_stats_data_unavailable`. Precondition: the site must be connected to WordPress.com. Results cached for ~5 minutes by WPCOM_Stats — safe to poll.',
165                'jetpack-stats'
166            ),
167            'input_schema'        => array(
168                'type'                 => 'object',
169                'default'              => array(),
170                'properties'           => new \stdClass(),
171                'additionalProperties' => false,
172            ),
173            'output_schema'       => array(
174                'type'       => 'object',
175                'properties' => array(
176                    'date'           => array( 'type' => 'string' ),
177                    'views_today'    => array( 'type' => 'integer' ),
178                    'visitors_today' => array( 'type' => 'integer' ),
179                    'views_week'     => array( 'type' => 'integer' ),
180                    'views_month'    => array( 'type' => 'integer' ),
181                    'streak'         => array( 'type' => 'object' ),
182                    'top_post'       => array( 'type' => array( 'object', 'null' ) ),
183                    'top_referrer'   => array( 'type' => array( 'object', 'null' ) ),
184                    'partial'        => array( 'type' => 'boolean' ),
185                    'errors'         => array( 'type' => 'array' ),
186                ),
187            ),
188            'execute_callback'    => array( __CLASS__, 'get_site_overview' ),
189            'permission_callback' => array( __CLASS__, 'can_view_stats' ),
190            'meta'                => array(
191                'annotations'  => array(
192                    'readonly'    => true,
193                    'destructive' => false,
194                    'idempotent'  => true,
195                ),
196                'show_in_rest' => true,
197                'mcp'          => array(
198                    'public' => true,
199                    'type'   => 'tool', // default is already "tool", but can be explicit.
200                ),
201            ),
202        );
203    }
204
205    /**
206     * Spec: jetpack-stats/get-top-content.
207     */
208    private static function spec_get_top_content(): array {
209        return array(
210            'label'               => __( 'Get top stats content', 'jetpack-stats' ),
211            'description'         => __(
212                'Return the top items for a chosen content type — posts, referrers, search terms, outbound clicks, tags/categories, authors, countries, downloads, or video plays — in one filtered call. Replaces nine atomic WPCOM endpoints with a single ability. Uniform shape: { type, period, date, num, max, items: [ { rank, label, value, href? } ] } — agents MUST NOT see a different shape per type. `label` is human-readable (post title, referrer host, search term, country name, etc.). `value` is the view/hit count for that item. `href` is present only when the item has a canonical URL. Precondition: site must be connected to WordPress.com.',
213                'jetpack-stats'
214            ),
215            'input_schema'        => array(
216                'type'                 => 'object',
217                'required'             => array( 'type' ),
218                'properties'           => array(
219                    'type'   => array(
220                        'type'        => 'string',
221                        'description' => __( 'Which top-N surface to fetch.', 'jetpack-stats' ),
222                        'enum'        => self::TOP_CONTENT_TYPES,
223                    ),
224                    'period' => array(
225                        'type'        => 'string',
226                        'description' => __( 'Aggregation period.', 'jetpack-stats' ),
227                        'enum'        => self::PERIODS,
228                        'default'     => 'day',
229                    ),
230                    'date'   => array(
231                        'type'        => 'string',
232                        'description' => __( 'End date (YYYY-MM-DD). Defaults to today.', 'jetpack-stats' ),
233                        'pattern'     => '^[0-9]{4}-[0-9]{2}-[0-9]{2}$',
234                    ),
235                    'num'    => array(
236                        'type'        => 'integer',
237                        'description' => __( 'How many prior periods to roll up (1-90).', 'jetpack-stats' ),
238                        'minimum'     => 1,
239                        'maximum'     => 90,
240                        'default'     => 1,
241                    ),
242                    'max'    => array(
243                        'type'        => 'integer',
244                        'description' => __( 'Results cap (1-100).', 'jetpack-stats' ),
245                        'minimum'     => 1,
246                        'maximum'     => 100,
247                        'default'     => 20,
248                    ),
249                ),
250                'additionalProperties' => false,
251            ),
252            'output_schema'       => array(
253                'type'       => 'object',
254                'properties' => array(
255                    'type'   => array( 'type' => 'string' ),
256                    'period' => array( 'type' => 'string' ),
257                    'date'   => array( 'type' => 'string' ),
258                    'num'    => array( 'type' => 'integer' ),
259                    'max'    => array( 'type' => 'integer' ),
260                    'items'  => array( 'type' => 'array' ),
261                ),
262            ),
263            'execute_callback'    => array( __CLASS__, 'get_top_content' ),
264            'permission_callback' => array( __CLASS__, 'can_view_stats' ),
265            'meta'                => array(
266                'annotations'  => array(
267                    'readonly'    => true,
268                    'destructive' => false,
269                    'idempotent'  => true,
270                ),
271                'show_in_rest' => true,
272                'mcp'          => array(
273                    'public' => true,
274                    'type'   => 'tool', // default is already "tool", but can be explicit.
275                ),
276            ),
277        );
278    }
279
280    /**
281     * Spec: jetpack-stats/get-post-views.
282     */
283    private static function spec_get_post_views(): array {
284        return array(
285            'label'               => __( 'Get views for a post', 'jetpack-stats' ),
286            'description'         => __(
287                'Return views history for a single post: total views, timeseries of per-period views, and the period metadata. Shape: { post_id, total_views, period, num, date, series: [ { date, views } ] }. Accepts post_id as integer or numeric string (the literal "0" is rejected only because WordPress has no post 0 — any positive numeric value is legal). Precondition: site must be connected to WordPress.com. Related: call jetpack-stats/get-top-content with type=posts first to discover which posts to drill into.',
288                'jetpack-stats'
289            ),
290            'input_schema'        => array(
291                'type'                 => 'object',
292                'required'             => array( 'post_id' ),
293                'properties'           => array(
294                    'post_id' => array(
295                        'type'        => array( 'integer', 'string' ),
296                        'description' => __( 'The post ID to fetch views for. Must be positive.', 'jetpack-stats' ),
297                    ),
298                    'period'  => array(
299                        'type'        => 'string',
300                        'description' => __( 'Aggregation period.', 'jetpack-stats' ),
301                        'enum'        => self::PERIODS,
302                        'default'     => 'day',
303                    ),
304                    'num'     => array(
305                        'type'        => 'integer',
306                        'description' => __( 'How many prior periods to include (1-90).', 'jetpack-stats' ),
307                        'minimum'     => 1,
308                        'maximum'     => 90,
309                        'default'     => 30,
310                    ),
311                    'date'    => array(
312                        'type'        => 'string',
313                        'description' => __( 'End date (YYYY-MM-DD). Defaults to today.', 'jetpack-stats' ),
314                        'pattern'     => '^[0-9]{4}-[0-9]{2}-[0-9]{2}$',
315                    ),
316                ),
317                'additionalProperties' => false,
318            ),
319            'output_schema'       => array(
320                'type'       => 'object',
321                'properties' => array(
322                    'post_id'     => array( 'type' => 'integer' ),
323                    'total_views' => array( 'type' => 'integer' ),
324                    'period'      => array( 'type' => 'string' ),
325                    'num'         => array( 'type' => 'integer' ),
326                    'date'        => array( 'type' => 'string' ),
327                    'series'      => array( 'type' => 'array' ),
328                ),
329            ),
330            'execute_callback'    => array( __CLASS__, 'get_post_views' ),
331            'permission_callback' => array( __CLASS__, 'can_view_stats' ),
332            'meta'                => array(
333                'annotations'  => array(
334                    'readonly'    => true,
335                    'destructive' => false,
336                    'idempotent'  => true,
337                ),
338                'show_in_rest' => true,
339                'mcp'          => array(
340                    'public' => true,
341                    'type'   => 'tool', // default is already "tool", but can be explicit.
342                ),
343            ),
344        );
345    }
346
347    /**
348     * Spec: jetpack-stats/get-visits.
349     */
350    private static function spec_get_visits(): array {
351        return array(
352            'label'               => __( 'Get site visits timeseries', 'jetpack-stats' ),
353            'description'         => __(
354                'Return a site-level views/visitors/likes/comments timeseries — answers "is traffic trending up?". Shape: { unit, quantity, date, fields, series: [ { date, views, visitors, likes, comments } ] }. Every series row always includes every field listed in the request (no per-row omission). Precondition: site must be connected to WordPress.com.',
355                'jetpack-stats'
356            ),
357            'input_schema'        => array(
358                'type'                 => 'object',
359                'default'              => array(),
360                'properties'           => array(
361                    'unit'     => array(
362                        'type'        => 'string',
363                        'description' => __( 'Granularity of each data point.', 'jetpack-stats' ),
364                        'enum'        => self::PERIODS,
365                        'default'     => 'day',
366                    ),
367                    'quantity' => array(
368                        'type'        => 'integer',
369                        'description' => __( 'How many data points to return (1-90).', 'jetpack-stats' ),
370                        'minimum'     => 1,
371                        'maximum'     => 90,
372                        'default'     => 30,
373                    ),
374                    'date'     => array(
375                        'type'        => 'string',
376                        'description' => __( 'End date (YYYY-MM-DD). Defaults to today.', 'jetpack-stats' ),
377                        'pattern'     => '^[0-9]{4}-[0-9]{2}-[0-9]{2}$',
378                    ),
379                    'fields'   => array(
380                        'type'        => 'array',
381                        'description' => __( 'Which metrics to include in each row. Defaults to views+visitors.', 'jetpack-stats' ),
382                        'items'       => array(
383                            'type' => 'string',
384                            'enum' => self::VISIT_FIELDS,
385                        ),
386                        'default'     => self::DEFAULT_VISIT_FIELDS,
387                    ),
388                ),
389                'additionalProperties' => false,
390            ),
391            'output_schema'       => array(
392                'type'       => 'object',
393                'properties' => array(
394                    'unit'     => array( 'type' => 'string' ),
395                    'quantity' => array( 'type' => 'integer' ),
396                    'date'     => array( 'type' => 'string' ),
397                    'fields'   => array( 'type' => 'array' ),
398                    'series'   => array( 'type' => 'array' ),
399                ),
400            ),
401            'execute_callback'    => array( __CLASS__, 'get_visits' ),
402            'permission_callback' => array( __CLASS__, 'can_view_stats' ),
403            'meta'                => array(
404                'annotations'  => array(
405                    'readonly'    => true,
406                    'destructive' => false,
407                    'idempotent'  => true,
408                ),
409                'show_in_rest' => true,
410                'mcp'          => array(
411                    'public' => true,
412                    'type'   => 'tool', // default is already "tool", but can be explicit.
413                ),
414            ),
415        );
416    }
417
418    /**
419     * Spec: jetpack-stats/get-followers.
420     */
421    private static function spec_get_followers(): array {
422        return array(
423            'label'               => __( 'Get follower counts', 'jetpack-stats' ),
424            'description'         => __(
425                'Return a breakdown of follower counts across email, WordPress.com, comment, and publicize (per-service) — answers "how is my audience growing?" in one call. Shape: { total, email, wpcom, comment, publicize: { <service>: count }, partial: bool, errors?: [string] }. Composes three WPCOM endpoints — if any sub-call fails, `partial` is true and `errors` lists the failed sub-calls; when `partial` is true, source counts owned by the failed sub-call(s) are placeholder zeros rather than confirmed zero counts (cross-reference `errors` before treating a `0` as authoritative). Precondition: site must be connected to WordPress.com.',
426                'jetpack-stats'
427            ),
428            'input_schema'        => array(
429                'type'                 => 'object',
430                'default'              => array(),
431                'properties'           => new \stdClass(),
432                'additionalProperties' => false,
433            ),
434            'output_schema'       => array(
435                'type'       => 'object',
436                'properties' => array(
437                    'total'     => array( 'type' => 'integer' ),
438                    'email'     => array( 'type' => 'integer' ),
439                    'wpcom'     => array( 'type' => 'integer' ),
440                    'comment'   => array( 'type' => 'integer' ),
441                    'publicize' => array( 'type' => 'object' ),
442                    'partial'   => array( 'type' => 'boolean' ),
443                    'errors'    => array( 'type' => 'array' ),
444                ),
445            ),
446            'execute_callback'    => array( __CLASS__, 'get_followers' ),
447            'permission_callback' => array( __CLASS__, 'can_view_stats' ),
448            'meta'                => array(
449                'annotations'  => array(
450                    'readonly'    => true,
451                    'destructive' => false,
452                    'idempotent'  => true,
453                ),
454                'show_in_rest' => true,
455                'mcp'          => array(
456                    'public' => true,
457                    'type'   => 'tool', // default is already "tool", but can be explicit.
458                ),
459            ),
460        );
461    }
462
463    /**
464     * Spec: jetpack-stats/get-settings.
465     */
466    private static function spec_get_settings(): array {
467        return array(
468            'label'               => __( 'Get Stats settings', 'jetpack-stats' ),
469            'description'         => __(
470                'Read the current Jetpack Stats settings: who sees the Stats admin bar + menu, whose visits are counted, and DNT behavior. Shape: { admin_bar, roles, count_roles, do_not_track }. `roles` is an array of role slugs that can view Stats; `count_roles` is an array of role slugs whose visits are counted. Call jetpack-stats/update-settings to change any of these.',
471                'jetpack-stats'
472            ),
473            'input_schema'        => array(
474                'type'                 => 'object',
475                'default'              => array(),
476                'properties'           => new \stdClass(),
477                'additionalProperties' => false,
478            ),
479            'output_schema'       => array(
480                'type'       => 'object',
481                'properties' => self::settings_output_properties(),
482            ),
483            'execute_callback'    => array( __CLASS__, 'get_settings' ),
484            'permission_callback' => array( __CLASS__, 'can_view_stats' ),
485            'meta'                => array(
486                'annotations'  => array(
487                    'readonly'    => true,
488                    'destructive' => false,
489                    'idempotent'  => true,
490                ),
491                'show_in_rest' => true,
492                'mcp'          => array(
493                    'public' => true,
494                    'type'   => 'tool', // default is already "tool", but can be explicit.
495                ),
496            ),
497        );
498    }
499
500    /**
501     * Spec: jetpack-stats/update-settings.
502     */
503    private static function spec_update_settings(): array {
504        return array(
505            'label'               => __( 'Update Stats settings', 'jetpack-stats' ),
506            'description'         => __(
507                'Update one or more Jetpack Stats settings. All fields are optional; only fields present in the call are written, and unrelated keys are preserved. Idempotent — setting a value to its current state returns changed=false. Shape: { changed, settings: { admin_bar, roles, count_roles, do_not_track } }. Role slugs in `roles` and `count_roles` are validated against the site\'s registered roles; unknown slugs return jetpack_stats_invalid_role. Narrowing `roles` can revoke Stats access for whole groups of users — confirm with the user before removing roles.',
508                'jetpack-stats'
509            ),
510            'input_schema'        => array(
511                'type'                 => 'object',
512                'properties'           => array(
513                    'admin_bar'    => array(
514                        'type'        => 'boolean',
515                        'description' => __( 'Whether to show the Stats item in the admin bar for users who can view Stats.', 'jetpack-stats' ),
516                    ),
517                    'roles'        => array(
518                        'type'        => 'array',
519                        'description' => __( 'Role slugs that can view Stats. Must be non-empty; each slug must be a registered role.', 'jetpack-stats' ),
520                        'items'       => array( 'type' => 'string' ),
521                        'minItems'    => 1,
522                    ),
523                    'count_roles'  => array(
524                        'type'        => 'array',
525                        'description' => __( 'Role slugs whose visits are counted. May be empty (count visits from all users).', 'jetpack-stats' ),
526                        'items'       => array( 'type' => 'string' ),
527                    ),
528                    'do_not_track' => array(
529                        'type'        => 'boolean',
530                        'description' => __( 'Whether to honor the browser Do Not Track header.', 'jetpack-stats' ),
531                    ),
532                ),
533                'additionalProperties' => false,
534                'minProperties'        => 1,
535            ),
536            'output_schema'       => array(
537                'type'       => 'object',
538                'properties' => array(
539                    'changed'  => array( 'type' => 'boolean' ),
540                    'settings' => array(
541                        'type'       => 'object',
542                        'properties' => self::settings_output_properties(),
543                    ),
544                ),
545            ),
546            'execute_callback'    => array( __CLASS__, 'update_settings' ),
547            'permission_callback' => array( __CLASS__, 'can_manage_settings' ),
548            'meta'                => array(
549                'annotations'  => array(
550                    'readonly'    => false,
551                    'destructive' => false,
552                    'idempotent'  => true,
553                ),
554                'show_in_rest' => true,
555                'mcp'          => array(
556                    'public' => true,
557                    'type'   => 'tool', // default is already "tool", but can be explicit.
558                ),
559            ),
560        );
561    }
562
563    /**
564     * Output schema properties shared by get-settings and update-settings' `settings` field.
565     */
566    private static function settings_output_properties(): array {
567        return array(
568            'admin_bar'    => array( 'type' => 'boolean' ),
569            'roles'        => array(
570                'type'  => 'array',
571                'items' => array( 'type' => 'string' ),
572            ),
573            'count_roles'  => array(
574                'type'  => 'array',
575                'items' => array( 'type' => 'string' ),
576            ),
577            'do_not_track' => array( 'type' => 'boolean' ),
578        );
579    }
580
581    /*
582    ---------------------------------------------------------------------
583     * Permission callbacks
584     * ---------------------------------------------------------------------
585     */
586
587    /**
588     * Read-side permission: view_stats.
589     *
590     * The Stats package's `Main::map_meta_caps` maps `view_stats` to the
591     * user's `read` capability when their role is listed in
592     * `stats_options['roles']`. Honored on self-hosted sites.
593     *
594     * @return bool
595     */
596    public static function can_view_stats(): bool {
597        return current_user_can( 'view_stats' );
598    }
599
600    /**
601     * Write-side permission: manage_options.
602     *
603     * Stats configuration writes modify the `stats_options` WP option,
604     * which includes the very roles that gate `view_stats`. Guard with the
605     * site-admin capability, not `view_stats`, so readers can't escalate.
606     *
607     * @return bool
608     */
609    public static function can_manage_settings(): bool {
610        return current_user_can( 'manage_options' );
611    }
612
613    /*
614    ---------------------------------------------------------------------
615     * Execute callbacks
616     * ---------------------------------------------------------------------
617     */
618
619    /**
620     * Execute: get-site-overview.
621     *
622     * @param array|null $input Ignored — zero-arg ability.
623     * @return array|WP_Error
624     */
625    public static function get_site_overview( $input = null ) {
626        unset( $input );
627        $stats = self::get_wpcom_stats();
628
629        $composed = self::compose_subcalls(
630            array(
631                'summary'    => $stats->get_stats_summary(),
632                'highlights' => $stats->get_highlights(),
633                'streak'     => $stats->get_streak(),
634            ),
635            __( 'Stats data could not be fetched from WordPress.com. Confirm the site is connected and try again.', 'jetpack-stats' )
636        );
637        if ( is_wp_error( $composed ) ) {
638            return $composed;
639        }
640        [ 'summary' => $summary, 'highlights' => $highlights, 'streak' => $streak ] = $composed['values'];
641        $errors = $composed['errors'];
642
643        $highlights_today    = isset( $highlights['today'] ) && is_array( $highlights['today'] ) ? $highlights['today'] : array();
644        $highlights_top_post = isset( $highlights_today['top_post'] ) && is_array( $highlights_today['top_post'] )
645            ? $highlights_today['top_post']
646            : null;
647
648        $out = array(
649            'date'           => self::first_string( array( $summary, $highlights_today ), 'date' ),
650            'views_today'    => self::as_int( $summary, 'views' ),
651            'visitors_today' => self::as_int( $summary, 'visitors' ),
652            'views_week'     => self::as_int( $summary, 'period_total_views' ),
653            'views_month'    => isset( $highlights_today['views_month'] ) ? (int) $highlights_today['views_month'] : 0,
654            'streak'         => self::extract_streak_summary( $streak ),
655            'top_post'       => null === $highlights_top_post ? null : array(
656                'id'    => isset( $highlights_top_post['id'] ) ? (int) $highlights_top_post['id'] : 0,
657                'title' => isset( $highlights_top_post['title'] ) ? (string) $highlights_top_post['title'] : '',
658                'views' => isset( $highlights_top_post['views'] ) ? (int) $highlights_top_post['views'] : 0,
659            ),
660            'top_referrer'   => self::extract_top_referrer( $highlights_today ),
661            'partial'        => ! empty( $errors ),
662        );
663
664        if ( ! empty( $errors ) ) {
665            $out['errors'] = $errors;
666        }
667
668        return $out;
669    }
670
671    /**
672     * Execute: get-top-content.
673     *
674     * @param array|null $input Input matching the ability's input_schema.
675     * @return array|WP_Error
676     */
677    public static function get_top_content( $input = null ) {
678        $input = is_array( $input ) ? $input : array();
679
680        if ( ! isset( $input['type'] ) || ! in_array( $input['type'], self::TOP_CONTENT_TYPES, true ) ) {
681            return new WP_Error(
682                self::ERROR_PREFIX . 'missing_type',
683                sprintf(
684                    /* translators: %s: comma-separated list of valid type values. */
685                    __( 'A `type` is required. Valid values: %s.', 'jetpack-stats' ),
686                    implode( ', ', self::TOP_CONTENT_TYPES )
687                )
688            );
689        }
690
691        $type   = $input['type'];
692        $period = self::pick_period( $input['period'] ?? null );
693        $date   = self::sanitize_date( $input['date'] ?? null );
694        $num    = self::clamp_int( $input['num'] ?? 1, 1, 90, 1 );
695        $max    = self::clamp_int( $input['max'] ?? 20, 1, 100, 20 );
696
697        $args = array(
698            'period' => $period,
699            'date'   => $date,
700            'num'    => $num,
701            'max'    => $max,
702        );
703
704        $stats = self::get_wpcom_stats();
705        $raw   = self::fetch_top_content_raw( $stats, $type, $args );
706        if ( is_wp_error( $raw ) ) {
707            return $raw;
708        }
709
710        $items = self::normalize_top_content_items( $type, $raw, $max );
711
712        return array(
713            'type'   => $type,
714            'period' => $period,
715            'date'   => $date,
716            'num'    => $num,
717            'max'    => $max,
718            'items'  => $items,
719        );
720    }
721
722    /**
723     * Execute: get-post-views.
724     *
725     * @param array|null $input Input matching the ability's input_schema.
726     * @return array|WP_Error
727     */
728    public static function get_post_views( $input = null ) {
729        $input = is_array( $input ) ? $input : array();
730
731        // Use isset()+is_numeric() — NOT empty() — so the literal "0" is rejected by the `> 0` check, not by a truthiness accident.
732        if ( ! isset( $input['post_id'] ) || ! is_numeric( $input['post_id'] ) || (int) $input['post_id'] <= 0 ) {
733            return new WP_Error(
734                self::ERROR_PREFIX . 'missing_post_id',
735                __( 'A positive post_id is required.', 'jetpack-stats' )
736            );
737        }
738
739        $post_id = (int) $input['post_id'];
740        $period  = self::pick_period( $input['period'] ?? null );
741        $num     = self::clamp_int( $input['num'] ?? 30, 1, 90, 30 );
742        $date    = self::sanitize_date( $input['date'] ?? null );
743
744        $args = array(
745            'period' => $period,
746            'num'    => $num,
747            'date'   => $date,
748        );
749
750        $stats = self::get_wpcom_stats();
751        $raw   = $stats->get_post_views( $post_id, $args );
752        if ( is_wp_error( $raw ) ) {
753            return $raw;
754        }
755
756        return array(
757            'post_id'     => $post_id,
758            'total_views' => isset( $raw['views'] ) ? (int) $raw['views'] : 0,
759            'period'      => $period,
760            'num'         => $num,
761            'date'        => $date,
762            'series'      => self::extract_post_views_series( $raw ),
763        );
764    }
765
766    /**
767     * Execute: get-visits.
768     *
769     * @param array|null $input Input matching the ability's input_schema.
770     * @return array|WP_Error
771     */
772    public static function get_visits( $input = null ) {
773        $input = is_array( $input ) ? $input : array();
774
775        $unit     = self::pick_period( $input['unit'] ?? null );
776        $quantity = self::clamp_int( $input['quantity'] ?? 30, 1, 90, 30 );
777        $date     = self::sanitize_date( $input['date'] ?? null );
778
779        // Pass user input FIRST to array_intersect so caller-supplied field order is preserved.
780        $fields = isset( $input['fields'] ) && is_array( $input['fields'] )
781            ? array_values( array_intersect( $input['fields'], self::VISIT_FIELDS ) )
782            : array();
783        if ( empty( $fields ) ) {
784            $fields = self::DEFAULT_VISIT_FIELDS;
785        }
786
787        $args = array(
788            'unit'        => $unit,
789            'quantity'    => $quantity,
790            'date'        => $date,
791            'stat_fields' => implode( ',', $fields ),
792        );
793
794        $stats = self::get_wpcom_stats();
795        $raw   = $stats->get_visits( $args );
796        if ( is_wp_error( $raw ) ) {
797            return $raw;
798        }
799
800        return array(
801            'unit'     => $unit,
802            'quantity' => $quantity,
803            'date'     => $date,
804            'fields'   => $fields,
805            'series'   => self::normalize_visits_series( $raw, $fields ),
806        );
807    }
808
809    /**
810     * Execute: get-followers.
811     *
812     * @param array|null $input Ignored — zero-arg ability.
813     * @return array|WP_Error
814     */
815    public static function get_followers( $input = null ) {
816        unset( $input );
817        $stats = self::get_wpcom_stats();
818
819        $composed = self::compose_subcalls(
820            array(
821                'followers'           => $stats->get_followers(),
822                'comment_followers'   => $stats->get_comment_followers(),
823                'publicize_followers' => $stats->get_publicize_followers(),
824            ),
825            __( 'Follower data could not be fetched from WordPress.com. Confirm the site is connected and try again.', 'jetpack-stats' )
826        );
827        if ( is_wp_error( $composed ) ) {
828            return $composed;
829        }
830        $followers         = $composed['values']['followers'];
831        $comment_followers = $composed['values']['comment_followers'];
832        $publicize         = $composed['values']['publicize_followers'];
833        $errors            = $composed['errors'];
834
835        $email = 0;
836        $wpcom = 0;
837        if ( isset( $followers['subscribers'] ) && is_array( $followers['subscribers'] ) ) {
838            foreach ( $followers['subscribers'] as $sub ) {
839                if ( isset( $sub['type'] ) && 'email' === $sub['type'] ) {
840                    $email += isset( $sub['value'] ) ? (int) $sub['value'] : 0;
841                } elseif ( isset( $sub['type'] ) && 'wpcom' === $sub['type'] ) {
842                    $wpcom += isset( $sub['value'] ) ? (int) $sub['value'] : 0;
843                }
844            }
845        } else {
846            $email = isset( $followers['email'] ) ? (int) $followers['email'] : 0;
847            $wpcom = isset( $followers['wpcom'] ) ? (int) $followers['wpcom'] : 0;
848        }
849
850        $comment = isset( $comment_followers['total'] ) ? (int) $comment_followers['total'] : 0;
851
852        $publicize_by_service = array();
853        if ( isset( $publicize['services'] ) && is_array( $publicize['services'] ) ) {
854            foreach ( $publicize['services'] as $row ) {
855                if ( isset( $row['service'] ) && isset( $row['followers'] ) ) {
856                    $publicize_by_service[ (string) $row['service'] ] = (int) $row['followers'];
857                }
858            }
859        }
860
861        $total = $email + $wpcom + $comment + array_sum( $publicize_by_service );
862
863        $out = array(
864            'total'     => $total,
865            'email'     => $email,
866            'wpcom'     => $wpcom,
867            'comment'   => $comment,
868            'publicize' => $publicize_by_service,
869            'partial'   => ! empty( $errors ),
870        );
871
872        if ( ! empty( $errors ) ) {
873            $out['errors'] = $errors;
874        }
875
876        return $out;
877    }
878
879    /**
880     * Execute: get-settings.
881     *
882     * @param array|null $input Ignored — zero-arg ability.
883     * @return array
884     */
885    public static function get_settings( $input = null ) {
886        unset( $input );
887        return self::settings_snapshot();
888    }
889
890    /**
891     * Execute: update-settings.
892     *
893     * @param array|null $input Input matching the ability's input_schema.
894     * @return array|WP_Error
895     */
896    public static function update_settings( $input = null ) {
897        $input = is_array( $input ) ? $input : array();
898
899        // At least one of the whitelisted keys must be present.
900        $provided = array_intersect_key( $input, array_flip( self::SETTINGS_KEYS ) );
901        if ( empty( $provided ) ) {
902            return new WP_Error(
903                self::ERROR_PREFIX . 'missing_setting_field',
904                sprintf(
905                    /* translators: %s: comma-separated list of writable field names. */
906                    __( 'Provide at least one of: %s.', 'jetpack-stats' ),
907                    implode( ', ', self::SETTINGS_KEYS )
908                )
909            );
910        }
911
912        // Validate role slugs against registered roles — but only load the role list
913        // if the caller is actually writing a role field. Boolean-only writes skip
914        // the wp_roles() resolution entirely. Role fields are detected from the
915        // option's default value type (array → role list).
916        $defaults    = Options::get_defaults();
917        $known_roles = null;
918        foreach ( self::SETTINGS_KEYS as $role_field ) {
919            if ( ! array_key_exists( $role_field, $provided ) ) {
920                continue;
921            }
922            if ( ! is_array( $defaults[ $role_field ] ?? null ) ) {
923                continue;
924            }
925            if ( null === $known_roles ) {
926                $known_roles = array_keys( wp_roles()->roles );
927            }
928            if ( ! is_array( $provided[ $role_field ] ) ) {
929                return new WP_Error(
930                    self::ERROR_PREFIX . 'invalid_' . $role_field,
931                    sprintf(
932                        /* translators: %s: the offending field name. */
933                        __( 'Field `%s` must be an array of role slugs.', 'jetpack-stats' ),
934                        $role_field
935                    )
936                );
937            }
938            // `roles` gates `view_stats` — an empty array would lock every user out, including
939            // the caller. Schema validation enforces minItems=1 on REST input, but direct PHP
940            // callers bypass that path; reject explicitly here.
941            if ( 'roles' === $role_field && empty( $provided[ $role_field ] ) ) {
942                return new WP_Error(
943                    self::ERROR_PREFIX . 'invalid_roles',
944                    __( 'Field `roles` must be a non-empty array of role slugs — an empty list would revoke Stats access for every user.', 'jetpack-stats' )
945                );
946            }
947            $sanitized = array();
948            foreach ( $provided[ $role_field ] as $role ) {
949                if ( ! is_string( $role ) || '' === $role ) {
950                    return new WP_Error(
951                        self::ERROR_PREFIX . 'invalid_role',
952                        sprintf(
953                            /* translators: 1: field name, 2: comma-separated list of valid role slugs. */
954                            __( 'Role slugs in `%1$s` must be non-empty strings. Known roles: %2$s.', 'jetpack-stats' ),
955                            $role_field,
956                            implode( ', ', $known_roles )
957                        )
958                    );
959                }
960                if ( ! in_array( $role, $known_roles, true ) ) {
961                    return new WP_Error(
962                        self::ERROR_PREFIX . 'invalid_role',
963                        sprintf(
964                            /* translators: 1: unknown role slug, 2: field name, 3: comma-separated list of valid role slugs. */
965                            __( 'Unknown role `%1$s` in `%2$s`. Known roles: %3$s.', 'jetpack-stats' ),
966                            $role,
967                            $role_field,
968                            implode( ', ', $known_roles )
969                        )
970                    );
971                }
972                $sanitized[] = $role;
973            }
974            $provided[ $role_field ] = array_values( array_unique( $sanitized ) );
975        }
976
977        $before  = self::settings_snapshot();
978        $changes = array();
979        foreach ( $provided as $key => $value ) {
980            if ( is_bool( $defaults[ $key ] ?? null ) ) {
981                $value = (bool) $value;
982            }
983            $current = $before[ $key ] ?? null;
984            if ( $current === $value ) {
985                continue;
986            }
987            $changes[ $key ] = $value;
988        }
989
990        // `changed` is derived from the POST-WRITE snapshot, not from `$changes` alone —
991        // if update_option fails or refuses to persist for any reason (DB error,
992        // serialization mismatch), we must not claim a change that didn't happen.
993        if ( ! empty( $changes ) ) {
994            // One merged write instead of N get+update cycles via set_option.
995            Options::set_options( $changes );
996        }
997
998        $after = self::settings_snapshot();
999
1000        return array(
1001            'changed'  => $after !== $before,
1002            'settings' => $after,
1003        );
1004    }
1005
1006    /*
1007    ---------------------------------------------------------------------
1008     * Helpers
1009     * ---------------------------------------------------------------------
1010     */
1011
1012    /**
1013     * Build a whitelisted snapshot of the current Stats configuration.
1014     *
1015     * @return array
1016     */
1017    private static function settings_snapshot(): array {
1018        $options  = Options::get_options();
1019        $defaults = Options::get_defaults();
1020        $out      = array();
1021        foreach ( self::SETTINGS_KEYS as $key ) {
1022            $raw     = $options[ $key ] ?? null;
1023            $default = $defaults[ $key ] ?? null;
1024            if ( is_bool( $default ) ) {
1025                $out[ $key ] = (bool) $raw;
1026            } elseif ( is_array( $default ) ) {
1027                $out[ $key ] = is_array( $raw ) ? array_values( $raw ) : array();
1028            } else {
1029                $out[ $key ] = $raw;
1030            }
1031        }
1032        return $out;
1033    }
1034
1035    /**
1036     * Compose multiple WPCOM sub-call results into a partial-tolerant envelope.
1037     *
1038     * Each named result is either an array (kept as-is) or a WP_Error
1039     * (replaced with `[]` and its key recorded under `errors`). Returns
1040     * `jetpack_stats_data_unavailable` if every sub-call failed.
1041     *
1042     * @param array  $named_results       Map of error-tag => array|WP_Error.
1043     * @param string $all_failed_message  Message for the all-failed WP_Error.
1044     * @return array{values: array, errors: array}|WP_Error
1045     */
1046    private static function compose_subcalls( array $named_results, string $all_failed_message ) {
1047        $values = array();
1048        $errors = array();
1049        foreach ( $named_results as $tag => $result ) {
1050            if ( is_wp_error( $result ) ) {
1051                $errors[]       = (string) $tag;
1052                $values[ $tag ] = array();
1053            } else {
1054                $values[ $tag ] = $result;
1055            }
1056        }
1057
1058        if ( count( $errors ) === count( $named_results ) ) {
1059            return new WP_Error( self::ERROR_PREFIX . 'data_unavailable', $all_failed_message );
1060        }
1061
1062        return array(
1063            'values' => $values,
1064            'errors' => $errors,
1065        );
1066    }
1067
1068    /**
1069     * Return the first non-empty string at `$key` across the given source arrays.
1070     *
1071     * @param array[] $sources Ordered list of arrays to probe.
1072     * @param string  $key     Key to read from each array.
1073     * @return string
1074     */
1075    private static function first_string( array $sources, string $key ): string {
1076        foreach ( $sources as $source ) {
1077            if ( isset( $source[ $key ] ) && '' !== $source[ $key ] ) {
1078                return (string) $source[ $key ];
1079            }
1080        }
1081        return '';
1082    }
1083
1084    /**
1085     * Resolve an aggregation period from raw input, defaulting to `day`.
1086     *
1087     * @param mixed $raw Raw input value.
1088     * @return string One of self::PERIODS.
1089     */
1090    private static function pick_period( $raw ): string {
1091        return is_string( $raw ) && in_array( $raw, self::PERIODS, true ) ? $raw : 'day';
1092    }
1093
1094    /**
1095     * Return a WPCOM_Stats instance. Filterable for tests.
1096     *
1097     * @return WPCOM_Stats
1098     */
1099    protected static function get_wpcom_stats(): WPCOM_Stats {
1100        /**
1101         * Filters the WPCOM_Stats instance used by the Stats abilities.
1102         *
1103         * @since 0.19.0
1104         *
1105         * @param WPCOM_Stats $wpcom_stats The default instance.
1106         */
1107        $instance = apply_filters( 'jetpack_stats_abilities_wpcom_stats', new WPCOM_Stats() );
1108        return $instance instanceof WPCOM_Stats ? $instance : new WPCOM_Stats();
1109    }
1110
1111    /**
1112     * Dispatch top-content raw fetch to the right WPCOM_Stats method.
1113     *
1114     * @param WPCOM_Stats $stats Client.
1115     * @param string      $type  Content type enum.
1116     * @param array       $args  Pre-built `{ period, date, num, max }` args — ignored for `tags` which takes only `max`.
1117     * @return array|WP_Error
1118     */
1119    private static function fetch_top_content_raw( WPCOM_Stats $stats, string $type, array $args ) {
1120        switch ( $type ) {
1121            case 'posts':
1122                return $stats->get_top_posts( $args );
1123            case 'referrers':
1124                return $stats->get_referrers( $args );
1125            case 'search-terms':
1126                return $stats->get_search_terms( $args );
1127            case 'clicks':
1128                return $stats->get_clicks( $args );
1129            case 'tags':
1130                // get_tags has a narrower arg surface — pass only `max`.
1131                return $stats->get_tags( array( 'max' => $args['max'] ) );
1132            case 'authors':
1133                return $stats->get_top_authors( $args );
1134            case 'countries':
1135                return $stats->get_views_by_country( $args );
1136            case 'downloads':
1137                return $stats->get_file_downloads( $args );
1138            case 'video-plays':
1139                return $stats->get_video_plays( $args );
1140        }
1141
1142        return new WP_Error( self::ERROR_PREFIX . 'invalid_type', __( 'Unknown top-content type.', 'jetpack-stats' ) );
1143    }
1144
1145    /**
1146     * Normalize a WPCOM top-content response into the uniform item shape.
1147     *
1148     * Most `type` values follow the `days -> <first-day> -> <list-key>` shape
1149     * and project through `TOP_CONTENT_MAP`. `tags` (flat `tags` array, no
1150     * `days` envelope) and `countries` (needs `country-info` code-to-name
1151     * join) are special-cased.
1152     *
1153     * @param string $type Content type enum.
1154     * @param array  $raw  Raw WPCOM response.
1155     * @param int    $max  Result cap.
1156     * @return array List of { rank, label, value, href? } items.
1157     */
1158    private static function normalize_top_content_items( string $type, array $raw, int $max ): array {
1159        if ( 'tags' === $type ) {
1160            $rows = array();
1161            $tags = isset( $raw['tags'] ) && is_array( $raw['tags'] ) ? $raw['tags'] : array();
1162            foreach ( $tags as $tag ) {
1163                $rows[] = array(
1164                    'label' => isset( $tag['tag'] ) ? (string) $tag['tag'] : '',
1165                    'value' => isset( $tag['views'] ) ? (int) $tag['views'] : 0,
1166                );
1167            }
1168            return self::rank_and_cap( $rows, $max );
1169        }
1170
1171        $day_data = self::first_day( $raw );
1172
1173        if ( 'countries' === $type ) {
1174            $rows         = array();
1175            $list         = isset( $day_data['views'] ) && is_array( $day_data['views'] ) ? $day_data['views'] : array();
1176            $country_info = isset( $raw['country-info'] ) && is_array( $raw['country-info'] ) ? $raw['country-info'] : array();
1177            foreach ( $list as $v ) {
1178                $code   = isset( $v['country_code'] ) ? (string) $v['country_code'] : '';
1179                $rows[] = array(
1180                    'label' => (string) ( $country_info[ $code ]['country_full'] ?? $code ),
1181                    'value' => isset( $v['views'] ) ? (int) $v['views'] : 0,
1182                );
1183            }
1184            return self::rank_and_cap( $rows, $max );
1185        }
1186
1187        $map = self::TOP_CONTENT_MAP[ $type ] ?? null;
1188        if ( null === $map ) {
1189            return array();
1190        }
1191
1192        $rows = array();
1193        $list = isset( $day_data[ $map['list'] ] ) && is_array( $day_data[ $map['list'] ] ) ? $day_data[ $map['list'] ] : array();
1194        foreach ( $list as $row ) {
1195            if ( ! is_array( $row ) ) {
1196                continue;
1197            }
1198            $label = isset( $row[ $map['label'] ] ) && '' !== $row[ $map['label'] ] ? (string) $row[ $map['label'] ] : '';
1199            if ( '' === $label && isset( $map['label_fallback'] ) && isset( $row[ $map['label_fallback'] ] ) ) {
1200                $label = (string) $row[ $map['label_fallback'] ];
1201            }
1202            $entry = array(
1203                'label' => $label,
1204                'value' => isset( $row[ $map['value'] ] ) ? (int) $row[ $map['value'] ] : 0,
1205            );
1206            if ( isset( $map['href'] ) && isset( $row[ $map['href'] ] ) ) {
1207                $entry['href'] = (string) $row[ $map['href'] ];
1208            }
1209            $rows[] = $entry;
1210        }
1211        return self::rank_and_cap( $rows, $max );
1212    }
1213
1214    /**
1215     * Pick the first `days` entry from a WPCOM days-keyed response.
1216     *
1217     * Different top-content endpoints key their per-day data under `days`
1218     * (posts, referrers, authors, countries, ...) or `days -> <date>`; a few
1219     * flatten it entirely (tags). This helper handles the common case.
1220     *
1221     * @param array $raw Raw WPCOM response.
1222     * @return array The first day's sub-array, or [].
1223     */
1224    private static function first_day( array $raw ): array {
1225        if ( ! isset( $raw['days'] ) || ! is_array( $raw['days'] ) || empty( $raw['days'] ) ) {
1226            return array();
1227        }
1228        $first = reset( $raw['days'] );
1229        return is_array( $first ) ? $first : array();
1230    }
1231
1232    /**
1233     * Rank, cap, and strip null href fields.
1234     *
1235     * @param array $rows Unranked rows.
1236     * @param int   $max  Result cap.
1237     * @return array Ranked + capped rows with `rank` injected.
1238     */
1239    private static function rank_and_cap( array $rows, int $max ): array {
1240        $rows = array_slice( $rows, 0, $max );
1241        $out  = array();
1242        foreach ( $rows as $i => $row ) {
1243            $entry = array(
1244                'rank'  => $i + 1,
1245                'label' => isset( $row['label'] ) ? (string) $row['label'] : '',
1246                'value' => isset( $row['value'] ) ? (int) $row['value'] : 0,
1247            );
1248            if ( isset( $row['href'] ) && '' !== $row['href'] ) {
1249                $entry['href'] = $row['href'];
1250            }
1251            $out[] = $entry;
1252        }
1253        return $out;
1254    }
1255
1256    /**
1257     * Extract a compact streak summary from the WPCOM streak response.
1258     *
1259     * @param array $streak Raw WPCOM streak response.
1260     * @return array Compact `{ current_length, longest_length, longest_start, longest_end }`.
1261     */
1262    private static function extract_streak_summary( array $streak ): array {
1263        $data = isset( $streak['streak'] ) && is_array( $streak['streak'] ) ? $streak['streak'] : array();
1264        return array(
1265            'current_length' => isset( $data['currentStreakLength'] ) ? (int) $data['currentStreakLength'] : 0,
1266            'longest_length' => isset( $data['longestStreakLength'] ) ? (int) $data['longestStreakLength'] : 0,
1267            'longest_start'  => isset( $data['longestStreakStart'] ) ? (string) $data['longestStreakStart'] : '',
1268            'longest_end'    => isset( $data['longestStreakEnd'] ) ? (string) $data['longestStreakEnd'] : '',
1269        );
1270    }
1271
1272    /**
1273     * Extract the top referrer from a highlights `today` block.
1274     *
1275     * @param array $today Highlights today block.
1276     * @return array|null { name, views } or null.
1277     */
1278    private static function extract_top_referrer( array $today ): ?array {
1279        $list = isset( $today['top_referrers'] ) && is_array( $today['top_referrers'] ) ? $today['top_referrers'] : array();
1280        if ( empty( $list ) ) {
1281            return null;
1282        }
1283        $first = $list[0];
1284        if ( ! is_array( $first ) ) {
1285            return null;
1286        }
1287        return array(
1288            'name'  => isset( $first['name'] ) ? (string) $first['name'] : '',
1289            'views' => isset( $first['views'] ) ? (int) $first['views'] : 0,
1290        );
1291    }
1292
1293    /**
1294     * Extract a post-views series from the WPCOM get_post_views response.
1295     *
1296     * WPCOM returns `data` as a list of `[date, views]` tuples under the
1297     * `fields` header. We normalize to `[{ date, views }]`.
1298     *
1299     * @param array $raw Raw WPCOM response.
1300     * @return array
1301     */
1302    private static function extract_post_views_series( array $raw ): array {
1303        if ( ! isset( $raw['data'] ) || ! is_array( $raw['data'] ) ) {
1304            return array();
1305        }
1306
1307        $fields    = isset( $raw['fields'] ) && is_array( $raw['fields'] ) ? $raw['fields'] : array( 'period', 'views' );
1308        $date_idx  = array_search( 'period', $fields, true );
1309        $views_idx = array_search( 'views', $fields, true );
1310        if ( false === $date_idx || false === $views_idx ) {
1311            // If either column is missing from the WPCOM response, the positional
1312            // fallback is unsafe (we might collide date/views on column 0). Drop
1313            // to empty rather than emit lies.
1314            return array();
1315        }
1316
1317        $series = array();
1318        foreach ( $raw['data'] as $row ) {
1319            if ( ! is_array( $row ) ) {
1320                continue;
1321            }
1322            $series[] = array(
1323                'date'  => isset( $row[ $date_idx ] ) ? (string) $row[ $date_idx ] : '',
1324                'views' => isset( $row[ $views_idx ] ) ? (int) $row[ $views_idx ] : 0,
1325            );
1326        }
1327        return $series;
1328    }
1329
1330    /**
1331     * Normalize the WPCOM get_visits response into `[{ date, <field>: int, ... }]`.
1332     *
1333     * @param array $raw    Raw WPCOM response.
1334     * @param array $fields Requested metric fields.
1335     * @return array
1336     */
1337    private static function normalize_visits_series( array $raw, array $fields ): array {
1338        if ( ! isset( $raw['data'] ) || ! is_array( $raw['data'] ) ) {
1339            return array();
1340        }
1341
1342        $raw_fields = isset( $raw['fields'] ) && is_array( $raw['fields'] ) ? $raw['fields'] : array();
1343        $field_idx  = array();
1344        foreach ( $raw_fields as $idx => $name ) {
1345            $field_idx[ (string) $name ] = $idx;
1346        }
1347        $date_idx = $field_idx['period'] ?? 0;
1348
1349        $series = array();
1350        foreach ( $raw['data'] as $row ) {
1351            if ( ! is_array( $row ) ) {
1352                continue;
1353            }
1354            $entry = array(
1355                'date' => isset( $row[ $date_idx ] ) ? (string) $row[ $date_idx ] : '',
1356            );
1357            foreach ( $fields as $field ) {
1358                $idx             = $field_idx[ $field ] ?? null;
1359                $entry[ $field ] = ( null !== $idx && isset( $row[ $idx ] ) ) ? (int) $row[ $idx ] : 0;
1360            }
1361            $series[] = $entry;
1362        }
1363        return $series;
1364    }
1365
1366    /**
1367     * Normalize a candidate date string. Returns today's date (UTC) on bad input.
1368     *
1369     * @param mixed $raw Raw input value.
1370     * @return string YYYY-MM-DD.
1371     */
1372    private static function sanitize_date( $raw ): string {
1373        if ( is_string( $raw ) && 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw ) ) {
1374            return $raw;
1375        }
1376        return gmdate( 'Y-m-d' );
1377    }
1378
1379    /**
1380     * Clamp an integer into [$min, $max] with a default on bad input.
1381     *
1382     * @param mixed $raw     Raw input.
1383     * @param int   $min     Minimum.
1384     * @param int   $max     Maximum.
1385     * @param int   $default Default on bad input.
1386     * @return int
1387     */
1388    private static function clamp_int( $raw, int $min, int $max, int $default ): int {
1389        if ( ! is_numeric( $raw ) ) {
1390            return $default;
1391        }
1392        $v = (int) $raw;
1393        if ( $v < $min ) {
1394            return $min;
1395        }
1396        if ( $v > $max ) {
1397            return $max;
1398        }
1399        return $v;
1400    }
1401
1402    /**
1403     * Safely read an int field from an array.
1404     *
1405     * @param array  $arr Array.
1406     * @param string $key Key.
1407     * @return int
1408     */
1409    private static function as_int( array $arr, string $key ): int {
1410        return isset( $arr[ $key ] ) ? (int) $arr[ $key ] : 0;
1411    }
1412}