<?php // phpcs:ignore

namespace SEOPress\Actions\Admin;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use SEOPress\Core\Hooks\ExecuteHooks;

/**
 * Module metabox
 */
class ModuleMetabox implements ExecuteHooks {

	/**
	 * The ModuleMetabox hooks.
	 *
	 * @since 5.0.0
	 *
	 * @return void
	 */
	public function hooks() {
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
		add_action( 'init', array( $this, 'enqueue' ) );
		add_action( 'add_meta_boxes', array( $this, 'registerClassicOpenerMetabox' ) );
		add_action( 'save_post', array( $this, 'saveClassicEditorMetaFallback' ), 10, 2 );

		if ( current_user_can( seopress_capability( 'edit_posts' ) ) ) { // phpcs:ignore
			// Priority 110 so the beacon survives builders that wipe the
			// script queue mid-flight (Avada Live Builder clears it at 100).
			add_action( 'wp_enqueue_scripts', array( $this, 'enqueueFrontend' ), 110 );
		}
	}

	/**
	 * Register the Classic Editor "SEO" metabox. The body provides the
	 * mount node for the React universal metabox so it renders inline
	 * where Classic users expect it, instead of hunting for the floating
	 * beacon or opening a separate overlay. Skipped on Gutenberg (which
	 * has its own sidebar panel) and when the universal metabox can't be
	 * enqueued (legacy classic metabox still handles that case).
	 *
	 * @since 9.9.0
	 *
	 * @return void
	 */
	public function registerClassicOpenerMetabox() {
		if ( ! seopress_get_service( 'EnqueueModuleMetabox' )->canEnqueue() ) {
			return;
		}

		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
		if ( $screen && method_exists( $screen, 'is_block_editor' ) && true === $screen->is_block_editor() ) {
			return;
		}

		$post_types = seopress_get_service( 'WordPressData' )->getPostTypes();
		$post_types = apply_filters( 'seopress_metaboxe_seo', $post_types );

		if ( empty( $post_types ) ) {
			return;
		}

		/**
		 * Filter the Classic Editor "SEO" metabox context.
		 *
		 * @since 9.8.0
		 *
		 * @param string $context Metabox context. Default 'normal'. Accepts 'normal', 'side', 'advanced'.
		 */
		$context = apply_filters( 'seopress_metabox_opener_context', 'normal' );

		/**
		 * Filter the Classic Editor "SEO" metabox priority.
		 *
		 * @since 9.8.0
		 *
		 * @param string $priority Metabox priority. Default 'default'. Accepts 'high', 'core', 'default', 'low'.
		 */
		$priority = apply_filters( 'seopress_metabox_opener_priority', 'default' );

		foreach ( array_keys( $post_types ) as $post_type ) {
			add_meta_box(
				'seopress_metabox_opener',
				__( 'SEO', 'wp-seopress' ),
				array( $this, 'renderClassicOpenerMetabox' ),
				$post_type,
				$context,
				$priority
			);
		}
	}

	/**
	 * Render the Classic Editor metabox body — just the mount node
	 * picked up by the React universal metabox to render inline.
	 *
	 * @since 9.9.0
	 *
	 * @return void
	 */
	public function renderClassicOpenerMetabox() {
		?>
		<div id="seopress-js-module-seo-metabox-embed"></div>
		<?php
	}

