<?php
/**
 * Jetpack SEO — the visibility command center for WordPress sites.
 *
 * Registers the `admin.php?page=jetpack-seo` screen via Admin_Menu so it is
 * reachable on self-hosted, Atomic/WoW, and Simple sites alike, and loads the
 * `@wordpress/build` (wp-build) dashboard bundle that renders it.
 *
 * @package automattic/jetpack-seo-package
 */

namespace Automattic\Jetpack\SEO;

use Automattic\Jetpack\Admin_UI\Admin_Menu;
use Automattic\Jetpack\Modules;
use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
use Jetpack_SEO_Titles;
use Jetpack_SEO_Utils;

/**
 * The main Initializer class. Registers the admin menu and loads the wp-build
 * dashboard, bootstrapping the React app's initial state onto the page.
 */
class Initializer {

	/**
	 * Jetpack SEO package version.
	 *
	 * @var string
	 */
	const PACKAGE_VERSION = '0.1.0';

	/**
	 * Filter name that gates the entire Jetpack SEO surface.
	 *
	 * When this filter returns true, the package registers its admin menu and
	 * loads the wp-build dashboard. Default false before release - when the
	 * filter is off the package registers no admin menu and no assets, and
	 * changes nothing about the existing Jetpack UI.
	 *
	 * @var string
	 */
	const FEATURE_FILTER = 'rsm_jetpack_seo';

	/**
	 * URL-facing menu slug (`admin.php?page=jetpack-seo`).
	 */
	const MENU_SLUG = 'jetpack-seo';

	/**
	 * Slug emitted by `@wordpress/build` (`wpPlugin.pages[0]`). wp-build's
	 * auto-generated enqueue callback only fires when `$screen->id` matches
	 * this value, so we alias the screen id to it via `current_screen` without
	 * changing the user-facing URL.
	 */
	const WP_BUILD_SLUG = 'jetpack-seo-dashboard';

	/**
	 * Render function generated by `@wordpress/build` into
	 * `build/pages/jetpack-seo-dashboard/page-wp-admin.php`. Naming convention:
	 * `{wpPlugin.name}_{page-with-underscores}_wp_admin_render_page`.
	 */
	const WP_BUILD_RENDER_FN = 'jetpack_seo_jetpack_seo_dashboard_wp_admin_render_page';

	/**
	 * Key under `window.JetpackScriptData` the React app reads its state from
	 * (`window.JetpackScriptData.seo`). Must match the JS-side reader in
	 * `_inc/data/get-overview.ts`.
	 */
	const SCRIPT_DATA_KEY = 'seo';

	/**
	 * Whether the package has been initialized.
	 *
	 * @var bool
	 */
	private static $initialized = false;

	/**
	 * Initialize the package.
	 *
	 * Called from the Jetpack plugin's `late_initialization()` hook.
	 *
	 * @return void
	 */
	public static function init() {
		if ( self::$initialized ) {
			return;
		}
		self::$initialized = true;

		// Gate the entire SEO surface behind the feature flag.
		if ( ! (bool) apply_filters( self::FEATURE_FILTER, false ) ) {
			return;
		}

		// Gate the entire SEO surface on the `seo-tools` module being active,
		// the same way other Jetpack modules do. When the module is off we
		// register nothing — no admin menu, no assets — rather than registering
		// everything and hiding the menu downstream.
		if ( ! self::is_seo_tools_module_active() ) {
			return;
		}

		// Priority 1: load the wp-build bundle (and define its render function)
		// before `add_menu_item()` runs at the default priority and needs it.
		add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
		add_action( 'admin_menu', array( __CLASS__, 'add_menu_item' ), 10 );

		// Expose the core `blog_public` option to the REST settings endpoint so
		// the Settings tab can save search-engine visibility via `/wp/v2/settings`.
		// (The Jetpack settings endpoint only accepts Jetpack options.) Writes
		// are still capability-gated by the core settings controller.
		add_action( 'rest_api_init', array( __CLASS__, 'register_rest_settings' ) );

		/**
		 * Fires after the Jetpack SEO package is initialized.
		 *
		 * @since 0.1.0
		 */
		do_action( 'jetpack_seo_init' );
	}

