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