	/**
	 * Persist SEO meta from the Classic Editor post form when the React
	 * metabox cannot reach the REST API. The React tabs render hidden
	 * inputs (associated with `<form id="post">` via the HTML `form`
	 * attribute) that mirror their Formik state, so a normal post save
	 * still carries the values even if the REST PUTs to `/seopress/v1/...`
	 * are blocked. Skipped during autosave/revision/REST and gated by a
	 * dedicated nonce. The list of meta keys is filterable so the Pro
	 * plugin can register its own.
	 *
	 * @since 9.8.0
	 *
	 * @param int      $post_id The post ID.
	 * @param \WP_Post $post    The post object.
	 *
	 * @return void
	 */
	public function saveClassicEditorMetaFallback( $post_id, $post ) {
		if ( ! isset( $_POST['seopress_metabox_classic_fallback_nonce'] ) ) {
			return;
		}

		$nonce = sanitize_text_field( wp_unslash( $_POST['seopress_metabox_classic_fallback_nonce'] ) );
		if ( ! wp_verify_nonce( $nonce, 'seopress_metabox_classic_fallback' ) ) {
			return;
		}

		if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
			return;
		}

		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}

		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return;
		}

		$meta_keys_map = $this->getClassicEditorFallbackMetaKeys();

		foreach ( $meta_keys_map as $meta_key => $sanitizer ) {
			if ( ! array_key_exists( $meta_key, $_POST ) ) {
				continue;
			}

			$raw   = wp_unslash( $_POST[ $meta_key ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
			$value = $this->sanitizeFallbackValue( $raw, $sanitizer );

			if ( null === $value || '' === $value || ( is_array( $value ) && empty( $value ) ) ) {
				delete_post_meta( $post_id, $meta_key );
			} else {
				update_post_meta( $post_id, $meta_key, $value );
			}
		}
	}

	/**
	 * Return the meta keys map for the Classic Editor fallback. Each entry
	 * maps a post_meta key to a sanitization spec accepted by
	 * `sanitizeFallbackValue()` — either a callable or one of:
	 * `'text'`, `'textarea'`, `'url'`, `'int'`, or
	 * `array( 'json', $deep_callable )` for JSON-encoded structured fields.
	 * Filterable via `seopress_metabox_classic_fallback_meta_keys` so the
	 * Pro plugin (and integrators) can extend the list.
	 *
	 * @since 9.8.0
	 *
	 * @return array<string, mixed>
	 */
	protected function getClassicEditorFallbackMetaKeys() {
		$keys = array(
			// Title & description tab.
			'_seopress_titles_title'               => 'text',
			'_seopress_titles_desc'                => 'textarea',
			// Advanced tab — robots/canonical/primary category.
			'_seopress_robots_canonical'           => 'url',
			'_seopress_robots_index'               => 'text',
			'_seopress_robots_follow'              => 'text',
			'_seopress_robots_imageindex'          => 'text',
			'_seopress_robots_snippet'             => 'text',
			'_seopress_robots_primary_cat'         => 'text',
			'_seopress_robots_breadcrumbs'         => 'text',
			// Redirections (per-post) tab.
			'_seopress_redirections_value'         => 'url',
			'_seopress_redirections_enabled'       => 'text',
			'_seopress_redirections_enabled_regex' => 'text',
			'_seopress_redirections_logged_status' => 'text',
			'_seopress_redirections_param'         => 'text',
			'_seopress_redirections_type'          => 'int',
			// Content analysis tab — comma-separated string mirroring the
			// REST PUT normalization in
			// SEOPress\Actions\Api\TargetKeywords::processPut().
			'_seopress_analysis_target_kw'         => function ( $raw ) {
				if ( ! is_string( $raw ) ) {
					return '';
				}
				$parts = array_filter(
					array_map( 'trim', explode( ',', $raw ) ),
					static function ( $value ) {
						return '' !== $value;
					}
				);
				return sanitize_text_field( implode( ',', $parts ) );
			},
		);

		// Social tab — per-platform keys, sanitizer derived from the field type.
		if ( class_exists( '\SEOPress\Helpers\Metas\SocialSettings' ) ) {
			$social_fields = array_merge(
				\SEOPress\Helpers\Metas\SocialSettings::getMetaKeysFacebook(),
				\SEOPress\Helpers\Metas\SocialSettings::getMetaKeysTwitter()
			);
			foreach ( $social_fields as $field ) {
				if ( empty( $field['key'] ) ) {
					continue;
				}
				$type = isset( $field['type'] ) ? $field['type'] : 'input';
				switch ( $type ) {
					case 'textarea':
						$keys[ $field['key'] ] = 'textarea';
						break;
					case 'upload':
						$keys[ $field['key'] ] = 'url';
						break;
					default:
						$keys[ $field['key'] ] = 'text';
				}
			}
		}

		/**
		 * Filter the meta keys persisted by the Classic Editor save_post
		 * fallback. Pro / integrators add their own keys here. Each value
		 * is either a string ('text', 'textarea', 'url', 'int'), a callable
		 * applied to the raw $_POST value, or `array( 'json', $deep_cb )`
		 * for JSON-encoded structured fields.
		 *
		 * @since 9.8.0
		 *
		 * @param array<string, mixed> $keys Meta key => sanitizer spec.
		 */
		return apply_filters( 'seopress_metabox_classic_fallback_meta_keys', $keys );
	}

	/**
	 * Apply a sanitizer spec to a raw $_POST value. Returns null when the
	 * value should be skipped (invalid JSON for json-typed entries).
	 *
	 * @since 9.8.0
	 *
	 * @param mixed $raw  Raw value from $_POST (already wp_unslash'd).
	 * @param mixed $spec Sanitizer spec — see getClassicEditorFallbackMetaKeys().
	 *
	 * @return mixed
	 */
	protected function sanitizeFallbackValue( $raw, $spec ) {
		if ( is_string( $spec ) ) {
			switch ( $spec ) {
				case 'textarea':
					return is_scalar( $raw ) ? sanitize_textarea_field( (string) $raw ) : '';
				case 'url':
					return is_scalar( $raw ) ? sanitize_url( (string) $raw ) : '';
				case 'int':
					return is_scalar( $raw ) ? (int) $raw : 0;
				case 'text':
				default:
					return is_scalar( $raw ) ? sanitize_text_field( (string) $raw ) : '';
			}
		}

		// JSON-encoded structured fields: ['json', $deep_callable].
		if ( is_array( $spec ) && isset( $spec[0] ) && 'json' === $spec[0] ) {
			if ( ! is_string( $raw ) || '' === $raw ) {
				return null;
			}
			$decoded = json_decode( $raw, true );
			if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) {
				return null;
			}
			$callback = isset( $spec[1] ) && is_callable( $spec[1] ) ? $spec[1] : 'sanitize_text_field';
			return is_array( $decoded ) ? map_deep( $decoded, $callback ) : $decoded;
		}

		if ( is_callable( $spec ) ) {
			return call_user_func( $spec, $raw );
		}

		return is_scalar( $raw ) ? sanitize_text_field( (string) $raw ) : '';
	}

	/**
	 * Enqueue module.
	 *
	 * @param array $args_localize The arguments localize.
	 *
	 * @since 5.0.0
	 *
	 * @return void
	 */
	protected function enqueueModule( $args_localize = array() ) {
		if ( ! seopress_get_service( 'EnqueueModuleMetabox' )->canEnqueue() ) {
			return;
		}

		// AMP compatibility.
		if ( function_exists( 'amp_is_request' ) && amp_is_request() ) {
			return;
		}

		// Bricks builder compatibility.
		if ( function_exists( 'bricks_is_builder_call' ) && bricks_is_builder_call() === true ) {
			return;
		}

		// Bricks builder compatibility: duplicated tag on homepage.
		if ( isset( $_GET['brickspreview'] ) ) { // phpcs:ignore
			return;
		}

		$is_gutenberg = false;
		if ( function_exists( 'get_current_screen' ) ) {
			$current_screen = get_current_screen();
			if ( $current_screen && method_exists( $current_screen, 'is_block_editor' ) ) {
				$is_gutenberg = true === get_current_screen()->is_block_editor();
			}
		}

		$dependencies = array( 'react', 'react-dom', 'wp-components' );
		if ( $is_gutenberg ) {
			$dependencies = array_merge( $dependencies, array( 'wp-edit-post', 'wp-plugins' ) );
		}

		wp_enqueue_media();
		wp_enqueue_style( 'wp-components' );
		wp_enqueue_style( 'seopress-metabox', SEOPRESS_URL_PUBLIC . '/metaboxe.css', array( 'wp-components' ), SEOPRESS_VERSION );
		wp_enqueue_script( 'seopress-metabox', SEOPRESS_URL_PUBLIC . '/metaboxe.js', $dependencies, SEOPRESS_VERSION, true );

		global $post;

		if ( post_type_supports( get_post_type( $post ), 'custom-fields' ) ) {
			wp_enqueue_script( 'seopress-pre-publish-checklist', SEOPRESS_URL_PUBLIC . '/editor/pre-publish-checklist/index.js', array(), SEOPRESS_VERSION, true );
		}
		if ( $is_gutenberg ) {
			// Check if metabox is disabled for this post type.
			if ( '1' === seopress_get_service( 'TitleOption' )->getSingleCptEnable( $post->post_type ) ) {
				return;
			}

			wp_enqueue_script( 'seopress-sidebar-panel', SEOPRESS_URL_PUBLIC . '/editor/sidebar-panel/index.js', array( 'wp-plugins', 'wp-editor', 'wp-element', 'wp-components', 'wp-i18n' ), SEOPRESS_VERSION, true );

			// Get score data for the current post.
			$score       = seopress_get_service( 'ContentAnalysisDatabase' )->getData( $post->ID, array( 'score' ) );
			$score_color = '#94a3b8'; // Default gray.

			if ( ! empty( $score ) && is_array( $score ) ) {
				// Flatten the score array.
				$score_flat = array();
				foreach ( $score as $item ) {
					if ( is_array( $item ) ) {
						$score_flat = array_merge( $score_flat, $item );
					}
				}

				// Both 'high' and 'medium' mean "should be improved" (orange/yellow).
				if ( in_array( 'high', $score_flat, true ) || in_array( 'medium', $score_flat, true ) ) {
					$score_color = '#f59e0b'; // Orange - should be improved.
				} else {
					$score_color = '#16a34a'; // Green - good.
				}
			}

			// Default to showing the score text; let the user opt out via
			// the new "SEO score in editor toolbar" setting. The legacy
			// seopress_toolbar_button_show_text filter still wins so
			// programmatic overrides keep working.
			$show_text = '1' !== seopress_get_service( 'AdvancedOption' )->getAppearanceAdminBarScoreText();

			wp_localize_script(
				'seopress-sidebar-panel',
				'seopressScore',
				array(
					'color'    => $score_color,
					'showText' => apply_filters( 'seopress_toolbar_button_show_text', $show_text ),
				)
			);
		}
		$value = wp_create_nonce( 'seopress_rest' );

		$tags = seopress_get_service( 'TagsToString' )->getTagsAvailable(
			array(
				'without_classes'     => array(
					'\SEOPress\Tags\PostThumbnailUrlHeight',
					'\SEOPress\Tags\PostThumbnailUrlWidth',

				),
				'without_classes_pos' => array( '\SEOPress\Tags\Schema', '\SEOPressPro\Tags\Schema' ),
			)
		);

		$get_locale = get_locale();
		if ( ! empty( $get_locale ) ) {
			$locale       = function_exists( 'locale_get_primary_language' ) ? locale_get_primary_language( get_locale() ) : get_locale();
			$country_code = function_exists( 'locale_get_region' ) ? locale_get_region( get_locale() ) : get_locale();
		} else {
			$locale       = 'en';
			$country_code = 'US';
		}

		$settings_advanced = seopress_get_service( 'AdvancedOption' );
		$user              = wp_get_current_user();
		$roles             = (array) $user->roles;

		// Get post ID - check multiple sources.
		$post_id   = null;
		$post_type = null;

		// In frontend singular context.
		if ( is_singular() ) {
			$post_id = get_the_ID();
		} else {
			// In admin, resolve the edited post from the request first.
			// Page builders such as Elementor Pro Theme Builder can replace
			// the global $post with one of their own templates
			// (elementor_library) while a WooCommerce product / post edit
			// screen is rendered. Relying on the global $post here would
			// leak that template into the SEO preview (wrong title, meta
			// description and ?elementor_library=... URL).
			$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;

			if ( $screen && 'post' === $screen->base ) {
				if ( isset( $_GET['post'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
					$post_id = absint( wp_unslash( $_GET['post'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
				} elseif ( isset( $_POST['post_ID'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
					$post_id = absint( wp_unslash( $_POST['post_ID'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
				}

				// Only trust the request value if it points to a real post.
				if ( $post_id && ! get_post( $post_id ) ) {
					$post_id = null;
				}
			}

			// Fall back to the global $post when the request did not give us
			// a usable ID (new posts, other admin screens, etc.).
			if ( ! $post_id && isset( $post ) && $post instanceof \WP_Post ) {
				$post_id = $post->ID;
			}
		}

		if ( $post_id ) {
			$post_type = get_post_type( $post_id );
		}

		// Compatibility with WooCommerce beta product page.
		if ( isset( $_GET['path'] ) && strpos( $_GET['path'], 'product' ) && isset( $_GET['page'] ) && 'wc-admin' === $_GET['page'] ) { // phpcs:ignore
			$data_path = explode( '/', $_GET['path'] ); // phpcs:ignore
			$post_id   = $data_path[ count( $data_path ) - 1 ];
		}

		$args = array_merge(
			array(
				'SEOPRESS_URL_PUBLIC'       => SEOPRESS_URL_PUBLIC,
				'SEOPRESS_URL_ASSETS'       => SEOPRESS_URL_ASSETS,
				'SEOPRESS_VERSION'          => SEOPRESS_VERSION,
				'SEOPRESS_PRO_VERSION'      => defined( 'SEOPRESS_PRO_VERSION' ) ? SEOPRESS_PRO_VERSION : '0',
				'SEOPRESS_PRO_IS_ACTIVATED' => is_plugin_active( 'wp-seopress-pro/seopress-pro.php' ) ? true : false,
				'SITENAME'                  => get_bloginfo( 'name' ),
				'SITEURL'                   => site_url(),
				'ADMIN_URL_TITLES'          => admin_url( 'admin.php?page=seopress-titles#tab=tab_seopress_titles_single' ),
				'ADMIN_URL_ARCHIVES_TITLES' => admin_url( 'admin.php?page=seopress-titles#tab=tab_seopress_titles_archives' ),
				'TAGS'                      => array_values( $tags ),
				'REST_URL'                  => rest_url(),
				'NONCE'                     => wp_create_nonce( 'wp_rest' ),
				// admin-ajax transport for the metabox's own REST calls. Some
				// hosts run a WAF that challenges the /wp-json path by rule
				// (o2switch Tiger Protect, Sucuri...) while leaving
				// admin-ajax.php alone, because blocking it would break core
				// WordPress admin. The classic metabox fetched over admin-ajax
				// before 9.8 and worked everywhere; the React fetcher routes
				// SEOPress REST calls through MetaboxRestProxy to keep that
				// reliability without any per-host configuration.
				'ADMIN_AJAX_URL'            => admin_url( 'admin-ajax.php' ),
				'AJAX_PROXY_NONCE'          => wp_create_nonce( 'seopress_metabox_proxy' ),
				// Nonce verified by saveClassicEditorMetaFallback() — lets the
				// Classic Editor post form rescue title/description writes when
				// the REST API is blocked (security plugin, hardening filter,
				// etc.) so the user does not silently lose their input.
				'CLASSIC_FALLBACK_NONCE'    => wp_create_nonce( 'seopress_metabox_classic_fallback' ),
				'POST_ID'                   => $post_id,
				'POST_TYPE'                 => $post_type,
				'POST_URL'                  => $post_id ? get_permalink( $post_id ) : null,
				// Source the metabox fetches client-side to analyze. A published
				// post is read logged-out from its permalink (POST_URL) for the
				// true crawler view; a draft is read from the preview URL with
				// the editor's session. POST_STATUS lets the client pick.
				'POST_PREVIEW_URL'          => $post_id ? seopress_get_service( 'RequestPreview' )->getLinkRequest( $post_id ) : null,
				'POST_STATUS'               => $post_id ? get_post_status( $post_id ) : null,
				'IS_GUTENBERG'              => apply_filters( 'seopress_module_metabox_is_gutenberg', $is_gutenberg ),
				// Distinguishes the admin screens (where the Classic Editor
				// "Open SEO editor" metabox button takes over the beacon's
				// role) from frontend contexts where the beacon is still the
				// only entry point into the overlay.
				'IS_ADMIN'                  => is_admin(),
				'SELECTOR_GUTENBERG'        => apply_filters( 'seopress_module_metabox_selector_gutenberg', '.edit-post-header .edit-post-header-toolbar__left' ),
				'TOGGLE_MOBILE_PREVIEW'     => apply_filters( 'seopress_toggle_mobile_preview', 1 ),
				'GOOGLE_SUGGEST'            => array(
					'ACTIVE'       => apply_filters( 'seopress_ui_metabox_google_suggest', false ),
					'LOCALE'       => $locale,
					'COUNTRY_CODE' => $country_code,
				),
				'USER_ROLES'                => array_values( $roles ),
				'ROLES_BLOCKED'             => array(
					'GLOBAL'           => $settings_advanced->getSecurityMetaboxRole(),
					'CONTENT_ANALYSIS' => $settings_advanced->getSecurityMetaboxRoleContentAnalysis(),
				),
				'OPTIONS'                   => array(
					'AI' => seopress_get_service( 'ToggleOption' )->getToggleAi() === '1' ? true : false,
				),
				// Per-user capability flags. Nested so wp_localize_script
				// does not cast their booleans to strings (it only stringifies
				// top-level scalars), which would defeat strict equality in
				// the React side.
				'CAPABILITIES'              => array(
					'IGNORE_ISSUES' => function_exists( 'seopress_capability' )
						? current_user_can( seopress_capability( 'manage_options', 'bot' ) )
						: current_user_can( 'manage_options' ),
				),
				'TABS'                      => array(
					'SCHEMAS' => apply_filters( 'seopress_active_schemas_manual_universal_metabox', false ),
				),
				'SUB_TABS'                  => array(
					'GOOGLE_NEWS'      => apply_filters( 'seopress_active_google_news', false ),
					'VIDEO_SITEMAP'    => apply_filters( 'seopress_active_video_sitemap', false ),
					'INSPECT_URL'      => apply_filters( 'seopress_active_inspect_url', false ),
					'INTERNAL_LINKING' => apply_filters( 'seopress_active_internal_linking', false ),
					'SCHEMA_MANUAL'    => apply_filters( 'seopress_active_schemas', false ),
					'SCHEMA_AUTOMATIC' => apply_filters( 'seopress_active_schemas_automatic_universal_metabox', false ),
				),
				'FAVICON'                   => get_site_icon_url( 32 ),
				'BEACON_SVG'                => apply_filters( 'seopress_beacon_svg', SEOPRESS_URL_ASSETS . '/img/beacon.svg' ),
				'AI_SVG'                    => apply_filters( 'seopress_ai_svg', SEOPRESS_URL_ASSETS . '/img/ai.svg' ),
				'CACHED_CONTENT_ANALYSIS'   => $post_id ? $this->getCachedContentAnalysis( $post_id ) : null,
				// Server-side snapshot of the Titles & Metas form so the
				// React overlay can render without a round trip to the
				// /title-description-metas REST endpoint. SWRConfig wires
				// this into the SWR cache as fallback data.
				'INITIAL_DATA'              => $post_id ? array(
					'titleDescription' => array(
						'title'       => html_entity_decode( (string) get_post_meta( $post_id, '_seopress_titles_title', true ), ENT_QUOTES | ENT_XML1, 'UTF-8' ),
						'description' => html_entity_decode( (string) get_post_meta( $post_id, '_seopress_titles_desc', true ), ENT_QUOTES | ENT_XML1, 'UTF-8' ),
					),
				) : null,
			),
			$args_localize
		);

		wp_localize_script( 'seopress-metabox', 'SEOPRESS_DATA', $args );
		wp_localize_script( 'seopress-metabox', 'SEOPRESS_I18N', seopress_get_service( 'I18nUniversalMetabox' )->getTranslations() );
	}

	/**
	 * Enqueue frontend.
	 *
	 * @since 5.0.0
	 *
	 * @return void
	 */
	public function enqueueFrontend() {
		$this->enqueueModule( array( 'POST_ID' => get_the_ID() ) );
	}

	/**
	 * Enqueue.
	 *
	 * @since 5.0.0
	 *
	 * @param string $page The page.
	 *
	 * @return void
	 */
	public function enqueue( $page ) {
		if ( ! in_array( $page, array( 'post.php', 'post-new.php', 'woocommerce_page_wc-admin' ), true ) ) {
			return;
		}
		$this->enqueueModule();
	}

	/**
	 * Enqueue elementor.
	 *
	 * @since 5.0.0
	 *
	 * @return void
	 */
	public function enqueueElementor() {
		$this->enqueueModule();
	}

	/**
	 * Get cached content analysis data formatted for frontend.
	 *
	 * @since 9.5.0
	 *
	 * @param int $post_id The post ID.
	 *
	 * @return array|null Cached content analysis data or null if not available.
	 */
	protected function getCachedContentAnalysis( $post_id ) {
		if ( ! $post_id ) {
			return null;
		}

		$cached = seopress_get_service( 'ContentAnalysisDatabase' )->getData( $post_id );

		if ( empty( $cached ) ) {
			return null;
		}

		// Format the data in the structure expected by the frontend.
		// The database stores flat values, but the frontend expects { value: ... } format.
		$data = array();

		// Map database columns to API response format.
		$mappings = array(
			'title'               => 'title',
			'description'         => 'description',
			'og_title'            => 'og:title',
			'og_description'      => 'og:description',
			'og_image'            => 'og:image',
			'og_url'              => 'og:url',
			'og_site_name'        => 'og:site_name',
			'twitter_title'       => 'twitter:title',
			'twitter_description' => 'twitter:description',
			'twitter_image'       => 'twitter:image',
			'twitter_image_src'   => 'twitter:image:src',
			'canonical'           => 'canonical',
			'meta_robots'         => 'meta_robots',
			'meta_google'         => 'meta_google',
		);

		foreach ( $mappings as $db_key => $api_key ) {
			if ( isset( $cached[ $db_key ] ) ) {
				$data[ $api_key ] = array( 'value' => $cached[ $db_key ] );
			}
		}

		// Handle array fields (h1, h2, h3, images, links).
		$array_mappings = array(
			'h1'              => 'h1',
			'h2'              => 'h2',
			'h3'              => 'h3',
			'images'          => 'images',
			'links_no_follow' => 'links_no_follow',
			'outbound_links'  => 'outbound_links',
			'internal_links'  => 'internal_links',
			'json_schemas'    => 'schemas',
		);

		foreach ( $array_mappings as $db_key => $api_key ) {
			if ( isset( $cached[ $db_key ] ) ) {
				$value = $cached[ $db_key ];
				// If stored as JSON string, decode it.
				if ( is_string( $value ) && ! empty( $value ) ) {
					$decoded = json_decode( $value, true );
					if ( json_last_error() === JSON_ERROR_NONE ) {
						$value = $decoded;
					}
				}
				$data[ $api_key ] = array( 'value' => is_array( $value ) ? $value : array() );
			}
		}

		// Add score if available.
		if ( isset( $cached['score'] ) ) {
			$score = $cached['score'];
			if ( is_string( $score ) && ! empty( $score ) ) {
				$decoded = json_decode( $score, true );
				if ( json_last_error() === JSON_ERROR_NONE ) {
					$score = $decoded;
				}
			}
			$data['score'] = $score;
		}

		// Add permalink/link_preview if available.
		if ( isset( $cached['permalink'] ) ) {
			$data['link_preview'] = $cached['permalink'];
		}

		// Run the same filter the REST endpoint applies so Pro can attach
		// the seopress_seo_issues map (used by the editor ignore controls).
		// Keeps the cached snapshot and a live refresh in lockstep.
		$data = apply_filters( 'seopress_content_analysis_response', $data, $post_id );

		return ! empty( $data ) ? $data : null;
	}
}