	/**
	 * Register the admin menu item.
	 *
	 * Uses Admin_Menu so the page is reachable on wp-admin across all site
	 * types. The render callback is wp-build's generated render function when
	 * the bundle is loaded (i.e. on the SEO page itself, after
	 * `maybe_load_wp_build()` ran at priority 1); otherwise it falls back to a
	 * bare mount node so the page never fatals on an unbuilt checkout.
	 *
	 * @return void
	 */
	public static function add_menu_item() {
		$callback = function_exists( self::WP_BUILD_RENDER_FN )
			? self::WP_BUILD_RENDER_FN
			: array( __CLASS__, 'render_fallback' );

		Admin_Menu::add_menu(
			'SEO',
			'SEO',
			'manage_options',
			self::MENU_SLUG,
			$callback,
			2
		);
	}

	/**
	 * On the SEO admin page, load the wp-build bundle, alias the screen id so
	 * wp-build enqueues its assets, and bootstrap the app's initial state.
	 *
	 * Hooked at `admin_menu` priority 1 so polyfills register and the render
	 * function is defined before `add_menu_item()` runs at priority 10.
	 *
	 * @return void
	 */
	public static function maybe_load_wp_build() {
		if ( ! self::is_seo_admin_request() ) {
			return;
		}

		self::load_wp_build();
		add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
		add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_script_data' ) );
	}

	/**
	 * Load wp-build's generated registration file and register the polyfills
	 * the bundle depends on. No-op on a fresh checkout before `pnpm build`, in
	 * which case `add_menu_item()` falls back to {@see self::render_fallback()}.
	 *
	 * @return void
	 */
	private static function load_wp_build() {
		$build_index = dirname( __DIR__ ) . '/build/build.php';

		if ( ! file_exists( $build_index ) ) {
			return;
		}

		require_once $build_index;

		WP_Build_Polyfills::register(
			'jetpack-seo',
			array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
		);
	}

	/**
	 * Alias the current screen id to wp-build's expected slug so its
	 * auto-generated enqueue callback fires for our user-facing page.
	 *
	 * @param \WP_Screen|null $screen The current screen object (passed by WP).
	 * @return void
	 */
	public static function alias_screen_id_for_wp_build( $screen ) {
		if ( ! is_object( $screen ) ) {
			return;
		}

		$screen->id = self::WP_BUILD_SLUG;
	}

	/**
	 * Bootstrap the React app's initial state onto `window.JetpackScriptData.seo`.
	 *
	 * Because wp-build pages load as ES modules, `wp_localize_script` can't
	 * attach data to them; the shared `jetpack_admin_js_script_data` filter
	 * (printed by the Script_Data package onto the `jetpack-script-data` handle
	 * the bundle already depends on) is the supported channel. Mirrors Podcast
	 * and Newsletter.
	 *
	 * @param array $data Script data being injected onto the page.
	 * @return array
	 */
	public static function inject_script_data( $data ) {
		if ( ! is_array( $data ) ) {
			$data = array();
		}

		$data[ self::SCRIPT_DATA_KEY ]['overview'] = self::get_overview_data();
		$data[ self::SCRIPT_DATA_KEY ]['settings'] = self::get_settings_data();

		return $data;
	}

	/**
	 * Fallback render used when the wp-build artifact is missing (unbuilt
	 * checkout). Renders a bare wrapper so the page loads without the app.
	 *
	 * @return void
	 */
	public static function render_fallback() {
		echo '<div class="wrap"><h1>SEO</h1></div>';
	}

