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_view_stats' ),
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     * Write-side permission: manage_options.
593     *
594     * Stats configuration writes modify the `stats_options` WP option,
595     * which includes the very roles that gate `view_stats`. Guard with the
596     * site-admin capability, not `view_stats`, so readers can't escalate.
597     *
598     * @return bool
599     */
600    public static function can_manage_settings(): bool {
601        return current_user_can( 'manage_options' );
602    }
603
604    /*
605    ---------------------------------------------------------------------
606     * Execute callbacks
607     * ---------------------------------------------------------------------
608     */
609
610    /**
611     * Execute: get-site-overview.
612     *
613     * @param array|null $input Ignored â€” zero-arg ability.
614     * @return array|WP_Error
615     */
616    public static function get_site_overview( $input = null ) {
617        unset( $input );
618        $stats = self::get_wpcom_stats();
619
620        $composed = self::compose_subcalls(
621            array(
622                'summary'    => $stats->get_stats_summary(),
623                'highlights' => $stats->get_highlights(),
624                'streak'     => $stats->get_streak(),
625            ),
626            __( 'Stats data could not be fetched from WordPress.com. Confirm the site is connected and try again.', 'jetpack-stats-pkg' )
627        );
628        if ( is_wp_error( $composed ) ) {
629            return $composed;
630        }
631        [ 'summary' => $summary, 'highlights' => $highlights, 'streak' => $streak ] = $composed['values'];
632        $errors = $composed['errors'];
633
634        $highlights_today    = isset( $highlights['today'] ) && is_array( $highlights['today'] ) ? $highlights['today'] : array();
635        $highlights_top_post = isset( $highlights_today['top_post'] ) && is_array( $highlights_today['top_post'] )
636            ? $highlights_today['top_post']
637            : null;
638
639        $out = array(
640            'date'           => self::first_string( array( $summary, $highlights_today ), 'date' ),
641            'views_today'    => self::as_int( $summary, 'views' ),
642            'visitors_today' => self::as_int( $summary, 'visitors' ),
643            'views_week'     => self::as_int( $summary, 'period_total_views' ),
644            'views_month'    => isset( $highlights_today['views_month'] ) ? (int) $highlights_today['views_month'] : 0,
645            'streak'         => self::extract_streak_summary( $streak ),
646            'top_post'       => null === $highlights_top_post ? null : array(
647                'id'    => isset( $highlights_top_post['id'] ) ? (int) $highlights_top_post['id'] : 0,
648                'title' => isset( $highlights_top_post['title'] ) ? (string) $highlights_top_post['title'] : '',
649                'views' => isset( $highlights_top_post['views'] ) ? (int) $highlights_top_post['views'] : 0,
650            ),
651            'top_referrer'   => self::extract_top_referrer( $highlights_today ),
652            'partial'        => ! empty( $errors ),
653        );
654
655        if ( ! empty( $errors ) ) {
656            $out['errors'] = $errors;
657        }
658
659        return $out;
660    }
661
662    /**
663     * Execute: get-top-content.
664     *
665     * @param array|null $input Input matching the ability's input_schema.
666     * @return array|WP_Error
667     */
668    public static function get_top_content( $input = null ) {
669        $input = is_array( $input ) ? $input : array();
670
671        if ( ! isset( $input['type'] ) || ! in_array( $input['type'], self::TOP_CONTENT_TYPES, true ) ) {
672            return new WP_Error(
673                self::ERROR_PREFIX . 'missing_type',
674                sprintf(
675                    /* translators: %s: comma-separated list of valid type values. */
676                    __( 'A `type` is required. Valid values: %s.', 'jetpack-stats-pkg' ),
677                    implode( ', ', self::TOP_CONTENT_TYPES )
678                )
679            );
680        }
681
682        $type   = $input['type'];
683        $period = self::pick_period( $input['period'] ?? null );
684        $date   = self::sanitize_date( $input['date'] ?? null );
685        $num    = self::clamp_int( $input['num'] ?? 1, 1, 90, 1 );
686        $max    = self::clamp_int( $input['max'] ?? 20, 1, 100, 20 );
687
688        $args = array(
689            'period' => $period,
690            'date'   => $date,
691            'num'    => $num,
692            'max'    => $max,
693        );
694
695        $stats = self::get_wpcom_stats();
696        $raw   = self::fetch_top_content_raw( $stats, $type, $args );
697        if ( is_wp_error( $raw ) ) {
698            return $raw;
699        }
700
701        $items = self::normalize_top_content_items( $type, $raw, $max );
702
703        return array(
704            'type'   => $type,
705            'period' => $period,
706            'date'   => $date,
707            'num'    => $num,
708            'max'    => $max,
709            'items'  => $items,
710        );
711    }
712
713    /**
714     * Execute: get-post-views.
715     *
716     * @param array|null $input Input matching the ability's input_schema.
717     * @return array|WP_Error
718     */
719    public static function get_post_views( $input = null ) {
720        $input = is_array( $input ) ? $input : array();
721
722        // Use isset()+is_numeric() â€” NOT empty() â€” so the literal "0" is rejected by the `> 0` check, not by a truthiness accident.
723        if ( ! isset( $input['post_id'] ) || ! is_numeric( $input['post_id'] ) || (int) $input['post_id'] <= 0 ) {
724            return new WP_Error(
725                self::ERROR_PREFIX . 'missing_post_id',
726                __( 'A positive post_id is required.', 'jetpack-stats-pkg' )
727            );
728        }
729
730        $post_id = (int) $input['post_id'];
731        $period  = self::pick_period( $input['period'] ?? null );
732        $num     = self::clamp_int( $input['num'] ?? 30, 1, 90, 30 );
733        $date    = self::sanitize_date( $input['date'] ?? null );
734
735        $args = array(
736            'period' => $period,
737            'num'    => $num,
738            'date'   => $date,
739        );
740
741        $stats = self::get_wpcom_stats();
742        $raw   = $stats->get_post_views( $post_id, $args );
743        if ( is_wp_error( $raw ) ) {
744            return $raw;
745        }
746
747        return array(
748            'post_id'     => $post_id,
749            'total_views' => isset( $raw['views'] ) ? (int) $raw['views'] : 0,
750            'period'      => $period,
751            'num'         => $num,
752            'date'        => $date,
753            'series'      => self::extract_post_views_series( $raw ),
754        );
755    }
756
757    /**
758     * Execute: get-visits.
759     *
760     * @param array|null $input Input matching the ability's input_schema.
761     * @return array|WP_Error
762     */
763    public static function get_visits( $input = null ) {
764        $input = is_array( $input ) ? $input : array();
765
766        $unit     = self::pick_period( $input['unit'] ?? null );
767        $quantity = self::clamp_int( $input['quantity'] ?? 30, 1, 90, 30 );
768        $date     = self::sanitize_date( $input['date'] ?? null );
769
770        // Pass user input FIRST to array_intersect so caller-supplied field order is preserved.
771        $fields = isset( $input['fields'] ) && is_array( $input['fields'] )
772            ? array_values( array_intersect( $input['fields'], self::VISIT_FIELDS ) )
773            : array();
774        if ( empty( $fields ) ) {
775            $fields = self::DEFAULT_VISIT_FIELDS;
776        }
777
778        $args = array(
779            'unit'        => $unit,
780            'quantity'    => $quantity,
781            'date'        => $date,
782            'stat_fields' => implode( ',', $fields ),
783        );
784
785        $stats = self::get_wpcom_stats();
786        $raw   = $stats->get_visits( $args );
787        if ( is_wp_error( $raw ) ) {
788            return $raw;
789        }
790
791        return array(
792            'unit'     => $unit,
793            'quantity' => $quantity,
794            'date'     => $date,
795            'fields'   => $fields,
796            'series'   => self::normalize_visits_series( $raw, $fields ),
797        );
798    }
799
800    /**
801     * Execute: get-followers.
802     *
803     * @param array|null $input Ignored â€” zero-arg ability.
804     * @return array|WP_Error
805     */
806    public static function get_followers( $input = null ) {
807        unset( $input );
808        $stats = self::get_wpcom_stats();
809
810        $composed = self::compose_subcalls(
811            array(
812                'followers'           => $stats->get_followers(),
813                'comment_followers'   => $stats->get_comment_followers(),
814                'publicize_followers' => $stats->get_publicize_followers(),
815            ),
816            __( 'Follower data could not be fetched from WordPress.com. Confirm the site is connected and try again.', 'jetpack-stats-pkg' )
817        );
818        if ( is_wp_error( $composed ) ) {
819            return $composed;
820        }
821        $followers         = $composed['values']['followers'];
822        $comment_followers = $composed['values']['comment_followers'];
823        $publicize         = $composed['values']['publicize_followers'];
824        $errors            = $composed['errors'];
825
826        $email = 0;
827        $wpcom = 0;
828        if ( isset( $followers['subscribers'] ) && is_array( $followers['subscribers'] ) ) {
829            foreach ( $followers['subscribers'] as $sub ) {
830                if ( isset( $sub['type'] ) && 'email' === $sub['type'] ) {
831                    $email += isset( $sub['value'] ) ? (int) $sub['value'] : 0;
832                } elseif ( isset( $sub['type'] ) && 'wpcom' === $sub['type'] ) {
833                    $wpcom += isset( $sub['value'] ) ? (int) $sub['value'] : 0;
834                }
835            }
836        } else {
837            $email = isset( $followers['email'] ) ? (int) $followers['email'] : 0;
838            $wpcom = isset( $followers['wpcom'] ) ? (int) $followers['wpcom'] : 0;
839        }
840
841        $comment = isset( $comment_followers['total'] ) ? (int) $comment_followers['total'] : 0;
842
843        $publicize_by_service = array();
844        if ( isset( $publicize['services'] ) && is_array( $publicize['services'] ) ) {
845            foreach ( $publicize['services'] as $row ) {
846                if ( isset( $row['service'] ) && isset( $row['followers'] ) ) {
847                    $publicize_by_service[ (string) $row['service'] ] = (int) $row['followers'];
848                }
849            }
850        }
851
852        $total = $email + $wpcom + $comment + array_sum( $publicize_by_service );
853
854        $out = array(
855            'total'     => $total,
856            'email'     => $email,
857            'wpcom'     => $wpcom,
858            'comment'   => $comment,
859            'publicize' => $publicize_by_service,
860            'partial'   => ! empty( $errors ),
861        );
862
863        if ( ! empty( $errors ) ) {
864            $out['errors'] = $errors;
865        }
866
867        return $out;
868    }
869
870    /**
871     * Execute: get-settings.
872     *
873     * @param array|null $input Ignored â€” zero-arg ability.
874     * @return array
875     */
876    public static function get_settings( $input = null ) {
877        unset( $input );
878        return Settings::get( Settings::KEYS );
879    }
880
881    /**
882     * Execute: update-settings.
883     *
884     * @param array|null $input Input matching the ability's input_schema.
885     * @return array|WP_Error
886     */
887    public static function update_settings( $input = null ) {
888        return Settings::update( is_array( $input ) ? $input : array(), Settings::KEYS );
889    }
890
891    /*
892    ---------------------------------------------------------------------
893     * Helpers
894     * ---------------------------------------------------------------------
895     */
896
897    /**
898     * Compose multiple WPCOM sub-call results into a partial-tolerant envelope.
899     *
900     * Each named result is either an array (kept as-is) or a WP_Error
901     * (replaced with `[]` and its key recorded under `errors`). Returns
902     * `jetpack_stats_data_unavailable` if every sub-call failed.
903     *
904     * @param array  $named_results       Map of error-tag => array|WP_Error.
905     * @param string $all_failed_message  Message for the all-failed WP_Error.
906     * @return array{values: array, errors: array}|WP_Error
907     */
908    private static function compose_subcalls( array $named_results, string $all_failed_message ) {
909        $values = array();
910        $errors = array();
911        foreach ( $named_results as $tag => $result ) {
912            if ( is_wp_error( $result ) ) {
913                $errors[]       = (string) $tag;
914                $values[ $tag ] = array();
915            } else {
916                $values[ $tag ] = $result;
917            }
918        }
919
920        if ( count( $errors ) === count( $named_results ) ) {
921            return new WP_Error( self::ERROR_PREFIX . 'data_unavailable', $all_failed_message );
922        }
923
924        return array(
925            'values' => $values,
926            'errors' => $errors,
927        );
928    }
929
930    /**
931     * Return the first non-empty string at `$key` across the given source arrays.
932     *
933     * @param array[] $sources Ordered list of arrays to probe.
934     * @param string  $key     Key to read from each array.
935     * @return string
936     */
937    private static function first_string( array $sources, string $key ): string {
938        foreach ( $sources as $source ) {
939            if ( isset( $source[ $key ] ) && '' !== $source[ $key ] ) {
940                return (string) $source[ $key ];
941            }
942        }
943        return '';
944    }
945
946    /**
947     * Resolve an aggregation period from raw input, defaulting to `day`.
948     *
949     * @param mixed $raw Raw input value.
950     * @return string One of self::PERIODS.
951     */
952    private static function pick_period( $raw ): string {
953        return is_string( $raw ) && in_array( $raw, self::PERIODS, true ) ? $raw : 'day';
954    }
955
956    /**
957     * Return a WPCOM_Stats instance. Filterable for tests.
958     *
959     * @return WPCOM_Stats
960     */
961    protected static function get_wpcom_stats(): WPCOM_Stats {
962        /**
963         * Filters the WPCOM_Stats instance used by the Stats abilities.
964         *
965         * @since 0.19.0
966         *
967         * @param WPCOM_Stats $wpcom_stats The default instance.
968         */
969        $instance = apply_filters( 'jetpack_stats_abilities_wpcom_stats', new WPCOM_Stats() );
970        return $instance instanceof WPCOM_Stats ? $instance : new WPCOM_Stats();
971    }
972
973    /**
974     * Dispatch top-content raw fetch to the right WPCOM_Stats method.
975     *
976     * @param WPCOM_Stats $stats Client.
977     * @param string      $type  Content type enum.
978     * @param array       $args  Pre-built `{ period, date, num, max }` args â€” ignored for `tags` which takes only `max`.
979     * @return array|WP_Error
980     */
981    private static function fetch_top_content_raw( WPCOM_Stats $stats, string $type, array $args ) {
982        switch ( $type ) {
983            case 'posts':
984                return $stats->get_top_posts( $args );
985            case 'referrers':
986                return $stats->get_referrers( $args );
987            case 'search-terms':
988                return $stats->get_search_terms( $args );
989            case 'clicks':
990                return $stats->get_clicks( $args );
991            case 'tags':
992                // get_tags has a narrower arg surface â€” pass only `max`.
993                return $stats->get_tags( array( 'max' => $args['max'] ) );
994            case 'authors':
995                return $stats->get_top_authors( $args );
996            case 'countries':
997                return $stats->get_views_by_country( $args );
998            case 'downloads':
999                return $stats->get_file_downloads( $args );
1000            case 'video-plays':
1001                return $stats->get_video_plays( $args );
1002        }
1003
1004        return new WP_Error( self::ERROR_PREFIX . 'invalid_type', __( 'Unknown top-content type.', 'jetpack-stats-pkg' ) );
1005    }
1006
1007    /**
1008     * Normalize a WPCOM top-content response into the uniform item shape.
1009     *
1010     * Most `type` values follow the `days -> <first-day> -> <list-key>` shape
1011     * and project through `TOP_CONTENT_MAP`. `tags` (flat `tags` array, no
1012     * `days` envelope) and `countries` (needs `country-info` code-to-name
1013     * join) are special-cased.
1014     *
1015     * @param string $type Content type enum.
1016     * @param array  $raw  Raw WPCOM response.
1017     * @param int    $max  Result cap.
1018     * @return array List of { rank, label, value, href? } items.
1019     */
1020    private static function normalize_top_content_items( string $type, array $raw, int $max ): array {
1021        if ( 'tags' === $type ) {
1022            $rows = array();
1023            $tags = isset( $raw['tags'] ) && is_array( $raw['tags'] ) ? $raw['tags'] : array();
1024            foreach ( $tags as $tag ) {
1025                $rows[] = array(
1026                    'label' => isset( $tag['tag'] ) ? (string) $tag['tag'] : '',
1027                    'value' => isset( $tag['views'] ) ? (int) $tag['views'] : 0,
1028                );
1029            }
1030            return self::rank_and_cap( $rows, $max );
1031        }
1032
1033        $day_data = self::first_day( $raw );
1034
1035        if ( 'countries' === $type ) {
1036            $rows         = array();
1037            $list         = isset( $day_data['views'] ) && is_array( $day_data['views'] ) ? $day_data['views'] : array();
1038            $country_info = isset( $raw['country-info'] ) && is_array( $raw['country-info'] ) ? $raw['country-info'] : array();
1039            foreach ( $list as $v ) {
1040                $code   = isset( $v['country_code'] ) ? (string) $v['country_code'] : '';
1041                $rows[] = array(
1042                    'label' => (string) ( $country_info[ $code ]['country_full'] ?? $code ),
1043                    'value' => isset( $v['views'] ) ? (int) $v['views'] : 0,
1044                );
1045            }
1046            return self::rank_and_cap( $rows, $max );
1047        }
1048
1049        $map = self::TOP_CONTENT_MAP[ $type ] ?? null;
1050        if ( null === $map ) {
1051            return array();
1052        }
1053
1054        $rows = array();
1055        $list = isset( $day_data[ $map['list'] ] ) && is_array( $day_data[ $map['list'] ] ) ? $day_data[ $map['list'] ] : array();
1056        foreach ( $list as $row ) {
1057            if ( ! is_array( $row ) ) {
1058                continue;
1059            }
1060            $label = isset( $row[ $map['label'] ] ) && '' !== $row[ $map['label'] ] ? (string) $row[ $map['label'] ] : '';
1061            if ( '' === $label && isset( $map['label_fallback'] ) && isset( $row[ $map['label_fallback'] ] ) ) {
1062                $label = (string) $row[ $map['label_fallback'] ];
1063            }
1064            $entry = array(
1065                'label' => $label,
1066                'value' => isset( $row[ $map['value'] ] ) ? (int) $row[ $map['value'] ] : 0,
1067            );
1068            if ( isset( $map['href'] ) && isset( $row[ $map['href'] ] ) ) {
1069                $entry['href'] = (string) $row[ $map['href'] ];
1070            }
1071            $rows[] = $entry;
1072        }
1073        return self::rank_and_cap( $rows, $max );
1074    }
1075
1076    /**
1077     * Pick the first `days` entry from a WPCOM days-keyed response.
1078     *
1079     * Different top-content endpoints key their per-day data under `days`
1080     * (posts, referrers, authors, countries, ...) or `days -> <date>`; a few
1081     * flatten it entirely (tags). This helper handles the common case.
1082     *
1083     * @param array $raw Raw WPCOM response.
1084     * @return array The first day's sub-array, or [].
1085     */
1086    private static function first_day( array $raw ): array {
1087        if ( ! isset( $raw['days'] ) || ! is_array( $raw['days'] ) || empty( $raw['days'] ) ) {
1088            return array();
1089        }
1090        $first = reset( $raw['days'] );
1091        return is_array( $first ) ? $first : array();
1092    }
1093
1094    /**
1095     * Rank, cap, and strip null href fields.
1096     *
1097     * @param array $rows Unranked rows.
1098     * @param int   $max  Result cap.
1099     * @return array Ranked + capped rows with `rank` injected.
1100     */
1101    private static function rank_and_cap( array $rows, int $max ): array {
1102        $rows = array_slice( $rows, 0, $max );
1103        $out  = array();
1104        foreach ( $rows as $i => $row ) {
1105            $entry = array(
1106                'rank'  => $i + 1,
1107                'label' => isset( $row['label'] ) ? (string) $row['label'] : '',
1108                'value' => isset( $row['value'] ) ? (int) $row['value'] : 0,
1109            );
1110            if ( isset( $row['href'] ) && '' !== $row['href'] ) {
1111                $entry['href'] = $row['href'];
1112            }
1113            $out[] = $entry;
1114        }
1115        return $out;
1116    }
1117
1118    /**
1119     * Extract a compact streak summary from the WPCOM streak response.
1120     *
1121     * @param array $streak Raw WPCOM streak response.
1122     * @return array Compact `{ current_length, longest_length, longest_start, longest_end }`.
1123     */
1124    private static function extract_streak_summary( array $streak ): array {
1125        $data = isset( $streak['streak'] ) && is_array( $streak['streak'] ) ? $streak['streak'] : array();
1126        return array(
1127            'current_length' => isset( $data['currentStreakLength'] ) ? (int) $data['currentStreakLength'] : 0,
1128            'longest_length' => isset( $data['longestStreakLength'] ) ? (int) $data['longestStreakLength'] : 0,
1129            'longest_start'  => isset( $data['longestStreakStart'] ) ? (string) $data['longestStreakStart'] : '',
1130            'longest_end'    => isset( $data['longestStreakEnd'] ) ? (string) $data['longestStreakEnd'] : '',
1131        );
1132    }
1133
1134    /**
1135     * Extract the top referrer from a highlights `today` block.
1136     *
1137     * @param array $today Highlights today block.
1138     * @return array|null { name, views } or null.
1139     */
1140    private static function extract_top_referrer( array $today ): ?array {
1141        $list = isset( $today['top_referrers'] ) && is_array( $today['top_referrers'] ) ? $today['top_referrers'] : array();
1142        if ( empty( $list ) ) {
1143            return null;
1144        }
1145        $first = $list[0];
1146        if ( ! is_array( $first ) ) {
1147            return null;
1148        }
1149        return array(
1150            'name'  => isset( $first['name'] ) ? (string) $first['name'] : '',
1151            'views' => isset( $first['views'] ) ? (int) $first['views'] : 0,
1152        );
1153    }
1154
1155    /**
1156     * Extract a post-views series from the WPCOM get_post_views response.
1157     *
1158     * WPCOM returns `data` as a list of `[date, views]` tuples under the
1159     * `fields` header. We normalize to `[{ date, views }]`.
1160     *
1161     * @param array $raw Raw WPCOM response.
1162     * @return array
1163     */
1164    private static function extract_post_views_series( array $raw ): array {
1165        if ( ! isset( $raw['data'] ) || ! is_array( $raw['data'] ) ) {
1166            return array();
1167        }
1168
1169        $fields    = isset( $raw['fields'] ) && is_array( $raw['fields'] ) ? $raw['fields'] : array( 'period', 'views' );
1170        $date_idx  = array_search( 'period', $fields, true );
1171        $views_idx = array_search( 'views', $fields, true );
1172        if ( false === $date_idx || false === $views_idx ) {
1173            // If either column is missing from the WPCOM response, the positional
1174            // fallback is unsafe (we might collide date/views on column 0). Drop
1175            // to empty rather than emit lies.
1176            return array();
1177        }
1178
1179        $series = array();
1180        foreach ( $raw['data'] as $row ) {
1181            if ( ! is_array( $row ) ) {
1182                continue;
1183            }
1184            $series[] = array(
1185                'date'  => isset( $row[ $date_idx ] ) ? (string) $row[ $date_idx ] : '',
1186                'views' => isset( $row[ $views_idx ] ) ? (int) $row[ $views_idx ] : 0,
1187            );
1188        }
1189        return $series;
1190    }
1191
1192    /**
1193     * Normalize the WPCOM get_visits response into `[{ date, <field>: int, ... }]`.
1194     *
1195     * @param array $raw    Raw WPCOM response.
1196     * @param array $fields Requested metric fields.
1197     * @return array
1198     */
1199    private static function normalize_visits_series( array $raw, array $fields ): array {
1200        if ( ! isset( $raw['data'] ) || ! is_array( $raw['data'] ) ) {
1201            return array();
1202        }
1203
1204        $raw_fields = isset( $raw['fields'] ) && is_array( $raw['fields'] ) ? $raw['fields'] : array();
1205        $field_idx  = array();
1206        foreach ( $raw_fields as $idx => $name ) {
1207            $field_idx[ (string) $name ] = $idx;
1208        }
1209        $date_idx = $field_idx['period'] ?? 0;
1210
1211        $series = array();
1212        foreach ( $raw['data'] as $row ) {
1213            if ( ! is_array( $row ) ) {
1214                continue;
1215            }
1216            $entry = array(
1217                'date' => isset( $row[ $date_idx ] ) ? (string) $row[ $date_idx ] : '',
1218            );
1219            foreach ( $fields as $field ) {
1220                $idx             = $field_idx[ $field ] ?? null;
1221                $entry[ $field ] = ( null !== $idx && isset( $row[ $idx ] ) ) ? (int) $row[ $idx ] : 0;
1222            }
1223            $series[] = $entry;
1224        }
1225        return $series;
1226    }
1227
1228    /**
1229     * Normalize a candidate date string. Returns today's date (UTC) on bad input.
1230     *
1231     * @param mixed $raw Raw input value.
1232     * @return string YYYY-MM-DD.
1233     */
1234    private static function sanitize_date( $raw ): string {
1235        if ( is_string( $raw ) && 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw ) ) {
1236            return $raw;
1237        }
1238        return gmdate( 'Y-m-d' );
1239    }
1240
1241    /**
1242     * Clamp an integer into [$min, $max] with a default on bad input.
1243     *
1244     * @param mixed $raw     Raw input.
1245     * @param int   $min     Minimum.
1246     * @param int   $max     Maximum.
1247     * @param int   $default Default on bad input.
1248     * @return int
1249     */
1250    private static function clamp_int( $raw, int $min, int $max, int $default ): int {
1251        if ( ! is_numeric( $raw ) ) {
1252            return $default;
1253        }
1254        $v = (int) $raw;
1255        if ( $v < $min ) {
1256            return $min;
1257        }
1258        if ( $v > $max ) {
1259            return $max;
1260        }
1261        return $v;
1262    }
1263
1264    /**
1265     * Safely read an int field from an array.
1266     *
1267     * @param array  $arr Array.
1268     * @param string $key Key.
1269     * @return int
1270     */
1271    private static function as_int( array $arr, string $key ): int {
1272        return isset( $arr[ $key ] ) ? (int) $arr[ $key ] : 0;
1273    }
1274}