Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.78% covered (success)
97.78%
792 / 810
76.74% covered (warning)
76.74%
33 / 43
CRAP
0.00% covered (danger)
0.00%
0 / 1
Stats_Abilities
98.02% covered (success)
98.02%
792 / 808
76.74% covered (warning)
76.74%
33 / 43
169
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%
53 / 53
100.00% covered (success)
100.00%
1 / 1
7
 get_top_content
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
7
 get_post_views
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
9.02
 get_visits
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
7.02
 get_followers
97.73% covered (success)
97.73%
43 / 44
0.00% covered (danger)
0.00%
0 / 1
9
 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
 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
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
7.01
 tag_rows
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 country_rows
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 first_period_block
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
8
 first_post
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 rows_at
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 pick_field
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 rank_and_cap
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 extract_streak_summary
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 all_posts_comment_followers
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 extract_post_views_series
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
11.80
 bucket_daily_views
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
8
 summary_window
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 period_start
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 normalize_visits_series
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
12.02
 resolve_date
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 today
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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     * (or `summary -> <list>`) array of rows into the uniform
60     * `{ rank, label, value, href?, id? }` shape. A field given as a list of keys
61     * reads the first of them that is set and not ''.
62     * `countries` (needs `country-info` join) and `tags` (rows that group
63     * several terms, no `days` envelope) are special-cased in the callback.
64     */
65    const TOP_CONTENT_MAP = array(
66        'posts'        => array(
67            'list'  => 'postviews',
68            'label' => 'title',
69            'value' => 'views',
70            'href'  => 'href',
71            'id'    => 'id',
72        ),
73        'referrers'    => array(
74            // WPCOM `stats/referrers` keys per-day data under `groups`, not `referrers` â€”
75            // each group exposes `name`, `total`, and (sometimes) `url`.
76            'list'  => 'groups',
77            'label' => 'name',
78            'value' => 'total',
79            'href'  => 'url',
80        ),
81        'search-terms' => array(
82            'list'  => 'search_terms',
83            'label' => 'term',
84            'value' => 'views',
85        ),
86        'clicks'       => array(
87            'list'  => 'clicks',
88            'label' => array( 'name', 'url' ),
89            'value' => 'views',
90            'href'  => 'url',
91        ),
92        'authors'      => array(
93            'list'  => 'authors',
94            'label' => 'name',
95            'value' => 'views',
96        ),
97        'downloads'    => array(
98            'list'  => 'files',
99            'label' => array( 'filename', 'relative_url' ),
100            'value' => array( 'downloads', 'download_count' ),
101            'href'  => array( 'download_url', 'relative_url' ),
102        ),
103        'video-plays'  => array(
104            'list'  => 'plays',
105            'label' => 'title',
106            'value' => 'plays',
107        ),
108    );
109
110    /**
111     * {@inheritDoc}
112     */
113    public static function get_category_slug(): string {
114        return self::CATEGORY_SLUG;
115    }
116
117    /**
118     * {@inheritDoc}
119     */
120    public static function get_category_definition(): array {
121        return array(
122            // "Jetpack" is a product name and should not be translated.
123            'label'       => 'Jetpack Stats',
124            'description' => __( 'Abilities for reading Jetpack Stats traffic insights and managing site-level Stats settings.', 'jetpack-stats-pkg' ),
125        );
126    }
127
128    /**
129     * {@inheritDoc}
130     */
131    public static function get_abilities(): array {
132        return array(
133            'jetpack-stats/get-site-overview' => self::spec_get_site_overview(),
134            'jetpack-stats/get-top-content'   => self::spec_get_top_content(),
135            'jetpack-stats/get-post-views'    => self::spec_get_post_views(),
136            'jetpack-stats/get-visits'        => self::spec_get_visits(),
137            'jetpack-stats/get-followers'     => self::spec_get_followers(),
138            'jetpack-stats/get-settings'      => self::spec_get_settings(),
139            'jetpack-stats/update-settings'   => self::spec_update_settings(),
140        );
141    }
142
143    /*
144    ---------------------------------------------------------------------
145     * Ability specs
146     * ---------------------------------------------------------------------
147     */
148
149    /**
150     * Spec: jetpack-stats/get-site-overview.
151     */
152    private static function spec_get_site_overview(): array {
153        return array(
154            'label'               => __( 'Get site stats overview', 'jetpack-stats-pkg' ),
155            'description'         => __(
156                'Return a single zero-argument snapshot answering "how is my site doing right now?" â€” today\'s views/visitors, views over the last 7 and 30 days, the current posting streak, today\'s top post, and today\'s 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] }. `views_week` and `views_month` are rolling totals for the last 7 and 30 days, today included (not calendar weeks or months). `streak.current_length` is 0 unless the latest posting streak reaches today or yesterday. `top_post` is today\'s most-viewed post or page (the home page / archives row is skipped, so `id` is always a real post ID); `top_post` and `top_referrer` are null when there is none today. Composes the WPCOM stats/visits, stats/streak, stats/top-posts, and stats/referrers endpoints â€” if any sub-call fails, `partial` is true and `errors` lists the failed sub-calls: `visits` (every views and visitors field), `streak`, `top_posts` (`top_post`), and `referrers` (`top_referrer`). Fields fed by a failed sub-call are placeholders (0, empty, or null) rather than confirmed values, so cross-reference `errors` before treating a 0 or null 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.',
157                'jetpack-stats-pkg'
158            ),
159            'input_schema'        => array(
160                'type'                 => 'object',
161                'default'              => array(),
162                'properties'           => new \stdClass(),
163                'additionalProperties' => false,
164            ),
165            'output_schema'       => array(
166                'type'       => 'object',
167                'properties' => array(
168                    'date'           => array( 'type' => 'string' ),
169                    'views_today'    => array( 'type' => 'integer' ),
170                    'visitors_today' => array( 'type' => 'integer' ),
171                    'views_week'     => array( 'type' => 'integer' ),
172                    'views_month'    => array( 'type' => 'integer' ),
173                    'streak'         => array( 'type' => 'object' ),
174                    'top_post'       => array( 'type' => array( 'object', 'null' ) ),
175                    'top_referrer'   => array( 'type' => array( 'object', 'null' ) ),
176                    'partial'        => array( 'type' => 'boolean' ),
177                    'errors'         => array( 'type' => 'array' ),
178                ),
179            ),
180            'execute_callback'    => array( __CLASS__, 'get_site_overview' ),
181            'permission_callback' => array( __CLASS__, 'can_view_stats' ),
182            'meta'                => array(
183                'annotations'  => array(
184                    'readonly'    => true,
185                    'destructive' => false,
186                    'idempotent'  => true,
187                ),
188                'show_in_rest' => true,
189                'mcp'          => array(
190                    'public' => true,
191                    'type'   => 'tool', // default is already "tool", but can be explicit.
192                ),
193            ),
194        );
195    }
196
197    /**
198     * Spec: jetpack-stats/get-top-content.
199     */
200    private static function spec_get_top_content(): array {
201        return array(
202            'label'               => __( 'Get top stats content', 'jetpack-stats-pkg' ),
203            'description'         => __(
204                '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?, id? } ] } â€” agents MUST NOT see a different shape per type. `label` is human-readable (post title, referrer host, search term, country name, etc.); a tags item names every tag and category it groups, and countries WordPress.com cannot place share one item. `value` is the view/hit count for that item. `href` is present only when the item has a canonical URL. `id` is the post ID on posts items other than the home page / archives row â€” pass it to jetpack-stats/get-post-views. With `num` above 1, items are totals across the `num` periods ending with the one containing `date` (e.g. period=day, num=7 is the last seven days). `tags` ignores `period`, `date` and `num` and covers the seven days ending yesterday. Precondition: site must be connected to WordPress.com.',
205                'jetpack-stats-pkg'
206            ),
207            'input_schema'        => array(
208                'type'                 => 'object',
209                'required'             => array( 'type' ),
210                'properties'           => array(
211                    'type'   => array(
212                        'type'        => 'string',
213                        'description' => __( 'Which top-N surface to fetch.', 'jetpack-stats-pkg' ),
214                        'enum'        => self::TOP_CONTENT_TYPES,
215                    ),
216                    'period' => array(
217                        'type'        => 'string',
218                        'description' => __( 'Aggregation period.', 'jetpack-stats-pkg' ),
219                        'enum'        => self::PERIODS,
220                        'default'     => 'day',
221                    ),
222                    'date'   => array(
223                        'type'        => 'string',
224                        'description' => __( 'End date (YYYY-MM-DD). Defaults to today.', 'jetpack-stats-pkg' ),
225                        'pattern'     => '^[0-9]{4}-[0-9]{2}-[0-9]{2}$',
226                    ),
227                    'num'    => array(
228                        'type'        => 'integer',
229                        'description' => __( 'How many prior periods to roll up (1-90).', 'jetpack-stats-pkg' ),
230                        'minimum'     => 1,
231                        'maximum'     => 90,
232                        'default'     => 1,
233                    ),
234                    'max'    => array(
235                        'type'        => 'integer',
236                        'description' => __( 'Results cap (1-100).', 'jetpack-stats-pkg' ),
237                        'minimum'     => 1,
238                        'maximum'     => 100,
239                        'default'     => 20,
240                    ),
241                ),
242                'additionalProperties' => false,
243            ),
244            'output_schema'       => array(
245                'type'       => 'object',
246                'properties' => array(
247                    'type'   => array( 'type' => 'string' ),
248                    'period' => array( 'type' => 'string' ),
249                    'date'   => array( 'type' => 'string' ),
250                    'num'    => array( 'type' => 'integer' ),
251                    'max'    => array( 'type' => 'integer' ),
252                    'items'  => array( 'type' => 'array' ),
253                ),
254            ),
255            'execute_callback'    => array( __CLASS__, 'get_top_content' ),
256            'permission_callback' => array( __CLASS__, 'can_view_stats' ),
257            'meta'                => array(
258                'annotations'  => array(
259                    'readonly'    => true,
260                    'destructive' => false,
261                    'idempotent'  => true,
262                ),
263                'show_in_rest' => true,
264                'mcp'          => array(
265                    'public' => true,
266                    'type'   => 'tool', // default is already "tool", but can be explicit.
267                ),
268            ),
269        );
270    }
271
272    /**
273     * Spec: jetpack-stats/get-post-views.
274     */
275    private static function spec_get_post_views(): array {
276        return array(
277            'label'               => __( 'Get views for a post', 'jetpack-stats-pkg' ),
278            'description'         => __(
279                '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 } ] }. `total_views` is the post\'s all-time view count. `series` always has `num` rows, oldest first, ending with the period that contains `date`; each row\'s `date` is the first day of its period (weeks start on Monday) and periods with no views are 0. Returns `jetpack_stats_post_not_found` when no post has that ID. 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 and pass an item\'s `id` as `post_id`.',
280                'jetpack-stats-pkg'
281            ),
282            'input_schema'        => array(
283                'type'                 => 'object',
284                'required'             => array( 'post_id' ),
285                'properties'           => array(
286                    'post_id' => array(
287                        'type'        => array( 'integer', 'string' ),
288                        'description' => __( 'The post ID to fetch views for. Must be positive.', 'jetpack-stats-pkg' ),
289                    ),
290                    'period'  => array(
291                        'type'        => 'string',
292                        'description' => __( 'Aggregation period.', 'jetpack-stats-pkg' ),
293                        'enum'        => self::PERIODS,
294                        'default'     => 'day',
295                    ),
296                    'num'     => array(
297                        'type'        => 'integer',
298                        'description' => __( 'How many prior periods to include (1-90).', 'jetpack-stats-pkg' ),
299                        'minimum'     => 1,
300                        'maximum'     => 90,
301                        'default'     => 30,
302                    ),
303                    'date'    => array(
304                        'type'        => 'string',
305                        'description' => __( 'End date (YYYY-MM-DD). Defaults to today.', 'jetpack-stats-pkg' ),
306                        'pattern'     => '^[0-9]{4}-[0-9]{2}-[0-9]{2}$',
307                    ),
308                ),
309                'additionalProperties' => false,
310            ),
311            'output_schema'       => array(
312                'type'       => 'object',
313                'properties' => array(
314                    'post_id'     => array( 'type' => 'integer' ),
315                    'total_views' => array( 'type' => 'integer' ),
316                    'period'      => array( 'type' => 'string' ),
317                    'num'         => array( 'type' => 'integer' ),
318                    'date'        => array( 'type' => 'string' ),
319                    'series'      => array( 'type' => 'array' ),
320                ),
321            ),
322            'execute_callback'    => array( __CLASS__, 'get_post_views' ),
323            'permission_callback' => array( __CLASS__, 'can_view_stats' ),
324            'meta'                => array(
325                'annotations'  => array(
326                    'readonly'    => true,
327                    'destructive' => false,
328                    'idempotent'  => true,
329                ),
330                'show_in_rest' => true,
331                'mcp'          => array(
332                    'public' => true,
333                    'type'   => 'tool', // default is already "tool", but can be explicit.
334                ),
335            ),
336        );
337    }
338
339    /**
340     * Spec: jetpack-stats/get-visits.
341     */
342    private static function spec_get_visits(): array {
343        return array(
344            'label'               => __( 'Get site visits timeseries', 'jetpack-stats-pkg' ),
345            'description'         => __(
346                '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.',
347                'jetpack-stats-pkg'
348            ),
349            'input_schema'        => array(
350                'type'                 => 'object',
351                'default'              => array(),
352                'properties'           => array(
353                    'unit'     => array(
354                        'type'        => 'string',
355                        'description' => __( 'Granularity of each data point.', 'jetpack-stats-pkg' ),
356                        'enum'        => self::PERIODS,
357                        'default'     => 'day',
358                    ),
359                    'quantity' => array(
360                        'type'        => 'integer',
361                        'description' => __( 'How many data points to return (1-90).', 'jetpack-stats-pkg' ),
362                        'minimum'     => 1,
363                        'maximum'     => 90,
364                        'default'     => 30,
365                    ),
366                    'date'     => array(
367                        'type'        => 'string',
368                        'description' => __( 'End date (YYYY-MM-DD). Defaults to today.', 'jetpack-stats-pkg' ),
369                        'pattern'     => '^[0-9]{4}-[0-9]{2}-[0-9]{2}$',
370                    ),
371                    'fields'   => array(
372                        'type'        => 'array',
373                        'description' => __( 'Which metrics to include in each row. Defaults to views+visitors.', 'jetpack-stats-pkg' ),
374                        'items'       => array(
375                            'type' => 'string',
376                            'enum' => self::VISIT_FIELDS,
377                        ),
378                        'default'     => self::DEFAULT_VISIT_FIELDS,
379                    ),
380                ),
381                'additionalProperties' => false,
382            ),
383            'output_schema'       => array(
384                'type'       => 'object',
385                'properties' => array(
386                    'unit'     => array( 'type' => 'string' ),
387                    'quantity' => array( 'type' => 'integer' ),
388                    'date'     => array( 'type' => 'string' ),
389                    'fields'   => array( 'type' => 'array' ),
390                    'series'   => array( 'type' => 'array' ),
391                ),
392            ),
393            'execute_callback'    => array( __CLASS__, 'get_visits' ),
394            'permission_callback' => array( __CLASS__, 'can_view_stats' ),
395            'meta'                => array(
396                'annotations'  => array(
397                    'readonly'    => true,
398                    'destructive' => false,
399                    'idempotent'  => true,
400                ),
401                'show_in_rest' => true,
402                'mcp'          => array(
403                    'public' => true,
404                    'type'   => 'tool', // default is already "tool", but can be explicit.
405                ),
406            ),
407        );
408    }
409
410    /**
411     * Spec: jetpack-stats/get-followers.
412     */
413    private static function spec_get_followers(): array {
414        return array(
415            'label'               => __( 'Get follower counts', 'jetpack-stats-pkg' ),
416            'description'         => __(
417                '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] }. `email` and `wpcom` are the site\'s email subscribers and WordPress.com followers; `comment` counts people following the comments on every post (following one post\'s comments is not counted), and is reported as unknown through `errors` when WordPress.com does not list that count on its first page; `publicize` is an object, empty when no social connection reports followers. Composes three WPCOM endpoints â€” if any sub-call fails, `partial` is true and `errors` lists the failed sub-calls: `followers` (`email` and `wpcom`), `comment_followers`, and `publicize_followers`. Counts fed by a failed sub-call are placeholders (0, or an empty `publicize`) rather than confirmed values, so cross-reference `errors` before treating them as authoritative. Precondition: site must be connected to WordPress.com.',
418                'jetpack-stats-pkg'
419            ),
420            'input_schema'        => array(
421                'type'                 => 'object',
422                'default'              => array(),
423                'properties'           => new \stdClass(),
424                'additionalProperties' => false,
425            ),
426            'output_schema'       => array(
427                'type'       => 'object',
428                'properties' => array(
429                    'total'     => array( 'type' => 'integer' ),
430                    'email'     => array( 'type' => 'integer' ),
431                    'wpcom'     => array( 'type' => 'integer' ),
432                    'comment'   => array( 'type' => 'integer' ),
433                    'publicize' => array( 'type' => 'object' ),
434                    'partial'   => array( 'type' => 'boolean' ),
435                    'errors'    => array( 'type' => 'array' ),
436                ),
437            ),
438            'execute_callback'    => array( __CLASS__, 'get_followers' ),
439            'permission_callback' => array( __CLASS__, 'can_view_stats' ),
440            'meta'                => array(
441                'annotations'  => array(
442                    'readonly'    => true,
443                    'destructive' => false,
444                    'idempotent'  => true,
445                ),
446                'show_in_rest' => true,
447                'mcp'          => array(
448                    'public' => true,
449                    'type'   => 'tool', // default is already "tool", but can be explicit.
450                ),
451            ),
452        );
453    }
454
455    /**
456     * Spec: jetpack-stats/get-settings.
457     */
458    private static function spec_get_settings(): array {
459        return array(
460            'label'               => __( 'Get Stats settings', 'jetpack-stats-pkg' ),
461            'description'         => __(
462                '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.',
463                'jetpack-stats-pkg'
464            ),
465            'input_schema'        => array(
466                'type'                 => 'object',
467                'default'              => array(),
468                'properties'           => new \stdClass(),
469                'additionalProperties' => false,
470            ),
471            'output_schema'       => array(
472                'type'       => 'object',
473                'properties' => self::settings_output_properties(),
474            ),
475            'execute_callback'    => array( __CLASS__, 'get_settings' ),
476            'permission_callback' => array( __CLASS__, 'can_view_stats' ),
477            'meta'                => array(
478                'annotations'  => array(
479                    'readonly'    => true,
480                    'destructive' => false,
481                    'idempotent'  => true,
482                ),
483                'show_in_rest' => true,
484                'mcp'          => array(
485                    'public' => true,
486                    'type'   => 'tool', // default is already "tool", but can be explicit.
487                ),
488            ),
489        );
490    }
491
492    /**
493     * Spec: jetpack-stats/update-settings.
494     */
495    private static function spec_update_settings(): array {
496        return array(
497            'label'               => __( 'Update Stats settings', 'jetpack-stats-pkg' ),
498            'description'         => __(
499                '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.',
500                'jetpack-stats-pkg'
501            ),
502            'input_schema'        => array(
503                'type'                 => 'object',
504                'properties'           => array(
505                    'admin_bar'    => array(
506                        'type'        => 'boolean',
507                        'description' => __( 'Whether to show the Stats item in the admin bar for users who can view Stats.', 'jetpack-stats-pkg' ),
508                    ),
509                    'roles'        => array(
510                        'type'        => 'array',
511                        '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' ),
512                        'items'       => array( 'type' => 'string' ),
513                        'minItems'    => 1,
514                    ),
515                    'count_roles'  => array(
516                        'type'        => 'array',
517                        'description' => __( 'Role slugs whose visits are counted. May be empty (count visits from all users).', 'jetpack-stats-pkg' ),
518                        'items'       => array( 'type' => 'string' ),
519                    ),
520                    'do_not_track' => array(
521                        'type'        => 'boolean',
522                        'description' => __( 'Whether to honor the browser Do Not Track header.', 'jetpack-stats-pkg' ),
523                    ),
524                ),
525                'additionalProperties' => false,
526                'minProperties'        => 1,
527            ),
528            'output_schema'       => array(
529                'type'       => 'object',
530                'properties' => array(
531                    'changed'  => array( 'type' => 'boolean' ),
532                    'settings' => array(
533                        'type'       => 'object',
534                        'properties' => self::settings_output_properties(),
535                    ),
536                ),
537            ),
538            'execute_callback'    => array( __CLASS__, 'update_settings' ),
539            'permission_callback' => array( __CLASS__, 'can_manage_settings' ),
540            'meta'                => array(
541                'annotations'  => array(
542                    'readonly'    => false,
543                    'destructive' => false,
544                    'idempotent'  => true,
545                ),
546                'show_in_rest' => true,
547                'mcp'          => array(
548                    'public' => true,
549                    'type'   => 'tool', // default is already "tool", but can be explicit.
550                ),
551            ),
552        );
553    }
554
555    /**
556     * Output schema properties shared by get-settings and update-settings' `settings` field.
557     */
558    private static function settings_output_properties(): array {
559        return array(
560            'admin_bar'    => array( 'type' => 'boolean' ),
561            'roles'        => array(
562                'type'  => 'array',
563                'items' => array( 'type' => 'string' ),
564            ),
565            'count_roles'  => array(
566                'type'  => 'array',
567                'items' => array( 'type' => 'string' ),
568            ),
569            'do_not_track' => array( 'type' => 'boolean' ),
570        );
571    }
572
573    /*
574    ---------------------------------------------------------------------
575     * Permission callbacks
576     * ---------------------------------------------------------------------
577     */
578
579    /**
580     * Read-side permission: view_stats.
581     *
582     * The Stats package's `Main::map_meta_caps` maps `view_stats` to the
583     * user's `read` capability when their role is listed in
584     * `stats_options['roles']`. Honored on self-hosted sites.
585     *
586     * @return bool
587     */
588    public static function can_view_stats(): bool {
589        return current_user_can( 'view_stats' );
590    }
591
592    /**
593     * Write-side permission: manage_options.
594     *
595     * Stats configuration writes modify the `stats_options` WP option,
596     * which includes the very roles that gate `view_stats`. Guard with the
597     * site-admin capability, not `view_stats`, so readers can't escalate.
598     *
599     * @return bool
600     */
601    public static function can_manage_settings(): bool {
602        return current_user_can( 'manage_options' );
603    }
604
605    /*
606    ---------------------------------------------------------------------
607     * Execute callbacks
608     * ---------------------------------------------------------------------
609     */
610
611    /**
612     * Execute: get-site-overview.
613     *
614     * @param array|null $input Ignored â€” zero-arg ability.
615     * @return array|WP_Error
616     */
617    public static function get_site_overview( $input = null ) {
618        unset( $input );
619        $stats = self::get_wpcom_stats();
620        $today = self::today();
621        $day   = array(
622            'period' => 'day',
623            'date'   => $today,
624        );
625
626        // A few rows, so a post can be found below the home page / archives row.
627        $post_rows = 10;
628
629        $composed = self::compose_subcalls(
630            array(
631                'visits'    => $stats->get_visits(
632                    array(
633                        'unit'        => 'day',
634                        'quantity'    => 30,
635                        'date'        => $today,
636                        'stat_fields' => 'views,visitors',
637                    )
638                ),
639                'streak'    => $stats->get_streak(),
640                'top_posts' => $stats->get_top_posts( $day + array( 'max' => $post_rows ) ),
641                'referrers' => $stats->get_referrers( $day + array( 'max' => 1 ) ),
642            ),
643            __( 'Stats data could not be fetched from WordPress.com. Confirm the site is connected and try again.', 'jetpack-stats-pkg' )
644        );
645        if ( is_wp_error( $composed ) ) {
646            return $composed;
647        }
648        $values = $composed['values'];
649        $errors = $composed['errors'];
650
651        // The series ends today, so its last row is today and its last 7 rows are the last 7 days.
652        $days     = self::normalize_visits_series( $values['visits'], array( 'views', 'visitors' ) );
653        $last_day = end( $days );
654        $top_post = self::first_post( self::normalize_top_content_items( 'posts', $values['top_posts'], $post_rows ) );
655        $top_ref  = self::normalize_top_content_items( 'referrers', $values['referrers'], 1 )[0] ?? null;
656
657        $out = array(
658            'date'           => $today,
659            'views_today'    => $last_day ? $last_day['views'] : 0,
660            'visitors_today' => $last_day ? $last_day['visitors'] : 0,
661            'views_week'     => (int) array_sum( array_column( array_slice( $days, -7 ), 'views' ) ),
662            'views_month'    => (int) array_sum( array_column( $days, 'views' ) ),
663            'streak'         => self::extract_streak_summary( $values['streak'], $today ),
664            'top_post'       => null === $top_post ? null : array(
665                'id'    => $top_post['id'],
666                'title' => $top_post['label'],
667                'views' => $top_post['value'],
668            ),
669            'top_referrer'   => null === $top_ref ? null : array(
670                'name'  => $top_ref['label'],
671                'views' => $top_ref['value'],
672            ),
673            'partial'        => ! empty( $errors ),
674        );
675
676        if ( ! empty( $errors ) ) {
677            $out['errors'] = $errors;
678        }
679
680        return $out;
681    }
682
683    /**
684     * Execute: get-top-content.
685     *
686     * @param array|null $input Input matching the ability's input_schema.
687     * @return array|WP_Error
688     */
689    public static function get_top_content( $input = null ) {
690        $input = is_array( $input ) ? $input : array();
691
692        if ( ! isset( $input['type'] ) || ! in_array( $input['type'], self::TOP_CONTENT_TYPES, true ) ) {
693            return new WP_Error(
694                self::ERROR_PREFIX . 'missing_type',
695                sprintf(
696                    /* translators: %s: comma-separated list of valid type values. */
697                    __( 'A `type` is required. Valid values: %s.', 'jetpack-stats-pkg' ),
698                    implode( ', ', self::TOP_CONTENT_TYPES )
699                )
700            );
701        }
702
703        $type   = $input['type'];
704        $period = self::pick_period( $input['period'] ?? null );
705        $date   = self::resolve_date( $input['date'] ?? null );
706        $num    = self::clamp_int( $input['num'] ?? 1, 1, 90, 1 );
707        $max    = self::clamp_int( $input['max'] ?? 20, 1, 100, 20 );
708        if ( is_wp_error( $date ) ) {
709            return $date;
710        }
711
712        $args = array(
713            'period' => $period,
714            'date'   => $date,
715            'max'    => $max,
716        );
717        // Without `summarize`, WPCOM returns each period separately and only the first would be read.
718        // Several summarized endpoints ignore `period` and read `num` as days, so ask for the days the periods cover.
719        if ( $num > 1 ) {
720            $args = array_merge( $args, self::summary_window( $period, $num, $date ) );
721        }
722
723        $stats = self::get_wpcom_stats();
724        $raw   = self::fetch_top_content_raw( $stats, $type, $args );
725        if ( is_wp_error( $raw ) ) {
726            return $raw;
727        }
728
729        $items = self::normalize_top_content_items( $type, $raw, $max, isset( $args['summarize'] ) );
730
731        return array(
732            'type'   => $type,
733            'period' => $period,
734            'date'   => $date,
735            'num'    => $num,
736            'max'    => $max,
737            'items'  => $items,
738        );
739    }
740
741    /**
742     * Execute: get-post-views.
743     *
744     * @param array|null $input Input matching the ability's input_schema.
745     * @return array|WP_Error
746     */
747    public static function get_post_views( $input = null ) {
748        $input = is_array( $input ) ? $input : array();
749
750        // Use isset()+is_numeric() â€” NOT empty() â€” so the literal "0" is rejected by the `> 0` check, not by a truthiness accident.
751        if ( ! isset( $input['post_id'] ) || ! is_numeric( $input['post_id'] ) || (int) $input['post_id'] <= 0 ) {
752            return new WP_Error(
753                self::ERROR_PREFIX . 'missing_post_id',
754                __( 'A positive post_id is required.', 'jetpack-stats-pkg' )
755            );
756        }
757
758        $post_id = (int) $input['post_id'];
759        $period  = self::pick_period( $input['period'] ?? null );
760        $num     = self::clamp_int( $input['num'] ?? 30, 1, 90, 30 );
761        $date    = self::resolve_date( $input['date'] ?? null );
762        if ( is_wp_error( $date ) ) {
763            return $date;
764        }
765
766        // WPCOM answers an unknown post with the same generic failure as an outage.
767        if ( ! get_post( $post_id ) ) {
768            return new WP_Error(
769                self::ERROR_PREFIX . 'post_not_found',
770                __( 'No post exists with that post_id.', 'jetpack-stats-pkg' ),
771                array( 'status' => 404 )
772            );
773        }
774
775        // The endpoint takes no period or date: `data` is every day since publication.
776        $stats = self::get_wpcom_stats();
777        $raw   = $stats->get_post_views( $post_id );
778        if ( is_wp_error( $raw ) ) {
779            return $raw;
780        }
781
782        return array(
783            'post_id'     => $post_id,
784            'total_views' => isset( $raw['views'] ) ? (int) $raw['views'] : 0,
785            'period'      => $period,
786            'num'         => $num,
787            'date'        => $date,
788            'series'      => self::bucket_daily_views( self::extract_post_views_series( $raw ), $period, $num, $date ),
789        );
790    }
791
792    /**
793     * Execute: get-visits.
794     *
795     * @param array|null $input Input matching the ability's input_schema.
796     * @return array|WP_Error
797     */
798    public static function get_visits( $input = null ) {
799        $input = is_array( $input ) ? $input : array();
800
801        $unit     = self::pick_period( $input['unit'] ?? null );
802        $quantity = self::clamp_int( $input['quantity'] ?? 30, 1, 90, 30 );
803        $date     = self::resolve_date( $input['date'] ?? null );
804        if ( is_wp_error( $date ) ) {
805            return $date;
806        }
807
808        // Pass user input FIRST to array_intersect so caller-supplied field order is preserved.
809        $fields = isset( $input['fields'] ) && is_array( $input['fields'] )
810            ? array_values( array_intersect( $input['fields'], self::VISIT_FIELDS ) )
811            : array();
812        if ( empty( $fields ) ) {
813            $fields = self::DEFAULT_VISIT_FIELDS;
814        }
815
816        $args = array(
817            'unit'        => $unit,
818            'quantity'    => $quantity,
819            'date'        => $date,
820            'stat_fields' => implode( ',', $fields ),
821        );
822
823        $stats = self::get_wpcom_stats();
824        $raw   = $stats->get_visits( $args );
825        if ( is_wp_error( $raw ) ) {
826            return $raw;
827        }
828
829        return array(
830            'unit'     => $unit,
831            'quantity' => $quantity,
832            'date'     => $date,
833            'fields'   => $fields,
834            'series'   => self::normalize_visits_series( $raw, $fields ),
835        );
836    }
837
838    /**
839     * Execute: get-followers.
840     *
841     * @param array|null $input Ignored â€” zero-arg ability.
842     * @return array|WP_Error
843     */
844    public static function get_followers( $input = null ) {
845        unset( $input );
846        $stats = self::get_wpcom_stats();
847
848        $composed = self::compose_subcalls(
849            array(
850                'followers'           => $stats->get_followers(
851                    array(
852                        'type' => 'all',
853                        'max'  => 1,
854                    )
855                ),
856                'comment_followers'   => $stats->get_comment_followers(),
857                'publicize_followers' => $stats->get_publicize_followers(),
858            ),
859            __( 'Follower data could not be fetched from WordPress.com. Confirm the site is connected and try again.', 'jetpack-stats-pkg' )
860        );
861        if ( is_wp_error( $composed ) ) {
862            return $composed;
863        }
864        $followers         = $composed['values']['followers'];
865        $comment_followers = $composed['values']['comment_followers'];
866        $publicize         = $composed['values']['publicize_followers'];
867        $errors            = $composed['errors'];
868
869        // The counts are totals on the response; `subscribers` is only a page of rows.
870        $email   = self::as_int( $followers, 'total_email' );
871        $wpcom   = self::as_int( $followers, 'total_wpcom' );
872        $comment = self::all_posts_comment_followers( $comment_followers );
873        if ( null === $comment ) {
874            $errors[] = 'comment_followers';
875            $comment  = 0;
876        }
877
878        $publicize_by_service = array();
879        if ( isset( $publicize['services'] ) && is_array( $publicize['services'] ) ) {
880            foreach ( $publicize['services'] as $row ) {
881                if ( isset( $row['service'] ) && isset( $row['followers'] ) ) {
882                    $publicize_by_service[ (string) $row['service'] ] = (int) $row['followers'];
883                }
884            }
885        }
886
887        $total = $email + $wpcom + $comment + array_sum( $publicize_by_service );
888
889        $out = array(
890            'total'     => $total,
891            'email'     => $email,
892            'wpcom'     => $wpcom,
893            'comment'   => $comment,
894            // An empty PHP array would encode as `[]`, not the object the schema promises.
895            'publicize' => (object) $publicize_by_service,
896            'partial'   => ! empty( $errors ),
897        );
898
899        if ( ! empty( $errors ) ) {
900            $out['errors'] = $errors;
901        }
902
903        return $out;
904    }
905
906    /**
907     * Execute: get-settings.
908     *
909     * @param array|null $input Ignored â€” zero-arg ability.
910     * @return array
911     */
912    public static function get_settings( $input = null ) {
913        unset( $input );
914        return Settings::get( Settings::KEYS );
915    }
916
917    /**
918     * Execute: update-settings.
919     *
920     * @param array|null $input Input matching the ability's input_schema.
921     * @return array|WP_Error
922     */
923    public static function update_settings( $input = null ) {
924        return Settings::update( is_array( $input ) ? $input : array(), Settings::KEYS );
925    }
926
927    /*
928    ---------------------------------------------------------------------
929     * Helpers
930     * ---------------------------------------------------------------------
931     */
932
933    /**
934     * Compose multiple WPCOM sub-call results into a partial-tolerant envelope.
935     *
936     * An array result is kept as-is. Anything else â€” a WP_Error, or the decoded
937     * body of a response that was not a JSON object â€” is replaced with `[]` and
938     * its key recorded under `errors`. Returns `jetpack_stats_data_unavailable`
939     * if every sub-call failed.
940     *
941     * @param array  $named_results       Map of error-tag => sub-call result.
942     * @param string $all_failed_message  Message for the all-failed WP_Error.
943     * @return array{values: array<string, array>, errors: string[]}|WP_Error
944     */
945    private static function compose_subcalls( array $named_results, string $all_failed_message ) {
946        $values = array();
947        $errors = array();
948        foreach ( $named_results as $tag => $result ) {
949            if ( is_array( $result ) ) {
950                $values[ $tag ] = $result;
951            } else {
952                $errors[]       = (string) $tag;
953                $values[ $tag ] = array();
954            }
955        }
956
957        if ( count( $errors ) === count( $named_results ) ) {
958            return new WP_Error( self::ERROR_PREFIX . 'data_unavailable', $all_failed_message );
959        }
960
961        return array(
962            'values' => $values,
963            'errors' => $errors,
964        );
965    }
966
967    /**
968     * Resolve an aggregation period from raw input, defaulting to `day`.
969     *
970     * @param mixed $raw Raw input value.
971     * @return string One of self::PERIODS.
972     */
973    private static function pick_period( $raw ): string {
974        return is_string( $raw ) && in_array( $raw, self::PERIODS, true ) ? $raw : 'day';
975    }
976
977    /**
978     * Return a WPCOM_Stats instance. Filterable for tests.
979     *
980     * @return WPCOM_Stats
981     */
982    protected static function get_wpcom_stats(): WPCOM_Stats {
983        /**
984         * Filters the WPCOM_Stats instance used by the Stats abilities.
985         *
986         * @since 0.19.0
987         *
988         * @param WPCOM_Stats $wpcom_stats The default instance.
989         */
990        $instance = apply_filters( 'jetpack_stats_abilities_wpcom_stats', new WPCOM_Stats() );
991        return $instance instanceof WPCOM_Stats ? $instance : new WPCOM_Stats();
992    }
993
994    /**
995     * Dispatch top-content raw fetch to the right WPCOM_Stats method.
996     *
997     * @param WPCOM_Stats $stats Client.
998     * @param string      $type  Content type enum.
999     * @param array       $args  Pre-built `{ period, date, max, num?, summarize? }` args â€” `tags` takes only `max`.
1000     * @return array|WP_Error
1001     */
1002    private static function fetch_top_content_raw( WPCOM_Stats $stats, string $type, array $args ) {
1003        switch ( $type ) {
1004            case 'posts':
1005                return $stats->get_top_posts( $args );
1006            case 'referrers':
1007                return $stats->get_referrers( $args );
1008            case 'search-terms':
1009                return $stats->get_search_terms( $args );
1010            case 'clicks':
1011                return $stats->get_clicks( $args );
1012            case 'tags':
1013                // get_tags has a narrower arg surface â€” pass only `max`.
1014                return $stats->get_tags( array( 'max' => $args['max'] ) );
1015            case 'authors':
1016                return $stats->get_top_authors( $args );
1017            case 'countries':
1018                return $stats->get_views_by_country( $args );
1019            case 'downloads':
1020                return $stats->get_file_downloads( $args );
1021            case 'video-plays':
1022                return $stats->get_video_plays( $args );
1023        }
1024
1025        return new WP_Error( self::ERROR_PREFIX . 'invalid_type', __( 'Unknown top-content type.', 'jetpack-stats-pkg' ) );
1026    }
1027
1028    /**
1029     * Normalize a WPCOM top-content response into the uniform item shape.
1030     *
1031     * Most `type` values follow the `days -> <date> -> <list-key>` shape (or
1032     * `summary -> <list-key>` when summarized) and project through
1033     * `TOP_CONTENT_MAP`. `tags` and `countries` are special-cased.
1034     *
1035     * @param string $type       Content type enum.
1036     * @param array  $raw        Raw WPCOM response.
1037     * @param int    $max        Result cap.
1038     * @param bool   $summarized Whether the request asked WPCOM to `summarize`.
1039     * @return array List of { rank, label, value, href?, id? } items.
1040     */
1041    private static function normalize_top_content_items( string $type, array $raw, int $max, bool $summarized = false ): array {
1042        if ( 'tags' === $type ) {
1043            return self::rank_and_cap( self::tag_rows( $raw ), $max );
1044        }
1045
1046        $day_data = self::first_period_block( $raw, $summarized );
1047
1048        if ( 'countries' === $type ) {
1049            return self::rank_and_cap( self::country_rows( $day_data, self::rows_at( $raw, 'country-info' ) ), $max );
1050        }
1051
1052        $map = self::TOP_CONTENT_MAP[ $type ] ?? null;
1053        if ( null === $map ) {
1054            return array();
1055        }
1056
1057        $rows = array();
1058        foreach ( self::rows_at( $day_data, $map['list'] ) as $row ) {
1059            $rows[] = array(
1060                'label' => self::pick_field( $row, $map['label'] ),
1061                'value' => self::pick_field( $row, $map['value'] ),
1062                'href'  => isset( $map['href'] ) ? self::pick_field( $row, $map['href'] ) : null,
1063                'id'    => isset( $map['id'] ) ? self::pick_field( $row, $map['id'] ) : null,
1064            );
1065        }
1066        return self::rank_and_cap( $rows, $max );
1067    }
1068
1069    /**
1070     * Project `stats/tags` rows, which carry no `days` envelope, into label/value rows.
1071     *
1072     * Each row groups one or more tags and categories under a shared view count, so
1073     * its label names them all; only a single-term row has one URL to link to.
1074     *
1075     * @param array $raw Raw WPCOM response: `{ tags: [ { tags: [ { type, name, link } ], views } ] }`.
1076     * @return array
1077     */
1078    private static function tag_rows( array $raw ): array {
1079        $rows = array();
1080        foreach ( self::rows_at( $raw, 'tags' ) as $row ) {
1081            $terms  = self::rows_at( $row, 'tags' );
1082            $rows[] = array(
1083                'label' => implode( ', ', array_column( $terms, 'name' ) ),
1084                'value' => $row['views'] ?? 0,
1085                'href'  => 1 === count( $terms ) ? ( array_column( $terms, 'link' )[0] ?? null ) : null,
1086            );
1087        }
1088        return $rows;
1089    }
1090
1091    /**
1092     * Project a `stats/country-views` period block into label/value rows named by `country-info`.
1093     *
1094     * Codes WordPress.com cannot name (AP and the legacy UK get `country_full: false`, and `-` marks
1095     * a failed lookup) share one "Unknown" row; the A1, A2 and ZZ pseudo-codes name no place and are dropped.
1096     *
1097     * @param array $day_data     Period block holding the `views` rows.
1098     * @param array $country_info Map of country code => `{ country_full }`.
1099     * @return array
1100     */
1101    private static function country_rows( array $day_data, array $country_info ): array {
1102        $unknown = __( 'Unknown', 'jetpack-stats-pkg' );
1103        $views   = array();
1104        foreach ( self::rows_at( $day_data, 'views' ) as $row ) {
1105            $code = (string) ( $row['country_code'] ?? '' );
1106            if ( in_array( $code, array( 'A1', 'A2', 'ZZ' ), true ) ) {
1107                continue;
1108            }
1109            $name            = $country_info[ $code ]['country_full'] ?? null;
1110            $label           = is_string( $name ) && '' !== $name ? $name : $unknown;
1111            $views[ $label ] = ( $views[ $label ] ?? 0 ) + (int) ( $row['views'] ?? 0 );
1112        }
1113        // Folding rows into "Unknown" can lift it above rows WordPress.com ranked higher.
1114        arsort( $views );
1115
1116        $rows = array();
1117        foreach ( $views as $label => $value ) {
1118            $rows[] = array(
1119                'label' => (string) $label,
1120                'value' => $value,
1121            );
1122        }
1123        return $rows;
1124    }
1125
1126    /**
1127     * Return the block of a WPCOM list response that holds the rows to report.
1128     *
1129     * A `summarize=1` request totals the periods under `summary` (`days -> summary`
1130     * for video plays); otherwise each period sits under `days -> <date>`, and a
1131     * single-period request has exactly one of those.
1132     *
1133     * @param array $raw        Raw WPCOM response.
1134     * @param bool  $summarized Whether the request asked WPCOM to `summarize`.
1135     * @return array The period block, or [].
1136     */
1137    private static function first_period_block( array $raw, bool $summarized = false ): array {
1138        if ( $summarized ) {
1139            $summary = ! empty( $raw['summary'] ) ? $raw['summary'] : ( $raw['days']['summary'] ?? array() );
1140            return is_array( $summary ) ? $summary : array();
1141        }
1142        if ( ! isset( $raw['days'] ) || ! is_array( $raw['days'] ) || empty( $raw['days'] ) ) {
1143            return array();
1144        }
1145        $first = reset( $raw['days'] );
1146        return is_array( $first ) ? $first : array();
1147    }
1148
1149    /**
1150     * Return the first posts item that is a post or page, skipping the home page / archives row.
1151     *
1152     * @param array $items Items from normalize_top_content_items().
1153     * @return array|null
1154     */
1155    private static function first_post( array $items ): ?array {
1156        foreach ( $items as $item ) {
1157            if ( isset( $item['id'] ) ) {
1158                return $item;
1159            }
1160        }
1161        return null;
1162    }
1163
1164    /**
1165     * Return the rows of the list (or the entries of the map) at `$key`, skipping any that are not arrays.
1166     *
1167     * @param array  $arr Array holding the list.
1168     * @param string $key Key of the list.
1169     * @return array[]
1170     */
1171    private static function rows_at( array $arr, string $key ): array {
1172        $list = $arr[ $key ] ?? null;
1173        return is_array( $list ) ? array_filter( $list, 'is_array' ) : array();
1174    }
1175
1176    /**
1177     * Read the first of the given keys that is present and non-empty on a row.
1178     *
1179     * @param array           $row  Row from a WPCOM list.
1180     * @param string|string[] $keys Key, or keys in order of preference.
1181     * @return mixed|null
1182     */
1183    private static function pick_field( array $row, $keys ) {
1184        foreach ( (array) $keys as $key ) {
1185            if ( isset( $row[ $key ] ) && '' !== $row[ $key ] ) {
1186                return $row[ $key ];
1187            }
1188        }
1189        return null;
1190    }
1191
1192    /**
1193     * Cap rows and turn them into ranked items, dropping empty `href`s and non-positive `id`s.
1194     *
1195     * @param array $rows Rows of `{ label, value, href?, id? }`.
1196     * @param int   $max  Result cap.
1197     * @return array Items of `{ rank, label, value, href?, id? }`.
1198     */
1199    private static function rank_and_cap( array $rows, int $max ): array {
1200        $items = array();
1201        foreach ( array_slice( $rows, 0, $max ) as $i => $row ) {
1202            $item = array(
1203                'rank'  => $i + 1,
1204                // WordPress.com sends titles and names HTML-encoded.
1205                'label' => html_entity_decode( (string) ( $row['label'] ?? '' ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ),
1206                'value' => (int) ( $row['value'] ?? 0 ),
1207            );
1208            if ( isset( $row['href'] ) && '' !== $row['href'] ) {
1209                $item['href'] = (string) $row['href'];
1210            }
1211            $id = (int) ( $row['id'] ?? 0 );
1212            if ( $id > 0 ) {
1213                $item['id'] = $id;
1214            }
1215            $items[] = $item;
1216        }
1217        return $items;
1218    }
1219
1220    /**
1221     * Extract a compact streak summary from the WPCOM streak response.
1222     *
1223     * WPCOM's `current` is the latest run of posting days even when it ended long ago, so it only counts
1224     * as current when it ends today or yesterday.
1225     *
1226     * @param array  $streak Raw WPCOM streak response: `{ streak: { long: { start, end, length }, current: { â€¦ } } }`.
1227     * @param string $today  The site's today, YYYY-MM-DD.
1228     * @return array Compact `{ current_length, longest_length, longest_start, longest_end }`.
1229     */
1230    private static function extract_streak_summary( array $streak, string $today ): array {
1231        $runs      = $streak['streak'] ?? array();
1232        $yesterday = gmdate( 'Y-m-d', strtotime( $today . ' -1 day' ) );
1233        $ongoing   = (string) ( $runs['current']['end'] ?? '' ) >= $yesterday;
1234        return array(
1235            'current_length' => $ongoing ? (int) ( $runs['current']['length'] ?? 0 ) : 0,
1236            'longest_length' => (int) ( $runs['long']['length'] ?? 0 ),
1237            'longest_start'  => (string) ( $runs['long']['start'] ?? '' ),
1238            'longest_end'    => (string) ( $runs['long']['end'] ?? '' ),
1239        );
1240    }
1241
1242    /**
1243     * Count the people following the comments on every post.
1244     *
1245     * WordPress.com lists them as an "All Posts" row with id 0. Each other row counts one post's
1246     * followers, so summing rows counts people twice, and `total` counts rows rather than people.
1247     * Rows are ranked by followers, 20 to a page, so the "All Posts" row can sit on a later page.
1248     *
1249     * @param array $raw Raw WPCOM comment-followers response (first page).
1250     * @return int|null Null when the row may be on a later page, so the count is unknown.
1251     */
1252    private static function all_posts_comment_followers( array $raw ): ?int {
1253        foreach ( self::rows_at( $raw, 'posts' ) as $row ) {
1254            if ( isset( $row['id'] ) && 0 === (int) $row['id'] ) {
1255                return self::as_int( $row, 'followers' );
1256            }
1257        }
1258        return self::as_int( $raw, 'pages' ) > 1 ? null : 0;
1259    }
1260
1261    /**
1262     * Extract a post-views series from the WPCOM get_post_views response.
1263     *
1264     * WPCOM returns `data` as a list of `[date, views]` tuples under the
1265     * `fields` header. We normalize to `[{ date, views }]`.
1266     *
1267     * @param array $raw Raw WPCOM response.
1268     * @return array
1269     */
1270    private static function extract_post_views_series( array $raw ): array {
1271        if ( ! isset( $raw['data'] ) || ! is_array( $raw['data'] ) ) {
1272            return array();
1273        }
1274
1275        $fields    = isset( $raw['fields'] ) && is_array( $raw['fields'] ) ? $raw['fields'] : array( 'period', 'views' );
1276        $date_idx  = array_search( 'period', $fields, true );
1277        $views_idx = array_search( 'views', $fields, true );
1278        if ( false === $date_idx || false === $views_idx ) {
1279            // If either column is missing from the WPCOM response, the positional
1280            // fallback is unsafe (we might collide date/views on column 0). Drop
1281            // to empty rather than emit lies.
1282            return array();
1283        }
1284
1285        $series = array();
1286        foreach ( $raw['data'] as $row ) {
1287            if ( ! is_array( $row ) ) {
1288                continue;
1289            }
1290            $series[] = array(
1291                'date'  => isset( $row[ $date_idx ] ) ? (string) $row[ $date_idx ] : '',
1292                'views' => isset( $row[ $views_idx ] ) ? (int) $row[ $views_idx ] : 0,
1293            );
1294        }
1295        return $series;
1296    }
1297
1298    /**
1299     * Total a daily views series into the `$num` periods ending with the one containing `$date`.
1300     *
1301     * @param array  $daily  Rows of `{ date: YYYY-MM-DD, views }`.
1302     * @param string $period One of self::PERIODS.
1303     * @param int    $num    Number of periods to return.
1304     * @param string $date   YYYY-MM-DD inside the last period.
1305     * @return array Rows of `{ date, views }`, oldest first, each dated by its period's first day.
1306     */
1307    private static function bucket_daily_views( array $daily, string $period, int $num, string $date ): array {
1308        $start = self::period_start( $date, $period );
1309        if ( null === $start ) {
1310            return array();
1311        }
1312
1313        $step   = array(
1314            'day'   => '-1 day',
1315            'week'  => '-7 days',
1316            'month' => '-1 month',
1317            'year'  => '-1 year',
1318        )[ $period ];
1319        $totals = array();
1320        for ( $i = 0; $i < $num; $i++ ) {
1321            $totals[ $start->format( 'Y-m-d' ) ] = 0;
1322            $start                               = $start->modify( $step );
1323        }
1324
1325        foreach ( $daily as $row ) {
1326            $row_start = self::period_start( $row['date'], $period );
1327            $key       = null === $row_start ? null : $row_start->format( 'Y-m-d' );
1328            if ( null !== $key && isset( $totals[ $key ] ) ) {
1329                $totals[ $key ] += (int) $row['views'];
1330            }
1331        }
1332
1333        $series = array();
1334        foreach ( array_reverse( $totals, true ) as $day => $views ) {
1335            $series[] = array(
1336                'date'  => $day,
1337                'views' => $views,
1338            );
1339        }
1340        return $series;
1341    }
1342
1343    /**
1344     * The days covering `$num` periods ending with the one containing `$date`, as summarized request args.
1345     *
1346     * With `summarize=1`, WPCOM's top-posts, referrers, clicks, country-views and video-plays ignore `period`
1347     * and read `num` as days, and top-authors and file-downloads return nothing for longer periods. Every one
1348     * of them totals `num` days ending on `date`, so the window is sent that way. The last period runs to its
1349     * end or to today, whichever is first, unless `$date` itself is later.
1350     *
1351     * @param string $period One of self::PERIODS.
1352     * @param int    $num    Number of periods, 2 or more.
1353     * @param string $date   YYYY-MM-DD inside the last period.
1354     * @return array `{ period: 'day', date, num, summarize: 1 }`.
1355     */
1356    private static function summary_window( string $period, int $num, string $date ): array {
1357        $utc        = new \DateTimeZone( 'UTC' );
1358        $requested  = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date, $utc );
1359        $today      = \DateTimeImmutable::createFromFormat( '!Y-m-d', self::today(), $utc );
1360        $last_start = self::period_start( $date, $period );
1361        $first      = $last_start->modify( '-' . ( $num - 1 ) . ' ' . $period );
1362        $end        = max( $requested, min( $last_start->modify( '+1 ' . $period . ' -1 day' ), $today ) );
1363
1364        return array(
1365            'period'    => 'day',
1366            'date'      => $end->format( 'Y-m-d' ),
1367            'num'       => (int) $first->diff( $end )->days + 1,
1368            'summarize' => 1,
1369        );
1370    }
1371
1372    /**
1373     * First day of the period containing a date; weeks start on Monday, as in WordPress.com Stats.
1374     *
1375     * @param string $date   YYYY-MM-DD.
1376     * @param string $period One of self::PERIODS.
1377     * @return \DateTimeImmutable|null Null when the date cannot be parsed.
1378     */
1379    private static function period_start( string $date, string $period ): ?\DateTimeImmutable {
1380        $day = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date, new \DateTimeZone( 'UTC' ) );
1381        if ( false === $day ) {
1382            return null;
1383        }
1384
1385        switch ( $period ) {
1386            case 'week':
1387                return $day->modify( '-' . ( (int) $day->format( 'N' ) - 1 ) . ' days' );
1388            case 'month':
1389                return $day->modify( 'first day of this month' );
1390            case 'year':
1391                return $day->setDate( (int) $day->format( 'Y' ), 1, 1 );
1392        }
1393        return $day;
1394    }
1395
1396    /**
1397     * Normalize the WPCOM get_visits response into `[{ date, <field>: int, ... }]`.
1398     *
1399     * @param array $raw    Raw WPCOM response.
1400     * @param array $fields Requested metric fields.
1401     * @return array
1402     */
1403    private static function normalize_visits_series( array $raw, array $fields ): array {
1404        if ( ! isset( $raw['data'] ) || ! is_array( $raw['data'] ) ) {
1405            return array();
1406        }
1407
1408        $raw_fields = isset( $raw['fields'] ) && is_array( $raw['fields'] ) ? $raw['fields'] : array();
1409        $field_idx  = array();
1410        foreach ( $raw_fields as $idx => $name ) {
1411            $field_idx[ (string) $name ] = $idx;
1412        }
1413        $date_idx = $field_idx['period'] ?? 0;
1414
1415        $series = array();
1416        foreach ( $raw['data'] as $row ) {
1417            if ( ! is_array( $row ) ) {
1418                continue;
1419            }
1420            $entry = array(
1421                'date' => isset( $row[ $date_idx ] ) ? (string) $row[ $date_idx ] : '',
1422            );
1423            foreach ( $fields as $field ) {
1424                $idx             = $field_idx[ $field ] ?? null;
1425                $entry[ $field ] = ( null !== $idx && isset( $row[ $idx ] ) ) ? (int) $row[ $idx ] : 0;
1426            }
1427            $series[] = $entry;
1428        }
1429        return $series;
1430    }
1431
1432    /**
1433     * Resolve the `date` input, defaulting to today when it is absent.
1434     *
1435     * @param mixed $raw Raw input value.
1436     * @return string|WP_Error YYYY-MM-DD, or jetpack_stats_invalid_date when it is not a real day.
1437     */
1438    private static function resolve_date( $raw ) {
1439        if ( null === $raw || '' === $raw ) {
1440            return self::today();
1441        }
1442        if ( is_string( $raw ) && 1 === preg_match( '/^(\d{4})-(\d{2})-(\d{2})\z/', $raw, $m ) && checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ) ) {
1443            return $raw;
1444        }
1445        return new WP_Error(
1446            self::ERROR_PREFIX . 'invalid_date',
1447            __( 'The `date` must be a real day in YYYY-MM-DD format, such as 2026-04-23.', 'jetpack-stats-pkg' )
1448        );
1449    }
1450
1451    /**
1452     * Return today in the site's timezone, which is how WordPress.com Stats splits days.
1453     *
1454     * Not wp_date(): plugins filter its output into other calendars.
1455     *
1456     * @return string YYYY-MM-DD.
1457     */
1458    private static function today(): string {
1459        return current_time( 'Y-m-d' );
1460    }
1461
1462    /**
1463     * Clamp an integer into [$min, $max] with a default on bad input.
1464     *
1465     * @param mixed $raw     Raw input.
1466     * @param int   $min     Minimum.
1467     * @param int   $max     Maximum.
1468     * @param int   $default Default on bad input.
1469     * @return int
1470     */
1471    private static function clamp_int( $raw, int $min, int $max, int $default ): int {
1472        if ( ! is_numeric( $raw ) ) {
1473            return $default;
1474        }
1475        $v = (int) $raw;
1476        if ( $v < $min ) {
1477            return $min;
1478        }
1479        if ( $v > $max ) {
1480            return $max;
1481        }
1482        return $v;
1483    }
1484
1485    /**
1486     * Safely read an int field from an array.
1487     *
1488     * @param array  $arr Array.
1489     * @param string $key Key.
1490     * @return int
1491     */
1492    private static function as_int( array $arr, string $key ): int {
1493        return isset( $arr[ $key ] ) ? (int) $arr[ $key ] : 0;
1494    }
1495}