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