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