Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.38% covered (success)
94.38%
235 / 249
68.18% covered (warning)
68.18%
15 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_AI_Page
96.71% covered (success)
96.71%
235 / 243
68.18% covered (warning)
68.18%
15 / 22
81
0.00% covered (danger)
0.00%
0 / 1
 should_render_wp_build
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_ai_admin_request
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 maybe_load_wp_build
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 add_actions
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 get_page_hook
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 add_page_actions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 admin_styles
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 load_agents_manager
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 get_agents_manager_agent_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_scheduled_tasks_provider
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 add_scheduled_tasks_data
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
 is_scheduled_tasks_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 page_admin_scripts
99.10% covered (success)
99.10%
110 / 111
0.00% covered (danger)
0.00%
0 / 1
19
 get_tracks_user_data
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 is_current_user_automattician
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 get_search_settings_url
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 has_my_jetpack
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 get_ai_plan_info
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
9.02
 is_jetpack_purchase
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_wpcom_plan_purchase
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 render
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 page_render
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
2.06
1<?php
2/**
3 * Jetpack AI admin page.
4 *
5 * Registers the "AI" submenu item under Jetpack and mounts the React-based
6 * MCP settings interface.
7 *
8 * @package automattic/jetpack
9 */
10
11use Automattic\Jetpack\Admin_UI\Admin_Menu;
12use Automattic\Jetpack\Agents_Manager\Agents_Manager;
13use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
14use Automattic\Jetpack\Connection\Manager as Connection_Manager;
15use Automattic\Jetpack\Feature_Flags\Feature_Flags;
16use Automattic\Jetpack\Modules;
17use Automattic\Jetpack\Redirect;
18use Automattic\Jetpack\Status;
19use Automattic\Jetpack\Status\Host;
20use Automattic\Jetpack\Terms_Of_Service;
21use Automattic\Jetpack\Tracking;
22
23if ( ! defined( 'ABSPATH' ) ) {
24    exit( 0 );
25}
26
27require_once dirname( __DIR__ ) . '/class-jetpack-ai-feature-flags.php';
28require_once dirname( __DIR__ ) . '/class-jetpack-ai-settings.php';
29require_once __DIR__ . '/class-jetpack-wp-build-page.php';
30
31/**
32 * Builds the Jetpack AI admin page and its sidebar menu entry.
33 */
34class Jetpack_AI_Page {
35
36    /**
37     * The wp-build route's page id, which must not be the `jetpack-ai` menu slug.
38     *
39     * @var string
40     */
41    const WP_BUILD_PAGE_ID = 'jetpack-ai-hub';
42
43    /**
44     * Whether this request renders through wp-build.
45     *
46     * Checks the render function too: if the build output is missing, the request would
47     * otherwise get no bundle at all now that the legacy entry is gone.
48     *
49     * @since 16.3
50     *
51     * @return bool
52     */
53    public static function should_render_wp_build() {
54        return function_exists( 'jetpack_plugin_jetpack_ai_hub_wp_admin_render_page' );
55    }
56
57    /**
58     * Whether the current request targets the AI Hub admin page.
59     *
60     * @since 16.3
61     *
62     * @return bool
63     */
64    private static function is_ai_admin_request() {
65        if ( ! is_admin() ) {
66            return false;
67        }
68
69        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the page slug only.
70        return isset( $_GET['page'] ) && 'jetpack-ai' === sanitize_text_field( wp_unslash( $_GET['page'] ) );
71    }
72
73    /**
74     * Load wp-build for the AI Hub page.
75     *
76     * @since 16.3
77     *
78     * @return void
79     */
80    public static function maybe_load_wp_build() {
81        if ( self::is_ai_admin_request() ) {
82            Jetpack_WP_Build_Page::load( self::WP_BUILD_PAGE_ID );
83        }
84    }
85
86    /**
87     * Register the page and its page-specific hooks.
88     *
89     * The AI Hub owns its full React layout, so it does not need the legacy
90     * Jetpack_Admin_Page lifecycle. Keeping this controller independent also
91     * lets WordPress.com Simple load the same page without replacing its
92     * request-wide Jetpack_Admin_Page compatibility stub.
93     */
94    public function add_actions() {
95        $is_offline_mode = ( new Status() )->is_offline_mode();
96
97        if ( ! current_user_can( 'manage_options' ) && ( $is_offline_mode || ! Jetpack::is_connection_ready() ) ) {
98            return;
99        }
100
101        if ( ! Jetpack::is_connection_ready() && ! $is_offline_mode ) {
102            return;
103        }
104
105        $hook = $this->get_page_hook();
106        if ( ! $hook ) {
107            return;
108        }
109
110        add_action( 'admin_print_scripts-' . $hook, array( $this, 'page_admin_scripts' ) );
111
112        // Preserve the standalone Jetpack page's existing base stylesheet. Simple
113        // never loaded it for the Hub because it conflicts with wpcom admin pages.
114        if ( ! ( new Host() )->is_wpcom_simple() ) {
115            add_action( 'admin_print_styles-' . $hook, array( $this, 'admin_styles' ) );
116        }
117
118        $this->add_page_actions( $hook );
119    }
120
121    /**
122     * Register the "AI" submenu under the Jetpack top-level menu.
123     *
124     * @return string|false Hook returned by Admin_Menu::add_menu().
125     */
126    public function get_page_hook() {
127        return Admin_Menu::add_menu(
128            // "Jetpack AI" is a product name and should not be translated.
129            'Jetpack AI',
130            'Jetpack AI',
131            'manage_options',
132            'jetpack-ai',
133            array( $this, 'render' ),
134            null,
135            // No product gate: the Hub also holds the MCP and Connectors tab, which works with AI off.
136            array( 'key' => 'jetpack-ai' )
137        );
138    }
139
140    /**
141     * Attach page-specific actions.
142     *
143     * @param string $hook The page hook returned by get_page_hook().
144     */
145    public function add_page_actions( $hook ) {
146        add_action( 'load-' . $hook, array( $this, 'load_agents_manager' ) );
147    }
148
149    /**
150     * Enqueue the stylesheet historically supplied by Jetpack_Admin_Page.
151     */
152    public function admin_styles() {
153        $min = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min';
154
155        wp_enqueue_style( 'jetpack-admin', plugins_url( "css/jetpack-admin{$min}.css", JETPACK__PLUGIN_FILE ), array( 'genericons', 'jetpack-connection' ), JETPACK__VERSION . '-20121016' );
156        wp_style_add_data( 'jetpack-admin', 'rtl', 'replace' );
157        wp_style_add_data( 'jetpack-admin', 'suffix', $min );
158    }
159
160    /**
161     * Request the existing Agents Manager shell for this page.
162     */
163    public function load_agents_manager() {
164        if ( ! self::is_scheduled_tasks_enabled() ) {
165            return;
166        }
167
168        Agents_Manager::init();
169
170        add_filter( 'agents_manager_should_load', '__return_true' );
171        add_filter( 'agents_manager_agent_id', array( $this, 'get_agents_manager_agent_id' ) );
172        add_filter( 'agents_manager_agent_providers', array( $this, 'add_scheduled_tasks_provider' ) );
173        add_filter( 'jetpack_ai_sidebar_agents_manager_data', array( $this, 'add_scheduled_tasks_data' ) );
174    }
175
176    /**
177     * Use the generic WP Orchestrator agent in AI Hub.
178     *
179     * @return string Agent ID.
180     */
181    public function get_agents_manager_agent_id() {
182        return 'wp-orchestrator';
183    }
184
185    /**
186     * Add the AI Hub provider that supplies scheduled task starter prompts.
187     *
188     * @param array $providers Existing provider module URLs.
189     * @return array Updated provider module URLs.
190     */
191    public function add_scheduled_tasks_provider( $providers ) {
192        $providers[] = add_query_arg(
193            'ver',
194            JETPACK__VERSION,
195            plugins_url( '_inc/jetpack-ai-scheduled-tasks-provider.js', JETPACK__PLUGIN_FILE )
196        );
197
198        return $providers;
199    }
200
201    /**
202     * Customize Agents Manager's empty view for the Scheduled tasks page.
203     *
204     * @param array $data Existing Agents Manager data.
205     * @return array Updated Agents Manager data.
206     */
207    public function add_scheduled_tasks_data( $data ) {
208        $current_user = wp_get_current_user();
209
210        $data['emptyViewHeading'] = sprintf(
211            /* translators: %s: Current user's display name. */
212            __( 'Howdy %s! Let’s schedule a task.', 'jetpack' ),
213            $current_user->display_name
214        );
215        $data['emptyViewHelp']                     = __( 'Got a different request? Ask away.', 'jetpack' );
216        $data['scheduledTaskEmptyViewSuggestions'] = array(
217            array(
218                'id'         => 'create-daily-reminder',
219                'label'      => __( 'Create a daily reminder', 'jetpack' ),
220                'prompt'     => __( 'Create a daily reminder', 'jetpack' ),
221                'autoSubmit' => true,
222            ),
223            array(
224                'id'         => 'draft-weekly-post',
225                'label'      => __( 'Draft a weekly post', 'jetpack' ),
226                'prompt'     => __( 'Draft a weekly post', 'jetpack' ),
227                'autoSubmit' => true,
228            ),
229            array(
230                'id'         => 'schedule-monthly-report',
231                'label'      => __( 'Schedule a monthly report', 'jetpack' ),
232                'prompt'     => __( 'Schedule a monthly report', 'jetpack' ),
233                'autoSubmit' => true,
234            ),
235        );
236
237        return $data;
238    }
239
240    /**
241     * Whether the Scheduled tasks tab and its Agents Manager sidebar are enabled.
242     *
243     * @since 16.2
244     *
245     * @return bool
246     */
247    private static function is_scheduled_tasks_enabled() {
248        return Feature_Flags::is_enabled( Jetpack_AI_Feature_Flags::SCHEDULED_TASKS );
249    }
250
251    /**
252     * Enqueue scripts and styles for the AI admin page.
253     */
254    public function page_admin_scripts() {
255        // wp-build owns the route bundle and its dependencies; this handle only carries the
256        // inline settings below, so the plugin version is version enough to bust its cache.
257        $script_version = JETPACK__VERSION;
258
259        $blog_id     = Connection_Manager::get_site_id( true );
260        $status      = new Status();
261        $site_suffix = $status->get_site_suffix();
262        // Use the plain hostname for the Atomic activity log URL â€” get_site_suffix() can
263        // include '::' for subdirectory installs, which would break the URL. This matches
264        // the approach used by jetpack-mu-wpcom for the sidebar Activity Log link.
265        $site_host         = wp_parse_url( home_url(), PHP_URL_HOST );
266        $activity_log_site = ( is_string( $site_host ) && '' !== $site_host ) ? $site_host : $site_suffix;
267
268        /*
269         * On Atomic link to WPCOM activity log; on self-hosted link to the local
270         * wp-admin page, which only exists while the `activity-log` module is on.
271         * An empty URL hides the row rather than linking to an unregistered page.
272         */
273        if ( ( new Host() )->is_woa_site() ) {
274            $activity_log_url = 'https://wordpress.com/activity-log/' . $activity_log_site;
275        } elseif ( ( new Modules() )->is_active( 'activity-log' ) ) {
276            $activity_log_url = admin_url( 'admin.php?page=jetpack-activity-log' );
277        } else {
278            $activity_log_url = '';
279        }
280
281        /*
282         * Link SEO settings to the dedicated Jetpack SEO page where it exists,
283         * falling back to the Traffic settings card. Checking the `rsm_jetpack_seo`
284         * filter is required in addition to the cohort check: is_seo_surface_visible()
285         * alone returns true on all of wpcom-platform even while the flag is off â€”
286         * it answers only the cohort half, and page registration requires both
287         * (see packages/seo Initializer::init()).
288         */
289        $seo_settings_url          = admin_url( 'admin.php?page=jetpack-settings#/traffic' );
290        $is_internal_test          = jetpack_is_internal_testing_environment();
291        $show_scheduled_tasks_view = self::is_scheduled_tasks_enabled();
292        if (
293            // The exact-symbol guard matters: the autoloader can select an older
294            // jetpack-seo copy from another plugin that has the class but not
295            // this method, and class_exists alone would then fatal here.
296            method_exists( '\Automattic\Jetpack\SEO\Initializer', 'is_seo_surface_visible' )
297            && (bool) apply_filters( 'rsm_jetpack_seo', false )
298            && \Automattic\Jetpack\SEO\Initializer::is_seo_surface_visible()
299        ) {
300            $seo_settings_url = admin_url( 'admin.php?page=jetpack-seo' );
301        }
302
303        // The route bundle is registered by wp-build; this handle exists only to carry the
304        // inline settings below, which the app reads from `window.jetpackAiSettings`.
305        wp_register_script( 'jetpack-ai-admin', false, array(), $script_version, true );
306        wp_enqueue_script( 'jetpack-ai-admin' );
307
308        // The Tracks sender (w.js); without it, queued events never leave the
309        // browser. Consent-gated like the other surfaces that load it.
310        $can_send_tracks = ( new Tracking( 'jetpack', new Connection_Manager() ) )->should_enable_tracking( new Terms_Of_Service(), $status );
311        if ( $can_send_tracks ) {
312            Tracking::register_tracks_functions_scripts( true );
313        }
314
315        // Unconditional, as on the other Jetpack admin pages: the connection store
316        // reads it, and only Scheduled tasks used to need it here.
317        Connection_Initial_State::render_script( 'jetpack-ai-admin' );
318
319        $host            = new Host();
320        $has_my_jetpack  = self::has_my_jetpack();
321        $is_offline_mode = $status->is_offline_mode();
322
323        /**
324         * Filters the host-specific AI Hub configuration.
325         *
326         * @since 16.2
327         *
328         * @param array $config AI Hub host configuration.
329         */
330        $config = apply_filters(
331            'jetpack_ai_admin_config',
332            array(
333                // The Overview and Features views launch on non-VIP self-hosted sites first.
334                // Keep this filterable so hosts can close them independently.
335                'showGatedViews'    => ! $host->is_vip_site()
336                    && ( ! $host->is_wpcom_platform() || ( $host->is_woa_site() && $is_internal_test ) ),
337                'showA12sBadge'     => $host->is_woa_site() && $is_internal_test,
338                // The same verdicts the feature-settings endpoint reports. That call
339                // exists for the AI Features toggles; the notice must not wait on it.
340                'isUserConnected'   => Jetpack_AI_Settings::user_is_connected(),
341                'isConnected'       => Jetpack_AI_Settings::site_is_connected(),
342                'hostAllowsAi'      => Jetpack_AI_Settings::host_allows_ai(),
343                'masterEnabled'     => Jetpack_AI_Settings::is_master_enabled(),
344                // The route, not a flag: each one documents a different hook.
345                'masterForcedOff'   => Jetpack_AI_Settings::get_master_forced_off_route(),
346                'isOfflineMode'     => $is_offline_mode,
347                'canConnectSite'    => current_user_can( 'jetpack_connect' ),
348                // These three answer one question; a filter changing one alone leaves
349                // a label pointing at a page that is not there.
350                'hasMyJetpack'      => $has_my_jetpack,
351                'userConnectionUrl' => $has_my_jetpack
352                    ? 'admin.php?page=my-jetpack#/connection'
353                    : 'admin.php?page=jetpack-settings#/connect-user',
354                'manageUrl'         => $has_my_jetpack
355                    ? 'admin.php?page=my-jetpack#/products'
356                    : 'admin.php?page=jetpack_modules',
357                'mcpSettingsApi'    => array(
358                    'path'   => '/wpcom/v2/jetpack-ai/mcp-settings',
359                    'format' => 'jetpack',
360                ),
361            )
362        );
363
364        $show_gated_views = ! empty( $config['showGatedViews'] );
365
366        $plan_info = $show_gated_views ? self::get_ai_plan_info() : array( 'name' => '' );
367
368        $settings = array(
369            'blogId'            => $blog_id ? (int) $blog_id : 0,
370            'activityLogUrl'    => $activity_log_url,
371            'seoSettingsUrl'    => $seo_settings_url,
372            'searchSettingsUrl' => self::get_search_settings_url(),
373            'siteAdminUrl'      => admin_url(),
374            'userConnectionUrl' => esc_url_raw( $config['userConnectionUrl'] ?? '' ),
375            'manageUrl'         => esc_url_raw( $config['manageUrl'] ?? '' ),
376            'hasMyJetpack'      => ! empty( $config['hasMyJetpack'] ),
377            'isConnected'       => ! empty( $config['isConnected'] ),
378            'hostAllowsAi'      => ! empty( $config['hostAllowsAi'] ),
379            'masterEnabled'     => ! empty( $config['masterEnabled'] ),
380            'masterForcedOff'   => in_array(
381                $config['masterForcedOff'] ?? '',
382                array(
383                    Jetpack_AI_Settings::FORCED_OFF_ROUTE_FILTER,
384                    Jetpack_AI_Settings::FORCED_OFF_ROUTE_FILTER_VIP,
385                    Jetpack_AI_Settings::FORCED_OFF_ROUTE_MODULES,
386                ),
387                true
388            ) ? $config['masterForcedOff'] : '',
389            'isOfflineMode'     => ! empty( $config['isOfflineMode'] ),
390            'canConnectSite'    => ! empty( $config['canConnectSite'] ),
391            'apiRoot'           => esc_url_raw( rest_url() ),
392            'apiNonce'          => wp_create_nonce( 'wp_rest' ),
393            'pluginUrl'         => plugins_url( '', JETPACK__PLUGIN_FILE ),
394            // Images ship from the plugin directory, so the plugin version is what busts their cache.
395            'assetsVersion'     => JETPACK__VERSION,
396            // The redirect entry bakes in the jetpack_ai_yearly product and
397            // a post-checkout return to this page, so both can be
398            // retargeted without shipping a code change.
399            'upgradeUrl'        => Redirect::get_url( 'jetpack-ai-hub-upgrade' ),
400            // The purchase granting AI â€” the usage card only uses it to pick
401            // the right loading-skeleton shape before the usage fetch lands.
402            // Only looked up when a gated view can render the card.
403            'planName'          => $plan_info['name'],
404            'showFeaturesView'  => $show_gated_views,
405            'showA12sBadge'     => ! empty( $config['showA12sBadge'] ),
406            // The tab and its Agents Manager sidebar ship disabled by default.
407            'featureFlags'      => array(
408                Jetpack_AI_Feature_Flags::SCHEDULED_TASKS => $show_scheduled_tasks_view,
409            ),
410            // The usage endpoint proxies as the current user, which needs
411            // their own WordPress.com account linked â€” not just the site.
412            'isUserConnected'   => ! empty( $config['isUserConnected'] ),
413            // Tracks audience properties for the jetpack_mcp_* events, per the
414            // Tracks standards for AI product events (AIINT-586). The client
415            // sends them as the strings 'true'/'false' (AIINT-576).
416            'isA11n'            => self::is_current_user_automattician(),
417            'isTest'            => $is_internal_test,
418            // Identity for Tracks; the lookup can call WordPress.com on a
419            // cache miss, so it shares the sender's guard.
420            'tracksUserData'    => $can_send_tracks ? self::get_tracks_user_data() : null,
421            'mcpSettingsApi'    => $config['mcpSettingsApi'] ?? array(),
422        );
423
424        wp_add_inline_script(
425            'jetpack-ai-admin',
426            'var jetpackAiSettings = ' . wp_json_encode(
427                $settings,
428                JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
429            ) . ';',
430            'before'
431        );
432
433        /*
434         * `@automattic/jetpack-analytics` reads `window.jpTracksContext.blog_id` at
435         * event-fire time and attaches it to every Tracks event fired from this page.
436         * Without it, JS-fired events from self-hosted sites carry no blog_id â€” the
437         * Tracks pixel cannot resolve the site â€” so the events cannot be joined to
438         * plan or site data. Mirrors Connection\Initial_State::render().
439         */
440        wp_add_inline_script(
441            'jetpack-ai-admin',
442            sprintf(
443                'window.jpTracksContext = window.jpTracksContext || {}; window.jpTracksContext.blog_id = %s;',
444                absint( $blog_id )
445            ),
446            'before'
447        );
448    }
449
450    /**
451     * Connected-user identity for Tracks; null when no WordPress.com account is
452     * linked. Two keys only, so email and locale stay out of the page HTML.
453     *
454     * @return array{userid:int, username:string}|null
455     */
456    private static function get_tracks_user_data() {
457        $identity = \Jetpack_Tracks_Client::get_connected_user_tracks_identity();
458        if ( ! is_array( $identity ) || ! isset( $identity['userid'] ) || ! isset( $identity['username'] ) ) {
459            return null;
460        }
461
462        return array(
463            'userid'   => (int) $identity['userid'],
464            'username' => (string) $identity['username'],
465        );
466    }
467
468    /**
469     * Whether the current user is an Automattician.
470     *
471     * Identity check for the Tracks `is_a11n` audience property â€” it answers
472     * "who is this", not "may they use the tool", so it deliberately does not
473     * consult the MCP allowlist: allowlisted external testers are not a11ns.
474     *
475     * On wpcom Simple/Atomic the platform's is_automattician() is authoritative.
476     * Self-hosted Jetpack has no platform check; there the Tracks identity of a
477     * connected user is their WordPress.com account, so the connected account's
478     * email domain is the identity signal.
479     *
480     * @return bool
481     */
482    private static function is_current_user_automattician() {
483        if ( function_exists( 'is_automattician' ) ) {
484            return (bool) is_automattician( get_current_user_id() );
485        }
486
487        $user_data = ( new Connection_Manager() )->get_connected_user_data();
488        $email     = is_array( $user_data ) && ! empty( $user_data['email'] )
489            ? strtolower( (string) $user_data['email'] )
490            : '';
491
492        return '' !== $email && '@automattic.com' === substr( $email, -15 );
493    }
494
495    /**
496     * The Search dashboard page the AI Answers row links to, or '' once a host
497     * has removed it. Removal is remove_submenu_page() on `admin_menu`, which only
498     * unsets the registry entry â€” menu_page_url() still answers for the page and
499     * wp-admin then denies it â€” so the registry is read after that hook has run.
500     *
501     * @return string
502     */
503    private static function get_search_settings_url() {
504        global $submenu;
505
506        // Search registers under the Jetpack menu, or menu-less (parent '') when
507        // `jetpack_search_should_add_search_submenu` says no; both are reachable.
508        foreach ( array( 'jetpack', '' ) as $parent ) {
509            foreach ( (array) ( $submenu[ $parent ] ?? array() ) as $item ) {
510                if ( isset( $item[2] ) && 'jetpack-search' === $item[2] ) {
511                    return admin_url( 'admin.php?page=jetpack-search#/ai-answers' );
512                }
513            }
514        }
515
516        return '';
517    }
518
519    /**
520     * Whether My Jetpack is loaded on this host.
521     *
522     * Hosts drop it with the `jetpack_my_jetpack_should_initialize` filter, and
523     * VIP removes it from outside this codebase, where that filter cannot answer.
524     *
525     * @return bool
526     */
527    private static function has_my_jetpack() {
528        if ( ( new Host() )->is_vip_site() ) {
529            return false;
530        }
531
532        return class_exists( 'Automattic\\Jetpack\\My_Jetpack\\Initializer' )
533            && method_exists( 'Automattic\\Jetpack\\My_Jetpack\\Initializer', 'should_initialize' )
534            && \Automattic\Jetpack\My_Jetpack\Initializer::should_initialize();
535    }
536
537    /**
538     * Name of the purchase granting this site AI ("Jetpack Complete"), from
539     * My Jetpack's purchase data â€” its Plans section's source.
540     *
541     * @return array{name: string} An empty string when nothing paid grants AI
542     *                             or the data is unavailable.
543     */
544    private static function get_ai_plan_info() {
545        $empty = array( 'name' => '' );
546
547        if ( ! class_exists( '\Automattic\Jetpack\My_Jetpack\Products\Jetpack_Ai' ) ) {
548            return $empty;
549        }
550
551        // The purchase lookup can make remote requests; cache the outcome
552        // (empty included) so the admin page pays that cost at most hourly.
553        $cached = get_transient( 'jetpack_ai_overview_plan_info' );
554        if ( is_array( $cached ) ) {
555            return array_merge( $empty, $cached );
556        }
557
558        // A failed lookup is not "no purchase": skip the hour-long cache so the
559        // next page load can try again instead of pinning a blank name.
560        if ( is_wp_error( \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_purchases() ) ) {
561            return $empty;
562        }
563
564        $purchase = \Automattic\Jetpack\My_Jetpack\Products\Jetpack_Ai::get_paid_plan_purchase_for_product();
565
566        // A WordPress.com site names its own plan, never a Jetpack one.
567        if ( self::is_jetpack_purchase( $purchase ) && ( new Host() )->is_woa_site() ) {
568            $purchase = self::get_wpcom_plan_purchase();
569        }
570
571        $info = $empty;
572        if ( $purchase && ! empty( $purchase->product_name ) && 'expired' !== ( $purchase->expiry_status ?? '' ) ) {
573            // The design shows the bare plan name ("Complete", "Business"), so
574            // trim the store names' brand prefixes; they are untranslated.
575            $info['name'] = (string) preg_replace( '/^(Jetpack|WordPress\.com) /', '', (string) $purchase->product_name );
576        }
577
578        set_transient( 'jetpack_ai_overview_plan_info', $info, HOUR_IN_SECONDS );
579
580        return $info;
581    }
582
583    /**
584     * Whether a purchase was bought from the Jetpack store.
585     *
586     * @param object|null $purchase Purchase from My Jetpack.
587     * @return bool
588     */
589    private static function is_jetpack_purchase( $purchase ) {
590        return (bool) $purchase && 0 === strpos( (string) ( $purchase->product_slug ?? '' ), 'jetpack_' );
591    }
592
593    /**
594     * The purchase behind the site's current WordPress.com plan.
595     *
596     * The plan record carries only a slug and a display name lives on purchases,
597     * so the slug is matched back to the purchase that created it.
598     *
599     * @return object|null Null when the plan or its purchase cannot be found.
600     */
601    private static function get_wpcom_plan_purchase() {
602        $current_plan = \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_plan();
603        $plan_slug    = is_array( $current_plan ) && ! empty( $current_plan['product_slug'] )
604            ? (string) $current_plan['product_slug']
605            : '';
606
607        // An empty slug simply matches nothing below, so it needs no guard.
608        foreach ( (array) \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_purchases() as $purchase ) {
609            if ( $plan_slug === ( $purchase->product_slug ?? '' ) ) {
610                return $purchase;
611            }
612        }
613
614        return null;
615    }
616
617    /**
618     * Override the base render() to skip wrap_ui entirely.
619     *
620     * Wrap_ui renders the Jetpack masthead header and static footer, which
621     * duplicate the header/footer that AdminPage (React) already provides.
622     * Calling page_render() directly lets AdminPage own the full layout.
623     */
624    public function render() {
625        $this->page_render();
626    }
627
628    /**
629     * Render the page, or say why it could not be rendered.
630     *
631     * The generated wp-build page owns the markup the app mounts into.
632     */
633    public function page_render() {
634        if ( self::should_render_wp_build() ) {
635            jetpack_plugin_jetpack_ai_hub_wp_admin_render_page(); // @phan-suppress-current-line PhanUndeclaredFunction -- should_render_wp_build() checks function_exists(); defined in the generated build/pages/, which Phan excludes.
636            return;
637        }
638
639        // The build output is missing; say so rather than leaving a silent blank page.
640        printf(
641            '<div class="wrap"><h1>%s</h1><div class="notice notice-error"><p>%s</p></div></div>',
642            esc_html__( 'Jetpack AI', 'jetpack' ),
643            esc_html__( 'Jetpack AI could not be loaded because its assets are missing. Reinstalling or updating the plugin usually fixes this. If it keeps happening, contact your site administrator or host.', 'jetpack' )
644        );
645    }
646}
647
648/*
649 * wp-build must load before add_actions() runs on any host, so hook it here: Jetpack_Admin and
650 * mu-wpcom's WordPress.com Simple integration both require this file before `admin_menu`, and
651 * add_action() dedupes.
652 */
653add_action( 'admin_menu', array( 'Jetpack_AI_Page', 'maybe_load_wp_build' ), 1 );