	/**
	 * Whether the current request targets the SEO admin page.
	 *
	 * @return bool
	 */
	private static function is_seo_admin_request() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
			return false;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		return self::MENU_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
	}

	/**
	 * Whether the `seo-tools` Jetpack module is currently active.
	 *
	 * @return bool
	 */
	private static function is_seo_tools_module_active() {
		if ( ! class_exists( 'Automattic\\Jetpack\\Modules' ) ) {
			return false;
		}
		return ( new Modules() )->is_active( 'seo-tools' );
	}

	/**
	 * Build the aggregated Overview state the dashboard renders.
	 *
	 * @return array
	 */
	public static function get_overview_data() {
		$modules = new Modules();
		// @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack and is guarded by class_exists.
		$seo_enabled = class_exists( 'Jetpack_SEO_Utils' ) && Jetpack_SEO_Utils::is_enabled_jetpack_seo();

		$codes = get_option( 'verification_services_codes', array() );
		if ( ! is_array( $codes ) ) {
			$codes = array();
		}

		return array(
			'site_visibility'   => array(
				'search_engines_visible' => (int) get_option( 'blog_public', 1 ) === 1,
				// The real `sitemaps` module is the source of truth (the Settings
				// toggle drives it via `/jetpack/v4/settings`).
				'sitemap_active'         => $modules->is_active( 'sitemaps' ),
				'sitemap_url'            => home_url( '/sitemap.xml' ),
				'seo_tools_active'       => $modules->is_active( 'seo-tools' ),
			),
			// Per-service booleans (a code is set or not) for the Overview's
			// Site verification card.
			'site_verification' => array(
				'google'    => ! empty( $codes['google'] ),
				'bing'      => ! empty( $codes['bing'] ),
				'pinterest' => ! empty( $codes['pinterest'] ),
				'yandex'    => ! empty( $codes['yandex'] ),
				'facebook'  => ! empty( $codes['facebook'] ),
			),
			'plan'              => array(
				'seo_enabled_for_site' => $seo_enabled,
			),
		);
	}

	/**
	 * Expose the core `blog_public` option to the REST settings endpoint.
	 *
	 * Search-engine visibility is a WordPress core option, not a Jetpack one,
	 * so the Settings tab saves it through `/wp/v2/settings` — which only
	 * round-trips settings registered with `show_in_rest`. The core settings
	 * controller enforces the `manage_options` capability on writes.
	 *
	 * @return void
	 */
	public static function register_rest_settings() {
		register_setting(
			'reading',
			'blog_public',
			array(
				'show_in_rest' => true,
				'type'         => 'integer',
				'default'      => 1,
			)
		);
	}

	/**
	 * Build the editable Settings state the Settings tab hydrates from.
	 *
	 * Read-only bootstrap only. Writes go through the existing
	 * `/jetpack/v4/settings` REST endpoint, which already validates and
	 * sanitizes each of these fields — this package registers no settings
	 * endpoint of its own. The reads here mirror the options/helpers that
	 * endpoint round-trips so the form hydrates without a request.
	 *
	 * @return array
	 */
	public static function get_settings_data() {
		$modules = new Modules();

		// @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Titles lives in plugins/jetpack and is guarded by class_exists.
		$title_formats = class_exists( 'Jetpack_SEO_Titles' ) ? Jetpack_SEO_Titles::get_custom_title_formats() : array();
		// @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack and is guarded by class_exists.
		$front_page_desc = class_exists( 'Jetpack_SEO_Utils' ) ? Jetpack_SEO_Utils::get_front_page_meta_description() : '';

		$codes = get_option( 'verification_services_codes', array() );
		if ( ! is_array( $codes ) ) {
			$codes = array();
		}

		return array(
			'search_engines_visible' => (int) get_option( 'blog_public', 1 ) === 1,
			// The real `sitemaps` module is the source of truth (not a bespoke
			// option). The Settings toggle drives it via `/jetpack/v4/settings`.
			'sitemap_active'         => $modules->is_active( 'sitemaps' ),
			// Cast to object so an empty format set serializes as `{}`, not `[]`.
			'title_formats'          => (object) $title_formats,
			'front_page_description' => (string) $front_page_desc,
			'verification'           => array(
				'google'    => isset( $codes['google'] ) ? (string) $codes['google'] : '',
				'bing'      => isset( $codes['bing'] ) ? (string) $codes['bing'] : '',
				'pinterest' => isset( $codes['pinterest'] ) ? (string) $codes['pinterest'] : '',
				'yandex'    => isset( $codes['yandex'] ) ? (string) $codes['yandex'] : '',
				'facebook'  => isset( $codes['facebook'] ) ? (string) $codes['facebook'] : '',
			),
		);
	}
}
